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:
@@ -17,6 +17,7 @@ import { CallParticipantTile } from './CallParticipantTile';
|
|||||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
|
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
import { SoundboardPanel } from './SoundboardPanel';
|
import { SoundboardPanel } from './SoundboardPanel';
|
||||||
@@ -78,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
micError,
|
micError,
|
||||||
clearMicError,
|
clearMicError,
|
||||||
retryMic,
|
retryMic,
|
||||||
|
dismissedShareUserIds,
|
||||||
} = useCall();
|
} = useCall();
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
@@ -88,6 +90,9 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const [volumeMenu, setVolumeMenu] = useState<
|
const [volumeMenu, setVolumeMenu] = useState<
|
||||||
{ userId: string; displayName: string; x: number; y: number } | null
|
{ userId: string; displayName: string; x: number; y: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [shareMenu, setShareMenu] = useState<
|
||||||
|
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
|
||||||
|
>(null);
|
||||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||||
// rather than "exactly one speaker" — matches Discord more closely and
|
// rather than "exactly one speaker" — matches Discord more closely and
|
||||||
// handles the case where two people talk briefly without the focus
|
// handles the case where two people talk briefly without the focus
|
||||||
@@ -104,16 +109,44 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||||
}, [activeSpeakers]);
|
}, [activeSpeakers]);
|
||||||
|
|
||||||
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||||
|
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||||
|
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||||
|
// stop your own share from the control bar.
|
||||||
|
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||||
if (tile.self) return;
|
if (tile.self) return;
|
||||||
if (tile.kind !== 'user') return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (tile.kind === 'user') {
|
||||||
|
setShareMenu(null);
|
||||||
setVolumeMenu({
|
setVolumeMenu({
|
||||||
userId: tile.userId,
|
userId: tile.userId,
|
||||||
displayName: tile.displayName,
|
displayName: tile.displayName,
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screen-tile. Check whether the participant has a published screen-share
|
||||||
|
// audio track so the menu can hide the volume/mute rows when there's
|
||||||
|
// nothing to control.
|
||||||
|
const participant = remoteParticipants.find((p) => p.identity === tile.userId);
|
||||||
|
let hasAudio = false;
|
||||||
|
if (participant) {
|
||||||
|
for (const pub of participant.audioTrackPublications.values()) {
|
||||||
|
if (pub.source === Track.Source.ScreenShareAudio) {
|
||||||
|
hasAudio = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVolumeMenu(null);
|
||||||
|
setShareMenu({
|
||||||
|
userId: tile.userId,
|
||||||
|
displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
|
||||||
|
hasAudio,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const active =
|
const active =
|
||||||
@@ -135,7 +168,15 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
// Sharer ids that survived the user's dismiss-set. If the user did
|
||||||
|
// "Zuschauen beenden" on someone's share, they drop out of the tile
|
||||||
|
// grid until that sharer stops + restarts (TrackUnsubscribed clears
|
||||||
|
// dismissedShareUserIds — see CallContext).
|
||||||
|
remoteSharerIds: new Set(
|
||||||
|
remoteScreenShares
|
||||||
|
.map((s) => s.participantId)
|
||||||
|
.filter((id) => !dismissedShareUserIds.has(id)),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Duration keeps ticking during reconnecting so the user sees the call is
|
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||||
@@ -157,11 +198,11 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||||
: t('app:call.connected');
|
: t('app:call.connected');
|
||||||
|
|
||||||
// A screen-share tile becomes the auto-focus target when no one explicitly
|
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||||
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||||
// distinguish a user's own avatar tile from their screen tile.
|
// plays. Focus falls back to the first tile so focus-mode always has
|
||||||
const screenTile = tiles.find((p) => p.kind === 'screen');
|
// something to show when no tile was explicitly picked.
|
||||||
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
|
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||||
|
|
||||||
const controls = (
|
const controls = (
|
||||||
@@ -212,13 +253,15 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (callMode === 'fullscreen') {
|
if (callMode === 'fullscreen') {
|
||||||
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||||
// someone is sharing a screen, OR the person who most recently started
|
// the person who most recently started speaking (tracked in
|
||||||
// speaking (tracked in lastStartedSpeakerId). "Most recent speaker"
|
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||||
// beats "exactly one currently speaking" because two people briefly
|
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||||
// overlapping shouldn't kick us out of auto-focus.
|
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||||
|
// because two people briefly overlapping shouldn't kick us out of
|
||||||
|
// auto-focus.
|
||||||
const autoSpeaker =
|
const autoSpeaker =
|
||||||
focusedId === null && screenTile === undefined && lastStartedSpeakerId !== null
|
focusedId === null && lastStartedSpeakerId !== null
|
||||||
? tiles.find(
|
? tiles.find(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.kind === 'user' &&
|
t.kind === 'user' &&
|
||||||
@@ -226,7 +269,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
t.userId === lastStartedSpeakerId,
|
t.userId === lastStartedSpeakerId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -253,7 +296,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
// Toggle: click the already-focused tile to return to grid.
|
// Toggle: click the already-focused tile to return to grid.
|
||||||
setFocusedId(focusedId === id ? null : id);
|
setFocusedId(focusedId === id ? null : id);
|
||||||
}}
|
}}
|
||||||
onTileContextMenu={openVolumeMenu}
|
onTileContextMenu={openTileContextMenu}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
// Any active popover / menu / banner pins the controls so the user
|
// Any active popover / menu / banner pins the controls so the user
|
||||||
// can interact with them without the chrome fading out under their
|
// can interact with them without the chrome fading out under their
|
||||||
@@ -262,6 +305,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
soundboardOpen ||
|
soundboardOpen ||
|
||||||
participantsOpen ||
|
participantsOpen ||
|
||||||
volumeMenu !== null ||
|
volumeMenu !== null ||
|
||||||
|
shareMenu !== null ||
|
||||||
micError !== null
|
micError !== null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -274,6 +318,16 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setVolumeMenu(null)}
|
onClose={() => setVolumeMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{shareMenu && (
|
||||||
|
<ScreenShareContextMenu
|
||||||
|
userId={shareMenu.userId}
|
||||||
|
displayName={shareMenu.displayName}
|
||||||
|
hasAudio={shareMenu.hasAudio}
|
||||||
|
x={shareMenu.x}
|
||||||
|
y={shareMenu.y}
|
||||||
|
onClose={() => setShareMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SoundboardPopover
|
<SoundboardPopover
|
||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
onClose={() => setSoundboardOpen(false)}
|
||||||
@@ -350,7 +404,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
setFocusedId(id);
|
setFocusedId(id);
|
||||||
if (callMode === 'grid') setCallMode('focus');
|
if (callMode === 'grid') setCallMode('focus');
|
||||||
}}
|
}}
|
||||||
onTileContextMenu={openVolumeMenu}
|
onTileContextMenu={openTileContextMenu}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -376,6 +430,17 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shareMenu && (
|
||||||
|
<ScreenShareContextMenu
|
||||||
|
userId={shareMenu.userId}
|
||||||
|
displayName={shareMenu.displayName}
|
||||||
|
hasAudio={shareMenu.hasAudio}
|
||||||
|
x={shareMenu.x}
|
||||||
|
y={shareMenu.y}
|
||||||
|
onClose={() => setShareMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SoundboardPopover
|
<SoundboardPopover
|
||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
onClose={() => setSoundboardOpen(false)}
|
||||||
@@ -675,6 +740,7 @@ function TileRender({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||||
>
|
>
|
||||||
<ScreenShareViewer
|
<ScreenShareViewer
|
||||||
@@ -879,6 +945,10 @@ function FullscreenCall({
|
|||||||
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
|
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
|
||||||
// popover opening via keepControlsVisible) brings them back immediately.
|
// popover opening via keepControlsVisible) brings them back immediately.
|
||||||
const [controlsVisible, setControlsVisible] = useState(true);
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
// Session-only toggle to collapse the participant strip while watching a
|
||||||
|
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
|
||||||
|
// participants" — gives the focused content the full fullscreen height.
|
||||||
|
const [stripHidden, setStripHidden] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
@@ -953,7 +1023,7 @@ function FullscreenCall({
|
|||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{others.length > 0 && (
|
{others.length > 0 && !stripHidden && (
|
||||||
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div
|
<div
|
||||||
@@ -1032,6 +1102,38 @@ function FullscreenCall({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hide-participant-strip toggle, only meaningful when there's a focus
|
||||||
|
+ extras to hide. Fades alongside the bottom control bar so idle
|
||||||
|
fullscreen still goes clean. */}
|
||||||
|
{hasFocus && others.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStripHidden((v) => !v)}
|
||||||
|
aria-pressed={stripHidden}
|
||||||
|
title={
|
||||||
|
stripHidden
|
||||||
|
? 'Teilnehmer einblenden'
|
||||||
|
: 'Teilnehmer ausblenden'
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
stripHidden
|
||||||
|
? 'Teilnehmer einblenden'
|
||||||
|
: 'Teilnehmer ausblenden'
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||||
|
(controlsVisible
|
||||||
|
? 'pointer-events-auto opacity-100 '
|
||||||
|
: 'pointer-events-none opacity-0 ') +
|
||||||
|
(stripHidden
|
||||||
|
? 'border-accent bg-accent/20 text-accent-fg'
|
||||||
|
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StripToggleIcon hidden={stripHidden} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
|
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
|
||||||
@@ -1046,6 +1148,30 @@ function FullscreenCall({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
|
||||||
|
// MicOff/HeadphonesOff naming convention used elsewhere.
|
||||||
|
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PttHint() {
|
function PttHint() {
|
||||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||||
useEffect(() => subscribePttSettings(setPtt), []);
|
useEffect(() => subscribePttSettings(setPtt), []);
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import {
|
||||||
|
getScreenShareVolume,
|
||||||
|
setScreenShareVolume,
|
||||||
|
subscribeScreenShareVolumes,
|
||||||
|
} from '../lib/screenShareVolumes';
|
||||||
|
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Participant whose screen share the user right-clicked. */
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
/** Whether the share has an audio track published. Controls whether the
|
||||||
|
* volume / mute rows render — without audio those would be no-ops. */
|
||||||
|
hasAudio: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_W = 260;
|
||||||
|
const MENU_H_WITH_AUDIO = 200;
|
||||||
|
const MENU_H_NO_AUDIO = 96;
|
||||||
|
|
||||||
|
// Context menu surfaced on right-click of a remote screen share. Mirrors
|
||||||
|
// Discord's stream menu: volume slider, audio mute toggle (independent of
|
||||||
|
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
|
||||||
|
// which both un-subscribes locally and dismisses the tile from the grid.
|
||||||
|
export function ScreenShareContextMenu({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
hasAudio,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const {
|
||||||
|
dismissShare,
|
||||||
|
screenShareAudioMutedIds,
|
||||||
|
setScreenShareAudioMuted,
|
||||||
|
} = useCall();
|
||||||
|
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeScreenShareVolumes(() => {
|
||||||
|
setVolume(getScreenShareVolume(userId));
|
||||||
|
}),
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-share-menu]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const muted = screenShareAudioMutedIds.has(userId);
|
||||||
|
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-share-menu
|
||||||
|
role="menu"
|
||||||
|
aria-label={t('app:call.share_menu_title', {
|
||||||
|
defaultValue: 'Bildschirmfreigabe von {{name}}',
|
||||||
|
name: displayName,
|
||||||
|
})}
|
||||||
|
style={{ left, top, width: MENU_W }}
|
||||||
|
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
|
||||||
|
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t('app:call.share_menu_owner', {
|
||||||
|
defaultValue: 'Bildschirmfreigabe · {{name}}',
|
||||||
|
name: displayName,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasAudio && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-fg">
|
||||||
|
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-fg-muted">
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setScreenShareVolume(userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScreenShareAudioMuted(userId, !muted)}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
|
||||||
|
) : (
|
||||||
|
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1">
|
||||||
|
{muted
|
||||||
|
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
|
||||||
|
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
dismissShare(userId);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { RemoteTrack } from 'livekit-client';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { RemoteScreenShare } from '../context/CallContext';
|
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
@@ -12,12 +12,21 @@ interface ScreenShareViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||||
// Lifted out of the old InCallPanel so the new CallDock stays lean.
|
// The watch-state lives in CallContext (not local useState) so it survives
|
||||||
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
// layout-mode changes (grid → focus → fullscreen) without resetting. Same
|
||||||
|
// reason the ScreenShareAudio mute follows this state — see attachTrack.
|
||||||
|
// Right-click handling happens one level up in TileRender — the wrapping
|
||||||
|
// div catches the event before it reaches the viewer's inner content.
|
||||||
|
export function ScreenShareViewer({
|
||||||
|
share,
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
}: ScreenShareViewerProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [watching, setWatching] = useState(false);
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
@@ -68,7 +77,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{watching && (
|
{watching && (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
@@ -78,21 +86,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
>
|
>
|
||||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (document.fullscreenElement === containerRef.current) {
|
|
||||||
void document.exitFullscreen();
|
|
||||||
}
|
|
||||||
setWatching(false);
|
|
||||||
}}
|
|
||||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setWatching(true)}
|
onClick={() => watchShare(share.participantId)}
|
||||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||||
style={{ aspectRatio: '16 / 9' }}
|
style={{ aspectRatio: '16 / 9' }}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ import {
|
|||||||
} from '../lib/callE2EE';
|
} from '../lib/callE2EE';
|
||||||
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||||
|
import {
|
||||||
|
clearScreenShareVolumes,
|
||||||
|
getScreenShareVolume,
|
||||||
|
} from '../lib/screenShareVolumes';
|
||||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
import { playEntry } from '../lib/soundboardPlayback';
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
import {
|
import {
|
||||||
@@ -142,6 +146,15 @@ interface CallContextValue {
|
|||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isCameraEnabled: boolean;
|
isCameraEnabled: boolean;
|
||||||
isDeafened: 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. */
|
/** identity -> their deafen state, received via data channel. */
|
||||||
remoteDeafen: Record<string, boolean>;
|
remoteDeafen: Record<string, boolean>;
|
||||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
/** 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
|
/** Retry mic acquisition using the current audioSettings. Safe to call
|
||||||
* multiple times; no-op if there's no active room. */
|
* multiple times; no-op if there's no active room. */
|
||||||
retryMic: () => Promise<void>;
|
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);
|
const CallContext = createContext<CallContextValue | null>(null);
|
||||||
@@ -237,6 +264,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
() => new Set<string>(),
|
() => new Set<string>(),
|
||||||
);
|
);
|
||||||
const [micError, setMicError] = useState<string | null>(null);
|
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 signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const presenceChannelRef = 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) => {
|
r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||||
detachTrack(track, publication, participant);
|
detachTrack(track, publication, participant);
|
||||||
if (track.kind === Track.Kind.Video) {
|
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));
|
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;
|
deafenedActive = next;
|
||||||
// Apply to every currently-attached remote-audio element. Fresh tracks
|
// Apply to every currently-attached remote-audio element. Fresh tracks
|
||||||
// that attach during a deafened session are muted in attachTrack above.
|
// 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>(
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
'audio[data-livekit-track]',
|
'audio[data-livekit-track]',
|
||||||
);
|
);
|
||||||
els.forEach((el) => {
|
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
|
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
|
||||||
@@ -1653,6 +1729,61 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setMicError(null);
|
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 retryMic = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) {
|
if (!r) {
|
||||||
@@ -1662,14 +1793,43 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
await setupMicPipeline(r);
|
await setupMicPipeline(r);
|
||||||
}, [setupMicPipeline]);
|
}, [setupMicPipeline]);
|
||||||
|
|
||||||
// Clear the stale mic-error state whenever a call fully tears down so the
|
// Clear the stale mic-error + screen-share session state whenever a call
|
||||||
// next join starts with a clean slate.
|
// fully tears down so the next join starts with a clean slate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.kind === 'idle' || state.kind === 'error') {
|
if (state.kind === 'idle' || state.kind === 'error') {
|
||||||
setMicError(null);
|
setMicError(null);
|
||||||
|
setWatchingShareUserIds(new Set<string>());
|
||||||
|
setDismissedShareUserIds(new Set<string>());
|
||||||
|
setScreenShareAudioMutedIds(new Set<string>());
|
||||||
|
clearScreenShareVolumes();
|
||||||
}
|
}
|
||||||
}, [state.kind]);
|
}, [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) => {
|
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ outputDeviceId: deviceId });
|
updateAudioSettings({ outputDeviceId: deviceId });
|
||||||
const sinkId = deviceId ?? '';
|
const sinkId = deviceId ?? '';
|
||||||
@@ -1777,6 +1937,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
micError,
|
micError,
|
||||||
clearMicError,
|
clearMicError,
|
||||||
retryMic,
|
retryMic,
|
||||||
|
watchingShareUserIds,
|
||||||
|
dismissedShareUserIds,
|
||||||
|
watchShare,
|
||||||
|
stopWatchingShare,
|
||||||
|
dismissShare,
|
||||||
|
screenShareAudioMutedIds,
|
||||||
|
setScreenShareAudioMuted,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -1817,6 +1984,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
micError,
|
micError,
|
||||||
clearMicError,
|
clearMicError,
|
||||||
retryMic,
|
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.
|
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
||||||
let deafenedActive = false;
|
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(
|
async function broadcastPresence(
|
||||||
room: Room,
|
room: Room,
|
||||||
deafened: boolean,
|
deafened: boolean,
|
||||||
@@ -1851,7 +2036,7 @@ async function broadcastPresence(
|
|||||||
|
|
||||||
function attachTrack(
|
function attachTrack(
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
_publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
): void {
|
): void {
|
||||||
if (track.kind === Track.Kind.Audio) {
|
if (track.kind === Track.Kind.Audio) {
|
||||||
@@ -1860,11 +2045,34 @@ function attachTrack(
|
|||||||
audio.autoplay = true;
|
audio.autoplay = true;
|
||||||
audio.setAttribute('playsinline', 'true');
|
audio.setAttribute('playsinline', 'true');
|
||||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
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) {
|
if (participant.identity) {
|
||||||
audio.setAttribute('data-participant', 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);
|
document.body.appendChild(audio);
|
||||||
// Apply persisted sinkId so the element routes to the user's chosen
|
// Apply persisted sinkId so the element routes to the user's chosen
|
||||||
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||||
|
|||||||
@@ -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