feat(calls): minimise the call PiP to a small bar; phones start minimised (#323) #414

Merged
jared merged 3 commits from fix/pip-minimise into lotus 2026-10-19 01:00:20 -04:00
+92 -7
View File
@@ -90,6 +90,9 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200; const PIP_MIN_W = 200;
const PIP_MIN_H = 112; const PIP_MIN_H = 112;
/** [Gitea #323] Minimised PiP bar size. */
const PIP_MINI_W = 220;
const PIP_MINI_H = 40;
/** Default PiP top: just below the 64px-ish room header. */ /** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72; const PIP_DEFAULT_TOP = 72;
@@ -1003,6 +1006,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
); );
const [pipIsFullscreen, setPipIsFullscreen] = useState(false); const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
// instead of the video. Remembered; phones start minimised.
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
try {
const v = localStorage.getItem('pip-minimized');
if (v !== null) return v === '1';
} catch {
/* storage unavailable */
}
return window.innerWidth < 600;
});
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
const togglePipMinimized = useCallback(() => {
setPipMinimized((m) => {
try {
localStorage.setItem('pip-minimized', m ? '0' : '1');
} catch {
/* storage unavailable */
}
return !m;
});
}, []);
useEffect(() => { useEffect(() => {
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement); const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
@@ -1103,6 +1128,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
} }
}, [pipMode, callVisible]); }, [pipMode, callVisible]);
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
// below has positioned and sized it). Keeps the bar on screen.
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el || !pipMode) return;
if (pipMinimized) {
if (el.offsetHeight > PIP_MINI_H) {
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
}
el.style.width = `${PIP_MINI_W}px`;
el.style.height = `${PIP_MINI_H}px`;
} else {
el.style.width = `${pipFullSizeRef.current.width}px`;
el.style.height = `${pipFullSizeRef.current.height}px`;
}
const rect = el.getBoundingClientRect();
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
}, [pipMode, pipMinimized, callEmbedRef]);
React.useEffect(() => { React.useEffect(() => {
if (!pipMode) return; if (!pipMode) return;
const onPipWindowResize = (): void => { const onPipWindowResize = (): void => {
@@ -1421,19 +1466,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
zIndex: 1, zIndex: 1,
background: 'transparent', // Minimised: cover the frame so only the bar shows.
background: pipMinimized ? color.Surface.Container : 'transparent',
cursor: 'grab', cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn // [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button. // inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined, pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex', display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end', justifyContent: 'flex-end',
alignItems: pipMinimized ? 'center' : 'flex-start',
padding: '6px', padding: '6px',
}} }}
> >
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}> <div
{document.fullscreenEnabled && ( style={{
display: 'flex',
gap: config.space.S100,
alignItems: 'center',
...(pipMinimized ? { width: '100%' } : {}),
}}
>
{/* Leftmost: the top-right corner is a resize handle. */}
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
aria-pressed={pipMinimized}
onClick={(e) => {
e.stopPropagation();
togglePipMinimized();
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
color: '#fff',
}}
>
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
</IconButton>
{!pipMinimized && document.fullscreenEnabled && (
<IconButton <IconButton
type="button" type="button"
size="300" size="300"
@@ -1465,14 +1541,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px', fontSize: '11px',
fontWeight: 600, fontWeight: 600,
pointerEvents: 'none', pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}} }}
> >
↗ Return to call {pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
</div> </div>
</div> </div>
</div> </div>
<PipMuteOverlay callEmbed={callEmbed} /> {!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => { {(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
const s = corner.includes('s'); const s = corner.includes('s');
const e2 = corner.includes('e'); const e2 = corner.includes('e');
const dots = [ const dots = [