mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-15 00:04:59 +08:00
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:
parent
6f03653532
commit
aa6b7056ae
@ -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%;
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user