2 Commits

Author SHA1 Message Date
Myx
be465fd297 Fix screenshare portals linux
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Queue Release Build / build-windows (push) Successful in 34m25s
Queue Release Build / build-linux (push) Successful in 40m26s
Queue Release Build / finalize (push) Successful in 3m44s
2026-03-11 17:54:04 +01:00
Myx
7b3caa0b61 Fix electron windows screen and window picker for screensharing
All checks were successful
Queue Release Build / prepare (push) Successful in 44s
Queue Release Build / build-windows (push) Successful in 32m25s
Queue Release Build / build-linux (push) Successful in 42m57s
Queue Release Build / finalize (push) Successful in 3m41s
2026-03-11 14:40:53 +01:00
16 changed files with 815 additions and 151 deletions

View File

@@ -26,101 +26,6 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates - `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 ## Main commands
- `npm run dev` starts Angular, the server, and Electron - `npm run dev` starts Angular, the server, and Electron

View File

@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
export function configureAppFlags(): void { export function configureAppFlags(): void {
linuxSpecificFlags(); linuxSpecificFlags();
audioFlags();
networkFlags(); networkFlags();
setupGpuEncodingFlags(); setupGpuEncodingFlags();
chromiumFlags(); chromiumFlags();
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
// Suppress Autofill devtools errors // Suppress Autofill devtools errors
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication'); app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
}
function audioFlags(): void { // Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
const enabledFeatures: string[] = [];
if (process.platform === 'linux') { if (process.platform === 'linux') {
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support // PipeWire-based audio pipeline for screen share audio capture
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess'); enabledFeatures.push('AudioServiceOutOfProcess');
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
enabledFeatures.push('WebRTCPipeWireCapturer');
}
const desktopSettings = readDesktopSettings();
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
enabledFeatures.push('VaapiVideoEncode');
}
if (enabledFeatures.length > 0) {
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
} }
} }
function linuxSpecificFlags(): void { function linuxSpecificFlags(): void {
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues if (process.platform !== 'linux') {
if (process.platform === 'linux') { return;
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
} }
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
// works for screen capture on Wayland compositors
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
} }
function networkFlags(): void { function networkFlags(): void {
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
} }
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
// Enable VA-API hardware video encoding on Linux
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
}
app.commandLine.appendSwitch('enable-gpu-rasterization'); app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy'); app.commandLine.appendSwitch('enable-zero-copy');
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers'); app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');

View File

@@ -204,16 +204,32 @@ export function setupSystemHandlers(): void {
}); });
ipcMain.handle('get-sources', async () => { ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({ try {
types: ['window', 'screen'], const thumbnailSize = { width: 240, height: 150 };
thumbnailSize: { width: 150, 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, id: source.id,
name: source.name, name: source.name,
thumbnail: source.thumbnail.toDataURL() thumbnail: source.thumbnail.toDataURL()
})); }));
} catch {
// desktopCapturer.getSources fails on Wayland; return empty so the
// renderer falls through to getDisplayMedia with the system picker.
return [];
}
}); });
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => { ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {

View File

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

View File

@@ -1,6 +1,8 @@
import { import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer,
session,
shell shell
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
} }
}); });
if (process.platform === 'linux') {
session.defaultSession.setDisplayMediaRequestHandler(
async (_request, respond) => {
// On Linux/Wayland the system picker (useSystemPicker: true) handles
// the portal. This handler is only reached if the system picker is
// unavailable (e.g. X11 without a portal). Fall back to
// desktopCapturer so the user still gets something.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({ video: firstSource });
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
}
if (process.env['NODE_ENV'] === 'development') { if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true' const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200' ? 'https://localhost:4200'

View File

@@ -11,18 +11,18 @@
"prebuild": "npm run bundle:rnnoise", "prebuild": "npm run bundle:rnnoise",
"prestart": "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", "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "npm run ng serve", "start": "ng serve",
"build": "npm run ng build", "build": "ng build",
"build:electron": "tsc -p tsconfig.electron.json", "build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "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", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage", "electron": "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: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": "./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\"", "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", "migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
@@ -36,7 +36,7 @@
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux", "electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build", "build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
"build:prod:win": "npm run build:prod:all && electron-builder --win", "build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run electron:full", "dev": "npm run build:electron && npm run electron:full",
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",

View File

@@ -94,5 +94,8 @@
<!-- Unified Settings Modal --> <!-- Unified Settings Modal -->
<app-settings-modal /> <app-settings-modal />
<!-- Shared Screen Share Source Picker -->
<app-screen-share-source-picker />
<!-- Shared Debug Console --> <!-- Shared Debug Console -->
<app-debug-console [showLauncher]="false" /> <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 { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.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 { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -43,7 +44,8 @@ import {
TitleBarComponent, TitleBarComponent,
FloatingVoiceControlsComponent, FloatingVoiceControlsComponent,
SettingsModalComponent, SettingsModalComponent,
DebugConsoleComponent DebugConsoleComponent,
ScreenShareSourcePickerComponent
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' 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 { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service'; import { TimeSyncService } from './time-sync.service';
import { DebuggingService } from './debugging.service'; import { DebuggingService } from './debugging.service';
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
import { import {
SignalingManager, SignalingManager,
@@ -81,6 +82,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
export class WebRTCService implements OnDestroy { export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService); private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService); private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
@@ -106,6 +108,7 @@ export class WebRTCService implements OnDestroy {
private readonly _isScreenSharing = signal(false); private readonly _isScreenSharing = signal(false);
private readonly _isNoiseReductionEnabled = signal(false); private readonly _isNoiseReductionEnabled = signal(false);
private readonly _screenStreamSignal = signal<MediaStream | null>(null); private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
private readonly _hasConnectionError = signal(false); private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null); private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false); private readonly _hasEverConnected = signal(false);
@@ -127,6 +130,7 @@ export class WebRTCService implements OnDestroy {
readonly isScreenSharing = computed(() => this._isScreenSharing()); readonly isScreenSharing = computed(() => this._isScreenSharing());
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
readonly screenStream = computed(() => this._screenStreamSignal()); readonly screenStream = computed(() => this._screenStreamSignal());
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
readonly hasConnectionError = computed(() => this._hasConnectionError()); readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => { readonly shouldShowConnectionError = computed(() => {
@@ -207,7 +211,16 @@ export class WebRTCService implements OnDestroy {
this.peerManager.activePeerConnections, this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId), 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(); this.wireManagerEvents();
@@ -853,18 +866,12 @@ export class WebRTCService implements OnDestroy {
* @returns The screen-capture {@link MediaStream}. * @returns The screen-capture {@link MediaStream}.
*/ */
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> { async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
const stream = await this.screenShareManager.startScreenShare(options); return await this.screenShareManager.startScreenShare(options);
this._isScreenSharing.set(true);
this._screenStreamSignal.set(stream);
return stream;
} }
/** Stop screen sharing and restore microphone audio on all peers. */ /** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void { stopScreenShare(): void {
this.screenShareManager.stopScreenShare(); this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
} }
/** Disconnect from the signaling server and clean up all state. */ /** Disconnect from the signaling server and clean up all state. */
@@ -899,6 +906,7 @@ export class WebRTCService implements OnDestroy {
this.screenShareManager.stopScreenShare(); this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false); this._isScreenSharing.set(false);
this._screenStreamSignal.set(null); this._screenStreamSignal.set(null);
this._isScreenShareRemotePlaybackSuppressed.set(false);
} }
/** Synchronise Angular signals from the MediaManager's internal state. */ /** Synchronise Angular signals from the MediaManager's internal state. */

View File

@@ -27,6 +27,24 @@ export interface ScreenShareCallbacks {
getLocalMediaStream(): MediaStream | null; getLocalMediaStream(): MediaStream | null;
renegotiate(peerId: string): Promise<void>; renegotiate(peerId: string): Promise<void>;
broadcastCurrentStates(): 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 { interface LinuxScreenShareAudioRoutingInfo {
@@ -71,12 +89,22 @@ interface LinuxScreenShareMonitorAudioPipeline {
unsubscribeEnded: () => void; unsubscribeEnded: () => void;
} }
interface DesktopSource { export interface DesktopSource {
id: string; id: string;
name: string; name: string;
thumbnail: string; thumbnail: string;
} }
interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
interface ScreenShareElectronApi { interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>; getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>; prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
@@ -162,11 +190,10 @@ export class ScreenShareManager {
/** /**
* Begin screen sharing. * Begin screen sharing.
* *
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing * On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
* path so remote voice playback is kept out of captured system audio. * path so remote voice playback is kept out of captured system audio.
* Otherwise prefers `getDisplayMedia` when system audio is requested so the * On other Electron builds, uses desktop capture. In browser contexts, uses
* browser can filter MeToYou's own playback via `restrictOwnAudio`, then * `getDisplayMedia`.
* falls back to Electron desktop capture when needed.
* *
* @param options - Screen-share capture options. * @param options - Screen-share capture options.
* @returns The captured screen {@link MediaStream}. * @returns The captured screen {@link MediaStream}.
@@ -178,6 +205,9 @@ export class ScreenShareManager {
...options ...options
}; };
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality]; const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
const electronDesktopCaptureAvailable = this.isElectronDesktopCaptureAvailable();
let captureMethod: ScreenShareCaptureMethod | null = null;
try { try {
this.logger.info('startScreenShare invoked', shareOptions); this.logger.info('startScreenShare invoked', shareOptions);
@@ -193,35 +223,46 @@ export class ScreenShareManager {
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) { if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
try { try {
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset); this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
captureMethod = 'linux-electron';
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error);
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', 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 { try {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset); this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
captureMethod = 'display-media';
if (this.activeScreenStream.getAudioTracks().length === 0) { if (this.activeScreenStream.getAudioTracks().length === 0) {
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture'); this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
this.activeScreenStream.getTracks().forEach((track) => track.stop()); this.activeScreenStream.getTracks().forEach((track) => track.stop());
this.activeScreenStream = null; this.activeScreenStream = null;
captureMethod = null;
} }
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error);
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', 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 { 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) { } catch (error) {
this.rethrowIfScreenShareAborted(error);
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error); this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
} }
} }
if (!this.activeScreenStream) { if (!this.activeScreenStream) {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset); this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
captureMethod = 'display-media';
} }
this.configureScreenStream(preset); this.configureScreenStream(preset);
@@ -230,6 +271,7 @@ export class ScreenShareManager {
this.attachScreenTracksToPeers(preset); this.attachScreenTracksToPeers(preset);
this.isScreenActive = true; this.isScreenActive = true;
this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod);
this.callbacks.broadcastCurrentStates(); this.callbacks.broadcastCurrentStates();
const activeScreenStream = this.activeScreenStream; const activeScreenStream = this.activeScreenStream;
@@ -271,6 +313,7 @@ export class ScreenShareManager {
this.screenAudioStream = null; this.screenAudioStream = null;
this.activeScreenPreset = null; this.activeScreenPreset = null;
this.isScreenActive = false; this.isScreenActive = false;
this.publishLocalScreenShareState(false, null);
this.callbacks.broadcastCurrentStates(); this.callbacks.broadcastCurrentStates();
this.callbacks.getActivePeers().forEach((peerData, peerId) => { this.callbacks.getActivePeers().forEach((peerData, peerId) => {
@@ -347,6 +390,47 @@ export class ScreenShareManager {
: null; : null;
} }
private isElectronDesktopCaptureAvailable(): boolean {
return !!this.getElectronApi()?.getSources && !this.isLinuxElectron();
}
private isLinuxElectron(): boolean {
if (!this.getElectronApi() || typeof navigator === 'undefined') {
return false;
}
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
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< private getRequiredLinuxElectronApi(): Required<Pick<
ScreenShareElectronApi, ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting' | 'prepareLinuxScreenShareAudioRouting'
@@ -562,7 +646,7 @@ export class ScreenShareManager {
private async startWithElectronDesktopCapturer( private async startWithElectronDesktopCapturer(
options: ScreenShareStartOptions, options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset preset: ScreenShareQualityPreset
): Promise<MediaStream> { ): Promise<ElectronDesktopCaptureResult> {
const electronApi = this.getElectronApi(); const electronApi = this.getElectronApi();
if (!electronApi?.getSources) { if (!electronApi?.getSources) {
@@ -570,13 +654,23 @@ export class ScreenShareManager {
} }
const sources = await electronApi.getSources(); 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.'); 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); this.logger.info('desktopCapturer constraints', electronConstraints);
@@ -584,7 +678,68 @@ export class ScreenShareManager {
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).'); 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' || error.name === 'NotAllowedError');
}
private rethrowIfScreenShareAborted(error: unknown): void {
if (this.isScreenShareSelectionAborted(error)) {
throw error;
}
} }
private isLinuxElectronAudioRoutingSupported(): boolean { private isLinuxElectronAudioRoutingSupported(): boolean {
@@ -625,7 +780,7 @@ export class ScreenShareManager {
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.'); throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
} }
desktopStream = await this.startWithElectronDesktopCapturer({ desktopStream = await this.startWithDisplayMedia({
...options, ...options,
includeSystemAudio: false includeSystemAudio: false
}, preset); }, preset);

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 { WebRTCService } from '../../../../core/services/webrtc.service';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
@@ -36,9 +40,15 @@ export class VoicePlaybackService {
private preferredOutputDeviceId = 'default'; private preferredOutputDeviceId = 'default';
private masterVolume = 1; private masterVolume = 1;
private deafened = false; private deafened = false;
private captureEchoSuppressed = false;
constructor() { constructor() {
this.loadPersistedVolumes(); this.loadPersistedVolumes();
effect(() => {
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
this.recalcAllGains();
});
} }
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void { handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
@@ -242,7 +252,7 @@ export class VoicePlaybackService {
if (!pipeline) if (!pipeline)
return; return;
if (this.deafened || this.isUserMuted(peerId)) { if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
pipeline.gainNode.gain.value = 0; pipeline.gainNode.gain.value = 0;
return; 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 { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
export { DebugConsoleComponent } from './components/debug-console/debug-console.component'; export { DebugConsoleComponent } from './components/debug-console/debug-console.component';
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.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'; export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';