diff --git a/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.test.ts b/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.test.ts index e93bc67be..0167ae058 100644 --- a/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.test.ts +++ b/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.test.ts @@ -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', () => { diff --git a/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.ts b/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.ts index d7c4fec6b..d982fb8e1 100644 --- a/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.ts +++ b/fluxer_app/src/features/voice/components/media_player/MediaControlsVisibilityStateMachine.ts @@ -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' ); } diff --git a/fluxer_app/src/features/voice/components/media_player/hooks/useControlsVisibility.ts b/fluxer_app/src/features/voice/components/media_player/hooks/useControlsVisibility.ts index dbc8c7e85..ffc881cdf 100644 --- a/fluxer_app/src/features/voice/components/media_player/hooks/useControlsVisibility.ts +++ b/fluxer_app/src/features/voice/components/media_player/hooks/useControlsVisibility.ts @@ -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( () => ({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 | 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,