From 7b3caa0b618b1348abd15a7b41840f9e92229004 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 11 Mar 2026 14:39:47 +0100 Subject: [PATCH] Fix electron windows screen and window picker for screensharing --- README.md | 95 --------- electron/ipc/system.ts | 20 +- electron/update/desktop-updater.ts | 2 +- package.json | 10 +- src/app/app.html | 3 + src/app/app.ts | 4 +- .../screen-share-source-picker.service.ts | 134 ++++++++++++ src/app/core/services/webrtc.service.ts | 24 ++- .../services/webrtc/screen-share.manager.ts | 173 ++++++++++++++-- .../services/voice-playback.service.ts | 14 +- .../screen-share-source-picker.component.html | 191 ++++++++++++++++++ .../screen-share-source-picker.component.scss | 64 ++++++ .../screen-share-source-picker.component.ts | 132 ++++++++++++ src/app/shared/index.ts | 1 + 14 files changed, 735 insertions(+), 132 deletions(-) create mode 100644 src/app/core/services/screen-share-source-picker.service.ts create mode 100644 src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.html create mode 100644 src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.scss create mode 100644 src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts diff --git a/README.md b/README.md index cbf849a..bac7521 100644 --- a/README.md +++ b/README.md @@ -26,101 +26,6 @@ Server files: - `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates -## Desktop auto updates - -The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response. - -Release flow: - -1. Build the desktop packages with `npm run electron:build` or the platform-specific Electron Builder commands. -2. Upload one version folder that contains the generated `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, and the matching installers/artifacts. -3. Generate or update the hosted manifest JSON with: - - `npm run release:manifest -- --feed-url https://your-cdn.example.com/metoyou/1.2.3` - -4. Set `releaseManifestUrl` in `server/data/variables.json` to the hosted manifest JSON URL. - -### GitHub / Gitea release assets - -If you publish desktop builds as release assets, use the release download URL as the manifest `feedUrl`. - -Examples: - -- GitHub tag `v1.2.3`: - - `https://github.com/OWNER/REPO/releases/download/v1.2.3` - -- Gitea tag `v1.2.3`: - - `https://gitea.example.com/OWNER/REPO/releases/download/v1.2.3` - -That release must include these assets with their normal Electron Builder names: - -- `latest.yml` -- `latest-mac.yml` -- `latest-linux.yml` -- Windows installer assets (`.exe`, `.blockmap`) -- macOS assets (`.dmg`, `.zip`) -- Linux assets (`.AppImage`, `.deb`) - -You should also upload `release-manifest.json` as a release asset. - -For a stable manifest URL, point the server at the latest-release asset URL: - -- GitHub: - - `https://github.com/OWNER/REPO/releases/latest/download/release-manifest.json` - -- Gitea: use the equivalent latest-release asset URL if your instance supports it, otherwise publish `release-manifest.json` at a separate stable URL. - -If you want the in-app "Specific version" option to list older releases too, keep one cumulative manifest and merge the previous file when generating the next one: - - `npm run release:manifest -- --existing ./release-manifest.json --feed-url https://github.com/OWNER/REPO/releases/download/v1.2.3 --version 1.2.3` - -The manifest format is: - -```json -{ - "schemaVersion": 1, - "generatedAt": "2026-03-10T12:00:00.000Z", - "minimumServerVersion": "1.0.0", - "pollIntervalMinutes": 30, - "versions": [ - { - "version": "1.2.3", - "feedUrl": "https://your-cdn.example.com/metoyou/1.2.3", - "publishedAt": "2026-03-10T12:00:00.000Z" - } - ] -} -``` - -`feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux. - -### Automated Gitea release queue - -The Gitea workflows in `.gitea/workflows/release-draft.yml` and `.gitea/workflows/publish-draft-release.yml` keep the existing desktop auto-update flow intact. - -On every push to `main` or `master`, the release workflow: - -1. Computes a semver release version from the current `package.json` major/minor version and the workflow run number. -2. Builds the Linux and Windows Electron packages. -3. Builds standalone server executables for Linux and Windows. -4. Downloads the latest published `release-manifest.json`, merges the new release feed URL, and uploads the updated manifest to the draft release. -5. Uploads the desktop installers, update descriptors, server executables, and `release-manifest.json` to the matching Gitea release page. - -The draft release uses the standard Gitea download path as its `feedUrl`: - - `https://YOUR_GITEA_HOST/OWNER/REPO/releases/download/vX.Y.Z` - -That means the current desktop auto-updater keeps working without any client-side changes once the draft release is approved and published. - -To enable the workflow: - -- Add a repository secret named `GITEA_RELEASE_TOKEN` with permission to create releases and upload release assets. -- Make sure your Gitea runner labels match the workflow `runs-on` values (`linux` and `windows`). -- After the draft release is reviewed, publish it either from the Gitea release page or by running the `Publish Draft Release` workflow with the queued release tag. - ## Main commands - `npm run dev` starts Angular, the server, and Electron diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4f266a1..9d310f0 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -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() diff --git a/electron/update/desktop-updater.ts b/electron/update/desktop-updater.ts index e9575e9..1ee9303 100644 --- a/electron/update/desktop-updater.ts +++ b/electron/update/desktop-updater.ts @@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean { return false; } - autoUpdater.quitAndInstall(false, true); + autoUpdater.quitAndInstall(true, true); return true; } diff --git a/package.json b/package.json index 90c59e8..daf9eac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/app.html b/src/app/app.html index 05b210e..40a912d 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -94,5 +94,8 @@ + + + diff --git a/src/app/app.ts b/src/app/app.ts index d43a1ff..47a8369 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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' diff --git a/src/app/core/services/screen-share-source-picker.service.ts b/src/app/core/services/screen-share-source-picker.service.ts new file mode 100644 index 0000000..e688c2b --- /dev/null +++ b/src/app/core/services/screen-share-source-picker.service.ts @@ -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(null); + + private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null; + private pendingReject: ((reason?: unknown) => void) | null = null; + + open( + sources: readonly Pick[], + initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio + ): Promise { + 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((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 + ): 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; + } +} diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index ccdeb90..7f332d4 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -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, '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(null); + private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); private readonly _hasConnectionError = signal(false); private readonly _connectionErrorMessage = signal(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 => 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 { - 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. */ diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index 6481b68..eb9bb6a 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -27,6 +27,24 @@ export interface ScreenShareCallbacks { getLocalMediaStream(): MediaStream | null; renegotiate(peerId: string): Promise; 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; prepareLinuxScreenShareAudioRouting?: () => Promise; @@ -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 { + ): Promise { 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 { + 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]); diff --git a/src/app/features/voice/voice-controls/services/voice-playback.service.ts b/src/app/features/voice/voice-controls/services/voice-playback.service.ts index 1e4eba4..be8144f 100644 --- a/src/app/features/voice/voice-controls/services/voice-playback.service.ts +++ b/src/app/features/voice/voice-controls/services/voice-playback.service.ts @@ -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; } diff --git a/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.html b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.html new file mode 100644 index 0000000..fd28a1d --- /dev/null +++ b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.html @@ -0,0 +1,191 @@ +@if (request(); as pickerRequest) { +
+ +
+ +
+} diff --git a/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.scss b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.scss new file mode 100644 index 0000000..5e1a98f --- /dev/null +++ b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.scss @@ -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; + } +} diff --git a/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts new file mode 100644 index 0000000..c58b1a2 --- /dev/null +++ b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts @@ -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('screen'); + readonly includeSystemAudio = signal(false); + readonly selectedSourceId = signal(null); + + private readonly sourceButtons = viewChildren>('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(); + } +} diff --git a/src/app/shared/index.ts b/src/app/shared/index.ts index 0068efb..2aed7da 100644 --- a/src/app/shared/index.ts +++ b/src/app/shared/index.ts @@ -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';