Fix electron windows screen and window picker for screensharing
Some checks failed
Queue Release Build / prepare (push) Successful in 13s
Queue Release Build / build-windows (push) Has started running
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled

This commit is contained in:
2026-03-11 14:39:47 +01:00
parent 22f583e6b3
commit fbdb5c38cf
13 changed files with 735 additions and 37 deletions

View File

@@ -204,12 +204,22 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const thumbnailSize = { width: 240, height: 150 };
const [screenSources, windowSources] = await Promise.all([
desktopCapturer.getSources({
types: ['screen'],
thumbnailSize
}),
desktopCapturer.getSources({
types: ['window'],
thumbnailSize,
fetchWindowIcons: true
})
]);
const sources = [...screenSources, ...windowSources];
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
return sources.map((source) => ({
return [...uniqueSources.values()].map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL()

View File

@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
return false;
}
autoUpdater.quitAndInstall(false, true);
autoUpdater.quitAndInstall(true, true);
return true;
}

View File

@@ -11,18 +11,18 @@
"prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "npm run ng serve",
"build": "npm run ng build",
"start": "ng serve",
"build": "ng build",
"build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "npm run ng build --configuration production --base-href='./'",
"build:prod": "ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"server:build": "cd server && npm run build",
"server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"npm run ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",

View File

@@ -94,5 +94,8 @@
<!-- Unified Settings Modal -->
<app-settings-modal />
<!-- Shared Screen Share Source Picker -->
<app-screen-share-source-picker />
<!-- Shared Debug Console -->
<app-debug-console [showLauncher]="false" />

View File

@@ -25,6 +25,7 @@ import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -43,7 +44,8 @@ import {
TitleBarComponent,
FloatingVoiceControlsComponent,
SettingsModalComponent,
DebugConsoleComponent
DebugConsoleComponent,
ScreenShareSourcePickerComponent
],
templateUrl: './app.html',
styleUrl: './app.scss'

View File

@@ -0,0 +1,134 @@
import {
Injectable,
computed,
signal
} from '@angular/core';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants';
export type ScreenShareSourceKind = 'screen' | 'window';
export interface ScreenShareSourceOption {
id: string;
kind: ScreenShareSourceKind;
name: string;
thumbnail: string;
}
export interface ScreenShareSourceSelection {
includeSystemAudio: boolean;
source: ScreenShareSourceOption;
}
interface ScreenShareSourcePickerRequest {
includeSystemAudio: boolean;
sources: readonly ScreenShareSourceOption[];
}
@Injectable({ providedIn: 'root' })
export class ScreenShareSourcePickerService {
readonly request = computed(() => this._request());
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
private pendingReject: ((reason?: unknown) => void) | null = null;
open(
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
): Promise<ScreenShareSourceSelection> {
if (sources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
this.cancelPendingRequest();
const normalizedSources = sources.map((source) => {
const kind = this.getSourceKind(source);
return {
...source,
kind,
name: this.getSourceDisplayName(source.name, kind)
};
});
this._request.set({
includeSystemAudio: initialIncludeSystemAudio,
sources: normalizedSources
});
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
this.pendingResolve = resolve;
this.pendingReject = reject;
});
}
confirm(sourceId: string, includeSystemAudio: boolean): void {
const activeRequest = this._request();
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
const resolve = this.pendingResolve;
if (!source || !resolve) {
return;
}
this.clearPendingRequest();
saveVoiceSettingsToStorage({ includeSystemAudio });
resolve({
includeSystemAudio,
source
});
}
cancel(): void {
this.cancelPendingRequest();
}
private cancelPendingRequest(): void {
const reject = this.pendingReject;
this.clearPendingRequest();
if (reject) {
reject(this.createAbortError());
}
}
private clearPendingRequest(): void {
this._request.set(null);
this.pendingResolve = null;
this.pendingReject = null;
}
private getSourceKind(
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
): ScreenShareSourceKind {
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
? 'screen'
: 'window';
}
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
const trimmedName = name.trim();
if (trimmedName) {
return trimmedName;
}
return kind === 'screen' ? 'Entire screen' : 'Window';
}
private createAbortError(): Error {
if (typeof DOMException !== 'undefined') {
return new DOMException('The user aborted a request.', 'AbortError');
}
const error = new Error('The user aborted a request.');
error.name = 'AbortError';
return error;
}
}

View File

