feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,16 +15,25 @@ import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import {
|
||||
getLiveCaptionsSettings,
|
||||
isLiveCaptionsSupported,
|
||||
subscribeLiveCaptionsSettings,
|
||||
updateLiveCaptionsSettings,
|
||||
} from '../lib/liveCaptions';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
import { ScreenSourcePicker } from './ScreenSourcePicker';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
import { SoundboardPanel } from './SoundboardPanel';
|
||||
import { UserProfilePopover } from './UserProfilePopover';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||
@@ -54,6 +63,19 @@ interface Tile {
|
||||
sharing: boolean;
|
||||
// True iff this is a remote user's screen-share tile.
|
||||
remoteSharing: boolean;
|
||||
// LiveKit-measured connection quality for this participant. Drives the
|
||||
// Discord-style Wifi badge on user-tiles. Undefined for screen-tiles.
|
||||
connectionQuality?: TileConnectionQuality;
|
||||
// Discord-style host marker — only true for the call initiator's tile in
|
||||
// group calls. Drives the crown.
|
||||
isHost: boolean;
|
||||
// True iff the user pinned this tile via right-click → "Anpinnen".
|
||||
// Mirrors the value of CallContext.focusedId for this tile id.
|
||||
pinned: boolean;
|
||||
// True iff this user-tile's owner is currently publishing a screen share.
|
||||
// Drives the LIVE pill on the avatar tile so the share-tile next to it
|
||||
// visually belongs to the same person. Always false on screen-kind tiles.
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export function InCallPanel({ conversation }: Props) {
|
||||
@@ -73,7 +95,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
callMode,
|
||||
focusedId,
|
||||
toggleMute,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
toggleCamera,
|
||||
toggleDeafen,
|
||||
@@ -84,19 +105,65 @@ export function InCallPanel({ conversation }: Props) {
|
||||
clearMicError,
|
||||
retryMic,
|
||||
dismissedShareUserIds,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||
const [participantsOpen, setParticipantsOpen] = useState(false);
|
||||
// Discord-style screen-share configure modal. Opens when the user clicks
|
||||
// the Share button while not yet sharing — collects FPS/resolution/audio
|
||||
// before triggering the OS source picker.
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
||||
// controls bar can show an "active" state without polling. Captions
|
||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
||||
// tracks the toggle state for the button.
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
||||
() => getLiveCaptionsSettings().enabled,
|
||||
);
|
||||
useEffect(
|
||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
e.code === 'KeyS'
|
||||
) {
|
||||
e.preventDefault();
|
||||
setStatsOverlayOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
const [volumeMenu, setVolumeMenu] = useState<
|
||||
{ userId: string; displayName: string; x: number; y: number } | null
|
||||
{
|
||||
userId: string;
|
||||
displayName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
tileId: string;
|
||||
self: boolean;
|
||||
} | null
|
||||
>(null);
|
||||
const [shareMenu, setShareMenu] = useState<
|
||||
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
|
||||
>(null);
|
||||
// Discord-style profile popover triggered from the right-click context menu
|
||||
// on a participant tile. Anchored at the same coords as the volume menu.
|
||||
const [profileMenu, setProfileMenu] = useState<
|
||||
{ userId: string; x: number; y: number } | null
|
||||
>(null);
|
||||
// Soundboard-count so the in-call bar only surfaces the music button when
|
||||
// the user actually has something to play. Matches Discord's "hide soundboard
|
||||
// when empty" behaviour — no point dangling a button that opens to a blank
|
||||
@@ -149,18 +216,22 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// 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;
|
||||
e.preventDefault();
|
||||
if (tile.kind === 'user') {
|
||||
// Self-tile gets the pin row only — there's no remote volume to control.
|
||||
setShareMenu(null);
|
||||
setVolumeMenu({
|
||||
userId: tile.userId,
|
||||
displayName: tile.displayName,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
tileId: tile.id,
|
||||
self: tile.self,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Self screen-tiles: same — pin row is still useful, share menu is not.
|
||||
if (tile.self) 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.
|
||||
@@ -212,6 +283,12 @@ export function InCallPanel({ conversation }: Props) {
|
||||
.map((s) => s.participantId)
|
||||
.filter((id) => !dismissedShareUserIds.has(id)),
|
||||
),
|
||||
connectionQualities,
|
||||
// Discord-style host crown — only show in group calls (>2 members).
|
||||
// 1:1s have no concept of "host" so the crown stays hidden.
|
||||
hostUserId: conversation.members.length > 2 ? callHostId : null,
|
||||
// Pin marker — focusedId === tile.id means the user explicitly pinned.
|
||||
pinnedTileId: focusedId,
|
||||
});
|
||||
|
||||
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||
@@ -254,12 +331,15 @@ export function InCallPanel({ conversation }: Props) {
|
||||
if (isScreenSharing) {
|
||||
void stopScreenShare();
|
||||
} else {
|
||||
setPickerOpen(true);
|
||||
// Discord-parity: open the configure-then-share modal instead of
|
||||
// jumping straight into the OS picker. Modal calls
|
||||
// startScreenShare with the chosen overrides on confirm.
|
||||
setSharePickerOpen(true);
|
||||
}
|
||||
}}
|
||||
onShareContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isScreenSharing) setPickerOpen(true);
|
||||
if (!isScreenSharing) setSharePickerOpen(true);
|
||||
}}
|
||||
onToggleVideo={() => void toggleCamera()}
|
||||
onToggleDeafen={toggleDeafen}
|
||||
@@ -275,6 +355,15 @@ export function InCallPanel({ conversation }: Props) {
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
// Live-Captions only when SpeechRecognition is available in the
|
||||
// runtime — Firefox lacks it, would just show a dead button.
|
||||
{...(isLiveCaptionsSupported()
|
||||
? {
|
||||
onToggleCaptions: () =>
|
||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
||||
captionsOn: captionsEnabled,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -358,6 +447,25 @@ export function InCallPanel({ conversation }: Props) {
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
// We're already in fullscreen here — toggling focusedId is enough,
|
||||
// no mode-switch needed.
|
||||
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||
}}
|
||||
// Self-tiles get no slider — the volume control would adjust the
|
||||
// remote volume of someone the local user isn't hearing.
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
? {}
|
||||
: {
|
||||
onShowProfile: () =>
|
||||
setProfileMenu({
|
||||
userId: volumeMenu.userId,
|
||||
x: volumeMenu.x,
|
||||
y: volumeMenu.y,
|
||||
}),
|
||||
})}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -381,6 +489,28 @@ export function InCallPanel({ conversation }: Props) {
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
{profileMenu && (
|
||||
<UserProfilePopover
|
||||
userId={profileMenu.userId}
|
||||
profile={
|
||||
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
|
||||
}
|
||||
x={profileMenu.x}
|
||||
y={profileMenu.y}
|
||||
onClose={() => setProfileMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{sharePickerOpen && (
|
||||
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
|
||||
)}
|
||||
{statsOverlayOpen && room && (
|
||||
<CallStatsOverlay
|
||||
room={room}
|
||||
members={conversation.members}
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +574,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile
|
||||
// collapses back to grid; clicking another tile swaps focus;
|
||||
// clicking any tile in grid mode focuses it.
|
||||
if (callMode === 'focus' && focusedId === id) {
|
||||
setFocusedId(null);
|
||||
setCallMode('grid');
|
||||
return;
|
||||
}
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
@@ -455,24 +593,45 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<PttHint />
|
||||
|
||||
<ScreenSourcePicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStart={async (opts) => {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
const isPinned = focusedId === volumeMenu.tileId;
|
||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
? {}
|
||||
: {
|
||||
onShowProfile: () =>
|
||||
setProfileMenu({
|
||||
userId: volumeMenu.userId,
|
||||
x: volumeMenu.x,
|
||||
y: volumeMenu.y,
|
||||
}),
|
||||
})}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileMenu && (
|
||||
<UserProfilePopover
|
||||
userId={profileMenu.userId}
|
||||
profile={
|
||||
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
|
||||
}
|
||||
x={profileMenu.x}
|
||||
y={profileMenu.y}
|
||||
onClose={() => setProfileMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareMenu && (
|
||||
<ScreenShareContextMenu
|
||||
userId={shareMenu.userId}
|
||||
@@ -495,6 +654,20 @@ export function InCallPanel({ conversation }: Props) {
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
|
||||
{sharePickerOpen && (
|
||||
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
|
||||
)}
|
||||
|
||||
{statsOverlayOpen && room && (
|
||||
<CallStatsOverlay
|
||||
room={room}
|
||||
members={conversation.members}
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -528,6 +701,13 @@ interface BuildArgs {
|
||||
isScreenSharing: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
connectionQualities: Record<string, ConnectionQuality>;
|
||||
/** Host userId (call initiator). Null in re-joined calls and 1:1 calls.
|
||||
* Used to mark exactly one user's tile with the crown. */
|
||||
hostUserId: string | null;
|
||||
/** Tile id (e.g. "user:abc") the user has pinned via right-click. Null
|
||||
* while no pin is active or while auto-tracking the active speaker. */
|
||||
pinnedTileId: string | null;
|
||||
}
|
||||
|
||||
function cameraTrackFor(
|
||||
@@ -554,6 +734,9 @@ function buildTiles({
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds,
|
||||
connectionQualities,
|
||||
hostUserId,
|
||||
pinnedTileId,
|
||||
}: BuildArgs): Tile[] {
|
||||
const remoteById = new Map<string, RemoteParticipant>();
|
||||
for (const p of remoteParticipants) {
|
||||
@@ -567,6 +750,7 @@ function buildTiles({
|
||||
// device / permission denied) the toggle stays false while the real
|
||||
// state is "muted". Combine both so the badge always matches reality.
|
||||
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
|
||||
const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined;
|
||||
out.push({
|
||||
kind: 'user',
|
||||
id: 'user:' + myId,
|
||||
@@ -580,6 +764,10 @@ function buildTiles({
|
||||
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
|
||||
sharing: false,
|
||||
remoteSharing: false,
|
||||
...(myQuality ? { connectionQuality: myQuality } : {}),
|
||||
isHost: hostUserId === myId,
|
||||
pinned: pinnedTileId === 'user:' + myId,
|
||||
streaming: isScreenSharing,
|
||||
});
|
||||
if (isScreenSharing) {
|
||||
out.push({
|
||||
@@ -595,6 +783,9 @@ function buildTiles({
|
||||
videoTrack: null,
|
||||
sharing: true,
|
||||
remoteSharing: false,
|
||||
isHost: false,
|
||||
pinned: pinnedTileId === 'screen:' + myId,
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -602,6 +793,7 @@ function buildTiles({
|
||||
if (m.userId === myId) continue;
|
||||
const rp = remoteById.get(m.userId);
|
||||
if (!rp) continue;
|
||||
const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined;
|
||||
out.push({
|
||||
kind: 'user',
|
||||
id: 'user:' + m.userId,
|
||||
@@ -619,6 +811,10 @@ function buildTiles({
|
||||
videoTrack: cameraTrackFor(rp),
|
||||
sharing: false,
|
||||
remoteSharing: false,
|
||||
...(peerQuality ? { connectionQuality: peerQuality } : {}),
|
||||
isHost: hostUserId === m.userId,
|
||||
pinned: pinnedTileId === 'user:' + m.userId,
|
||||
streaming: remoteSharerIds.has(m.userId),
|
||||
});
|
||||
if (remoteSharerIds.has(m.userId)) {
|
||||
out.push({
|
||||
@@ -634,6 +830,9 @@ function buildTiles({
|
||||
videoTrack: null,
|
||||
sharing: false,
|
||||
remoteSharing: true,
|
||||
isHost: false,
|
||||
pinned: pinnedTileId === 'screen:' + m.userId,
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -757,25 +956,7 @@ function TileRender({
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
if (tile.self) {
|
||||
// Local screenshare preview — we don't mirror a copy of the outgoing
|
||||
// stream. Render a labelled placeholder card so the user knows their
|
||||
// share is live without double-encoding the stream.
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
|
||||
(onClick ? 'cursor-pointer' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider">
|
||||
Live
|
||||
</div>
|
||||
<div className="text-sm">{tile.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LocalSharePreview displayName={tile.displayName} {...(onClick ? { onClick } : {})} />;
|
||||
}
|
||||
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||
@@ -806,6 +987,10 @@ function TileRender({
|
||||
video={tile.video}
|
||||
videoTrack={tile.videoTrack}
|
||||
e2ee={e2ee}
|
||||
isHost={tile.isHost}
|
||||
pinned={tile.pinned}
|
||||
streaming={tile.streaming}
|
||||
{...(tile.connectionQuality ? { connectionQuality: tile.connectionQuality } : {})}
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
@@ -837,6 +1022,9 @@ function CallStage({
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
@@ -897,12 +1085,14 @@ function FocusedTile({
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
activeSpeakers: Set<string>;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
@@ -913,6 +1103,7 @@ function FocusedTile({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1268,6 +1459,130 @@ function MicErrorBanner({
|
||||
);
|
||||
}
|
||||
|
||||
// Preview card for the local sharer's screen-tile. Shows a "Live" label and,
|
||||
// when the share carries a ScreenShareAudio track, a small overlay button to
|
||||
// mute that outgoing audio publication without tearing down the share. The
|
||||
// mute state lives in CallContext so the toggle survives re-renders and
|
||||
// resets cleanly when the share ends.
|
||||
function LocalSharePreview({
|
||||
displayName,
|
||||
onClick,
|
||||
}: {
|
||||
displayName: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall();
|
||||
const [hasShareAudio, setHasShareAudio] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setHasShareAudio(false);
|
||||
return;
|
||||
}
|
||||
const compute = () => {
|
||||
const lp = room.localParticipant;
|
||||
let found = false;
|
||||
for (const pub of lp.audioTrackPublications.values()) {
|
||||
if (pub.source === Track.Source.ScreenShareAudio) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHasShareAudio(found);
|
||||
};
|
||||
compute();
|
||||
const onPub = () => compute();
|
||||
room.on(RoomEvent.LocalTrackPublished, onPub);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, onPub);
|
||||
return () => {
|
||||
room.off(RoomEvent.LocalTrackPublished, onPub);
|
||||
room.off(RoomEvent.LocalTrackUnpublished, onPub);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
|
||||
(onClick ? 'cursor-pointer' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider">Live</div>
|
||||
<div className="text-sm">{displayName}</div>
|
||||
</div>
|
||||
{hasShareAudio && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleOutgoingShareAudioMute();
|
||||
}}
|
||||
aria-pressed={outgoingShareAudioMuted}
|
||||
aria-label={
|
||||
outgoingShareAudioMuted
|
||||
? 'Sound der Übertragung wieder senden'
|
||||
: 'Sound der Übertragung stumm'
|
||||
}
|
||||
title={
|
||||
outgoingShareAudioMuted
|
||||
? 'Sound der Übertragung wieder senden'
|
||||
: 'Sound der Übertragung stumm'
|
||||
}
|
||||
className={
|
||||
'absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border backdrop-blur-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(outgoingShareAudioMuted
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-rose-600 hover:bg-rose-500/30 dark:text-rose-300'
|
||||
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-200')
|
||||
}
|
||||
>
|
||||
{outgoingShareAudioMuted ? <SpeakerOffIconInline /> : <SpeakerOnIconInline />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakerOnIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakerOffIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
|
||||
function MicOffIconInline() {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user