feat(call): full-screen call when a phone is turned sideways (#126) #451
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user