@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service';
import { DebuggingService } from './debugging.service';
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
import {
SignalingManager,
@@ -81,6 +82,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
@@ -106,6 +108,7 @@ export class WebRTCService implements OnDestroy {
private readonly _isScreenSharing = signal(false);
private readonly _isNoiseReductionEnabled = signal(false);
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false);
@@ -127,6 +130,7 @@ export class WebRTCService implements OnDestroy {
readonly isScreenSharing = computed(() => this._isScreenSharing());
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
readonly screenStream = computed(() => this._screenStreamSignal());
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => {
@@ -207,7 +211,16 @@ export class WebRTCService implements OnDestroy {
this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates()
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
sources,
options.includeSystemAudio
),
updateLocalScreenShareState: (state): void => {
this._isScreenSharing.set(state.active);
this._screenStreamSignal.set(state.stream);
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
}
});
this.wireManagerEvents();
@@ -853,18 +866,12 @@ export class WebRTCService implements OnDestroy {
* @returns The screen-capture {@link MediaStream}.
*/
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
const stream = await this.screenShareManager.startScreenShare(options);
this._isScreenSharing.set(true);
this._screenStreamSignal.set(stream);
return stream;
return await this.screenShareManager.startScreenShare(options);
}
/** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void {
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
}
/** Disconnect from the signaling server and clean up all state. */
@@ -899,6 +906,7 @@ export class WebRTCService implements OnDestroy {
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
this._isScreenShareRemotePlaybackSuppressed.set(false);
}
/** Synchronise Angular signals from the MediaManager's internal state. */

View File

@@ -27,6 +27,24 @@ export interface ScreenShareCallbacks {
getLocalMediaStream(): MediaStream | null;
renegotiate(peerId: string): Promise<void>;
broadcastCurrentStates(): void;
selectDesktopSource?(
sources: readonly { id: string; name: string; thumbnail: string }[],
options: { includeSystemAudio: boolean }
): Promise<{
includeSystemAudio: boolean;
source: { id: string; name: string; thumbnail: string };
}>;
updateLocalScreenShareState?(state: LocalScreenShareState): void;
}
type ScreenShareCaptureMethod = 'display-media' | 'electron-desktop' | 'linux-electron';
export interface LocalScreenShareState {
active: boolean;
captureMethod: ScreenShareCaptureMethod | null;
includeSystemAudio: boolean;
stream: MediaStream | null;
suppressRemotePlayback: boolean;
}
interface LinuxScreenShareAudioRoutingInfo {
@@ -71,12 +89,22 @@ interface LinuxScreenShareMonitorAudioPipeline {
unsubscribeEnded: () => void;
}
interface DesktopSource {
export interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
@@ -162,11 +190,10 @@ export class ScreenShareManager {
/**
* Begin screen sharing.
*
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
* path so remote voice playback is kept out of captured system audio.
* Otherwise prefers `getDisplayMedia` when system audio is requested so the
* browser can filter MeToYou's own playback via `restrictOwnAudio`, then
* falls back to Electron desktop capture when needed.
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
* path so remote voice playback is kept out of captured system audio.
* On other Electron builds, uses desktop capture. In browser contexts, uses
* `getDisplayMedia`.
*
* @param options - Screen-share capture options.
* @returns The captured screen {@link MediaStream}.
@@ -178,6 +205,9 @@ export class ScreenShareManager {
...options
};
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
const electronDesktopCaptureAvailable = this.isElectronDesktopCaptureAvailable();
let captureMethod: ScreenShareCaptureMethod | null = null;
try {
this.logger.info('startScreenShare invoked', shareOptions);
@@ -193,35 +223,47 @@ export class ScreenShareManager {
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
try {
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
captureMethod = 'linux-electron';
} catch (error) {
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
}
}
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
if (!this.activeScreenStream && shareOptions.includeSystemAudio && !electronDesktopCaptureAvailable) {
try {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
captureMethod = 'display-media';
if (this.activeScreenStream.getAudioTracks().length === 0) {
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
this.activeScreenStream.getTracks().forEach((track) => track.stop());
this.activeScreenStream = null;
captureMethod = null;
}
} catch (error) {
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
}
}
if (!this.activeScreenStream && this.getElectronApi()?.getSources) {
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
try {
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
const electronCapture = await this.startWithElectronDesktopCapturer(shareOptions, preset);
this.activeScreenStream = electronCapture.stream;
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
captureMethod = 'electron-desktop';
} catch (error) {
if (this.isScreenShareSelectionAborted(error)) {
throw error;
}
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
}
}
if (!this.activeScreenStream) {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
captureMethod = 'display-media';
}
this.configureScreenStream(preset);
@@ -230,6 +272,7 @@ export class ScreenShareManager {
this.attachScreenTracksToPeers(preset);
this.isScreenActive = true;
this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod);
this.callbacks.broadcastCurrentStates();
const activeScreenStream = this.activeScreenStream;
@@ -271,6 +314,7 @@ export class ScreenShareManager {
this.screenAudioStream = null;
this.activeScreenPreset = null;
this.isScreenActive = false;
this.publishLocalScreenShareState(false, null);
this.callbacks.broadcastCurrentStates();
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
@@ -347,6 +391,39 @@ export class ScreenShareManager {
: null;
}
private isElectronDesktopCaptureAvailable(): boolean {
return !!this.getElectronApi()?.getSources;
}
private isWindowsElectron(): boolean {
if (!this.isElectronDesktopCaptureAvailable() || typeof navigator === 'undefined') {
return false;
}
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
private publishLocalScreenShareState(
includeSystemAudio: boolean,
captureMethod: ScreenShareCaptureMethod | null
): void {
this.callbacks.updateLocalScreenShareState?.({
active: this.isScreenActive,
captureMethod: this.isScreenActive ? captureMethod : null,
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
stream: this.isScreenActive ? this.activeScreenStream : null,
suppressRemotePlayback: this.isScreenActive
&& this.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio, captureMethod)
});
}
private shouldSuppressRemotePlaybackDuringShare(
includeSystemAudio: boolean,
captureMethod: ScreenShareCaptureMethod | null
): boolean {
return includeSystemAudio && captureMethod === 'electron-desktop' && this.isWindowsElectron();
}
private getRequiredLinuxElectronApi(): Required<Pick<
ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting'
@@ -562,7 +639,7 @@ export class ScreenShareManager {
private async startWithElectronDesktopCapturer(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<MediaStream> {
): Promise<ElectronDesktopCaptureResult> {
const electronApi = this.getElectronApi();
if (!electronApi?.getSources) {
@@ -570,13 +647,23 @@ export class ScreenShareManager {
}
const sources = await electronApi.getSources();
const screenSource = sources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) ?? sources[0];
const selection = await this.resolveElectronDesktopSource(sources, options.includeSystemAudio);
const captureOptions = {
...options,
includeSystemAudio: selection.includeSystemAudio
};
if (!screenSource) {
if (!selection.source) {
throw new Error('No desktop capture sources were available.');
}
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, preset);
this.logger.info('Selected Electron desktop source', {
includeSystemAudio: selection.includeSystemAudio,
sourceId: selection.source.id,
sourceName: selection.source.name
});
const electronConstraints = this.buildElectronDesktopConstraints(selection.source.id, captureOptions, preset);
this.logger.info('desktopCapturer constraints', electronConstraints);
@@ -584,7 +671,61 @@ export class ScreenShareManager {
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
}
return await navigator.mediaDevices.getUserMedia(electronConstraints);
return {
includeSystemAudio: selection.includeSystemAudio,
stream: await navigator.mediaDevices.getUserMedia(electronConstraints)
};
}
private async resolveElectronDesktopSource(
sources: DesktopSource[],
includeSystemAudio: boolean
): Promise<ElectronDesktopSourceSelection> {
const orderedSources = this.sortElectronDesktopSources(sources);
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
?? orderedSources[0];
if (orderedSources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
if (!this.isWindowsElectron() || orderedSources.length < 2) {
return {
includeSystemAudio,
source: defaultSource
};
}
if (!this.callbacks.selectDesktopSource) {
return {
includeSystemAudio,
source: defaultSource
};
}
return await this.callbacks.selectDesktopSource(orderedSources, { includeSystemAudio });
}
private sortElectronDesktopSources(sources: DesktopSource[]): DesktopSource[] {
return [...sources].sort((left, right) => {
const weightDiff = this.getElectronDesktopSourceWeight(left) - this.getElectronDesktopSourceWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return left.name.localeCompare(right.name);
});
}
private getElectronDesktopSourceWeight(source: DesktopSource): number {
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
? 0
: 1;
}
private isScreenShareSelectionAborted(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
private isLinuxElectronAudioRoutingSupported(): boolean {
@@ -625,11 +766,13 @@ export class ScreenShareManager {
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
}
desktopStream = await this.startWithElectronDesktopCapturer({
const desktopCapture = await this.startWithElectronDesktopCapturer({
...options,
includeSystemAudio: false
}, preset);
desktopStream = desktopCapture.stream;
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);

View File

@@ -1,4 +1,8 @@
import { Injectable, inject } from '@angular/core';
import {
Injectable,
effect,
inject
} from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
@@ -36,9 +40,15 @@ export class VoicePlaybackService {
private preferredOutputDeviceId = 'default';
private masterVolume = 1;
private deafened = false;
private captureEchoSuppressed = false;
constructor() {
this.loadPersistedVolumes();
effect(() => {
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
this.recalcAllGains();
});
}
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
@@ -242,7 +252,7 @@ export class VoicePlaybackService {
if (!pipeline)
return;
if (this.deafened || this.isUserMuted(peerId)) {
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
pipeline.gainNode.gain.value = 0;
return;
}

View File

@@ -0,0 +1,191 @@
@if (request(); as pickerRequest) {
<div
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm"
(click)="cancel()"
(keydown.enter)="cancel()"
(keydown.space)="cancel()"
role="button"
tabindex="0"
aria-label="Close source picker"
></div>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<section
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
aria-labelledby="screen-share-source-picker-title"
aria-describedby="screen-share-source-picker-description"
tabindex="-1"
>
<header class="border-b border-border p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 id="screen-share-source-picker-title" class="text-lg font-semibold text-foreground">
Choose what to share
</h2>
<p id="screen-share-source-picker-description" class="mt-1 text-sm text-muted-foreground">
Select a screen or window to start sharing.
</p>
</div>
<label
class="flex items-center justify-between gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 lg:min-w-80"
for="screen-share-include-system-audio-toggle"
>
<div>
<p class="text-sm font-medium text-foreground">Include system audio</p>
<p class="text-xs text-muted-foreground">Share desktop sound with viewers.</p>
</div>
<span class="relative inline-flex items-center cursor-pointer">
<input
id="screen-share-include-system-audio-toggle"
type="checkbox"
class="sr-only peer"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
/>
<span
class="relative block h-5 w-10 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-transform after:content-[''] peer-checked:after:translate-x-full"
></span>
</span>
</label>
</div>
<div class="mt-4 flex flex-wrap gap-2" role="tablist" aria-label="Share source type">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
role="tab"
[attr.aria-selected]="activeTab() === 'screen'"
[disabled]="getTabCount('screen') === 0"
[class.border-primary]="activeTab() === 'screen'"
[class.bg-primary/10]="activeTab() === 'screen'"
[class.text-primary]="activeTab() === 'screen'"
[class.border-border]="activeTab() !== 'screen'"
[class.bg-secondary/30]="activeTab() !== 'screen'"
[class.text-foreground]="activeTab() !== 'screen'"
(click)="setActiveTab('screen')"
>
Entire screen
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
{{ getTabCount('screen') }}
</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
role="tab"
[attr.aria-selected]="activeTab() === 'window'"
[disabled]="getTabCount('window') === 0"
[class.border-primary]="activeTab() === 'window'"
[class.bg-primary/10]="activeTab() === 'window'"
[class.text-primary]="activeTab() === 'window'"
[class.border-border]="activeTab() !== 'window'"
[class.bg-secondary/30]="activeTab() !== 'window'"
[class.text-foreground]="activeTab() !== 'window'"
(click)="setActiveTab('window')"
>
Windows
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
{{ getTabCount('window') }}
</span>
</button>
</div>
@if (includeSystemAudio()) {
<p class="mt-3 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
Computer audio will be shared. MeToYou audio is filtered when supported, and your microphone stays on its normal voice track.
</p>
}
</header>
<div class="screen-share-source-picker__body">
@if (filteredSources().length > 0) {
<div
class="screen-share-source-picker__grid"
[class.screen-share-source-picker__grid--screen]="activeTab() === 'screen'"
[class.screen-share-source-picker__grid--window]="activeTab() === 'window'"
>
@for (source of filteredSources(); track trackSource($index, source)) {
<button
#sourceButton
type="button"
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
[attr.aria-pressed]="selectedSourceId() === source.id"
[attr.data-source-id]="source.id"
[class.border-primary]="selectedSourceId() === source.id"
[class.bg-primary/10]="selectedSourceId() === source.id"
[class.text-primary]="selectedSourceId() === source.id"
[class.border-border]="selectedSourceId() !== source.id"
[class.bg-secondary/30]="selectedSourceId() !== source.id"
[class.text-foreground]="selectedSourceId() !== source.id"
(click)="selectSource(source.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<span class="screen-share-source-picker__preview">
<img [ngSrc]="source.thumbnail" [alt]="source.name" fill />
</span>
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ source.kind === 'screen' ? 'Entire screen' : 'Window' }}
</p>
</div>
<span
class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border text-[10px]"
[class.border-primary]="selectedSourceId() === source.id"
[class.bg-primary]="selectedSourceId() === source.id"
[class.text-primary-foreground]="selectedSourceId() === source.id"
[class.border-border]="selectedSourceId() !== source.id"
>
@if (selectedSourceId() === source.id) {
}
</span>
</div>
</button>
}
</div>
} @else {
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
<div>
<p class="text-sm font-medium text-foreground">
No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available
</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ activeTab() === 'screen'
? 'No displays were reported by Electron right now.'
: 'Restore the window you want to share and try again.' }}
</p>
</div>
</div>
}
</div>
<footer class="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="!selectedSourceId()"
(click)="confirmSelection()"
>
Start sharing
</button>
</footer>
</section>
</div>
}

View File

@@ -0,0 +1,64 @@
:host {
display: contents;
}
.screen-share-source-picker__body {
max-height: min(36rem, calc(100vh - 15rem));
overflow: auto;
}
.screen-share-source-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.75rem;
align-content: start;
padding: 1.25rem;
}
.screen-share-source-picker__grid--screen {
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 15rem));
justify-content: start;
}
.screen-share-source-picker__grid--window {
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
.screen-share-source-picker__source {
cursor: pointer;
min-height: 100%;
}
.screen-share-source-picker__source:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.screen-share-source-picker__preview {
display: block;
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 0.875rem;
background: hsl(var(--secondary) / 0.45);
}
.screen-share-source-picker__preview img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
background: hsl(var(--secondary) / 0.3);
}
@media (max-width: 640px) {
.screen-share-source-picker__body {
max-height: calc(100vh - 22rem);
}
.screen-share-source-picker__grid {
grid-template-columns: 1fr;
padding: 1rem;
}
}

View File

@@ -0,0 +1,132 @@
import { CommonModule, NgOptimizedImage } from '@angular/common';
import {
Component,
ElementRef,
HostListener,
computed,
effect,
inject,
signal,
viewChildren
} from '@angular/core';
import {
ScreenShareSourceKind,
ScreenShareSourceOption,
ScreenShareSourcePickerService
} from '../../../core/services/screen-share-source-picker.service';
@Component({
selector: 'app-screen-share-source-picker',
standalone: true,
imports: [CommonModule, NgOptimizedImage],
templateUrl: './screen-share-source-picker.component.html',
styleUrl: './screen-share-source-picker.component.scss',
host: {
style: 'display: contents;'
}
})
export class ScreenShareSourcePickerComponent {
readonly picker = inject(ScreenShareSourcePickerService);
readonly request = this.picker.request;
readonly sources = computed(() => this.request()?.sources ?? []);
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
readonly filteredSources = computed(() => {
return this.activeTab() === 'screen'
? this.screenSources()
: this.windowSources();
});
readonly hasOpenRequest = computed(() => !!this.request());
readonly activeTab = signal<ScreenShareSourceKind>('screen');
readonly includeSystemAudio = signal(false);
readonly selectedSourceId = signal<string | null>(null);
private readonly sourceButtons = viewChildren<ElementRef<HTMLButtonElement>>('sourceButton');
constructor() {
effect(() => {
const request = this.request();
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen')
? 'screen'
: 'window';
this.activeTab.set(defaultTab);
this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
});
effect(() => {
const sources = this.filteredSources();
const selectedSourceId = this.selectedSourceId();
if (!sources.some((source) => source.id === selectedSourceId)) {
this.selectedSourceId.set(sources[0]?.id ?? null);
}
if (sources.length === 0) {
return;
}
window.requestAnimationFrame(() => {
const activeSourceId = this.selectedSourceId();
const targetButton = this.sourceButtons().find(
(button) => button.nativeElement.dataset['sourceId'] === activeSourceId
) ?? this.sourceButtons()[0];
targetButton?.nativeElement.focus();
});
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.hasOpenRequest()) {
this.cancel();
}
}
trackSource(_index: number, source: ScreenShareSourceOption): string {
return source.id;
}
setActiveTab(tab: ScreenShareSourceKind): void {
if (!this.getTabSources(tab).length) {
return;
}
this.activeTab.set(tab);
}
getTabCount(tab: ScreenShareSourceKind): number {
return this.getTabSources(tab).length;
}
selectSource(sourceId: string): void {
this.selectedSourceId.set(sourceId);
}
onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked);
}
confirmSelection(): void {
const sourceId = this.selectedSourceId();
if (!sourceId) {
return;
}
this.picker.confirm(sourceId, this.includeSystemAudio());
}
cancel(): void {
this.picker.cancel();
}
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
return tab === 'screen'
? this.screenSources()
: this.windowSources();
}
}

View File

@@ -9,4 +9,5 @@ export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-au
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
export { DebugConsoleComponent } from './components/debug-console/debug-console.component';
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';