8baac2fd1e
Equal-grid cells no longer set aspect-video — on wide chat panels this forced cell height = width × 9/16 (~400px on a 700px panel) which pushed the row past the section's max-h and ate the controls bar below. n>=2 cells now fill grid tracks normally via auto-rows-fr; the solo case (n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self- center so a single-user-alone-calling view doesn't stretch into a full-width slab. Same change applied to the fullscreen-grid path plus +16px bottom-padding (pb-28) so audio-only avatars' name chip clears the floating controls bar. Docked stage strip thumbs (focus + bento) switch from aspect-video shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the row width evenly under the share above, instead of clinging to the left edge with dead space to the right. Fullscreen-cinema strip keeps the small aspect-video thumbs the user explicitly approved. ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes it via a new `cinema` prop on TileRender so the in-share fullscreen icon doesn't visually collide with FullscreenCall's strip-hidden toggle at the same top-right corner. docs/superpowers/specs + plans for the Discord-style tile handling workstream are committed alongside the implementation that completed it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
import type { RemoteTrack } from 'livekit-client';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
|
import { MonitorShareIcon } from './icons';
|
|
|
|
interface ScreenShareViewerProps {
|
|
share: RemoteScreenShare;
|
|
avatarUrl: string | null;
|
|
displayName: string;
|
|
/** Suppress the in-share fullscreen toggle button. Used inside cinema
|
|
* mode where (a) the window is already OS-fullscreen and (b) the
|
|
* toggle collides visually with FullscreenCall's strip-hidden button
|
|
* at the same top-right corner. */
|
|
hideFullscreenToggle?: boolean;
|
|
}
|
|
|
|
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
|
// The watch-state lives in CallContext (not local useState) so it survives
|
|
// 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,
|
|
hideFullscreenToggle = false,
|
|
}: ScreenShareViewerProps) {
|
|
const { t } = useTranslation(['app']);
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const { watchingShareUserIds, watchShare } = useCall();
|
|
const watching = watchingShareUserIds.has(share.participantId);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
|
|
|
useEffect(() => {
|
|
if (!watching) return;
|
|
const el = videoRef.current;
|
|
if (!el) return;
|
|
const track: RemoteTrack = share.track;
|
|
track.attach(el);
|
|
return () => {
|
|
track.detach(el);
|
|
};
|
|
}, [share.track, watching]);
|
|
|
|
// Esc exits CSS fullscreen.
|
|
useEffect(() => {
|
|
if (!isFullscreen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setIsFullscreen(false);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [isFullscreen]);
|
|
|
|
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
|
|
// including the left sidebar + chat list. Native Fullscreen API is
|
|
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
|
|
// beyond what `fixed inset-0 z-[60]` already gives us.
|
|
const toggleFullscreen = () => {
|
|
setIsFullscreen((v) => !v);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={
|
|
isFullscreen
|
|
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
|
: 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
|
|
}
|
|
>
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
|
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1 truncate">
|
|
{t('app:call.is_sharing_screen', {
|
|
name: displayName,
|
|
defaultValue: displayName + ' teilt den Bildschirm',
|
|
})}
|
|
</span>
|
|
{watching && !hideFullscreenToggle && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleFullscreen}
|
|
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
|
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
|
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"
|
|
>
|
|
<FullscreenIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{watching ? (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
// Suppress the browser's built-in <video> context menu
|
|
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
|
// to the wrapping tile div in InCallPanel — that's where the
|
|
// app's volume / mute menu is wired up. Without this, the
|
|
// native menu opens on top of ours in focus + fullscreen
|
|
// modes (where the video covers the whole tile).
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onDoubleClick={toggleFullscreen}
|
|
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => watchShare(share.participantId)}
|
|
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
|
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
|
>
|
|
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
|
|
<PlayIcon className="ml-0.5 h-6 w-6" />
|
|
</span>
|
|
<span className="text-xs font-medium text-white/90">
|
|
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
|
if (avatarUrl) {
|
|
return (
|
|
<>
|
|
<img
|
|
src={avatarUrl}
|
|
alt=""
|
|
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
|
|
<span
|
|
aria-hidden="true"
|
|
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
|
>
|
|
{letter}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
{...props}
|
|
>
|
|
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
|
</svg>
|
|
);
|
|
}
|