From aa6b7056aed22cf1e6c61c40eb342b57fdfedcc3 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 8 Aug 2024 17:21:47 -0400 Subject: [PATCH 1/2] Show controls on tap/hover on small screens This changes the mobile landscape view to automatically hide the controls, giving more visibility to the video underneath, and show them on tap/hover. --- src/room/InCallView.module.css | 58 ++++++++----------------- src/room/InCallView.tsx | 60 ++++++++++++++++++++++---- src/room/LayoutToggle.tsx | 12 +++++- src/state/CallViewModel.ts | 79 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 51 deletions(-) diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index caf289d7..21662ecf 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -22,31 +22,6 @@ limitations under the License. overflow-y: auto; } -.controlsOverlay { - position: relative; - flex: 1; - display: flex; - flex-direction: column; - overflow: auto; - overflow-inline: hidden; - /* There used to be a contain: strict here, but due to some bugs in Firefox, - this was causing the Z-ordering of modals to glitch out. It can be added back - if those issues appear to be resolved. */ -} - -.centerMessage { - display: flex; - flex: 1; - justify-content: center; - align-items: center; - flex-direction: column; -} - -.centerMessage p { - display: block; - margin-bottom: 0; -} - .header { position: sticky; flex-shrink: 0; @@ -82,6 +57,24 @@ limitations under the License. ); } +.footer.overlay { + position: absolute; + inset-block-end: 0; + inset-inline: 0; + opacity: 1; + transition: opacity 0.15s; +} + +.footer.overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.footer.overlay:has(:focus-visible) { + opacity: 1; + pointer-events: initial; +} + .logo { grid-area: logo; justify-self: start; @@ -120,21 +113,6 @@ limitations under the License. } } -.footerThin { - padding-top: var(--cpd-space-3x); - padding-bottom: var(--cpd-space-5x); -} - -.footerHidden { - display: none; -} - -.footer.overlay { - position: absolute; - inset-block-end: 0; - inset-inline: 0; -} - .fixedGrid { position: absolute; inline-size: 100%; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 581502fc..067218e5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -24,7 +24,9 @@ import { ConnectionState, Room } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, + PointerEvent, PropsWithoutRef, + TouchEvent, forwardRef, useCallback, useEffect, @@ -88,6 +90,8 @@ import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); +const maxTapDurationMs = 400; + export interface ActiveCallProps extends Omit { e2eeSystem: EncryptionSystem; @@ -198,6 +202,38 @@ export const InCallView: FC = ({ const windowMode = useObservableEagerState(vm.windowMode); const layout = useObservableEagerState(vm.layout); const gridMode = useObservableEagerState(vm.gridMode); + const showHeader = useObservableEagerState(vm.showHeader); + const showFooter = useObservableEagerState(vm.showFooter); + + // Ideally we could detect taps by listening for click events and checking + // that the pointerType of the event is "touch", but this isn't yet supported + // in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility + // Instead we have to watch for sufficiently fast touch events. + const touchStart = useRef(null); + const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []); + const onTouchEnd = useCallback(() => { + const start = touchStart.current; + if (start !== null && Date.now() - start <= maxTapDurationMs) + vm.tapScreen(); + touchStart.current = null; + }, [vm]); + const onTouchCancel = useCallback(() => (touchStart.current = null), []); + + // We also need to tell the layout toggle to prevent touch events from + // bubbling up, or else the controls will be dismissed before a change event + // can be registered on the toggle + const onLayoutToggleTouchEnd = useCallback( + (e: TouchEvent) => e.stopPropagation(), + [], + ); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (e.pointerType === "mouse") vm.hoverScreen(); + }, + [vm], + ); + const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); @@ -465,12 +501,10 @@ export const InCallView: FC = ({ footer = (
{!mobile && !hideHeader && (
@@ -488,6 +522,7 @@ export const InCallView: FC = ({ className={styles.layout} layout={gridMode} setLayout={setGridMode} + onTouchEnd={onLayoutToggleTouchEnd} /> )}
@@ -495,9 +530,16 @@ export const InCallView: FC = ({ } return ( -
- {windowMode !== "pip" && - windowMode !== "flat" && +
+ {showHeader && (hideHeader ? ( // Cosmetic header to fill out space while still affecting the bounds // of the grid diff --git a/src/room/LayoutToggle.tsx b/src/room/LayoutToggle.tsx index ed68198c..d0dc1ba0 100644 --- a/src/room/LayoutToggle.tsx +++ b/src/room/LayoutToggle.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ChangeEvent, FC, useCallback } from "react"; +import { ChangeEvent, FC, TouchEvent, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@vector-im/compound-web"; import { @@ -31,9 +31,15 @@ interface Props { layout: Layout; setLayout: (layout: Layout) => void; className?: string; + onTouchEnd?: (e: TouchEvent) => void; } -export const LayoutToggle: FC = ({ layout, setLayout, className }) => { +export const LayoutToggle: FC = ({ + layout, + setLayout, + className, + onTouchEnd, +}) => { const { t } = useTranslation(); const onChange = useCallback( @@ -50,6 +56,7 @@ export const LayoutToggle: FC = ({ layout, setLayout, className }) => { value="spotlight" checked={layout === "spotlight"} onChange={onChange} + onTouchEnd={onTouchEnd} /> @@ -60,6 +67,7 @@ export const LayoutToggle: FC = ({ layout, setLayout, className }) => { value="grid" checked={layout === "grid"} onChange={onChange} + onTouchEnd={onTouchEnd} /> diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d5a551e0..8ed61bbb 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -41,6 +41,7 @@ import { merge, mergeAll, of, + race, sample, scan, shareReplay, @@ -48,6 +49,8 @@ import { startWith, switchAll, switchMap, + switchScan, + take, throttleTime, timer, zip, @@ -71,6 +74,7 @@ import { import { accumulate, finalizeValue } from "../observable-utils"; import { ObservableScope } from "./ObservableScope"; import { duplicateTiles } from "../settings/settings"; +import { isFirefox } from "../Platform"; // How long we wait after a focus switch before showing the real participant // list again @@ -720,6 +724,81 @@ export class CallViewModel extends ViewModel { shareReplay(1), ); + private readonly screenTap = new Subject(); + private readonly screenHover = new Subject(); + private readonly screenUnhover = new Subject(); + + /** + * Callback for when the user taps the call view. + */ + public tapScreen(): void { + this.screenTap.next(); + } + + /** + * Callback for when the user hovers over the call view. + */ + public hoverScreen(): void { + this.screenHover.next(); + } + + /** + * Callback for when the user stops hovering over the call view. + */ + public unhoverScreen(): void { + this.screenUnhover.next(); + } + + public readonly showHeader: Observable = this.windowMode.pipe( + map((mode) => mode !== "pip" && mode !== "flat"), + distinctUntilChanged(), + shareReplay(1), + ); + + public readonly showFooter = this.windowMode.pipe( + switchMap((mode) => { + switch (mode) { + case "pip": + return of(false); + case "normal": + case "narrow": + return of(true); + case "flat": + // Sadly Firefox has some layering glitches that prevent the footer + // from appearing properly. They happen less often if we never hide + // the footer. + if (isFirefox()) return of(true); + // Show/hide the footer in response to interactions + return merge( + this.screenTap.pipe(map(() => "tap" as const)), + this.screenHover.pipe(map(() => "hover" as const)), + ).pipe( + switchScan( + (state, interaction) => + interaction === "tap" + ? state + ? // Toggle visibility on tap + of(false) + : // Hide after a timeout + timer(6000).pipe( + map(() => false), + startWith(true), + ) + : // Show on hover and hide after a timeout + race(timer(3000), this.screenUnhover.pipe(take(1))).pipe( + map(() => false), + startWith(true), + ), + false, + ), + startWith(false), + ); + } + }), + distinctUntilChanged(), + shareReplay(1), + ); + public constructor( // A call is permanently tied to a single Matrix room and LiveKit room private readonly matrixRoom: MatrixRoom, From 6443e911dc9d09ea90b3a5df4ae1cd9d42047f3b Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 9 Aug 2024 10:07:50 -0400 Subject: [PATCH 2/2] Make the breakpoint a bit smaller --- src/state/CallViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 8ed61bbb..58183f16 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -533,7 +533,7 @@ export class CallViewModel extends ViewModel { // Our layouts for flat windows are better at adapting to a small width // than our layouts for narrow windows are at adapting to a small height, // so we give "flat" precedence here - if (height <= 660) return "flat"; + if (height <= 600) return "flat"; if (width <= 600) return "narrow"; return "normal"; }),