fix(app): hide the video controls again after a few idle seconds (#1846)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:30:01 +02:00
1 parent aa1c636cc2
commit 5cb893245f
3 files changed
+59 -29

No files matched your search

@@ -42,17 +42,24 @@ describe('MediaControlsVisibilityStateMachine', () => {
expect(selectMediaControlsVisible(snapshot, signals())).toBe(true); expect(selectMediaControlsVisible(snapshot, signals())).toBe(true);
}); });
it('keeps controls visible when disabled, paused, interacting, or hovered', () => { it('keeps controls visible when disabled, paused, or interacting', () => {
let snapshot = createMediaControlsVisibilitySnapshot(); let snapshot = createMediaControlsVisibilitySnapshot();
snapshot = send(snapshot, {type: 'controls.hide'}); snapshot = send(snapshot, {type: 'controls.hide'});
expect(selectMediaControlsVisible(snapshot, signals({disabled: true}))).toBe(true); expect(selectMediaControlsVisible(snapshot, signals({disabled: true}))).toBe(true);
expect(selectMediaControlsVisible(snapshot, signals({isPlaying: false}))).toBe(true); expect(selectMediaControlsVisible(snapshot, signals({isPlaying: false}))).toBe(true);
expect(selectMediaControlsVisible(snapshot, signals({isInteracting: true}))).toBe(true); expect(selectMediaControlsVisible(snapshot, signals({isInteracting: true}))).toBe(true);
});
it('lets a resting pointer inside the player still lose the controls', () => {
let snapshot = createMediaControlsVisibilitySnapshot();
snapshot = send(snapshot, {type: 'controls.mouseEnter'}); snapshot = send(snapshot, {type: 'controls.mouseEnter'});
snapshot = send(snapshot, {type: 'controls.hide'});
expect(selectMediaControlsVisible(snapshot, signals())).toBe(true); expect(selectMediaControlsVisible(snapshot, signals())).toBe(true);
snapshot = send(snapshot, {type: 'controls.hide'});
expect(getMediaControlsVisibilityValue(snapshot)).toBe('hidden');
expect(selectMediaControlsVisible(snapshot, signals())).toBe(false);
}); });
it('hides on pointer leave only while playback is active and not interacting', () => { it('hides on pointer leave only while playback is active and not interacting', () => {
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate'; import {getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
export interface MediaControlsVisibilitySignals { export interface MediaControlsVisibilitySignals {
disabled: boolean; disabled: boolean;
@@ -18,19 +18,10 @@ export type MediaControlsVisibilityEvent =
| {type: 'controls.mouseLeave'; signals: MediaControlsVisibilitySignals} | {type: 'controls.mouseLeave'; signals: MediaControlsVisibilitySignals}
| {type: 'controls.touchStart'; signals: MediaControlsVisibilitySignals}; | {type: 'controls.touchStart'; signals: MediaControlsVisibilitySignals};
interface MediaControlsVisibilityContext {
isHovered: boolean;
}
export const mediaControlsVisibilityStateMachine = setup({ export const mediaControlsVisibilityStateMachine = setup({
types: {} as { types: {} as {
context: MediaControlsVisibilityContext;
events: MediaControlsVisibilityEvent; events: MediaControlsVisibilityEvent;
}, },
actions: {
markHovered: assign(() => ({isHovered: true})),
markUnhovered: assign(() => ({isHovered: false})),
},
guards: { guards: {
shouldHideOnPointerLeave: ({event}) => shouldHideOnPointerLeave: ({event}) =>
event.type === 'controls.mouseLeave' && event.signals.isPlaying && !event.signals.isInteracting, event.type === 'controls.mouseLeave' && event.signals.isPlaying && !event.signals.isInteracting,
@@ -39,7 +30,6 @@ export const mediaControlsVisibilityStateMachine = setup({
}, },
}).createMachine({ }).createMachine({
id: 'mediaControlsVisibility', id: 'mediaControlsVisibility',
context: () => ({isHovered: false}),
initial: 'visible', initial: 'visible',
states: { states: {
visible: { visible: {
@@ -47,11 +37,8 @@ export const mediaControlsVisibilityStateMachine = setup({
'controls.show': {target: 'visible'}, 'controls.show': {target: 'visible'},
'controls.hide': {target: 'hidden'}, 'controls.hide': {target: 'hidden'},
'controls.mouseMove': {target: 'visible'}, 'controls.mouseMove': {target: 'visible'},
'controls.mouseEnter': {target: 'visible', actions: 'markHovered'}, 'controls.mouseEnter': {target: 'visible'},
'controls.mouseLeave': [ 'controls.mouseLeave': [{target: 'hidden', guard: 'shouldHideOnPointerLeave'}, {target: 'visible'}],
{target: 'hidden', guard: 'shouldHideOnPointerLeave', actions: 'markUnhovered'},
{target: 'visible', actions: 'markUnhovered'},
],
'controls.touchStart': [{target: 'hidden', guard: 'shouldToggleOnTouch'}, {target: 'visible'}], 'controls.touchStart': [{target: 'hidden', guard: 'shouldToggleOnTouch'}, {target: 'visible'}],
}, },
}, },
@@ -60,8 +47,8 @@ export const mediaControlsVisibilityStateMachine = setup({
'controls.show': {target: 'visible'}, 'controls.show': {target: 'visible'},
'controls.hide': {target: 'hidden'}, 'controls.hide': {target: 'hidden'},
'controls.mouseMove': {target: 'visible'}, 'controls.mouseMove': {target: 'visible'},
'controls.mouseEnter': {target: 'visible', actions: 'markHovered'}, 'controls.mouseEnter': {target: 'visible'},
'controls.mouseLeave': {target: 'hidden', actions: 'markUnhovered'}, 'controls.mouseLeave': {target: 'hidden'},
'controls.touchStart': {target: 'visible'}, 'controls.touchStart': {target: 'visible'},
}, },
}, },
@@ -96,7 +83,6 @@ export function selectMediaControlsVisible(
signals.disabled || signals.disabled ||
!signals.isPlaying || !signals.isPlaying ||
signals.isInteracting || signals.isInteracting ||
snapshot.context.isHovered ||
getMediaControlsVisibilityValue(snapshot) === 'visible' getMediaControlsVisibilityValue(snapshot) === 'visible'
); );
} }
@@ -8,7 +8,10 @@ import {
selectMediaControlsVisible, selectMediaControlsVisible,
transitionMediaControlsVisibilitySnapshot, transitionMediaControlsVisibilitySnapshot,
} from '@app/features/voice/components/media_player/MediaControlsVisibilityStateMachine'; } from '@app/features/voice/components/media_player/MediaControlsVisibilityStateMachine';
import {useCallback, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
export const CONTROLS_AUTOHIDE_DELAY_MS = 3000;
export const CONTROLS_AUTOHIDE_FULLSCREEN_DELAY_MS = 1000;
interface UseControlsVisibilityOptions { interface UseControlsVisibilityOptions {
autohideDelay?: number; autohideDelay?: number;
@@ -30,7 +33,12 @@ export interface UseControlsVisibilityReturn {
} }
export function useControlsVisibility(options: UseControlsVisibilityOptions = {}): UseControlsVisibilityReturn { export function useControlsVisibility(options: UseControlsVisibilityOptions = {}): UseControlsVisibilityReturn {
const {disabled = false, isPlaying = false, isInteracting = false} = options; const {
autohideDelay = CONTROLS_AUTOHIDE_DELAY_MS,
disabled = false,
isPlaying = false,
isInteracting = false,
} = options;
const signals = useMemo<MediaControlsVisibilitySignals>( const signals = useMemo<MediaControlsVisibilitySignals>(
() => ({disabled, isPlaying, isInteracting}), () => ({disabled, isPlaying, isInteracting}),
[disabled, isPlaying, isInteracting], [disabled, isPlaying, isInteracting],
@@ -39,27 +47,56 @@ export function useControlsVisibility(options: UseControlsVisibilityOptions = {}
const send = useCallback((event: MediaControlsVisibilityEvent) => { const send = useCallback((event: MediaControlsVisibilityEvent) => {
setSnapshot((currentSnapshot) => transitionMediaControlsVisibilitySnapshot(currentSnapshot, event)); setSnapshot((currentSnapshot) => transitionMediaControlsVisibilitySnapshot(currentSnapshot, event));
}, []); }, []);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current === null) return;
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}, []);
const armHideTimer = useCallback(() => {
clearHideTimer();
if (disabled || !isPlaying || isInteracting) return;
hideTimerRef.current = setTimeout(() => {
hideTimerRef.current = null;
send({type: 'controls.hide'});
}, autohideDelay);
}, [autohideDelay, clearHideTimer, disabled, isInteracting, isPlaying, send]);
useEffect(() => {
if (disabled || !isPlaying || isInteracting) {
clearHideTimer();
send({type: 'controls.show'});
return clearHideTimer;
}
armHideTimer();
return clearHideTimer;
}, [armHideTimer, clearHideTimer, disabled, isInteracting, isPlaying, send]);
const showControls = useCallback(() => { const showControls = useCallback(() => {
send({type: 'controls.show'}); send({type: 'controls.show'});
}, [send]); armHideTimer();
}, [armHideTimer, send]);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
clearHideTimer();
send({type: 'controls.hide'}); send({type: 'controls.hide'});
}, [send]); }, [clearHideTimer, send]);
const visibilityValue = getMediaControlsVisibilityValue(snapshot); const visibilityValue = getMediaControlsVisibilityValue(snapshot);
const handleMouseMove = useCallback(() => { const handleMouseMove = useCallback(() => {
if (visibilityValue === 'hidden') { if (visibilityValue === 'hidden') {
send({type: 'controls.mouseMove'}); send({type: 'controls.mouseMove'});
} }
}, [send, visibilityValue]); armHideTimer();
}, [armHideTimer, send, visibilityValue]);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
send({type: 'controls.mouseEnter'}); send({type: 'controls.mouseEnter'});
}, [send]); armHideTimer();
}, [armHideTimer, send]);
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
clearHideTimer();
send({type: 'controls.mouseLeave', signals}); send({type: 'controls.mouseLeave', signals});
}, [send, signals]); }, [clearHideTimer, send, signals]);
const handleTouchStart = useCallback(() => { const handleTouchStart = useCallback(() => {
send({type: 'controls.touchStart', signals}); send({type: 'controls.touchStart', signals});
}, [send, signals]); armHideTimer();
}, [armHideTimer, send, signals]);
const controlsVisible = selectMediaControlsVisible(snapshot, signals); const controlsVisible = selectMediaControlsVisible(snapshot, signals);
return { return {
controlsVisible, controlsVisible,