Merge pull request #2548 from robintown/hide-controls
Show controls on tap/hover on small screens
This commit is contained in:
@@ -22,31 +22,6 @@ limitations under the License.
|
|||||||
overflow-y: auto;
|
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 {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
flex-shrink: 0;
|
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 {
|
.logo {
|
||||||
grid-area: logo;
|
grid-area: logo;
|
||||||
justify-self: start;
|
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 {
|
.fixedGrid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { ConnectionState, Room } from "livekit-client";
|
|||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import {
|
import {
|
||||||
FC,
|
FC,
|
||||||
|
PointerEvent,
|
||||||
PropsWithoutRef,
|
PropsWithoutRef,
|
||||||
|
TouchEvent,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -88,6 +90,8 @@ import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
|||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
|
const maxTapDurationMs = 400;
|
||||||
|
|
||||||
export interface ActiveCallProps
|
export interface ActiveCallProps
|
||||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||||
e2eeSystem: EncryptionSystem;
|
e2eeSystem: EncryptionSystem;
|
||||||
@@ -198,6 +202,38 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const windowMode = useObservableEagerState(vm.windowMode);
|
const windowMode = useObservableEagerState(vm.windowMode);
|
||||||
const layout = useObservableEagerState(vm.layout);
|
const layout = useObservableEagerState(vm.layout);
|
||||||
const gridMode = useObservableEagerState(vm.gridMode);
|
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 [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
@@ -473,12 +509,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
footer = (
|
footer = (
|
||||||
<div
|
<div
|
||||||
ref={footerRef}
|
ref={footerRef}
|
||||||
className={classNames(
|
className={classNames(styles.footer, {
|
||||||
styles.footer,
|
[styles.overlay]: windowMode === "flat",
|
||||||
!showControls &&
|
[styles.hidden]: !showFooter || (!showControls && hideHeader),
|
||||||
(hideHeader ? styles.footerHidden : styles.footerThin),
|
})}
|
||||||
{ [styles.overlay]: windowMode === "flat" },
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{!mobile && !hideHeader && (
|
{!mobile && !hideHeader && (
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
@@ -496,6 +530,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
className={styles.layout}
|
className={styles.layout}
|
||||||
layout={gridMode}
|
layout={gridMode}
|
||||||
setLayout={setGridMode}
|
setLayout={setGridMode}
|
||||||
|
onTouchEnd={onLayoutToggleTouchEnd}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -503,9 +538,16 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.inRoom} ref={containerRef}>
|
<div
|
||||||
{windowMode !== "pip" &&
|
className={styles.inRoom}
|
||||||
windowMode !== "flat" &&
|
ref={containerRef}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={onTouchCancel}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerOut={onPointerOut}
|
||||||
|
>
|
||||||
|
{showHeader &&
|
||||||
(hideHeader ? (
|
(hideHeader ? (
|
||||||
// Cosmetic header to fill out space while still affecting the bounds
|
// Cosmetic header to fill out space while still affecting the bounds
|
||||||
// of the grid
|
// of the grid
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChangeEvent, FC, useCallback } from "react";
|
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
@@ -31,9 +31,15 @@ interface Props {
|
|||||||
layout: Layout;
|
layout: Layout;
|
||||||
setLayout: (layout: Layout) => void;
|
setLayout: (layout: Layout) => void;
|
||||||
className?: string;
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
@@ -50,6 +56,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
|||||||
value="spotlight"
|
value="spotlight"
|
||||||
checked={layout === "spotlight"}
|
checked={layout === "spotlight"}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||||
@@ -60,6 +67,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
|||||||
value="grid"
|
value="grid"
|
||||||
checked={layout === "grid"}
|
checked={layout === "grid"}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<GridIcon aria-hidden width={24} height={24} />
|
<GridIcon aria-hidden width={24} height={24} />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
merge,
|
merge,
|
||||||
mergeAll,
|
mergeAll,
|
||||||
of,
|
of,
|
||||||
|
race,
|
||||||
sample,
|
sample,
|
||||||
scan,
|
scan,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
startWith,
|
startWith,
|
||||||
switchAll,
|
switchAll,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
switchScan,
|
||||||
|
take,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
zip,
|
zip,
|
||||||
@@ -71,6 +74,7 @@ import {
|
|||||||
import { accumulate, finalizeValue } from "../observable-utils";
|
import { accumulate, finalizeValue } from "../observable-utils";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import { duplicateTiles } from "../settings/settings";
|
import { duplicateTiles } from "../settings/settings";
|
||||||
|
import { isFirefox } from "../Platform";
|
||||||
|
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
@@ -529,7 +533,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Our layouts for flat windows are better at adapting to a small width
|
// 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,
|
// than our layouts for narrow windows are at adapting to a small height,
|
||||||
// so we give "flat" precedence here
|
// so we give "flat" precedence here
|
||||||
if (height <= 660) return "flat";
|
if (height <= 600) return "flat";
|
||||||
if (width <= 600) return "narrow";
|
if (width <= 600) return "narrow";
|
||||||
return "normal";
|
return "normal";
|
||||||
}),
|
}),
|
||||||
@@ -739,6 +743,81 @@ export class CallViewModel extends ViewModel {
|
|||||||
shareReplay(1),
|
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(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
private readonly matrixRoom: MatrixRoom,
|
private readonly matrixRoom: MatrixRoom,
|
||||||
|
|||||||
Reference in New Issue
Block a user