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` 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
|
||||||
|
|||||||
@@ -204,12 +204,22 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
const sources = await desktopCapturer.getSources({
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
types: ['window', 'screen'],
|
const [screenSources, windowSources] = await Promise.all([
|
||||||
thumbnailSize: { width: 150, height: 150 }
|
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()
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.quitAndInstall(false, true);
|
autoUpdater.quitAndInstall(true, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
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 { 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. */
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -164,9 +192,8 @@ export class ScreenShareManager {
|
|||||||
*
|
*
|
||||||
* 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,47 @@ 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.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.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) {
|
||||||
|
if (this.isScreenShareSelectionAborted(error)) {
|
||||||
|
throw 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 +272,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 +314,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 +391,39 @@ export class ScreenShareManager {
|
|||||||
: null;
|
: 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<
|
private getRequiredLinuxElectronApi(): Required<Pick<
|
||||||
ScreenShareElectronApi,
|
ScreenShareElectronApi,
|
||||||
| 'prepareLinuxScreenShareAudioRouting'
|
| 'prepareLinuxScreenShareAudioRouting'
|
||||||
@@ -562,7 +639,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 +647,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 +671,61 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLinuxElectronAudioRoutingSupported(): boolean {
|
private isLinuxElectronAudioRoutingSupported(): boolean {
|
||||||
@@ -625,11 +766,13 @@ 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({
|
const desktopCapture = await this.startWithElectronDesktopCapturer({
|
||||||
...options,
|
...options,
|
||||||
includeSystemAudio: false
|
includeSystemAudio: false
|
||||||
}, preset);
|
}, preset);
|
||||||
|
|
||||||
|
desktopStream = desktopCapture.stream;
|
||||||
|
|
||||||
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
|
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
|
||||||
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user