From 6e6213a43bdc5d3c5b07b3222196ed0706ae4553 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 24 Oct 2026 22:50:01 -0400 Subject: [PATCH] feat(call): full-screen call when a phone is turned sideways (#126) Landscape, at most 500px tall, touch input (a sideways phone is ~840px wide, the Tablet size class, so the trigger is the screen shape), in this room's joined call, chat closed: the call takes the whole screen. No room header, sidebar or call-status bar; safe-area insets respected. - Taps inside the call frame never reach the app, so while the controls are hidden a transparent layer covers the call: the first tap shows the call bar plus Chat / Exit full screen, which hide again after 4 s without a touch; with them up the call underneath stays usable. - In an Android browser tab the first tap also enters the browser's own full screen (hides the address bar); left again on rotate back, hang up or exit. No effect on iPhone or the installed app. - Exit full screen lasts until the phone is upright again. - The frame is transparent (room wallpaper), so in full screen it gets an opaque base colour under the wallpaper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 11 +- src/app/features/call/CallView.tsx | 39 +++++- .../features/call/ImmersiveCallOverlay.tsx | 129 ++++++++++++++++++ src/app/features/call/landscapeCall.test.ts | 34 +++++ src/app/features/call/landscapeCall.ts | 41 ++++++ src/app/features/room/Room.tsx | 31 ++++- src/app/pages/CallStatusRenderer.tsx | 6 +- src/app/state/callEmbed.ts | 3 + 8 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 src/app/features/call/ImmersiveCallOverlay.tsx create mode 100644 src/app/features/call/landscapeCall.test.ts create mode 100644 src/app/features/call/landscapeCall.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 05365483f..ee75fcb5f 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 5973d9bf6..cbd079a5c 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -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; 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 ( + + + {callEmbed && joined && ( + + )} + {callEmbed && joined && } + + ); + } + return ( @@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) { ); } -export function CallView() { +export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) { const room = useRoom(); const callContainerRef = useRef(null) as React.RefObject; 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 ( + + + + ); + } + return ( + 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(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 ( +
(e.key === 'Enter' || e.key === ' ') && reveal()} + style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }} + /> + ); + } + + return ( + + + + + + + + ); +} diff --git a/src/app/features/call/landscapeCall.test.ts b/src/app/features/call/landscapeCall.test.ts new file mode 100644 index 000000000..c2f62e426 --- /dev/null +++ b/src/app/features/call/landscapeCall.test.ts @@ -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); +}); diff --git a/src/app/features/call/landscapeCall.ts b/src/app/features/call/landscapeCall.ts new file mode 100644 index 000000000..f776a4665 --- /dev/null +++ b/src/app/features/call/landscapeCall.ts @@ -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; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 2526e5119..a13795063 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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() { - {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + {immersive && setImmersiveDismissed(true)} />} + {!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && ( diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx index 2836886e9..bb50618ab 100644 --- a/src/app/pages/CallStatusRenderer.tsx +++ b/src/app/pages/CallStatusRenderer.tsx @@ -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; diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 516a7e0a7..8d905c828 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -18,3 +18,6 @@ export const callEmbedAtom = atom(false); + +/** [Gitea #126] The call is full screen (phone turned sideways). */ +export const immersiveCallAtom = atom(false);