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);
});
it('keeps controls visible when disabled, paused, interacting, or hovered', () => {
it('keeps controls visible when disabled, paused, or interacting', () => {
let snapshot = createMediaControlsVisibilitySnapshot();
snapshot = send(snapshot, {type: 'controls.hide'});
expect(selectMediaControlsVisible(snapshot, signals({disabled: true}))).toBe(true);
expect(selectMediaControlsVisible(snapshot, signals({isPlaying: false}))).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.hide'});
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', () => {
@@ -1,6 +1,6 @@
// 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 {
disabled: boolean;
@@ -18,19 +18,10 @@ export type MediaControlsVisibilityEvent =
| {type: 'controls.mouseLeave'; signals: MediaControlsVisibilitySignals}
| {type: 'controls.touchStart'; signals: MediaControlsVisibilitySignals};
interface MediaControlsVisibilityContext {
isHovered: boolean;
}
export const mediaControlsVisibilityStateMachine = setup({
types: {} as {
context: MediaControlsVisibilityContext;
events: MediaControlsVisibilityEvent;
},
actions: {
markHovered: assign(() => ({isHovered: true})),
markUnhovered: assign(() => ({isHovered: false})),
},
guards: {
shouldHideOnPointerLeave: ({event}) =>
event.type === 'controls.mouseLeave' && event.signals.isPlaying && !event.signals.isInteracting,
@@ -39,7 +30,6 @@ export const mediaControlsVisibilityStateMachine = setup({
},
}).createMachine({
id: 'mediaControlsVisibility',
context: () => ({isHovered: false}),
initial: 'visible',
states: {
visible: {
@@ -47,11 +37,8 @@ export const mediaControlsVisibilityStateMachine = setup({
'controls.show': {target: 'visible'},
'controls.hide': {target: 'hidden'},
'controls.mouseMove': {target: 'visible'},
'controls.mouseEnter': {target: 'visible', actions: 'markHovered'},
'controls.mouseLeave': [
{target: 'hidden', guard: 'shouldHideOnPointerLeave', actions: 'markUnhovered'},
{target: 'visible', actions: 'markUnhovered'},
],
'controls.mouseEnter': {target: 'visible'},
'controls.mouseLeave': [{target: 'hidden', guard: 'shouldHideOnPointerLeave'}, {target: 'visible'}],
'controls.touchStart': [{target: 'hidden', guard: 'shouldToggleOnTouch'}, {target: 'visible'}],
},
},
@@ -60,8 +47,8 @@ export const mediaControlsVisibilityStateMachine = setup({
'controls.show': {target: 'visible'},
'controls.hide': {target: 'hidden'},
'controls.mouseMove': {target: 'visible'},
'controls.mouseEnter': {target: 'visible', actions: 'markHovered'},
'controls.mouseLeave': {target: 'hidden', actions: 'markUnhovered'},
'controls.mouseEnter': {target: 'visible'},
'controls.mouseLeave': {target: 'hidden'},
'controls.touchStart': {target: 'visible'},
},
},
@@ -96,7 +83,6 @@ export function selectMediaControlsVisible(
signals.disabled ||
!signals.isPlaying ||
signals.isInteracting ||
snapshot.context.isHovered ||
getMediaControlsVisibilityValue(snapshot) === 'visible'
);
}
@@ -8,7 +8,10 @@ import {
selectMediaControlsVisible,
transitionMediaControlsVisibilitySnapshot,
} 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 {
autohideDelay?: number;
@@ -30,7 +33,12 @@ export interface 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>(
() => ({disabled, isPlaying, isInteracting}),
[disabled, isPlaying, isInteracting],
@@ -39,27 +47,56 @@ export function useControlsVisibility(options: UseControlsVisibilityOptions = {}
const send = useCallback((event: MediaControlsVisibilityEvent) => {
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(() => {
send({type: 'controls.show'});
}, [send]);
armHideTimer();
}, [armHideTimer, send]);
const hideControls = useCallback(() => {
clearHideTimer();
send({type: 'controls.hide'});
}, [send]);
}, [clearHideTimer, send]);
const visibilityValue = getMediaControlsVisibilityValue(snapshot);
const handleMouseMove = useCallback(() => {
if (visibilityValue === 'hidden') {
send({type: 'controls.mouseMove'});
}
}, [send, visibilityValue]);
armHideTimer();
}, [armHideTimer, send, visibilityValue]);
const handleMouseEnter = useCallback(() => {
send({type: 'controls.mouseEnter'});
}, [send]);
armHideTimer();
}, [armHideTimer, send]);
const handleMouseLeave = useCallback(() => {
clearHideTimer();
send({type: 'controls.mouseLeave', signals});
}, [send, signals]);
}, [clearHideTimer, send, signals]);
const handleTouchStart = useCallback(() => {
send({type: 'controls.touchStart', signals});
}, [send, signals]);
armHideTimer();
}, [armHideTimer, send, signals]);
const controlsVisible = selectMediaControlsVisible(snapshot, signals);
return {
controlsVisible,