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:
@@ -64,6 +64,10 @@ import {
|
||||
} from '../lib/callE2EE';
|
||||
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||
import {
|
||||
clearScreenShareVolumes,
|
||||
getScreenShareVolume,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||
import { playEntry } from '../lib/soundboardPlayback';
|
||||
import {
|
||||
@@ -142,6 +146,15 @@ interface CallContextValue {
|
||||
isScreenSharing: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
isDeafened: boolean;
|
||||
/** Participants whose screen share the local user has actively clicked
|
||||
* "Bildschirm anschauen" on. Session-only (cleared on call end). Used to
|
||||
* gate both the <video> rendering and the ScreenShareAudio playback so
|
||||
* sound only plays after an explicit opt-in. */
|
||||
watchingShareUserIds: ReadonlySet<string>;
|
||||
/** Participants whose share has been right-click dismissed ("Zuschauen
|
||||
* beenden"). Filters their screen-tile out of the grid until they stop
|
||||
* + restart sharing (track-unsubscribe clears the entry). */
|
||||
dismissedShareUserIds: ReadonlySet<string>;
|
||||
/** identity -> their deafen state, received via data channel. */
|
||||
remoteDeafen: Record<string, boolean>;
|
||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||
@@ -205,6 +218,20 @@ interface CallContextValue {
|
||||
/** Retry mic acquisition using the current audioSettings. Safe to call
|
||||
* multiple times; no-op if there's no active room. */
|
||||
retryMic: () => Promise<void>;
|
||||
/** Flip a user's screen share into the watching state (video plays + audio
|
||||
* unmutes). */
|
||||
watchShare: (userId: string) => void;
|
||||
/** Flip out of watching state (video pauses / placeholder + audio mutes).
|
||||
* Does NOT dismiss the tile — use dismissShare to hide it entirely. */
|
||||
stopWatchingShare: (userId: string) => void;
|
||||
/** Remove a share tile from view for the rest of this session (or until
|
||||
* the sharer stops + restarts). Also clears watching if applicable. */
|
||||
dismissShare: (userId: string) => void;
|
||||
/** Per-share-audio manual mute flag (in addition to the watching gate).
|
||||
* When true, the ScreenShareAudio stays muted even when watching is on
|
||||
* — lets the user watch the video without the audio track. */
|
||||
screenShareAudioMutedIds: ReadonlySet<string>;
|
||||
setScreenShareAudioMuted: (userId: string, muted: boolean) => void;
|
||||
}
|
||||
|
||||
const CallContext = createContext<CallContextValue | null>(null);
|
||||
@@ -237,6 +264,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
() => new Set<string>(),
|
||||
);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const [watchingShareUserIds, setWatchingShareUserIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
const [dismissedShareUserIds, setDismissedShareUserIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
const [screenShareAudioMutedIds, setScreenShareAudioMutedIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set<string>());
|
||||
|
||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||
@@ -702,7 +738,34 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
detachTrack(track, publication, participant);
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
const wasShare =
|
||||
track.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShare;
|
||||
setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid));
|
||||
// Clean up watching / dismissed state for the sharer so a fresh
|
||||
// restart from the same user shows the overlay again (Discord
|
||||
// resets dismiss when a new stream begins).
|
||||
if (wasShare && participant.identity) {
|
||||
const identity = participant.identity;
|
||||
setWatchingShareUserIds((prev) => {
|
||||
if (!prev.has(identity)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(identity);
|
||||
return next;
|
||||
});
|
||||
setDismissedShareUserIds((prev) => {
|
||||
if (!prev.has(identity)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(identity);
|
||||
return next;
|
||||
});
|
||||
setScreenShareAudioMutedIds((prev) => {
|
||||
if (!prev.has(identity)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(identity);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1159,11 +1222,24 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
deafenedActive = next;
|
||||
// Apply to every currently-attached remote-audio element. Fresh tracks
|
||||
// that attach during a deafened session are muted in attachTrack above.
|
||||
// When un-deafening, screen-share-audio elements should fall back to
|
||||
// the watching state (muted unless the user clicked "Bildschirm
|
||||
// anschauen") rather than being blanket-unmuted like mic tracks.
|
||||
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-livekit-track]',
|
||||
);
|
||||
els.forEach((el) => {
|
||||
el.muted = next;
|
||||
if (next) {
|
||||
el.muted = true;
|
||||
return;
|
||||
}
|
||||
const source = el.getAttribute('data-track-source');
|
||||
if (source === 'screenshare') {
|
||||
const pid = el.getAttribute('data-participant');
|
||||
el.muted = !(pid && watchingShareUserIdsMirror.has(pid));
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
|
||||
@@ -1653,6 +1729,61 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setMicError(null);
|
||||
}, []);
|
||||
|
||||
const watchShare = useCallback((userId: string) => {
|
||||
setWatchingShareUserIds((prev) => {
|
||||
if (prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(userId);
|
||||
return next;
|
||||
});
|
||||
// Un-dismiss in case the user had dismissed earlier in the session and
|
||||
// now wants to watch again (Discord also lets you re-subscribe).
|
||||
setDismissedShareUserIds((prev) => {
|
||||
if (!prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stopWatchingShare = useCallback((userId: string) => {
|
||||
setWatchingShareUserIds((prev) => {
|
||||
if (!prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissShare = useCallback((userId: string) => {
|
||||
setWatchingShareUserIds((prev) => {
|
||||
if (!prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
setDismissedShareUserIds((prev) => {
|
||||
if (prev.has(userId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setScreenShareAudioMuted = useCallback(
|
||||
(userId: string, muted: boolean) => {
|
||||
setScreenShareAudioMutedIds((prev) => {
|
||||
const has = prev.has(userId);
|
||||
if (muted === has) return prev;
|
||||
const next = new Set(prev);
|
||||
if (muted) next.add(userId);
|
||||
else next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const retryMic = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
if (!r) {
|
||||
@@ -1662,14 +1793,43 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
await setupMicPipeline(r);
|
||||
}, [setupMicPipeline]);
|
||||
|
||||
// Clear the stale mic-error state whenever a call fully tears down so the
|
||||
// next join starts with a clean slate.
|
||||
// Clear the stale mic-error + screen-share session state whenever a call
|
||||
// fully tears down so the next join starts with a clean slate.
|
||||
useEffect(() => {
|
||||
if (state.kind === 'idle' || state.kind === 'error') {
|
||||
setMicError(null);
|
||||
setWatchingShareUserIds(new Set<string>());
|
||||
setDismissedShareUserIds(new Set<string>());
|
||||
setScreenShareAudioMutedIds(new Set<string>());
|
||||
clearScreenShareVolumes();
|
||||
}
|
||||
}, [state.kind]);
|
||||
|
||||
// Keep the module-level mirrors in sync so attachTrack (which is defined
|
||||
// outside the React component and runs from LiveKit event callbacks) can
|
||||
// decide the initial muted-state for ScreenShareAudio elements. Also
|
||||
// re-applies the muted state to already-attached elements on every flip
|
||||
// — covers both watching changes and manual mute toggles from the
|
||||
// context menu.
|
||||
useEffect(() => {
|
||||
watchingShareUserIdsMirror = watchingShareUserIds;
|
||||
screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
|
||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-track-source="screenshare"]',
|
||||
);
|
||||
nodes.forEach((el) => {
|
||||
if (deafenedActive) {
|
||||
el.muted = true;
|
||||
return;
|
||||
}
|
||||
const pid = el.getAttribute('data-participant');
|
||||
if (!pid) return;
|
||||
const watching = watchingShareUserIds.has(pid);
|
||||
const manualMuted = screenShareAudioMutedIds.has(pid);
|
||||
el.muted = !watching || manualMuted;
|
||||
});
|
||||
}, [watchingShareUserIds, screenShareAudioMutedIds]);
|
||||
|
||||
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||
updateAudioSettings({ outputDeviceId: deviceId });
|
||||
const sinkId = deviceId ?? '';
|
||||
@@ -1777,6 +1937,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
micError,
|
||||
clearMicError,
|
||||
retryMic,
|
||||
watchingShareUserIds,
|
||||
dismissedShareUserIds,
|
||||
watchShare,
|
||||
stopWatchingShare,
|
||||
dismissShare,
|
||||
screenShareAudioMutedIds,
|
||||
setScreenShareAudioMuted,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -1817,6 +1984,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
micError,
|
||||
clearMicError,
|
||||
retryMic,
|
||||
watchingShareUserIds,
|
||||
dismissedShareUserIds,
|
||||
watchShare,
|
||||
stopWatchingShare,
|
||||
dismissShare,
|
||||
screenShareAudioMutedIds,
|
||||
setScreenShareAudioMuted,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1834,6 +2008,17 @@ export function useCall(): CallContextValue {
|
||||
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
||||
let deafenedActive = false;
|
||||
|
||||
// Same pattern for the "which shares is the user actively watching" set —
|
||||
// used by attachTrack to decide whether a freshly-landed ScreenShareAudio
|
||||
// track should start muted. Synced from React via a useEffect inside
|
||||
// CallProvider.
|
||||
let watchingShareUserIdsMirror: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
// Manual mute flags for screen-share audio, independent of the watching
|
||||
// state. When a userId sits in here, their share-audio stays muted even
|
||||
// after the user clicked "Bildschirm anschauen".
|
||||
let screenShareAudioMutedIdsMirror: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
async function broadcastPresence(
|
||||
room: Room,
|
||||
deafened: boolean,
|
||||
@@ -1851,7 +2036,7 @@ async function broadcastPresence(
|
||||
|
||||
function attachTrack(
|
||||
track: RemoteTrack,
|
||||
_publication: RemoteTrackPublication,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
@@ -1860,11 +2045,34 @@ function attachTrack(
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute('playsinline', 'true');
|
||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||
const isScreenShareAudio =
|
||||
track.source === Track.Source.ScreenShareAudio ||
|
||||
publication.source === Track.Source.ScreenShareAudio;
|
||||
if (isScreenShareAudio) {
|
||||
audio.setAttribute('data-track-source', 'screenshare');
|
||||
}
|
||||
if (participant.identity) {
|
||||
audio.setAttribute('data-participant', participant.identity);
|
||||
audio.volume = getParticipantVolume(participant.identity);
|
||||
audio.volume = isScreenShareAudio
|
||||
? getScreenShareVolume(participant.identity)
|
||||
: getParticipantVolume(participant.identity);
|
||||
}
|
||||
// Mute rules, in priority order:
|
||||
// 1. Deafen wins — user chose to hear nothing at all.
|
||||
// 2. ScreenShareAudio is muted until the user explicitly clicks
|
||||
// "Bildschirm anschauen" (watching gate).
|
||||
// 3. ScreenShareAudio is also muted when the user flipped the
|
||||
// manual mute toggle in the share context menu, regardless of
|
||||
// watching state.
|
||||
// 4. Everything else starts audible.
|
||||
if (deafenedActive) {
|
||||
audio.muted = true;
|
||||
} else if (isScreenShareAudio) {
|
||||
const pid = participant.identity ?? '';
|
||||
const watching = pid !== '' && watchingShareUserIdsMirror.has(pid);
|
||||
const manualMuted = pid !== '' && screenShareAudioMutedIdsMirror.has(pid);
|
||||
audio.muted = !watching || manualMuted;
|
||||
}
|
||||
if (deafenedActive) audio.muted = true;
|
||||
document.body.appendChild(audio);
|
||||
// Apply persisted sinkId so the element routes to the user's chosen
|
||||
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||
|
||||
Reference in New Issue
Block a user