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.
This commit is contained in:
Robin 2024-08-08 17:21:47 -04:00
parent 6f03653532
commit aa6b7056ae
4 changed files with 158 additions and 51 deletions

View File

@ -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%;

View File

@ -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<InCallViewProps, "livekitRoom" | "connState"> {
e2eeSystem: EncryptionSystem;
@ -198,6 +202,38 @@ export const InCallView: FC<InCallViewProps> = ({
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<number | null>(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<InCallViewProps> = ({
footer = (
<div
ref={footerRef}
className={classNames(
styles.footer,
!showControls &&
(hideHeader ? styles.footerHidden : styles.footerThin),
{ [styles.overlay]: windowMode === "flat" },
)}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader),
})}
>
{!mobile && !hideHeader && (
<div className={styles.logo}>
@ -488,6 +522,7 @@ export const InCallView: FC<InCallViewProps> = ({
className={styles.layout}
layout={gridMode}
setLayout={setGridMode}
onTouchEnd={onLayoutToggleTouchEnd}
/>
)}
</div>
@ -495,9 +530,16 @@ export const InCallView: FC<InCallViewProps> = ({
}
return (
<div className={styles.inRoom} ref={containerRef}>
{windowMode !== "pip" &&
windowMode !== "flat" &&
<div
className={styles.inRoom}
ref={containerRef}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
{showHeader &&
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid

View File

@ -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<Props> = ({ layout, setLayout, className }) => {
export const LayoutToggle: FC<Props> = ({
layout,
setLayout,
className,
onTouchEnd,
}) => {
const { t } = useTranslation();
const onChange = useCallback(
@ -50,6 +56,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
value="spotlight"
checked={layout === "spotlight"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<SpotlightIcon aria-hidden width={24} height={24} />
@ -60,6 +67,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
value="grid"
checked={layout === "grid"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<GridIcon aria-hidden width={24} height={24} />

View File

@ -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<void>();
private readonly screenHover = new Subject<void>();
private readonly screenUnhover = new Subject<void>();
/**
* 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<boolean> = 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,