From 44869b0ce442aab13ad501b2ca972621d3b96a84 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:35:58 +0200 Subject: [PATCH] fix(app): stop seeking and hidden tabs from stranding playback (#1874) --- .../embeds/media/VoiceMessagePlayer.tsx | 165 +++-- .../media_player/MediaProgressBar.module.css | 14 + .../VideoPlayerRenderStateMachine.test.ts | 53 ++ .../VideoPlayerRenderStateMachine.ts | 8 +- .../media_player/components/AudioPlayer.tsx | 37 +- .../components/InlineAudioPlayer.tsx | 27 +- .../components/MediaProgressBar.tsx | 114 +++- .../media_player/components/VideoPlayer.tsx | 612 +++++++++++------- .../hooks/useHiddenPlaybackGuard.ts | 41 ++ .../media_player/hooks/useMediaPlayer.ts | 348 +++++----- .../media_player/hooks/useMediaProgress.ts | 218 +++---- .../media_player/hooks/useMetadataPreload.ts | 23 + .../hooks/useVideoDecodability.ts | 63 ++ .../media_player/utils/MediaConstants.ts | 2 - .../media_player/utils/MediaDecodability.ts | 90 +++ .../media_player/utils/MediaSeekUtils.test.ts | 326 +++++++++- .../media_player/utils/MediaSeekUtils.ts | 123 ++++ .../src/features/voice/state/AudioVolume.ts | 59 -- 18 files changed, 1611 insertions(+), 712 deletions(-) create mode 100644 fluxer_app/src/features/voice/components/media_player/hooks/useHiddenPlaybackGuard.ts create mode 100644 fluxer_app/src/features/voice/components/media_player/hooks/useMetadataPreload.ts create mode 100644 fluxer_app/src/features/voice/components/media_player/hooks/useVideoDecodability.ts create mode 100644 fluxer_app/src/features/voice/components/media_player/utils/MediaDecodability.ts delete mode 100644 fluxer_app/src/features/voice/state/AudioVolume.ts diff --git a/fluxer_app/src/features/channel/components/embeds/media/VoiceMessagePlayer.tsx b/fluxer_app/src/features/channel/components/embeds/media/VoiceMessagePlayer.tsx index 1c2ff8e2d..61d5c8ca2 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/VoiceMessagePlayer.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/VoiceMessagePlayer.tsx @@ -16,7 +16,6 @@ import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_p import {useMediaPlayer} from '@app/features/voice/components/media_player/hooks/useMediaPlayer'; import {useMediaProgress} from '@app/features/voice/components/media_player/hooks/useMediaProgress'; import {useMediaVolume} from '@app/features/voice/components/media_player/hooks/useMediaVolume'; -import {AUDIO_PLAYBACK_RATES} from '@app/features/voice/components/media_player/utils/MediaConstants'; import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; @@ -32,6 +31,9 @@ const VOICE_MESSAGE_PLAYER_DESCRIPTOR = msg({ comment: 'Short label in the channel and chat voice message player. Keep it concise.', }); const logger = new Logger('VoiceMessagePlayer'); +const WAVEFORM_BAR_COUNT = 32; +const VOICE_PLAYBACK_RATE_STEPS = [0.75, 1, 1.5, 2] as const; +const voicePlaybackStops = new Map void>(); export interface VoiceMessagePlayerProps extends BaseMediaProps { src: string; @@ -44,12 +46,13 @@ export interface VoiceMessagePlayerProps extends BaseMediaProps { snapshotIndex?: number; } -function decodeWaveform(waveform: string): Array { +export function decodeWaveform(waveform: string): Array { + if (!waveform) return []; try { const decoded = atob(waveform); const values: Array = []; for (let i = 0; i < decoded.length; i++) { - values.push(decoded.charCodeAt(i)); + values.push(decoded.charCodeAt(i) / 255); } return values; } catch (error) { @@ -58,16 +61,36 @@ function decodeWaveform(waveform: string): Array { } } -function normaliseWaveform(values: Array): Array { - if (values.length === 0) return values; - let maxValue = 0; - for (let i = 0; i < values.length; i++) { - if (values[i] > maxValue) { - maxValue = values[i]; - } +export function resampleWaveform(samples: ReadonlyArray, barCount: number): Array { + if (samples.length === barCount) return [...samples]; + if (samples.length < barCount) { + return [...samples, ...new Array(barCount - samples.length).fill(0)]; } - if (maxValue <= 0) return values; - return values.map((value) => Math.min(255, Math.round((value / maxValue) * 255))); + const ratio = samples.length / barCount; + const resampled: Array = []; + let cursor = 0; + while (resampled.length < barCount) { + const boundary = Math.round((resampled.length + 1) * ratio); + let total = 0; + let taken = 0; + for (let i = cursor; i < boundary && i < samples.length; i++) { + total += samples[i]; + taken++; + } + resampled.push(taken > 0 ? total / taken : 0); + cursor = boundary; + } + return resampled; +} + +export function countPlayedBars(progressPercent: number, barCount: number): number { + if (barCount <= 0) return 0; + return Math.max(0, Math.min(barCount, Math.round((progressPercent / 100) * barCount))); +} + +export function waveformBarHeightPercent(value: number): number { + const clamped = Math.max(0, Math.min(1, value)); + return ((22 * clamped + 2) / 24) * 100; } function formatTime(time: number): string { @@ -95,11 +118,13 @@ const VoiceMessagePlayer: React.FC = observer( const messageViewContext = useMaybeMessageViewContext(); const effectiveSrc = buildMediaProxyURL(src); const [hasStarted, setHasStarted] = useState(false); + const [wantsMetadata, setWantsMetadata] = useState(false); const [prePlayCurrentTime, setPrePlayCurrentTime] = useState(0); const pendingPlayRef = useRef(false); const pendingSeekPercentageRef = useRef(null); - const {mediaRef, state, play, toggle, setPlaybackRate} = useMediaPlayer({ - persistVolume: true, + const instanceKeyRef = useRef({}); + const {mediaRef, state, play, pause, toggle, setPlaybackRate} = useMediaPlayer({ + mediaKind: 'voice-message', }); const {currentTime, duration, progress, seekToPercentage} = useMediaProgress({ mediaRef, @@ -130,27 +155,26 @@ const VoiceMessagePlayer: React.FC = observer( }, [message, messageViewContext?.channel, src, contentHash, attachmentId, onDelete, snapshotIndex], ); - const waveformBars = useMemo(() => normaliseWaveform(decodeWaveform(waveform)), [waveform]); - const waveformValues = waveformBars.length > 0 ? waveformBars : new Array(48).fill(128); - const MAX_WAVEFORM_BARS = 32; - const downsampledWaveform = useMemo(() => { - if (waveformValues.length <= MAX_WAVEFORM_BARS) return waveformValues; - const result: Array = []; - const step = waveformValues.length / MAX_WAVEFORM_BARS; - for (let i = 0; i < MAX_WAVEFORM_BARS; i++) { - const start = Math.floor(i * step); - const end = Math.floor((i + 1) * step); - let sum = 0; - for (let j = start; j < end; j++) { - sum += waveformValues[j]; - } - result.push(Math.round(sum / (end - start))); - } - return result; - }, [waveformValues]); + const waveformBars = useMemo(() => resampleWaveform(decodeWaveform(waveform), WAVEFORM_BAR_COUNT), [waveform]); const displayDuration = duration > 0 ? duration : (initialDuration ?? 0); const isLoading = hasStarted && state.isBuffering; const isActive = state.isPlaying || isLoading; + useEffect(() => { + const instanceKey = instanceKeyRef.current; + voicePlaybackStops.set(instanceKey, pause); + return () => { + voicePlaybackStops.delete(instanceKey); + }; + }, [pause]); + useEffect(() => { + if (!state.isPlaying) return; + const instanceKey = instanceKeyRef.current; + for (const [key, stop] of voicePlaybackStops) { + if (key !== instanceKey) { + stop(); + } + } + }, [state.isPlaying]); useEffect(() => { if (hasStarted && pendingPlayRef.current) { const timer = setTimeout(() => { @@ -188,6 +212,9 @@ const VoiceMessagePlayer: React.FC = observer( media.removeEventListener('durationchange', applyPendingSeek); }; }, [hasStarted, mediaRef]); + const handleContainerMouseEnter = useCallback(() => { + setWantsMetadata(true); + }, []); const handlePlayToggle = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -257,28 +284,29 @@ const VoiceMessagePlayer: React.FC = observer( const displayCurrentTime = !hasStarted && displayDuration > 0 ? prePlayCurrentTime : currentTime; const displayProgress = !hasStarted && displayDuration > 0 ? (prePlayCurrentTime / displayDuration) * 100 : progress; + const playedBarCount = countPlayedBars(displayProgress, waveformBars.length); const waveformElements = useMemo( () => - downsampledWaveform.map((value, index) => { - const height = Math.max(8, Math.round((value / 255) * 100)); - const barProgress = ((index + 0.5) / downsampledWaveform.length) * 100; - const isPast = barProgress <= displayProgress; - return ( - - ); - }), - [displayProgress, downsampledWaveform], + waveformBars.map((value, index) => ( + + )), + [playedBarCount, waveformBars], ); + const hasBegunPlayback = hasStarted || displayCurrentTime > 0; + const remainingSeconds = Math.max(0, Math.ceil(displayDuration - displayCurrentTime)); + const readoutText = + displayDuration > 0 ? formatTime(hasBegunPlayback ? remainingSeconds : Math.ceil(displayDuration)) : '--:--'; const isMobile = MobileLayout.enabled; return ( = observer( > ); diff --git a/fluxer_app/src/features/voice/components/media_player/MediaProgressBar.module.css b/fluxer_app/src/features/voice/components/media_player/MediaProgressBar.module.css index 8c2ae15e7..8b68d3946 100644 --- a/fluxer_app/src/features/voice/components/media_player/MediaProgressBar.module.css +++ b/fluxer_app/src/features/voice/components/media_player/MediaProgressBar.module.css @@ -40,6 +40,7 @@ left: 0; top: 0; height: 100%; + width: var(--media-progress-value, 0%); background-color: var(--brand-primary-light); border-radius: var(--radius-full); will-change: width; @@ -48,6 +49,7 @@ .thumb { position: absolute; top: 50%; + left: var(--media-progress-value, 0%); width: 0.75rem; height: 0.75rem; background-color: var(--brand-primary-light); @@ -70,6 +72,18 @@ transition: width 100ms linear; } +.container:not(.isDragging) .thumb { + transition: + opacity 150ms ease, + transform 150ms ease, + left 100ms linear; +} + +.container[data-playhead-predicted='true']:not(.isDragging) .fill, +.container[data-playhead-predicted='true']:not(.isDragging) .thumb { + transition: none; +} + .container:hover .thumb, .container:focus-within .thumb, .isDragging .thumb { diff --git a/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.test.ts b/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.test.ts index a5fe5fb08..b2a3ced67 100644 --- a/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.test.ts +++ b/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.test.ts @@ -14,6 +14,7 @@ function signals(overrides: Partial = {}): VideoPlayer return { autoPlay: false, hasPlayed: false, + wantsMetadata: false, isPlaying: false, isPaused: true, isEnded: false, @@ -99,3 +100,55 @@ describe('VideoPlayerRenderStateMachine', () => { expect(selectVideoPlayerPlayPauseIndicator(snapshot)).toBeNull(); }); }); + +describe('video player preload rungs', () => { + it('holds a resting player at the bottom rung with nothing attached', () => { + const model = selectVideoPlayerRenderModel(signals()); + + expect(model.shouldAttachSource).toBe(false); + expect(model.preloadAttribute).toBe('none'); + }); + + it('climbs to the metadata rung on interest without committing to playback', () => { + const model = selectVideoPlayerRenderModel(signals({wantsMetadata: true})); + + expect(model.shouldAttachSource).toBe(true); + expect(model.preloadAttribute).toBe('metadata'); + expect(model.shouldHideVideo).toBe(true); + expect(model.shouldShowPosterOverlay).toBe(true); + }); + + it('hands the element over to the browser once playback has started', () => { + const model = selectVideoPlayerRenderModel(signals({hasPlayed: true, wantsMetadata: true})); + + expect(model.shouldAttachSource).toBe(true); + expect(model.preloadAttribute).toBeUndefined(); + }); + + it('does not climb back down when playback pauses', () => { + const model = selectVideoPlayerRenderModel( + signals({hasPlayed: true, wantsMetadata: true, isPlaying: false, isPaused: true}), + ); + + expect(model.shouldAttachSource).toBe(true); + expect(model.preloadAttribute).toBeUndefined(); + }); + + it('keeps an autoplaying player off the metadata rung, since it goes straight to playback', () => { + const resting = selectVideoPlayerRenderModel(signals({autoPlay: true})); + const playing = selectVideoPlayerRenderModel(signals({autoPlay: true, hasPlayed: true})); + + expect(resting.preloadAttribute).toBe('none'); + expect(playing.preloadAttribute).toBeUndefined(); + }); + + it('separates the three rungs from one another', () => { + const rungs = [ + selectVideoPlayerRenderModel(signals()).preloadAttribute, + selectVideoPlayerRenderModel(signals({wantsMetadata: true})).preloadAttribute, + selectVideoPlayerRenderModel(signals({hasPlayed: true})).preloadAttribute, + ]; + + expect(rungs).toEqual(['none', 'metadata', undefined]); + }); +}); diff --git a/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.ts b/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.ts index f6b15a379..76a14f5f0 100644 --- a/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.ts +++ b/fluxer_app/src/features/voice/components/media_player/VideoPlayerRenderStateMachine.ts @@ -5,9 +5,12 @@ import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from ' export type VideoPlayerRenderState = 'poster' | 'playing' | 'paused' | 'ended' | 'error'; export type VideoPlayerPlayPauseIndicator = 'play' | 'pause'; +export type VideoPlayerPreloadAttribute = 'none' | 'metadata' | undefined; + export interface VideoPlayerRenderSignals { autoPlay: boolean; hasPlayed: boolean; + wantsMetadata: boolean; isPlaying: boolean; isPaused: boolean; isEnded: boolean; @@ -17,6 +20,7 @@ export interface VideoPlayerRenderSignals { export interface VideoPlayerRenderModel { renderState: VideoPlayerRenderState; shouldAttachSource: boolean; + preloadAttribute: VideoPlayerPreloadAttribute; shouldHideVideo: boolean; shouldShowPosterOverlay: boolean; shouldShowControlsOverlay: boolean; @@ -41,6 +45,7 @@ interface VideoPlayerRenderContext { const DEFAULT_RENDER_SIGNALS: VideoPlayerRenderSignals = { autoPlay: false, hasPlayed: false, + wantsMetadata: false, isPlaying: false, isPaused: true, isEnded: false, @@ -60,7 +65,8 @@ function createRenderModel(signals: VideoPlayerRenderSignals): VideoPlayerRender const renderState = selectRenderState(signals); return { renderState, - shouldAttachSource: signals.hasPlayed, + shouldAttachSource: signals.hasPlayed || signals.wantsMetadata, + preloadAttribute: signals.hasPlayed ? undefined : signals.wantsMetadata ? 'metadata' : 'none', shouldHideVideo: !signals.hasPlayed, shouldShowPosterOverlay: !signals.hasPlayed && !signals.autoPlay, shouldShowControlsOverlay: signals.hasPlayed || signals.autoPlay, diff --git a/fluxer_app/src/features/voice/components/media_player/components/AudioPlayer.tsx b/fluxer_app/src/features/voice/components/media_player/components/AudioPlayer.tsx index eee563f0c..de8713082 100644 --- a/fluxer_app/src/features/voice/components/media_player/components/AudioPlayer.tsx +++ b/fluxer_app/src/features/voice/components/media_player/components/AudioPlayer.tsx @@ -9,11 +9,12 @@ import {MediaProgressBar} from '@app/features/voice/components/media_player/comp import {MediaVolumeControl} from '@app/features/voice/components/media_player/components/MediaVolumeControl'; import {useMediaPlayer} from '@app/features/voice/components/media_player/hooks/useMediaPlayer'; import {useMediaProgress} from '@app/features/voice/components/media_player/hooks/useMediaProgress'; +import {useMediaVolume} from '@app/features/voice/components/media_player/hooks/useMediaVolume'; +import {useMetadataPreload} from '@app/features/voice/components/media_player/hooks/useMetadataPreload'; import { AUDIO_PLAYBACK_RATES, DEFAULT_SEEK_AMOUNT, } from '@app/features/voice/components/media_player/utils/MediaConstants'; -import AudioVolume from '@app/features/voice/state/AudioVolume'; import {formatDuration} from '@fluxer/date_utils/src/DateDuration'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; @@ -56,11 +57,9 @@ export function AudioPlayer({ const [prePlayCurrentTime, setPrePlayCurrentTime] = useState(0); const pendingPlayRef = useRef(false); const pendingSeekPercentageRef = useRef(null); - const [volume, setVolumeState] = useState(AudioVolume.volume); - const [isMuted, setIsMutedState] = useState(AudioVolume.isMuted); const {mediaRef, state, play, toggle, seekRelative, setPlaybackRate} = useMediaPlayer({ + mediaKind: 'audio', autoPlay, - persistVolume: false, persistPlaybackRate: true, }); const { @@ -76,27 +75,8 @@ export function AudioPlayer({ mediaRef, initialDuration, }); - useEffect(() => { - const media = mediaRef.current; - if (!media) return; - media.volume = volume; - media.muted = isMuted; - }, [mediaRef, volume, isMuted]); - const setVolume = useCallback( - (newVolume: number) => { - const clamped = Math.max(0, Math.min(1, newVolume)); - setVolumeState(clamped); - AudioVolume.setVolume(clamped); - if (isMuted && clamped > 0) { - setIsMutedState(false); - } - }, - [isMuted], - ); - const toggleMute = useCallback(() => { - setIsMutedState((prev) => !prev); - AudioVolume.toggleMute(); - }, []); + const {volume, isMuted, setVolume, toggleMute} = useMediaVolume({mediaRef}); + const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted); useEffect(() => { if (hasStarted && pendingPlayRef.current) { const timer = setTimeout(() => { @@ -222,13 +202,14 @@ export function AudioPlayer({
{/* biome-ignore lint/a11y/useMediaCaption: voice/audio attachments have no caption track source */}