feat(call): Discord-style screen-share UX
- Watch-gate lifted into CallContext. watchingShareUserIds / dismissedShareUserIds / screenShareAudioMutedIds as session-only state, cleared on CallState.idle and on TrackUnsubscribed for each sharer. Survives layout changes (grid <-> focus <-> fullscreen) without resetting which the old local-state viewer dropped on remount. - ScreenShareAudio tracks tagged via data-track-source="screenshare" at attach-time; initial muted follows watching + manual mute mirrors so audio never plays before the user clicks "Bildschirm anschauen". Deafen still wins at the top of the priority chain. - New screenShareVolumes store (session-only, keyed by participantId). attachTrack pulls the initial volume from this store for screenshare audio elements so the context-menu slider takes effect immediately. - Screen shares are no longer auto-promoted to focus. They render as equal-size grid tiles like everyone else; user clicks to focus. The "Bildschirm anschauen" overlay replaces auto-play as the opt-in. - Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden" really hides the tile until the sharer stops + restarts. - New ScreenShareContextMenu (portal, Esc / outside-click to close): volume slider + audio mute toggle when the share has audio + a destructive "Zuschauen beenden" row. Wired via a dispatcher in InCallPanel that picks between participant-volume and share-menu based on tile.kind. - Fullscreen cinema gets a "Hide participant strip" toggle (top-right, session-only) so focused content reaches the full viewport when the bottom thumbnail row would otherwise steal 160px. Fades with the auto-hide controls; only surfaces when there's a focus + peers to hide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Session-only per-share audio volume. Mirror of `participantVolumes` but
|
||||
// NOT persisted — when the user leaves the call or restarts the app, these
|
||||
// reset to default. Intentional: the relevant trackSid is ephemeral anyway,
|
||||
// and users don't expect screen-share volume to survive between sessions.
|
||||
//
|
||||
// Keys are participantIds (LiveKit identity). There's one screen-share per
|
||||
// participant at a time in LiveKit, so keying by id keeps the API aligned
|
||||
// with how the context menu surfaces the control ("Dennis's share").
|
||||
|
||||
const DEFAULT_VOLUME = 1;
|
||||
|
||||
type VolumeMap = Record<string, number>;
|
||||
type Listener = (map: VolumeMap) => void;
|
||||
|
||||
let current: VolumeMap = {};
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function clamp(v: number): number {
|
||||
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn(current);
|
||||
}
|
||||
|
||||
export function getScreenShareVolume(userId: string): number {
|
||||
return current[userId] ?? DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
export function setScreenShareVolume(userId: string, volume: number): void {
|
||||
const next = clamp(volume);
|
||||
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
|
||||
current = { ...current, [userId]: next };
|
||||
applyToAttachedElements(userId, next);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribeScreenShareVolumes(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
// Reset on call end — called from CallContext when CallState.idle triggers.
|
||||
export function clearScreenShareVolumes(): void {
|
||||
if (Object.keys(current).length === 0) return;
|
||||
current = {};
|
||||
notify();
|
||||
}
|
||||
|
||||
// Live-apply to any <audio> element already attached for this share. The
|
||||
// elements are tagged by attachTrack in CallContext with
|
||||
// `data-participant="<identity>"` + `data-track-source="screenshare"` — the
|
||||
// combined selector makes sure we don't retarget the mic audio for the same
|
||||
// user (different track-source).
|
||||
function applyToAttachedElements(userId: string, volume: number): void {
|
||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-participant="' +
|
||||
cssEscape(userId) +
|
||||
'"][data-track-source="screenshare"]',
|
||||
);
|
||||
nodes.forEach((el) => {
|
||||
el.volume = volume;
|
||||
});
|
||||
}
|
||||
|
||||
function cssEscape(v: string): string {
|
||||
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
|
||||
?.escape === 'function') {
|
||||
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
|
||||
}
|
||||
return v.replace(/"/g, '\\"');
|
||||
}
|
||||
Reference in New Issue
Block a user