Fix electron windows screen and window picker for screensharing
This commit is contained in:
95
README.md
95
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
10
package.json
10
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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
134
src/app/core/services/screen-share-source-picker.service.ts
Normal file
134
src/app/core/services/screen-share-source-picker.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
@@ -164,9 +192,8 @@ export class ScreenShareManager {
|
||||
*
|
||||
* 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 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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user