feat(call): full-screen call when a phone is turned sideways (#126) #451

Merged
jared merged 1 commits from feat/landscape-call into lotus 2026-10-24 23:13:17 -04:00
8 changed files with 284 additions and 10 deletions
+9 -2
View File
@@ -37,7 +37,7 @@ import {
useCallThemeSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
@@ -954,6 +954,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const immersiveCall = useAtomValue(immersiveCallAtom);
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
@@ -1458,7 +1459,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
...(pipMode
? { background: color.Surface.Container }
: callVisible
? wallpaperStyle
? {
// [Gitea #126] Full screen: the frame is transparent, so give
// it an opaque base under the wallpaper or the room's
// sidebar / call bar show through.
...(immersiveCall ? { backgroundColor: color.Background.Container } : {}),
...wallpaperStyle,
}
: {}),
}}
ref={callEmbedRef}
+37 -2
View File
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc';
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
/** [Gitea #126] Full screen: controls float over the call instead. */
onExitImmersive?: () => void;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
function CallJoined({ joined, containerRef, onExitImmersive }: CallJoinedProps) {
const callEmbed = useCallEmbed();
if (onExitImmersive) {
return (
<Box grow="Yes" direction="Column" style={{ position: 'relative' }}>
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && (
<ImmersiveCallOverlay callEmbed={callEmbed} onExit={onExitImmersive} />
)}
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
</Box>
);
}
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
@@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
);
}
export function CallView() {
export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useCallEmbedPlacementSync(callContainerRef);
@@ -227,6 +242,26 @@ export function CallView() {
// made it into the call (a hung iframe / blank spinner otherwise).
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
if (onExitImmersive && currentJoined) {
// [Gitea #126] Cover the screen with a transparent layer above the call
// frame: the frame follows the placeholder (useCallEmbedPlacementSync), so
// it goes full screen too; the layer only takes touches on its own
// tap-catcher / controls, so the call underneath stays usable.
return (
<Box
style={{
position: 'fixed',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
paddingTop: 'env(safe-area-inset-top)',
}}
>
<CallJoined joined containerRef={callContainerRef} onExitImmersive={onExitImmersive} />
</Box>
);
}
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
@@ -0,0 +1,129 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { CallEmbed } from '../../plugins/call';
import { CallControls } from './CallControls';
import { callChatAtom } from '../../state/callEmbed';
import { CONTROLS_AUTOHIDE_MS, wantsBrowserFullscreen } from './landscapeCall';
const isStandalone = (): boolean =>
window.matchMedia?.('(display-mode: standalone)').matches ||
(navigator as { standalone?: boolean }).standalone === true;
/**
* [Gitea #126] Full-screen call controls. Taps inside the call frame never
* reach us, so while the controls are hidden a transparent layer covers the
* call: the first tap shows them (and is otherwise swallowed, as in native
* video apps); with them up the layer is gone, so the call stays usable. They
* hide again after a few seconds without a touch. The first tap in an Android
* browser tab also enters the browser's own full screen (hides the address
* bar); it is left again when this unmounts (rotate back, hang up, exit).
*/
export function ImmersiveCallOverlay({
callEmbed,
onExit,
}: {
callEmbed: CallEmbed;
onExit: () => void;
}) {
const [shown, setShown] = useState(false);
const timer = useRef<number | undefined>(undefined);
const enteredFullscreen = useRef(false);
const setChat = useSetAtom(callChatAtom);
const armHide = useCallback(() => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setShown(false), CONTROLS_AUTOHIDE_MS);
}, []);
useEffect(
() => () => {
window.clearTimeout(timer.current);
if (enteredFullscreen.current && document.fullscreenElement === document.documentElement) {
document.exitFullscreen().catch(() => undefined);
}
},
[],
);
const reveal = () => {
if (
wantsBrowserFullscreen({
userAgent: navigator.userAgent,
standalone: isStandalone(),
fullscreenEnabled: !!document.fullscreenEnabled,
alreadyFullscreen: !!document.fullscreenElement,
})
) {
document.documentElement
.requestFullscreen({ navigationUI: 'hide' })
.then(() => {
enteredFullscreen.current = true;
})
.catch(() => undefined);
}
setShown(true);
armHide();
};
if (!shown) {
return (
<div
role="button"
tabIndex={0}
aria-label="Show call controls"
onClick={reveal}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && reveal()}
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
/>
);
}
return (
<Box
direction="Column"
// Any touch on the controls keeps them up a little longer.
onPointerDown={armHide}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto',
paddingLeft: 'env(safe-area-inset-left)',
paddingRight: 'env(safe-area-inset-right)',
paddingBottom: 'env(safe-area-inset-bottom)',
background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0))',
}}
>
<Box
justifyContent="End"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.Message} />}
onClick={() => setChat(true)}
>
<Text size="B300">Chat</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.Cross} />}
onClick={onExit}
style={{ minWidth: toRem(40) }}
>
<Text size="B300">Exit full screen</Text>
</Button>
</Box>
<CallControls callEmbed={callEmbed} />
</Box>
);
}
@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldGoImmersive, wantsBrowserFullscreen } from './landscapeCall';
const base = { landscapePhone: true, joinedHere: true, chatOpen: false, dismissed: false };
test('[#126] full screen only for a joined call on a sideways phone', () => {
assert.equal(shouldGoImmersive(base), true);
assert.equal(shouldGoImmersive({ ...base, landscapePhone: false }), false, 'upright / tablet');
assert.equal(shouldGoImmersive({ ...base, joinedHere: false }), false, 'prescreen / other room');
assert.equal(shouldGoImmersive({ ...base, chatOpen: true }), false, 'chat open');
assert.equal(shouldGoImmersive({ ...base, dismissed: true }), false, 'user exited');
});
test('[#126] browser full screen only in an Android browser tab', () => {
const env = {
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/140 Mobile',
standalone: false,
fullscreenEnabled: true,
alreadyFullscreen: false,
};
assert.equal(wantsBrowserFullscreen(env), true);
assert.equal(wantsBrowserFullscreen({ ...env, standalone: true }), false, 'installed app');
assert.equal(
wantsBrowserFullscreen({
...env,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari',
}),
false,
'iPhone',
);
assert.equal(wantsBrowserFullscreen({ ...env, alreadyFullscreen: true }), false);
assert.equal(wantsBrowserFullscreen({ ...env, fullscreenEnabled: false }), false);
});
+41
View File
@@ -0,0 +1,41 @@
/**
* [Gitea #126] Phone turned sideways in a call: the call takes the whole
* screen. A phone in landscape is ~840px wide (the "Tablet" size class), so
* the trigger is the shape of the screen, not the size class: landscape, at
* most 500px tall, touch input. Tablets and desktops never match.
*/
export const LANDSCAPE_PHONE_QUERY =
'(orientation: landscape) and (max-height: 500px) and (pointer: coarse)';
/** Controls hide again after this long without a touch on them. */
export const CONTROLS_AUTOHIDE_MS = 4000;
export type ImmersiveInputs = {
landscapePhone: boolean;
/** This room's call is the one we are in, and we have joined it. */
joinedHere: boolean;
/** The in-call chat is open (it replaces the call on small screens). */
chatOpen: boolean;
/** "Exit full screen" was pressed since the phone was last upright. */
dismissed: boolean;
};
/** Pure: whether the call should be full screen. */
export const shouldGoImmersive = (i: ImmersiveInputs): boolean =>
i.landscapePhone && i.joinedHere && !i.chatOpen && !i.dismissed;
/**
* Pure: use the browser's own full-screen mode too? Only where an address bar
* takes room: an Android browser tab (not the installed app / iOS, which have
* none or don't support it), when the page isn't already full screen.
*/
export const wantsBrowserFullscreen = (env: {
userAgent: string;
standalone: boolean;
fullscreenEnabled: boolean;
alreadyFullscreen: boolean;
}): boolean =>
/Android/i.test(env.userAgent) &&
!env.standalone &&
env.fullscreenEnabled &&
!env.alreadyFullscreen;
+26 -5
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { threadsListAtom } from '../../state/threadsList';
@@ -57,6 +58,25 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
// [Gitea #126] Phone turned sideways in this room's call: full screen.
const landscapePhone = useMediaQuery(LANDSCAPE_PHONE_QUERY);
const joinedAnywhere = useCallJoined(callEmbed);
const [immersiveDismissed, setImmersiveDismissed] = useState(false);
useEffect(() => {
// "Exit full screen" lasts until the phone is upright again.
if (!landscapePhone) setImmersiveDismissed(false);
}, [landscapePhone]);
const setImmersiveCall = useSetAtom(immersiveCallAtom);
const immersive = shouldGoImmersive({
landscapePhone,
joinedHere: joinedAnywhere && callEmbed?.roomId === room.roomId,
chatOpen: chat,
dismissed: immersiveDismissed,
});
useEffect(() => {
setImmersiveCall(immersive);
return () => setImmersiveCall(false);
}, [immersive, setImmersiveCall]);
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
// rendered as siblings of RoomView, not descendants — share the same computed
// positions instead of falling back to the context default (Gitea #38).
@@ -170,7 +190,8 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}>
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
+5 -1
View File
@@ -1,4 +1,6 @@
import React from 'react';
import { useAtomValue } from 'jotai';
import { immersiveCallAtom } from '../state/callEmbed';
import { useCallEmbed } from '../hooks/useCallEmbed';
import { CallStatus } from '../features/call-status';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
@@ -9,8 +11,10 @@ export function CallStatusRenderer() {
const selectedRoom = useSelectedRoom();
const screenSize = useScreenSizeContext();
// [Gitea #126] The full-screen call has its own controls.
const immersive = useAtomValue(immersiveCallAtom);
if (!callEmbed) return null;
if (!callEmbed || immersive) return null;
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
+3
View File
@@ -18,3 +18,6 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
);
export const callChatAtom = atom<boolean>(false);
/** [Gitea #126] The call is full screen (phone turned sideways). */
export const immersiveCallAtom = atom<boolean>(false);