fix(app): stop seeking and hidden tabs from stranding playback (#1874)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:35:58 +02:00
1 parent 213dd53ee8
commit 44869b0ce4
18 files changed
+1611 -712

No files matched your search

@@ -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<object, () => void>();
export interface VoiceMessagePlayerProps extends BaseMediaProps {
src: string;
@@ -44,12 +46,13 @@ export interface VoiceMessagePlayerProps extends BaseMediaProps {
snapshotIndex?: number;
}
function decodeWaveform(waveform: string): Array<number> {
export function decodeWaveform(waveform: string): Array<number> {
if (!waveform) return [];
try {
const decoded = atob(waveform);
const values: Array<number> = [];
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<number> {
}
}
function normaliseWaveform(values: Array<number>): Array<number> {
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<number>, barCount: number): Array<number> {
if (samples.length === barCount) return [...samples];
if (samples.length < barCount) {
return [...samples, ...new Array<number>(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<number> = [];
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<VoiceMessagePlayerProps> = 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<number | null>(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<VoiceMessagePlayerProps> = 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<number> = [];
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<VoiceMessagePlayerProps> = 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<VoiceMessagePlayerProps> = 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 (
<span
key={`waveform-${index}`}
className={clsx(styles.waveformBar, isPast && styles.waveformBarPast)}
style={{height: `${height}%`}}
data-flx="channel.embeds.media.voice-message-player.waveform-elements.waveform-bar"
/>
);
}),
[displayProgress, downsampledWaveform],
waveformBars.map((value, index) => (
<span
key={`waveform-${index}`}
className={clsx(styles.waveformBar, index < playedBarCount && styles.waveformBarPast)}
style={{height: `${waveformBarHeightPercent(value)}%`}}
data-flx="channel.embeds.media.voice-message-player.waveform-elements.waveform-bar"
/>
)),
[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 (
<motion.div
className={clsx(styles.container, isActive && styles.containerActive)}
onContextMenu={handleContextMenu}
onMouseEnter={handleContainerMouseEnter}
role="region"
aria-label={i18n._(VOICE_MESSAGE_PLAYER_DESCRIPTOR)}
initial={false}
@@ -290,8 +318,8 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
>
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={hasStarted ? effectiveSrc : undefined}
preload="none"
src={hasStarted || wantsMetadata ? effectiveSrc : undefined}
preload={wantsMetadata ? 'metadata' : 'none'}
data-flx="channel.embeds.media.voice-message-player.audio"
>
<track kind="captions" data-flx="channel.embeds.media.voice-message-player.track" />
@@ -351,6 +379,9 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
aria-valuenow={Math.round(displayProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={
displayDuration > 0 ? `${formatTime(displayCurrentTime)} / ${formatTime(displayDuration)}` : undefined
}
data-flx="channel.embeds.media.voice-message-player.waveform-container.waveform-click"
>
{waveformElements}
@@ -366,28 +397,26 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2, ease: 'easeOut'}}
data-flx="channel.embeds.media.voice-message-player.timestamp"
>
{formatTime(displayCurrentTime)} / {formatTime(displayDuration)}
{readoutText}
</motion.span>
<MediaPlaybackRate
rate={state.playbackRate}
onRateChange={setPlaybackRate}
rates={VOICE_PLAYBACK_RATE_STEPS}
size="small"
className={styles.speedControl}
data-flx="channel.embeds.media.voice-message-player.speed-control"
/>
{!isMobile && (
<>
<MediaPlaybackRate
rate={state.playbackRate}
onRateChange={setPlaybackRate}
rates={AUDIO_PLAYBACK_RATES}
size="small"
className={styles.speedControl}
data-flx="channel.embeds.media.voice-message-player.speed-control"
/>
<MediaVerticalVolumeControl
volume={volume}
isMuted={isMuted}
onVolumeChange={setVolume}
onToggleMute={toggleMute}
iconSize={16}
className={styles.volumeControl}
data-flx="channel.embeds.media.voice-message-player.volume-control"
/>
</>
<MediaVerticalVolumeControl
volume={volume}
isMuted={isMuted}
onVolumeChange={setVolume}
onToggleMute={toggleMute}
iconSize={16}
className={styles.volumeControl}
data-flx="channel.embeds.media.voice-message-player.volume-control"
/>
)}
</motion.div>
);
@@ -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 {
@@ -14,6 +14,7 @@ function signals(overrides: Partial<VideoPlayerRenderSignals> = {}): 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]);
});
});
@@ -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,
@@ -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<number | null>(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({
<div
ref={containerRef}
className={clsx(styles.container, isMobile && styles.mobile, className)}
onPointerEnter={escalateToMetadata}
data-flx="voice.media-player.audio-player.container"
>
{/* biome-ignore lint/a11y/useMediaCaption: voice/audio attachments have no caption track source */}
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={hasStarted ? src : undefined}
preload="none"
src={sourceAttribute}
preload={preloadAttribute}
data-flx="voice.media-player.audio-player.audio"
/>
{title && (
@@ -242,6 +223,8 @@ export function AudioPlayer({
buffered={buffered}
currentTime={displayCurrentTime}
duration={displayDuration}
mediaRef={mediaRef}
isPlaying={state.isPlaying}
onSeek={handleSeek}
onSeekPreview={handleSeekPreview}
onSeekStart={startSeeking}
@@ -17,8 +17,10 @@ import {MediaVolumeControl} from '@app/features/voice/components/media_player/co
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 styles from '@app/features/voice/components/media_player/InlineAudioPlayer.module.css';
import {AUDIO_PLAYBACK_RATES} from '@app/features/voice/components/media_player/utils/MediaConstants';
import {detachMediaElementSource} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import {formatDuration} from '@fluxer/date_utils/src/DateDuration';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -26,7 +28,7 @@ import {DownloadSimpleIcon, PauseIcon, PlayIcon, StarIcon} from '@phosphor-icons
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const AUDIO_PLAYER_DESCRIPTOR = msg({
message: 'Audio player',
@@ -95,7 +97,7 @@ export function InlineAudioPlayer({
const [hasStarted, setHasStarted] = useState(false);
const pendingPlayRef = useRef(false);
const {mediaRef, state, play, toggle, setPlaybackRate} = useMediaPlayer({
persistVolume: true,
mediaKind: 'audio',
});
const {
currentTime,
@@ -113,7 +115,14 @@ export function InlineAudioPlayer({
const {volume, isMuted, setVolume, toggleMute} = useMediaVolume({
mediaRef,
});
const displayDuration = initialDuration ?? duration;
const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted);
const displayDuration = duration > 0 ? duration : (initialDuration ?? 0);
useLayoutEffect(() => {
const media = mediaRef.current;
return () => {
detachMediaElementSource(media ?? mediaRef.current);
};
}, [mediaRef, isMobile]);
const isLoading = hasStarted && state.isBuffering;
const isActive = state.isPlaying || isLoading;
const {name: fileName, extension: fileExtension} = extension
@@ -194,12 +203,13 @@ export function InlineAudioPlayer({
backgroundColor: isActive ? 'var(--brand-primary)' : 'var(--background-secondary)',
}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2, ease: 'easeOut'}}
onPointerEnter={escalateToMetadata}
data-flx="voice.media-player.inline-audio-player.mobile-container.context-menu"
>
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={hasStarted ? src : undefined}
preload="none"
src={sourceAttribute}
preload={preloadAttribute}
data-flx="voice.media-player.inline-audio-player.audio"
>
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track" />
@@ -320,12 +330,13 @@ export function InlineAudioPlayer({
onContextMenu={onContextMenu}
role="group"
aria-label={i18n._(AUDIO_PLAYER_DESCRIPTOR)}
onPointerEnter={escalateToMetadata}
data-flx="voice.media-player.inline-audio-player.container.context-menu"
>
<audio
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={hasStarted ? src : undefined}
preload="none"
src={sourceAttribute}
preload={preloadAttribute}
data-flx="voice.media-player.inline-audio-player.audio--2"
>
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track--2" />
@@ -385,6 +396,8 @@ export function InlineAudioPlayer({
buffered={buffered}
currentTime={currentTime}
duration={displayDuration}
mediaRef={mediaRef}
isPlaying={state.isPlaying}
onSeek={handleSeek}
onSeekPreview={handleSeekPreview}
onSeekStart={startSeeking}
@@ -4,15 +4,25 @@ import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTool
import {useSliderTooltip} from '@app/features/ui/components/slider/useSliderTooltip';
import styles from '@app/features/voice/components/media_player/MediaProgressBar.module.css';
import {
type BufferedSpanFraction,
clampPercentage,
EMPTY_BUFFERED_SPANS,
getEffectiveMediaDuration,
getSeekPercentageFromClientX,
stepPlayheadPrediction,
} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import {formatDuration} from '@fluxer/date_utils/src/DateDuration';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
export const PLAYHEAD_CSS_PROPERTY = '--media-progress-value';
export interface MediaElementRef {
readonly current: HTMLMediaElement | null;
}
const MEDIA_PROGRESS_DESCRIPTOR = msg({
message: 'Media progress',
@@ -20,7 +30,7 @@ const MEDIA_PROGRESS_DESCRIPTOR = msg({
});
export interface MediaProgressBarProps {
progress: number;
buffered?: number;
buffered?: ReadonlyArray<BufferedSpanFraction>;
currentTime?: number;
duration?: number;
isSeeking?: boolean;
@@ -37,11 +47,13 @@ export interface MediaProgressBarProps {
className?: string;
compact?: boolean;
ariaLabel?: string;
mediaRef?: MediaElementRef;
isPlaying?: boolean;
}
export function MediaProgressBar({
progress,
buffered = 0,
buffered = EMPTY_BUFFERED_SPANS,
currentTime = 0,
duration = 0,
onSeek,
@@ -57,6 +69,8 @@ export function MediaProgressBar({
className,
compact = false,
ariaLabel,
mediaRef,
isPlaying = false,
}: MediaProgressBarProps) {
const {i18n} = useLingui();
const containerRef = useRef<HTMLDivElement>(null);
@@ -278,6 +292,74 @@ export function MediaProgressBar({
);
const displayProgress = clampPercentage(isDragging && hoverPosition !== null ? hoverPosition : progress);
const displayCurrentTime = duration > 0 ? (displayProgress / 100) * duration : currentTime;
const committedProgressRef = useRef(displayProgress);
const durationRef = useRef(duration);
useLayoutEffect(() => {
committedProgressRef.current = displayProgress;
durationRef.current = duration;
containerRef.current?.style.setProperty(PLAYHEAD_CSS_PROPERTY, `${displayProgress}%`);
});
const shouldPredictPlayhead = isPlaying && !isDragging && mediaRef != null;
useEffect(() => {
if (!shouldPredictPlayhead) return;
const container = containerRef.current;
const media = mediaRef?.current;
if (!container || !media) return;
let frame: number | null = null;
let predictedSeconds: number | null = null;
let lastFrameMs: number | null = null;
const writeCommittedValue = () => {
container.style.setProperty(PLAYHEAD_CSS_PROPERTY, `${committedProgressRef.current}%`);
};
const stop = () => {
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
predictedSeconds = null;
lastFrameMs = null;
writeCommittedValue();
};
const tick = () => {
frame = null;
if (document.hidden) {
stop();
return;
}
const nowMs = performance.now();
const elapsedSeconds = lastFrameMs === null ? 0 : (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
predictedSeconds = stepPlayheadPrediction({
predictedSeconds,
actualSeconds: media.currentTime,
elapsedSeconds,
playbackRate: media.playbackRate,
});
const totalSeconds = getEffectiveMediaDuration(media, durationRef.current);
if (totalSeconds > 0) {
container.style.setProperty(
PLAYHEAD_CSS_PROPERTY,
`${clampPercentage((predictedSeconds / totalSeconds) * 100)}%`,
);
}
frame = requestAnimationFrame(tick);
};
const handleVisibilityChange = () => {
if (document.hidden) {
stop();
} else if (frame === null) {
frame = requestAnimationFrame(tick);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
if (!document.hidden) {
frame = requestAnimationFrame(tick);
}
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
stop();
};
}, [mediaRef, shouldPredictPlayhead]);
const renderTooltipContent = useCallback(
() => (
<div className={styles.tooltipPreviewContent} data-flx="voice.media-player.media-progress-bar.tooltip-content">
@@ -314,25 +396,21 @@ export function MediaProgressBar({
aria-valuenow={Math.round(displayProgress)}
aria-valuetext={formatDuration(displayCurrentTime)}
tabIndex={0}
data-playhead-predicted={shouldPredictPlayhead ? 'true' : undefined}
data-flx="voice.media-player.media-progress-bar.container.key-down"
>
<div className={styles.track} data-flx="voice.media-player.media-progress-bar.track">
<div
className={styles.buffered}
style={{width: `${buffered}%`}}
data-flx="voice.media-player.media-progress-bar.buffered"
/>
<div
className={styles.fill}
style={{width: `${displayProgress}%`}}
data-flx="voice.media-player.media-progress-bar.fill"
/>
{buffered.map((bufferedSpan, index) => (
<div
key={index}
className={styles.buffered}
style={{left: `${bufferedSpan.offset * 100}%`, width: `${bufferedSpan.span * 100}%`}}
data-flx="voice.media-player.media-progress-bar.buffered"
/>
))}
<div className={styles.fill} data-flx="voice.media-player.media-progress-bar.fill" />
</div>
<div
className={styles.thumb}
style={{left: `${displayProgress}%`}}
data-flx="voice.media-player.media-progress-bar.thumb"
/>
<div className={styles.thumb} data-flx="voice.media-player.media-progress-bar.thumb" />
{shouldRenderTooltip && (
<div
ref={tooltipAnchorRef}
@@ -6,6 +6,7 @@ import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUti
import {observeResize} from '@app/features/platform/utils/SharedResizeObserver';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {PortalHostContext} from '@app/features/ui/overlay/PortalHostContext';
import {MediaFullscreenButton} from '@app/features/voice/components/media_player/components/MediaFullscreenButton';
import {MediaPipButton} from '@app/features/voice/components/media_player/components/MediaPipButton';
import {MediaPlayButton} from '@app/features/voice/components/media_player/components/MediaPlayButton';
@@ -13,17 +14,30 @@ import {MediaPlaybackRate} from '@app/features/voice/components/media_player/com
import {MediaProgressBar} from '@app/features/voice/components/media_player/components/MediaProgressBar';
import {MediaTimeDisplay} from '@app/features/voice/components/media_player/components/MediaTimeDisplay';
import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_player/components/MediaVerticalVolumeControl';
import {useControlsVisibility} from '@app/features/voice/components/media_player/hooks/useControlsVisibility';
import {
CONTROLS_AUTOHIDE_DELAY_MS,
CONTROLS_AUTOHIDE_FULLSCREEN_DELAY_MS,
useControlsVisibility,
} from '@app/features/voice/components/media_player/hooks/useControlsVisibility';
import {useHiddenPlaybackGuard} from '@app/features/voice/components/media_player/hooks/useHiddenPlaybackGuard';
import {useMediaFullscreen} from '@app/features/voice/components/media_player/hooks/useMediaFullscreen';
import {useMediaKeyboard} from '@app/features/voice/components/media_player/hooks/useMediaKeyboard';
import {useMediaPiP} from '@app/features/voice/components/media_player/hooks/useMediaPiP';
import {useMediaPlayer} from '@app/features/voice/components/media_player/hooks/useMediaPlayer';
import {useMediaProgress} from '@app/features/voice/components/media_player/hooks/useMediaProgress';
import {mediaGainFromVolumeSetting} from '@app/features/voice/components/media_player/hooks/useMediaVolume';
import {useVideoDecodability} from '@app/features/voice/components/media_player/hooks/useVideoDecodability';
import {
SEEK_STEP,
VIDEO_BREAKPOINTS,
VIDEO_PLAYBACK_RATES,
VOLUME_STEP,
} from '@app/features/voice/components/media_player/utils/MediaConstants';
import {clampMediaTime} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import {
armPendingSeekTarget,
clampMediaTime,
detachMediaElementSource,
} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import styles from '@app/features/voice/components/media_player/VideoPlayer.module.css';
import {
createVideoPlayerRenderSnapshot,
@@ -39,7 +53,7 @@ import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
const VIDEO_DESCRIPTOR = msg({
message: 'Video',
@@ -73,6 +87,18 @@ const VIDEO_PLAYER_KEYSHORTCUTS = [
'8',
'9',
].join(' ');
export {detachMediaElementSource} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
function useMediaSourceRelease(mediaRef: React.RefObject<HTMLMediaElement | null>): void {
useLayoutEffect(() => {
const media = mediaRef.current;
return () => {
detachMediaElementSource(media ?? mediaRef.current);
};
}, [mediaRef]);
}
const SEEK_PREVIEW_SEEK_EPSILON_SECONDS = 0.05;
const SEEK_PREVIEW_THUMBNAIL_MIN_WIDTH = 88;
const SEEK_PREVIEW_THUMBNAIL_MAX_WIDTH = 144;
@@ -83,6 +109,7 @@ interface VideoPlayerProps {
poster?: string;
placeholder?: string;
duration?: number;
initialTime?: number;
width?: number;
height?: number;
autoPlay?: boolean;
@@ -117,6 +144,7 @@ function VideoSeekPreviewThumbnail({src, poster, time}: VideoSeekPreviewThumbnai
if (Math.abs(video.currentTime - nextTime) <= SEEK_PREVIEW_SEEK_EPSILON_SECONDS) return;
video.currentTime = nextTime;
}, []);
useMediaSourceRelease(videoRef);
useEffect(() => {
seekPreviewVideo(time);
}, [seekPreviewVideo, time]);
@@ -146,6 +174,7 @@ export const VideoPlayer = observer(function VideoPlayer({
poster,
placeholder,
duration: initialDuration,
initialTime,
width,
height,
autoPlay = false,
@@ -168,6 +197,10 @@ export const VideoPlayer = observer(function VideoPlayer({
const [showPlayPauseIndicator, setShowPlayPauseIndicator] = useState<'play' | 'pause' | null>(null);
const renderSnapshotRef = useRef(createVideoPlayerRenderSnapshot());
const restoreFocusAfterFullscreenRef = useRef<HTMLElement | null>(null);
const [isSeekHoldActive, setIsSeekHoldActive] = useState(false);
const hasAppliedInitialTimeRef = useRef(false);
const [wantsMetadata, setWantsMetadata] = useState(false);
const [fullscreenPortalRoot, setFullscreenPortalRoot] = useState<HTMLElement | null>(null);
const doubleClickPlaybackWasPausedRef = useRef<boolean | null>(null);
const [loadedPosterSrc, setLoadedPosterSrc] = useState<string | null>(() => {
if (poster && ImageCacheUtils.hasImage(poster)) return poster;
@@ -201,38 +234,58 @@ export const VideoPlayer = observer(function VideoPlayer({
cleanup();
};
}, [loadedPosterSrc, poster]);
const [volume, setVolumeState] = useState(VideoVolume.volume);
const [isMuted, setIsMutedState] = useState(VideoVolume.isMuted);
const {mediaRef, state, play, toggle, seekRelative, setPlaybackRate} = useMediaPlayer({
autoPlay,
loop,
persistVolume: false,
persistPlaybackRate: true,
onLoadedMetadata: (duration, media) => {
if (!(media instanceof HTMLVideoElement)) return;
const {videoWidth, videoHeight} = media;
if (videoWidth <= 0 || videoHeight <= 0) return;
onLoadedMetadata?.({
width: videoWidth,
height: videoHeight,
duration: Number.isFinite(duration) ? duration : 0,
});
const [draftVolume, setDraftVolume] = useState<number | null>(null);
const uncommittedVolumeRef = useRef<number | null>(null);
const volume = draftVolume ?? VideoVolume.volume;
const isMuted = draftVolume !== null ? draftVolume === 0 : VideoVolume.isMuted;
const {mediaRef, state, play, pause, resumeHold, releaseHold, toggle, seekRelative, setPlaybackRate} = useMediaPlayer(
{
mediaKind: 'video',
autoPlay,
loop,
persistPlaybackRate: true,
onLoadedMetadata: (duration, media) => {
if (initialTime !== undefined && !hasAppliedInitialTimeRef.current) {
hasAppliedInitialTimeRef.current = true;
const resumeTime = clampMediaTime(initialTime, duration);
armPendingSeekTarget(media, resumeTime);
media.currentTime = resumeTime;
}
if (!(media instanceof HTMLVideoElement)) return;
const {videoWidth, videoHeight} = media;
if (videoWidth <= 0 || videoHeight <= 0) return;
onLoadedMetadata?.({
width: videoWidth,
height: videoHeight,
duration: Number.isFinite(duration) ? duration : 0,
});
},
onPlay: () => setIsSeekHoldActive(false),
onEnded,
},
onEnded,
);
useMediaSourceRelease(mediaRef);
const transportIsPlaying = state.isPlaying || isSeekHoldActive;
const holdPlaybackWhileDocumentHidden = useCallback(() => {
pause('hidden-document');
}, [pause]);
const releasePlaybackWhenDocumentVisible = useCallback(() => {
resumeHold('hidden-document');
}, [resumeHold]);
useHiddenPlaybackGuard({
videoRef: mediaRef as React.RefObject<HTMLVideoElement | null>,
onHoldPlayback: holdPlaybackWhileDocumentHidden,
onReleasePlayback: releasePlaybackWhenDocumentVisible,
});
const {
currentTime,
duration,
progress,
buffered,
previewSeekToPercentage,
seekToPercentage,
startSeeking,
endSeeking,
} = useMediaProgress({
const {currentTime, duration, progress, buffered, seekToPercentage, startSeeking, endSeeking} = useMediaProgress({
mediaRef,
initialDuration,
});
useVideoDecodability({
videoRef: mediaRef as React.RefObject<HTMLVideoElement | null>,
isPlaying: state.isPlaying,
src,
});
const [isSeekPreviewActive, setIsSeekPreviewActive] = useState(false);
const [seekPreviewPercentage, setSeekPreviewPercentage] = useState<number | null>(null);
const seekPreviewTime =
@@ -240,22 +293,43 @@ export const VideoPlayer = observer(function VideoPlayer({
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
media.volume = volume;
media.volume = mediaGainFromVolumeSetting(volume);
media.muted = isMuted;
}, [mediaRef, volume, isMuted]);
const handleVolumeChange = useCallback(
(newVolume: number) => {
const clamped = Math.max(0, Math.min(1, newVolume));
setVolumeState(clamped);
VideoVolume.setVolume(clamped);
if (isMuted && clamped > 0) {
setIsMutedState(false);
}
},
[isMuted],
);
const commitVolume = useCallback(() => {
const uncommittedVolume = uncommittedVolumeRef.current;
uncommittedVolumeRef.current = null;
if (uncommittedVolume === null) return;
VideoVolume.setVolume(uncommittedVolume);
VideoVolume.setMuted(uncommittedVolume === 0);
setDraftVolume(null);
}, []);
const handleVolumeChange = useCallback((newVolume: number) => {
const clamped = Math.max(0, Math.min(1, newVolume));
uncommittedVolumeRef.current = clamped;
setDraftVolume(clamped);
}, []);
useEffect(() => {
if (draftVolume === null) return;
window.addEventListener('pointerup', commitVolume);
window.addEventListener('pointercancel', commitVolume);
window.addEventListener('keyup', commitVolume);
window.addEventListener('blur', commitVolume);
return () => {
window.removeEventListener('pointerup', commitVolume);
window.removeEventListener('pointercancel', commitVolume);
window.removeEventListener('keyup', commitVolume);
window.removeEventListener('blur', commitVolume);
};
}, [commitVolume, draftVolume]);
useEffect(() => {
return () => {
commitVolume();
};
}, [commitVolume]);
const handleToggleMute = useCallback(() => {
setIsMutedState((prev) => !prev);
uncommittedVolumeRef.current = null;
setDraftVolume(null);
VideoVolume.toggleMute();
}, []);
const {isFullscreen, supportsFullscreen, toggleFullscreen} = useMediaFullscreen({
@@ -266,6 +340,7 @@ export const VideoPlayer = observer(function VideoPlayer({
videoRef: mediaRef as React.RefObject<HTMLVideoElement | null>,
});
const {controlsVisible, showControls, containerProps} = useControlsVisibility({
autohideDelay: isFullscreen ? CONTROLS_AUTOHIDE_FULLSCREEN_DELAY_MS : CONTROLS_AUTOHIDE_DELAY_MS,
isPlaying: state.isPlaying,
isInteracting,
});
@@ -274,43 +349,57 @@ export const VideoPlayer = observer(function VideoPlayer({
selectVideoPlayerRenderModel({
autoPlay,
hasPlayed,
wantsMetadata,
isPlaying: state.isPlaying,
isPaused: state.isPaused,
isEnded: state.isEnded,
hasError: state.error !== null,
}),
[autoPlay, hasPlayed, state.error, state.isEnded, state.isPaused, state.isPlaying],
[autoPlay, hasPlayed, wantsMetadata, state.error, state.isEnded, state.isPaused, state.isPlaying],
);
useEffect(() => {
const snapshot = transitionVideoPlayerRenderSnapshot(renderSnapshotRef.current, {
type: 'video.observePlayback',
signals: {
hasPlayed,
isPlaying: state.isPlaying,
isPlaying: transportIsPlaying,
},
});
renderSnapshotRef.current = snapshot;
const indicator = selectVideoPlayerPlayPauseIndicator(snapshot);
if (document.visibilityState === 'hidden') {
return undefined;
}
if (indicator) {
setShowPlayPauseIndicator(indicator);
const timer = setTimeout(() => setShowPlayPauseIndicator(null), 500);
return () => clearTimeout(timer);
}
return undefined;
}, [state.isPlaying, hasPlayed]);
}, [transportIsPlaying, hasPlayed]);
useMediaKeyboard({
containerRef,
enabled: true,
captureDocumentKeys: isFullscreen,
onTogglePlay: toggle,
onSeekBackward: (amount) => seekRelative(-amount),
onSeekForward: (amount) => seekRelative(amount),
onVolumeUp: () => handleVolumeChange(volume + 0.1),
onVolumeDown: () => handleVolumeChange(volume - 0.1),
onSeekBackward: () => seekRelative(-SEEK_STEP),
onSeekForward: () => seekRelative(SEEK_STEP),
onVolumeUp: () => {
handleVolumeChange(volume + VOLUME_STEP);
commitVolume();
},
onVolumeDown: () => {
handleVolumeChange(volume - VOLUME_STEP);
commitVolume();
},
onToggleMute: handleToggleMute,
onToggleFullscreen: toggleFullscreen,
onSeekPercentage: seekToPercentage,
});
const inheritedPortalHost = useContext(PortalHostContext);
useLayoutEffect(() => {
setFullscreenPortalRoot(isFullscreen ? containerRef.current : null);
}, [isFullscreen]);
useEffect(() => {
if (!isFullscreen) return;
const container = containerRef.current;
@@ -383,27 +472,40 @@ export const VideoPlayer = observer(function VideoPlayer({
);
const handleSeekPreview = useCallback(
(percentage: number) => {
if (showSeekPreviewThumbnail) {
setSeekPreviewPercentage(percentage);
previewSeekToPercentage(percentage);
return;
}
setSeekPreviewPercentage(null);
setSeekPreviewPercentage(showSeekPreviewThumbnail ? percentage : null);
seekToPercentage(percentage);
},
[previewSeekToPercentage, seekToPercentage, showSeekPreviewThumbnail],
[seekToPercentage, showSeekPreviewThumbnail],
);
const handleSeekStart = useCallback(() => {
const media = mediaRef.current;
const wasPlaying = media != null && !media.paused && !media.ended;
if (wasPlaying) {
setIsSeekHoldActive(true);
pause('seek');
}
setIsInteracting(true);
setIsSeekPreviewActive(showSeekPreviewThumbnail);
startSeeking();
}, [showSeekPreviewThumbnail, startSeeking]);
}, [mediaRef, pause, showSeekPreviewThumbnail, startSeeking]);
const handleSeekEnd = useCallback(() => {
setIsInteracting(false);
setIsSeekPreviewActive(false);
setSeekPreviewPercentage(null);
endSeeking();
}, [endSeeking]);
const media = mediaRef.current;
const mediaDuration = media?.duration ?? 0;
const isAtEnd =
media != null && Number.isFinite(mediaDuration) && mediaDuration > 0 && media.currentTime >= mediaDuration;
if (isAtEnd) {
releaseHold('seek');
setIsSeekHoldActive(false);
return;
}
if (!resumeHold('seek')) {
setIsSeekHoldActive(false);
}
}, [endSeeking, mediaRef, releaseHold, resumeHold]);
const handleSeekHover = useCallback(
(percentage: number) => {
if (!showSeekPreviewThumbnail) return;
@@ -455,9 +557,13 @@ export const VideoPlayer = observer(function VideoPlayer({
},
[isMobile, restoreDoubleClickPlaybackState, supportsFullscreen, toggleFullscreen],
);
const escalateToMetadata = useCallback(() => {
setWantsMetadata(true);
}, []);
const handleFocusCapture = useCallback(() => {
escalateToMetadata();
showControls();
}, [showControls]);
}, [escalateToMetadata, showControls]);
const isSmall = containerWidth < VIDEO_BREAKPOINTS.SMALL;
const isMedium = containerWidth < VIDEO_BREAKPOINTS.MEDIUM;
const seekPreviewThumbnailWidth = Math.round(
@@ -466,7 +572,7 @@ export const VideoPlayer = observer(function VideoPlayer({
Math.min(SEEK_PREVIEW_THUMBNAIL_MAX_WIDTH, containerWidth * SEEK_PREVIEW_THUMBNAIL_WIDTH_RATIO),
),
);
const progressPreviewPortalRoot = isFullscreen ? containerRef.current : undefined;
const progressPreviewPortalRoot = fullscreenPortalRoot;
const containerStyle: React.CSSProperties = {
...style,
...(width && height && !fillContainer
@@ -476,205 +582,215 @@ export const VideoPlayer = observer(function VideoPlayer({
: {}),
};
return (
<FocusRing offset={-2} data-flx="voice.media-player.video-player.focus-ring">
<div
ref={containerRef}
className={clsx(
styles.container,
fillContainer && styles.fillContainer,
isFullscreen && styles.fullscreen,
className,
)}
style={containerStyle}
role="group"
aria-label={videoLabel}
aria-keyshortcuts={VIDEO_PLAYER_KEYSHORTCUTS}
tabIndex={-1}
data-media-fullscreen-root="true"
onFocusCapture={handleFocusCapture}
data-flx="voice.media-player.video-player.container"
{...containerProps}
>
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
className={clsx(styles.video, renderModel.shouldHideVideo && styles.videoHidden)}
src={renderModel.shouldAttachSource ? src : undefined}
preload="none"
playsInline
data-embed-media="true"
onClick={handleVideoClick}
onDoubleClick={handleVideoDoubleClick}
<PortalHostContext.Provider value={fullscreenPortalRoot ?? inheritedPortalHost}>
<FocusRing offset={-2} data-flx="voice.media-player.video-player.focus-ring">
<div
ref={containerRef}
className={clsx(
styles.container,
fillContainer && styles.fillContainer,
isFullscreen && styles.fullscreen,
className,
)}
style={containerStyle}
role="group"
aria-label={videoLabel}
data-flx="voice.media-player.video-player.video"
aria-keyshortcuts={VIDEO_PLAYER_KEYSHORTCUTS}
tabIndex={-1}
data-media-fullscreen-root="true"
onFocusCapture={handleFocusCapture}
onPointerEnter={escalateToMetadata}
data-flx="voice.media-player.video-player.container"
{...containerProps}
>
<track kind="captions" data-flx="voice.media-player.video-player.track" />
</video>
{renderModel.shouldShowPosterOverlay && (
<FocusRing offset={-2} data-flx="voice.media-player.video-player.focus-ring--2">
<button
type="button"
className={styles.posterOverlay}
onClick={handlePosterClick}
aria-label={i18n._(PLAY_VIDEO_DESCRIPTOR)}
data-flx="voice.media-player.video-player.poster-overlay.poster-click.button"
>
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence">
{thumbHashURL && !posterLoaded && (
<motion.img
key="thumbhash"
initial={{opacity: 1}}
exit={{opacity: Accessibility.useReducedMotion ? 1 : 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2}}
src={thumbHashURL}
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
className={clsx(styles.video, renderModel.shouldHideVideo && styles.videoHidden)}
src={renderModel.shouldAttachSource ? src : undefined}
preload={renderModel.preloadAttribute}
crossOrigin="anonymous"
playsInline
data-embed-media="true"
onClick={handleVideoClick}
onDoubleClick={handleVideoDoubleClick}
data-flx="voice.media-player.video-player.video"
>
<track kind="captions" data-flx="voice.media-player.video-player.track" />
</video>
{renderModel.shouldShowPosterOverlay && (
<FocusRing offset={-2} data-flx="voice.media-player.video-player.focus-ring--2">
<button
type="button"
className={styles.posterOverlay}
onClick={handlePosterClick}
aria-label={i18n._(PLAY_VIDEO_DESCRIPTOR)}
data-flx="voice.media-player.video-player.poster-overlay.poster-click.button"
>
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence">
{thumbHashURL && !posterLoaded && (
<motion.img
key="thumbhash"
initial={{opacity: 1}}
exit={{opacity: Accessibility.useReducedMotion ? 1 : 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2}}
src={thumbHashURL}
alt=""
aria-hidden={true}
className={styles.thumbHashPlaceholder}
data-flx="voice.media-player.video-player.thumb-hash-placeholder"
/>
)}
</AnimatePresence>
{poster && posterLoaded && (
<img
src={poster}
alt=""
className={styles.thumbHashPlaceholder}
data-flx="voice.media-player.video-player.thumb-hash-placeholder"
className={styles.posterImage}
data-flx="voice.media-player.video-player.poster-image"
/>
)}
</AnimatePresence>
{poster && posterLoaded && (
<img
src={poster}
alt=""
className={styles.posterImage}
data-flx="voice.media-player.video-player.poster-image"
/>
)}
<span
className={styles.playOverlayButton}
aria-hidden="true"
data-flx="voice.media-player.video-player.play-overlay-button"
>
<PlayIcon size={remFromPx(24)} weight="fill" data-flx="voice.media-player.video-player.play-icon" />
</span>
</button>
</FocusRing>
)}
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence--2">
{showPlayPauseIndicator && (
<motion.div
className={styles.playPauseIndicator}
initial={
Accessibility.useReducedMotion
? {opacity: 1, scale: 1, x: '-50%', y: '-50%'}
: {opacity: 0, scale: 0.5, x: '-50%', y: '-50%'}
}
animate={{opacity: 1, scale: 1, x: '-50%', y: '-50%'}}
exit={
Accessibility.useReducedMotion
? {opacity: 1, scale: 1, x: '-50%', y: '-50%'}
: {opacity: 0, scale: 1.2, x: '-50%', y: '-50%'}
}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2}}
data-flx="voice.media-player.video-player.play-pause-indicator"
>
{showPlayPauseIndicator === 'play' ? (
<PlayIcon size={remFromPx(24)} weight="fill" data-flx="voice.media-player.video-player.play-icon--2" />
) : (
<PauseIcon size={remFromPx(24)} weight="fill" data-flx="voice.media-player.video-player.pause-icon" />
)}
</motion.div>
<span
className={styles.playOverlayButton}
aria-hidden="true"
data-flx="voice.media-player.video-player.play-overlay-button"
>
<PlayIcon size={remFromPx(24)} weight="fill" data-flx="voice.media-player.video-player.play-icon" />
</span>
</button>
</FocusRing>
)}
</AnimatePresence>
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence--3">
{renderModel.shouldShowControlsOverlay && (
<motion.div
className={styles.controlsOverlay}
initial={{y: Accessibility.useReducedMotion ? 0 : '100%'}}
animate={{y: controlsVisible ? 0 : '100%'}}
exit={{y: Accessibility.useReducedMotion ? 0 : '100%'}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2, ease: 'easeOut'}}
data-flx="voice.media-player.video-player.controls-overlay"
>
<MediaProgressBar
progress={progress}
buffered={buffered}
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
onSeekPreview={handleSeekPreview}
onSeekStart={handleSeekStart}
onSeekEnd={handleSeekEnd}
onSeekHover={handleSeekHover}
onSeekHoverEnd={handleSeekHoverEnd}
previewThumbnail={
showSeekPreviewThumbnail && seekPreviewPercentage !== null && duration > 0 ? (
<VideoSeekPreviewThumbnail
src={src}
poster={poster}
time={seekPreviewTime ?? currentTime}
data-flx="voice.media-player.video-player.video-seek-preview-thumbnail"
/>
) : null
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence--2">
{showPlayPauseIndicator && (
<motion.div
className={styles.playPauseIndicator}
initial={
Accessibility.useReducedMotion
? {opacity: 1, scale: 1, x: '-50%', y: '-50%'}
: {opacity: 0, scale: 0.5, x: '-50%', y: '-50%'}
}
previewThumbnailWidth={seekPreviewThumbnailWidth}
previewPortalRoot={progressPreviewPortalRoot}
className={styles.progressBar}
compact
data-flx="voice.media-player.video-player.progress-bar"
/>
<div className={styles.controlsRow} data-flx="voice.media-player.video-player.controls-row">
<div className={styles.controlsLeft} data-flx="voice.media-player.video-player.controls-left">
<MediaPlayButton
isPlaying={state.isPlaying}
onToggle={toggle}
size="small"
data-flx="voice.media-player.video-player.media-play-button"
animate={{opacity: 1, scale: 1, x: '-50%', y: '-50%'}}
exit={
Accessibility.useReducedMotion
? {opacity: 1, scale: 1, x: '-50%', y: '-50%'}
: {opacity: 0, scale: 1.2, x: '-50%', y: '-50%'}
}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2}}
data-flx="voice.media-player.video-player.play-pause-indicator"
>
{showPlayPauseIndicator === 'play' ? (
<PlayIcon
size={remFromPx(24)}
weight="fill"
data-flx="voice.media-player.video-player.play-icon--2"
/>
<MediaVerticalVolumeControl
volume={volume}
isMuted={isMuted}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={18}
data-flx="voice.media-player.video-player.media-vertical-volume-control"
/>
{!isSmall && (
<MediaTimeDisplay
currentTime={currentTime}
duration={duration}
) : (
<PauseIcon size={remFromPx(24)} weight="fill" data-flx="voice.media-player.video-player.pause-icon" />
)}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence data-flx="voice.media-player.video-player.animate-presence--3">
{renderModel.shouldShowControlsOverlay && (
<motion.div
className={styles.controlsOverlay}
initial={{y: Accessibility.useReducedMotion ? 0 : '100%'}}
animate={{y: controlsVisible ? 0 : '100%'}}
exit={{y: Accessibility.useReducedMotion ? 0 : '100%'}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2, ease: 'easeOut'}}
data-flx="voice.media-player.video-player.controls-overlay"
>
<MediaProgressBar
progress={progress}
buffered={buffered}
currentTime={currentTime}
duration={duration}
mediaRef={mediaRef}
isPlaying={state.isPlaying}
onSeek={handleSeek}
onSeekPreview={handleSeekPreview}
onSeekStart={handleSeekStart}
onSeekEnd={handleSeekEnd}
onSeekHover={handleSeekHover}
onSeekHoverEnd={handleSeekHoverEnd}
previewThumbnail={
showSeekPreviewThumbnail && seekPreviewPercentage !== null && duration > 0 ? (
<VideoSeekPreviewThumbnail
src={src}
poster={poster}
time={seekPreviewTime ?? currentTime}
data-flx="voice.media-player.video-player.video-seek-preview-thumbnail"
/>
) : null
}
previewThumbnailWidth={seekPreviewThumbnailWidth}
previewPortalRoot={progressPreviewPortalRoot}
className={styles.progressBar}
compact
data-flx="voice.media-player.video-player.progress-bar"
/>
<div className={styles.controlsRow} data-flx="voice.media-player.video-player.controls-row">
<div className={styles.controlsLeft} data-flx="voice.media-player.video-player.controls-left">
<MediaPlayButton
isPlaying={transportIsPlaying}
onToggle={toggle}
size="small"
data-flx="voice.media-player.video-player.media-time-display"
data-flx="voice.media-player.video-player.media-play-button"
/>
)}
</div>
<div className={styles.controlsCenter} data-flx="voice.media-player.video-player.controls-center" />
<div className={styles.controlsRight} data-flx="voice.media-player.video-player.controls-right">
{!isSmall && (
<MediaPlaybackRate
rate={state.playbackRate}
onRateChange={setPlaybackRate}
rates={VIDEO_PLAYBACK_RATES}
size="small"
data-flx="voice.media-player.video-player.media-playback-rate"
/>
)}
{!isMedium && supportsPiP && (
<MediaPipButton
isPiP={isPiP}
supportsPiP={supportsPiP}
onToggle={togglePiP}
<MediaVerticalVolumeControl
volume={volume}
isMuted={isMuted}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={18}
size="small"
data-flx="voice.media-player.video-player.media-pip-button"
data-flx="voice.media-player.video-player.media-vertical-volume-control"
/>
)}
{supportsFullscreen && (
<MediaFullscreenButton
isFullscreen={isFullscreen}
supportsFullscreen={supportsFullscreen}
onToggle={toggleFullscreen}
iconSize={18}
size="small"
data-flx="voice.media-player.video-player.media-fullscreen-button"
/>
)}
{!isSmall && (
<MediaTimeDisplay
currentTime={currentTime}
duration={duration}
size="small"
data-flx="voice.media-player.video-player.media-time-display"
/>
)}
</div>
<div className={styles.controlsCenter} data-flx="voice.media-player.video-player.controls-center" />
<div className={styles.controlsRight} data-flx="voice.media-player.video-player.controls-right">
{!isSmall && (
<MediaPlaybackRate
rate={state.playbackRate}
onRateChange={setPlaybackRate}
rates={VIDEO_PLAYBACK_RATES}
size="small"
data-flx="voice.media-player.video-player.media-playback-rate"
/>
)}
{!isMedium && supportsPiP && (
<MediaPipButton
isPiP={isPiP}
supportsPiP={supportsPiP}
onToggle={togglePiP}
iconSize={18}
size="small"
data-flx="voice.media-player.video-player.media-pip-button"
/>
)}
{supportsFullscreen && (
<MediaFullscreenButton
isFullscreen={isFullscreen}
supportsFullscreen={supportsFullscreen}
onToggle={toggleFullscreen}
iconSize={18}
size="small"
data-flx="voice.media-player.video-player.media-fullscreen-button"
/>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</FocusRing>
</motion.div>
)}
</AnimatePresence>
</div>
</FocusRing>
</PortalHostContext.Provider>
);
});
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useEffect, useRef} from 'react';
interface UseHiddenPlaybackGuardOptions {
videoRef: React.RefObject<HTMLVideoElement | null>;
onHoldPlayback: () => void;
onReleasePlayback: () => void;
}
export function useHiddenPlaybackGuard({
videoRef,
onHoldPlayback,
onReleasePlayback,
}: UseHiddenPlaybackGuardOptions): void {
const callbacksRef = useRef({onHoldPlayback, onReleasePlayback});
callbacksRef.current = {onHoldPlayback, onReleasePlayback};
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const isPlayingOutsideTheDocument = () => document.pictureInPictureElement === video;
const shouldHoldPlayback = () => document.visibilityState === 'hidden' && !isPlayingOutsideTheDocument();
const handlePlay = () => {
if (!shouldHoldPlayback()) return;
callbacksRef.current.onHoldPlayback();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
callbacksRef.current.onReleasePlayback();
};
video.addEventListener('play', handlePlay);
document.addEventListener('visibilitychange', handleVisibilityChange);
if (!video.paused && shouldHoldPlayback()) {
callbacksRef.current.onHoldPlayback();
}
return () => {
video.removeEventListener('play', handlePlay);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [videoRef]);
}
@@ -2,15 +2,26 @@
import AppStorage from '@app/features/platform/state/PersistentStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {DEFAULT_VOLUME} from '@app/features/voice/components/media_player/utils/MediaConstants';
import {
armPendingSeekTarget,
clampMediaTime,
clearPendingSeekTarget,
detachMediaElementSource,
quantiseMediaTimeToSecond,
readPendingSeekTarget,
} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import {useInAppMediaSoundCapture} from '@app/features/voice/hooks/useInAppMediaSoundCapture';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const VOLUME_STORAGE_KEY = 'fluxer:media:volume';
const MUTE_STORAGE_KEY = 'fluxer:media:muted';
const PLAYBACK_RATE_STORAGE_KEY = 'fluxer:media:playbackRate';
const PLAYBACK_RATE_STORAGE_KEY = 'fluxer:media:playbackRates';
const logger = new Logger('useMediaPlayer');
export type PlaybackHoldReason = 'user' | 'hidden-document' | 'seek';
export type MediaPlaybackKind = 'video' | 'audio' | 'voice-message';
export type MediaFailureCode = 'error' | 'abort' | 'emptied' | 'stalled';
export interface MediaPlayerState {
isPlaying: boolean;
isPaused: boolean;
@@ -19,23 +30,19 @@ export interface MediaPlayerState {
isSeeking: boolean;
currentTime: number;
duration: number;
bufferedRanges: TimeRanges | null;
volume: number;
isMuted: boolean;
playbackRate: number;
error: Error | null;
failureCode: MediaFailureCode | null;
}
export interface UseMediaPlayerOptions {
mediaKind?: MediaPlaybackKind;
autoPlay?: boolean;
loop?: boolean;
muted?: boolean;
initialVolume?: number;
initialPlaybackRate?: number;
persistVolume?: boolean;
persistPlaybackRate?: boolean;
onEnded?: () => void;
onError?: (error: Error) => void;
onError?: (error: Error, code: MediaFailureCode) => void;
onPlay?: () => void;
onPause?: () => void;
onTimeUpdate?: (currentTime: number) => void;
@@ -46,65 +53,38 @@ export interface UseMediaPlayerReturn {
mediaRef: React.RefObject<HTMLMediaElement | null>;
state: MediaPlayerState;
play: () => Promise<void>;
pause: () => void;
pause: (reason?: PlaybackHoldReason) => void;
resumeHold: (reason: PlaybackHoldReason) => boolean;
releaseHold: (reason: PlaybackHoldReason) => boolean;
toggle: () => Promise<void>;
seek: (time: number) => void;
seekRelative: (delta: number) => void;
seekPercentage: (percentage: number) => void;
setVolume: (volume: number) => void;
toggleMute: () => void;
setPlaybackRate: (rate: number) => void;
}
function getStoredVolume(): number {
try {
const stored = AppStorage.getItem(VOLUME_STORAGE_KEY);
if (stored !== null) {
const value = parseFloat(stored);
if (Number.isFinite(value) && value >= 0 && value <= 1) {
return value;
}
}
} catch {}
return DEFAULT_VOLUME;
}
function getStoredMuted(): boolean {
try {
return AppStorage.getItem(MUTE_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function getStoredPlaybackRate(): number {
function readStoredPlaybackRates(): Record<string, unknown> {
try {
const stored = AppStorage.getItem(PLAYBACK_RATE_STORAGE_KEY);
if (stored !== null) {
const value = parseFloat(stored);
if (Number.isFinite(value) && value >= 0.25 && value <= 4) {
return value;
}
}
if (stored === null) return {};
const parsed: unknown = JSON.parse(stored);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
} catch {}
return {};
}
function getStoredPlaybackRate(kind: MediaPlaybackKind): number {
const value = readStoredPlaybackRates()[kind];
if (typeof value === 'number' && Number.isFinite(value) && value >= 0.25 && value <= 4) {
return value;
}
return 1;
}
function storeVolume(volume: number): void {
function storePlaybackRate(kind: MediaPlaybackKind, rate: number): void {
try {
AppStorage.setItem(VOLUME_STORAGE_KEY, volume.toString());
} catch {}
}
function storeMuted(muted: boolean): void {
try {
AppStorage.setItem(MUTE_STORAGE_KEY, muted.toString());
} catch {}
}
function storePlaybackRate(rate: number): void {
try {
AppStorage.setItem(PLAYBACK_RATE_STORAGE_KEY, rate.toString());
AppStorage.setItem(PLAYBACK_RATE_STORAGE_KEY, JSON.stringify({...readStoredPlaybackRates(), [kind]: rate}));
} catch {}
}
@@ -113,6 +93,10 @@ const isAbortError = (error: unknown): boolean => {
if (error.name === 'AbortError') return true;
return error.message.toLowerCase().includes('interrupted');
};
export const isAutoplayBlockedError = (error: unknown): boolean =>
error instanceof Error && error.name === 'NotAllowedError';
const normalizeError = (error: unknown): Error => {
if (error instanceof Error) return error;
return new Error(typeof error === 'string' ? error : 'Unknown media error');
@@ -120,12 +104,10 @@ const normalizeError = (error: unknown): Error => {
export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPlayerReturn {
const {
mediaKind = 'video',
autoPlay = false,
loop = false,
muted: initialMuted,
initialVolume,
initialPlaybackRate,
persistVolume = true,
persistPlaybackRate = true,
onEnded,
onError,
@@ -136,9 +118,9 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
} = options;
const mediaRef = useRef<HTMLMediaElement | null>(null);
useInAppMediaSoundCapture(mediaRef);
const previousVolumeRef = useRef<number>(DEFAULT_VOLUME);
const callbacksRef = useRef({onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata});
callbacksRef.current = {onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata};
const initialElementSettingsRef = useRef({loop});
const [state, setState] = useState<MediaPlayerState>(() => ({
isPlaying: false,
isPaused: true,
@@ -147,24 +129,26 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
isSeeking: false,
currentTime: 0,
duration: 0,
bufferedRanges: null,
volume: initialVolume ?? (persistVolume ? getStoredVolume() : DEFAULT_VOLUME),
isMuted: initialMuted ?? (persistVolume ? getStoredMuted() : false),
playbackRate: initialPlaybackRate ?? (persistPlaybackRate ? getStoredPlaybackRate() : 1),
playbackRate: initialPlaybackRate ?? (persistPlaybackRate ? getStoredPlaybackRate(mediaKind) : 1),
error: null,
failureCode: null,
}));
const holdReasonRef = useRef<PlaybackHoldReason | null>(null);
const recoverPlaybackWhenReadyRef = useRef(false);
const initialPlaybackRateRef = useRef(state.playbackRate);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
media.volume = state.volume;
media.muted = state.isMuted;
media.playbackRate = state.playbackRate;
media.loop = loop;
const settings = initialElementSettingsRef.current;
media.defaultPlaybackRate = initialPlaybackRateRef.current;
media.playbackRate = initialPlaybackRateRef.current;
media.loop = settings.loop;
}, []);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
const handlePlay = () => {
holdReasonRef.current = null;
setState((prev) => ({
...prev,
isPlaying: true,
@@ -182,61 +166,91 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
callbacksRef.current.onPause?.();
};
const handleEnded = () => {
clearPendingSeekTarget(media);
holdReasonRef.current = null;
setState((prev) => ({
...prev,
isPlaying: false,
isPaused: true,
isEnded: true,
isBuffering: false,
}));
callbacksRef.current.onEnded?.();
};
const handleTimeUpdate = () => {
const currentTime = media.currentTime;
setState((prev) => ({
...prev,
currentTime,
bufferedRanges: media.buffered,
}));
callbacksRef.current.onTimeUpdate?.(currentTime);
const currentTime = quantiseMediaTimeToSecond(media.currentTime);
setState((prev) => (prev.currentTime === currentTime ? prev : {...prev, currentTime}));
callbacksRef.current.onTimeUpdate?.(media.currentTime);
};
const publishDuration = () => {
const duration = Number.isFinite(media.duration) ? media.duration : 0;
setState((prev) => (prev.duration === duration ? prev : {...prev, duration}));
};
const handleLoadedMetadata = () => {
const duration = media.duration;
setState((prev) => ({
...prev,
duration: Number.isFinite(duration) ? duration : 0,
}));
callbacksRef.current.onLoadedMetadata?.(duration, media);
publishDuration();
callbacksRef.current.onLoadedMetadata?.(media.duration, media);
};
const handleDurationChange = () => {
const duration = media.duration;
setState((prev) => ({
...prev,
duration: Number.isFinite(duration) ? duration : 0,
}));
};
const handleWaiting = () => {
setState((prev) => ({...prev, isBuffering: true}));
};
const handleCanPlay = () => {
setState((prev) => ({...prev, isBuffering: false}));
publishDuration();
};
const handleSeeking = () => {
setState((prev) => ({...prev, isSeeking: true}));
setState((prev) => (prev.isSeeking ? prev : {...prev, isSeeking: true}));
};
const handleWaiting = () => {
if (!media.paused) {
recoverPlaybackWhenReadyRef.current = true;
}
setState((prev) => (prev.isBuffering ? prev : {...prev, isBuffering: true}));
};
const handleReadyToPlay = () => {
setState((prev) =>
prev.isBuffering || prev.failureCode === 'stalled'
? {...prev, isBuffering: false, failureCode: prev.failureCode === 'stalled' ? null : prev.failureCode}
: prev,
);
if (!recoverPlaybackWhenReadyRef.current) return;
recoverPlaybackWhenReadyRef.current = false;
if (holdReasonRef.current !== null) return;
if (!media.paused) return;
void play();
};
const reportFailure = (code: MediaFailureCode) => {
recoverPlaybackWhenReadyRef.current = false;
const failure = new Error(`Media playback ${code}`);
setState((prev) => ({...prev, isBuffering: false, failureCode: code}));
callbacksRef.current.onError?.(failure, code);
};
const handleStalled = () => {
const failure = new Error('Media playback stalled');
setState((prev) => ({...prev, isBuffering: true, failureCode: 'stalled'}));
callbacksRef.current.onError?.(failure, 'stalled');
};
const handleAbort = () => reportFailure('abort');
const handleEmptied = () => reportFailure('emptied');
const handleSeeked = () => {
setState((prev) => ({...prev, isSeeking: false}));
};
const handleVolumeChange = () => {
setState((prev) => ({
...prev,
volume: media.volume,
isMuted: media.muted,
}));
clearPendingSeekTarget(media);
setState((prev) => (prev.isSeeking ? {...prev, isSeeking: false} : prev));
};
const handleRateChange = () => {
setState((prev) =>
prev.playbackRate === media.playbackRate ? prev : {...prev, playbackRate: media.playbackRate},
);
};
const handleResourceLoadStart = () => {
clearPendingSeekTarget(media);
holdReasonRef.current = null;
recoverPlaybackWhenReadyRef.current = false;
setState((prev) => ({
...prev,
playbackRate: media.playbackRate,
isPlaying: false,
isPaused: true,
isEnded: false,
isBuffering: false,
isSeeking: false,
currentTime: 0,
duration: 0,
error: null,
failureCode: null,
}));
};
const handleError = () => {
@@ -244,14 +258,9 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
const errorMessage = error
? new Error(error.message || 'Media playback error')
: new Error('Unknown media error');
setState((prev) => ({...prev, error: errorMessage}));
callbacksRef.current.onError?.(errorMessage);
};
const handleProgress = () => {
setState((prev) => ({
...prev,
bufferedRanges: media.buffered,
}));
recoverPlaybackWhenReadyRef.current = false;
setState((prev) => ({...prev, isBuffering: false, error: errorMessage, failureCode: 'error'}));
callbacksRef.current.onError?.(errorMessage, 'error');
};
media.addEventListener('play', handlePlay);
media.addEventListener('pause', handlePause);
@@ -260,16 +269,22 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
media.addEventListener('loadedmetadata', handleLoadedMetadata);
media.addEventListener('durationchange', handleDurationChange);
media.addEventListener('waiting', handleWaiting);
media.addEventListener('canplay', handleCanPlay);
media.addEventListener('canplay', handleReadyToPlay);
media.addEventListener('canplaythrough', handleReadyToPlay);
media.addEventListener('stalled', handleStalled);
media.addEventListener('abort', handleAbort);
media.addEventListener('emptied', handleEmptied);
media.addEventListener('seeking', handleSeeking);
media.addEventListener('seeked', handleSeeked);
media.addEventListener('volumechange', handleVolumeChange);
media.addEventListener('ratechange', handleRateChange);
media.addEventListener('error', handleError);
media.addEventListener('progress', handleProgress);
media.addEventListener('loadstart', handleResourceLoadStart);
if (media.readyState >= 1) {
handleLoadedMetadata();
}
if (!media.paused) {
setState((prev) => (prev.isPlaying ? prev : {...prev, isPlaying: true, isPaused: false, isEnded: false}));
}
return () => {
media.removeEventListener('play', handlePlay);
media.removeEventListener('pause', handlePause);
@@ -278,50 +293,91 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
media.removeEventListener('loadedmetadata', handleLoadedMetadata);
media.removeEventListener('durationchange', handleDurationChange);
media.removeEventListener('waiting', handleWaiting);
media.removeEventListener('canplay', handleCanPlay);
media.removeEventListener('canplay', handleReadyToPlay);
media.removeEventListener('canplaythrough', handleReadyToPlay);
media.removeEventListener('stalled', handleStalled);
media.removeEventListener('abort', handleAbort);
media.removeEventListener('emptied', handleEmptied);
media.removeEventListener('seeking', handleSeeking);
media.removeEventListener('seeked', handleSeeked);
media.removeEventListener('volumechange', handleVolumeChange);
media.removeEventListener('ratechange', handleRateChange);
media.removeEventListener('error', handleError);
media.removeEventListener('progress', handleProgress);
media.removeEventListener('loadstart', handleResourceLoadStart);
};
}, []);
useEffect(() => {
const media = mediaRef.current;
if (!media || !autoPlay) return;
const tryPlay = () => {
media.play().catch((error) => {
logger.debug('Autoplay prevented:', error);
let attempted = false;
const attemptAutoplay = () => {
if (attempted) return;
attempted = true;
const started = media.play();
if (started == null) return;
started.catch((error) => {
if (isAutoplayBlockedError(error) || isAbortError(error)) {
logger.debug('Autoplay was refused by the browser; leaving the video paused:', error);
return;
}
logger.error('Autoplay failed:', normalizeError(error));
});
};
tryPlay();
media.addEventListener('loadstart', tryPlay);
const attemptAutoplayOnSource = () => {
attempted = false;
attemptAutoplay();
};
attemptAutoplay();
media.addEventListener('loadstart', attemptAutoplayOnSource);
return () => {
media.removeEventListener('loadstart', tryPlay);
media.removeEventListener('loadstart', attemptAutoplayOnSource);
};
}, [autoPlay]);
useLayoutEffect(() => {
const media = mediaRef.current;
return () => {
detachMediaElementSource(media ?? mediaRef.current);
};
}, []);
const play = useCallback(async () => {
const media = mediaRef.current;
if (!media) return;
try {
await media.play();
setState((prev) => ({...prev, error: null}));
setState((prev) => (prev.error === null ? prev : {...prev, error: null}));
} catch (error) {
if (isAbortError(error)) {
logger.debug('Play interrupted before it could start:', error);
return;
}
if (isAutoplayBlockedError(error)) {
logger.debug('Play was refused by the browser:', error);
return;
}
const normalizedError = normalizeError(error);
logger.error('Play failed:', normalizedError);
setState((prev) => ({...prev, error: normalizedError}));
}
}, []);
const pause = useCallback(() => {
const pause = useCallback((reason: PlaybackHoldReason = 'user') => {
const media = mediaRef.current;
if (!media) return;
holdReasonRef.current = reason;
media.pause();
}, []);
const resumeHold = useCallback(
(reason: PlaybackHoldReason) => {
if (holdReasonRef.current !== reason) return false;
holdReasonRef.current = null;
void play();
return true;
},
[play],
);
const releaseHold = useCallback((reason: PlaybackHoldReason) => {
if (holdReasonRef.current !== reason) return false;
holdReasonRef.current = null;
return true;
}, []);
const toggle = useCallback(async () => {
const media = mediaRef.current;
if (!media) return;
@@ -334,14 +390,15 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
const seek = useCallback((time: number) => {
const media = mediaRef.current;
if (!media) return;
const clampedTime = Math.max(0, Math.min(time, media.duration || 0));
const clampedTime = clampMediaTime(time, media.duration);
armPendingSeekTarget(media, clampedTime);
media.currentTime = clampedTime;
}, []);
const seekRelative = useCallback(
(delta: number) => {
const media = mediaRef.current;
if (!media) return;
seek(media.currentTime + delta);
seek((readPendingSeekTarget(media) ?? media.currentTime) + delta);
},
[seek],
);
@@ -350,58 +407,33 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
if (!media || !Number.isFinite(media.duration)) return;
const clampedPercentage = Math.max(0, Math.min(100, percentage));
const time = (clampedPercentage / 100) * media.duration;
armPendingSeekTarget(media, time);
media.currentTime = time;
}, []);
const setVolume = useCallback(
(volume: number) => {
const media = mediaRef.current;
if (!media) return;
const clampedVolume = Math.max(0, Math.min(1, volume));
media.volume = clampedVolume;
if (clampedVolume > 0) {
previousVolumeRef.current = clampedVolume;
}
if (persistVolume) {
storeVolume(clampedVolume);
}
},
[persistVolume],
);
const toggleMute = useCallback(() => {
const media = mediaRef.current;
if (!media) return;
const newMuted = !media.muted;
media.muted = newMuted;
if (!newMuted && media.volume === 0) {
media.volume = previousVolumeRef.current || DEFAULT_VOLUME;
}
if (persistVolume) {
storeMuted(newMuted);
}
}, [persistVolume]);
const setPlaybackRate = useCallback(
(rate: number) => {
const media = mediaRef.current;
if (!media) return;
const clampedRate = Math.max(0.25, Math.min(4, rate));
media.defaultPlaybackRate = clampedRate;
media.playbackRate = clampedRate;
if (persistPlaybackRate) {
storePlaybackRate(clampedRate);
storePlaybackRate(mediaKind, clampedRate);
}
},
[persistPlaybackRate],
[mediaKind, persistPlaybackRate],
);
return {
mediaRef,
state,
play,
pause,
resumeHold,
releaseHold,
toggle,
seek,
seekRelative,
seekPercentage,
setVolume,
toggleMute,
setPlaybackRate,
};
}
@@ -1,25 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
areBufferedSpanFractionsEqual,
armPendingSeekTarget,
type BufferedSpanFraction,
clampMediaTime,
clampPercentage,
getBufferedPercentage,
clearPendingSeekTarget,
collectBufferedSpanFractions,
EMPTY_BUFFERED_SPANS,
getEffectiveMediaDuration,
readPendingSeekTarget,
} from '@app/features/voice/components/media_player/utils/MediaSeekUtils';
import {useCallback, useEffect, useRef, useState} from 'react';
const SEEK_SETTLED_TOLERANCE_SECONDS = 0.5;
interface UseMediaProgressOptions {
mediaRef: React.RefObject<HTMLMediaElement | null>;
initialDuration?: number;
updateInterval?: number;
useRAF?: boolean;
}
export interface UseMediaProgressReturn {
currentTime: number;
duration: number;
progress: number;
buffered: number;
buffered: ReadonlyArray<BufferedSpanFraction>;
isSeeking: boolean;
previewSeekToPercentage: (percentage: number) => void;
seekToPercentage: (percentage: number) => void;
@@ -29,18 +35,15 @@ export interface UseMediaProgressReturn {
}
export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProgressReturn {
const {mediaRef, initialDuration, updateInterval = 100, useRAF = true} = options;
const {mediaRef, initialDuration} = options;
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(initialDuration ?? 0);
const [buffered, setBuffered] = useState(0);
const [buffered, setBuffered] = useState<ReadonlyArray<BufferedSpanFraction>>(EMPTY_BUFFERED_SPANS);
const [isSeeking, setIsSeeking] = useState(false);
const [pendingProgress, setPendingProgress] = useState<number | null>(null);
const rafRef = useRef<number | null>(null);
const intervalRef = useRef<number | null>(null);
const isSeekingRef = useRef(false);
const isDraggingRef = useRef(false);
const deferredSeekPercentageRef = useRef<number | null>(null);
const fallbackDurationRef = useRef(initialDuration ?? 0);
const pendingSeekPercentageRef = useRef<number | null>(null);
const lastRafUpdateAtRef = useRef(0);
useEffect(() => {
fallbackDurationRef.current = initialDuration ?? 0;
}, [initialDuration]);
@@ -49,6 +52,14 @@ export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProg
previousCurrentTime === nextCurrentTime ? previousCurrentTime : nextCurrentTime,
);
}, []);
const setBufferedIfChanged = useCallback((nextBuffered: ReadonlyArray<BufferedSpanFraction>) => {
setBuffered((previousBuffered) =>
areBufferedSpanFractionsEqual(previousBuffered, nextBuffered) ? previousBuffered : nextBuffered,
);
}, []);
const setPendingProgressIfChanged = useCallback((nextProgress: number | null) => {
setPendingProgress((previousProgress) => (previousProgress === nextProgress ? previousProgress : nextProgress));
}, []);
const setDurationFromMedia = useCallback((rawDuration: number) => {
const hasRealDuration = Number.isFinite(rawDuration) && rawDuration > 0;
setDuration((previousDuration) => {
@@ -60,139 +71,91 @@ export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProg
return previousDuration === nextDuration ? previousDuration : nextDuration;
});
}, []);
const setBufferedIfChanged = useCallback((nextBuffered: number) => {
setBuffered((previousBuffered) => (previousBuffered === nextBuffered ? previousBuffered : nextBuffered));
}, []);
const setPendingProgressIfChanged = useCallback((nextProgress: number | null) => {
setPendingProgress((previousProgress) => (previousProgress === nextProgress ? previousProgress : nextProgress));
}, []);
const updateProgress = useCallback(() => {
const media = mediaRef.current;
if (!media || isSeekingRef.current) return;
if (pendingSeekPercentageRef.current !== null) return;
const newCurrentTime = media.currentTime;
const rawDuration = media.duration;
const newBuffered = getBufferedPercentage(media);
setCurrentTimeIfChanged(newCurrentTime);
setDurationFromMedia(rawDuration);
setBufferedIfChanged(newBuffered);
}, [mediaRef, setBufferedIfChanged, setCurrentTimeIfChanged, setDurationFromMedia]);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
updateProgress();
const cancelRaf = () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
const cancelInterval = () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const startRaf = () => {
if (rafRef.current !== null) return;
const tick = (timestamp: number) => {
if (timestamp - lastRafUpdateAtRef.current >= updateInterval) {
lastRafUpdateAtRef.current = timestamp;
updateProgress();
}
if (!media.paused && !media.ended) {
rafRef.current = requestAnimationFrame(tick);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(tick);
};
const startInterval = () => {
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(updateProgress, updateInterval);
};
const startProgressUpdates = () => {
updateProgress();
if (useRAF) {
startRaf();
} else {
startInterval();
}
};
const stopProgressUpdates = () => {
updateProgress();
cancelRaf();
cancelInterval();
const applyDeferredSeek = () => {
const deferredPercentage = deferredSeekPercentageRef.current;
if (deferredPercentage === null) return;
const effectiveDuration = getEffectiveMediaDuration(media, 0);
if (effectiveDuration <= 0) return;
deferredSeekPercentageRef.current = null;
const time = (deferredPercentage / 100) * effectiveDuration;
armPendingSeekTarget(media, time);
media.currentTime = time;
setCurrentTimeIfChanged(time);
};
const handleLoadedMetadata = () => {
const rawDuration = media.duration;
if (Number.isFinite(rawDuration) && rawDuration > 0) {
setDuration((previousDuration) => (previousDuration === rawDuration ? previousDuration : rawDuration));
if (pendingSeekPercentageRef.current !== null) {
const time = (pendingSeekPercentageRef.current / 100) * rawDuration;
media.currentTime = time;
setCurrentTimeIfChanged(time);
pendingSeekPercentageRef.current = null;
setPendingProgressIfChanged(null);
}
return;
}
setDuration((previousDuration) => (previousDuration > 0 ? previousDuration : fallbackDurationRef.current));
setDurationFromMedia(media.duration);
applyDeferredSeek();
};
const handleProgress = () => {
setBufferedIfChanged(getBufferedPercentage(media));
const handleDurationChange = () => {
setDurationFromMedia(media.duration);
};
const handleTimeUpdate = () => {
if (!isSeekingRef.current && pendingSeekPercentageRef.current === null) {
setCurrentTimeIfChanged(media.currentTime);
const seekTarget = readPendingSeekTarget(media);
if (seekTarget !== null && Math.abs(media.currentTime - seekTarget) <= SEEK_SETTLED_TOLERANCE_SECONDS) {
clearPendingSeekTarget(media);
}
if (
isDraggingRef.current ||
readPendingSeekTarget(media) !== null ||
deferredSeekPercentageRef.current !== null
) {
return;
}
setCurrentTimeIfChanged(media.currentTime);
setPendingProgressIfChanged(null);
};
const handleProgress = () => {
setBufferedIfChanged(collectBufferedSpanFractions(media));
};
const handleSeeking = () => {
if (!isSeekingRef.current) {
if (!isDraggingRef.current) {
setIsSeeking(true);
}
};
const handleSeeked = () => {
if (!isSeekingRef.current) {
setIsSeeking(false);
}
clearPendingSeekTarget(media);
setCurrentTimeIfChanged(media.currentTime);
if (isDraggingRef.current) return;
setIsSeeking(false);
setPendingProgressIfChanged(null);
};
const handleEnded = () => {
clearPendingSeekTarget(media);
setCurrentTimeIfChanged(media.currentTime);
};
const handleEmptied = () => {
clearPendingSeekTarget(media);
deferredSeekPercentageRef.current = null;
setPendingProgressIfChanged(null);
setBufferedIfChanged(EMPTY_BUFFERED_SPANS);
};
media.addEventListener('loadedmetadata', handleLoadedMetadata);
media.addEventListener('progress', handleProgress);
media.addEventListener('durationchange', handleDurationChange);
media.addEventListener('timeupdate', handleTimeUpdate);
media.addEventListener('progress', handleProgress);
media.addEventListener('seeking', handleSeeking);
media.addEventListener('seeked', handleSeeked);
media.addEventListener('play', startProgressUpdates);
media.addEventListener('playing', startProgressUpdates);
media.addEventListener('pause', stopProgressUpdates);
media.addEventListener('ended', stopProgressUpdates);
if (!media.paused && !media.ended) {
startProgressUpdates();
media.addEventListener('ended', handleEnded);
media.addEventListener('emptied', handleEmptied);
setDurationFromMedia(media.duration);
if (media.readyState >= 1) {
setCurrentTimeIfChanged(media.currentTime);
setBufferedIfChanged(collectBufferedSpanFractions(media));
}
return () => {
media.removeEventListener('loadedmetadata', handleLoadedMetadata);
media.removeEventListener('progress', handleProgress);
media.removeEventListener('durationchange', handleDurationChange);
media.removeEventListener('timeupdate', handleTimeUpdate);
media.removeEventListener('progress', handleProgress);
media.removeEventListener('seeking', handleSeeking);
media.removeEventListener('seeked', handleSeeked);
media.removeEventListener('play', startProgressUpdates);
media.removeEventListener('playing', startProgressUpdates);
media.removeEventListener('pause', stopProgressUpdates);
media.removeEventListener('ended', stopProgressUpdates);
cancelRaf();
cancelInterval();
media.removeEventListener('ended', handleEnded);
media.removeEventListener('emptied', handleEmptied);
};
}, [
mediaRef,
setBufferedIfChanged,
setCurrentTimeIfChanged,
setPendingProgressIfChanged,
updateProgress,
updateInterval,
useRAF,
]);
}, [mediaRef, setBufferedIfChanged, setCurrentTimeIfChanged, setDurationFromMedia, setPendingProgressIfChanged]);
const previewSeekToPercentage = useCallback(
(percentage: number) => {
const clampedPercentage = clampPercentage(percentage);
@@ -211,13 +174,14 @@ export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProg
const effectiveDuration = getEffectiveMediaDuration(media, 0);
if (media && effectiveDuration > 0) {
const time = (clampedPercentage / 100) * effectiveDuration;
deferredSeekPercentageRef.current = null;
armPendingSeekTarget(media, time);
media.currentTime = time;
setCurrentTimeIfChanged(time);
pendingSeekPercentageRef.current = null;
setPendingProgressIfChanged(null);
setPendingProgressIfChanged(clampedPercentage);
return;
}
pendingSeekPercentageRef.current = clampedPercentage;
deferredSeekPercentageRef.current = clampedPercentage;
setPendingProgressIfChanged(clampedPercentage);
const fallbackDuration = fallbackDurationRef.current;
if (fallbackDuration > 0) {
@@ -231,19 +195,27 @@ export function useMediaProgress(options: UseMediaProgressOptions): UseMediaProg
const media = mediaRef.current;
if (!media) return;
const clampedTime = clampMediaTime(time, media.duration);
armPendingSeekTarget(media, clampedTime);
media.currentTime = clampedTime;
setCurrentTimeIfChanged(clampedTime);
const effectiveDuration = getEffectiveMediaDuration(media, fallbackDurationRef.current);
if (effectiveDuration > 0) {
setPendingProgressIfChanged(clampPercentage((clampedTime / effectiveDuration) * 100));
}
},
[mediaRef, setCurrentTimeIfChanged],
[mediaRef, setCurrentTimeIfChanged, setPendingProgressIfChanged],
);
const startSeeking = useCallback(() => {
isSeekingRef.current = true;
isDraggingRef.current = true;
setIsSeeking(true);
}, []);
const endSeeking = useCallback(() => {
isSeekingRef.current = false;
isDraggingRef.current = false;
setIsSeeking(false);
}, []);
if (readPendingSeekTarget(mediaRef.current) === null) {
setPendingProgressIfChanged(null);
}
}, [mediaRef, setPendingProgressIfChanged]);
const progress = pendingProgress !== null ? pendingProgress : duration > 0 ? (currentTime / duration) * 100 : 0;
return {
currentTime,
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useCallback, useState} from 'react';
export type MediaPreloadAttribute = 'none' | 'metadata';
export interface UseMetadataPreloadReturn {
escalateToMetadata: () => void;
sourceAttribute: string | undefined;
preloadAttribute: MediaPreloadAttribute;
}
export function useMetadataPreload(src: string, hasStarted: boolean): UseMetadataPreloadReturn {
const [wantsMetadata, setWantsMetadata] = useState(false);
const escalateToMetadata = useCallback(() => {
setWantsMetadata(true);
}, []);
return {
escalateToMetadata,
sourceAttribute: hasStarted || wantsMetadata ? src : undefined,
preloadAttribute: wantsMetadata ? 'metadata' : 'none',
};
}
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
import {
createVideoDecodabilityProbe,
type VideoDecodabilityProbe,
} from '@app/features/voice/components/media_player/utils/MediaDecodability';
import type React from 'react';
import {useEffect, useRef} from 'react';
const logger = new Logger('VideoDecodability');
export interface UseVideoDecodabilityOptions {
videoRef: React.RefObject<HTMLVideoElement | null>;
isPlaying: boolean;
src: string;
}
export function useVideoDecodability(options: UseVideoDecodabilityOptions): void {
const {videoRef, isPlaying, src} = options;
const probeRef = useRef<VideoDecodabilityProbe | null>(null);
useEffect(() => {
const probe = createVideoDecodabilityProbe({
readSample: () => {
const video = videoRef.current;
if (!video) return null;
return {
videoHeight: video.videoHeight,
readyState: video.readyState,
currentTime: video.currentTime,
};
},
schedule: (callback, delayMs) => {
const timer = setTimeout(callback, delayMs);
return () => clearTimeout(timer);
},
onVerdict: (verdict) => {
if (verdict !== 'undecodable') return;
logger.warn('video advanced its clock without ever decoding a frame', {
src,
readyState: videoRef.current?.readyState ?? null,
currentTime: videoRef.current?.currentTime ?? null,
});
},
});
probeRef.current = probe;
return () => {
probeRef.current = null;
probe.cancel();
};
}, [src, videoRef]);
useEffect(() => {
if (!isPlaying) return;
const video = videoRef.current;
const probe = probeRef.current;
if (video == null || probe == null) return;
probe.probe();
video.addEventListener('timeupdate', probe.probe);
return () => {
video.removeEventListener('timeupdate', probe.probe);
};
}, [isPlaying, videoRef]);
}
@@ -3,7 +3,6 @@
export const AUDIO_PLAYBACK_RATES = [1, 1.5, 2, 0.75] as const;
export const DEFAULT_SEEK_AMOUNT = 10;
export const DEFAULT_VOLUME = 1;
export const PLAYBACK_RATES = [1, 1.5, 2, 0.75] as const;
export const VIDEO_PLAYBACK_RATES = [1, 1.5, 2, 0.75] as const;
export const VIDEO_BREAKPOINTS = {
SMALL: 240,
@@ -14,4 +13,3 @@ export const VOLUME_STEP = 0.1;
export const SEEK_STEP = 10;
export const VOLUME_STORAGE_KEY = 'fluxer:media_player:volume';
export const MUTE_STORAGE_KEY = 'fluxer:media_player:muted';
export const PLAYBACK_RATE_STORAGE_KEY = 'fluxer:media_player:playback-rate';
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const HAVE_CURRENT_DATA = 2;
export const DECODABILITY_RECHECK_DELAY_MS = 1500;
export const DECODABILITY_MIN_ADVANCE_SECONDS = 0.5;
export interface VideoDecodabilitySample {
videoHeight: number;
readyState: number;
currentTime: number;
}
export type VideoDecodability = 'decodable' | 'undecodable' | 'unknown';
export type DecodabilityFirstLook = 'decodable' | 'recheck' | 'unknown';
export function classifyDecodabilitySample(sample: VideoDecodabilitySample): DecodabilityFirstLook {
if (!Number.isFinite(sample.videoHeight) || !Number.isFinite(sample.readyState)) return 'unknown';
if (sample.videoHeight > 0) return 'decodable';
if (sample.readyState >= HAVE_CURRENT_DATA) return 'recheck';
return 'unknown';
}
export function classifyDecodabilityRecheck(
first: VideoDecodabilitySample,
second: VideoDecodabilitySample,
): VideoDecodability {
if (!Number.isFinite(second.videoHeight)) return 'unknown';
if (second.videoHeight > 0) return 'decodable';
if (!Number.isFinite(first.currentTime) || !Number.isFinite(second.currentTime)) return 'unknown';
if (second.currentTime > first.currentTime + DECODABILITY_MIN_ADVANCE_SECONDS) return 'undecodable';
return 'unknown';
}
export type DecodabilityScheduler = (callback: () => void, delayMs: number) => () => void;
export interface VideoDecodabilityProbeOptions {
readSample: () => VideoDecodabilitySample | null;
schedule: DecodabilityScheduler;
onVerdict: (verdict: Exclude<VideoDecodability, 'unknown'>) => void;
}
export interface VideoDecodabilityProbe {
probe: () => void;
cancel: () => void;
getVerdict: () => VideoDecodability;
}
export function createVideoDecodabilityProbe(options: VideoDecodabilityProbeOptions): VideoDecodabilityProbe {
const {readSample, schedule, onVerdict} = options;
let verdict: VideoDecodability = 'unknown';
let cancelRecheck: (() => void) | null = null;
const settle = (next: Exclude<VideoDecodability, 'unknown'>) => {
verdict = next;
onVerdict(next);
};
const probe = () => {
if (verdict !== 'unknown' || cancelRecheck !== null) return;
const first = readSample();
if (first === null) return;
const firstLook = classifyDecodabilitySample(first);
if (firstLook === 'decodable') {
settle('decodable');
return;
}
if (firstLook !== 'recheck') return;
cancelRecheck = schedule(() => {
cancelRecheck = null;
const second = readSample();
if (second === null) return;
const rechecked = classifyDecodabilityRecheck(first, second);
if (rechecked !== 'unknown') {
settle(rechecked);
}
}, DECODABILITY_RECHECK_DELAY_MS);
};
const cancel = () => {
if (cancelRecheck === null) return;
const pending = cancelRecheck;
cancelRecheck = null;
pending();
};
return {probe, cancel, getVerdict: () => verdict};
}
@@ -1,12 +1,22 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {isAutoplayBlockedError} from '@app/features/voice/components/media_player/hooks/useMediaPlayer';
import {afterEach, describe, expect, it} from 'vitest';
import {
areBufferedSpanFractionsEqual,
armPendingSeekTarget,
clampMediaTime,
clampPercentage,
clearPendingSeekTarget,
collectBufferedSpanFractions,
detachMediaElementSource,
getBufferedPercentage,
getEffectiveMediaDuration,
getSeekPercentageFromClientX,
readPendingSeekTarget,
resolveDoubleTapSeekDirection,
stepPlayheadPrediction,
} from './MediaSeekUtils';
function mediaWithBufferedRanges({
@@ -89,3 +99,317 @@ describe('MediaSeekUtils', () => {
expect(resolveDoubleTapSeekDirection(first, {x: 24, width: 100, time: 1500})).toBeNull();
});
});
describe('detachMediaElementSource', () => {
const loadedElements: Array<HTMLMediaElement> = [];
let restoreLoad: (() => void) | null = null;
const stubLoad = (implementation: (element: HTMLMediaElement) => void): void => {
const prototype = window.HTMLMediaElement.prototype;
const original = Object.getOwnPropertyDescriptor(prototype, 'load');
Object.defineProperty(prototype, 'load', {
configurable: true,
writable: true,
value: function (this: HTMLMediaElement) {
implementation(this);
},
});
restoreLoad = () => {
if (original) Object.defineProperty(prototype, 'load', original);
else Reflect.deleteProperty(prototype as unknown as Record<string, unknown>, 'load');
};
};
const buildVideoWithChildren = (): HTMLVideoElement => {
const video = document.createElement('video');
video.setAttribute('src', 'https://media.test/clip.mp4');
const source = document.createElement('source');
source.setAttribute('src', 'https://media.test/clip.webm');
source.setAttribute('type', 'video/webm');
const poster = document.createElement('img');
poster.setAttribute('src', 'https://media.test/poster.webp');
video.append(source, poster);
document.body.append(video);
return video;
};
afterEach(() => {
loadedElements.length = 0;
restoreLoad?.();
restoreLoad = null;
document.body.innerHTML = '';
});
it('strips every url off the element and its children and then reloads it', () => {
stubLoad((element) => loadedElements.push(element));
const video = buildVideoWithChildren();
detachMediaElementSource(video);
expect(video.getAttribute('src')).toBeNull();
expect(video.querySelector('source')?.getAttribute('src')).toBeNull();
expect(video.querySelector('source')?.getAttribute('type')).toBeNull();
expect(video.querySelector('img')?.getAttribute('src')).toBeNull();
expect(loadedElements).toEqual([video]);
});
it('still clears the urls when the element refuses to reload', () => {
stubLoad(() => {
throw new Error('load is not implemented');
});
const video = buildVideoWithChildren();
expect(() => detachMediaElementSource(video)).not.toThrow();
expect(video.getAttribute('src')).toBeNull();
expect(video.querySelector('source')?.getAttribute('src')).toBeNull();
});
it('does nothing when there is no element left to release', () => {
stubLoad((element) => loadedElements.push(element));
expect(() => detachMediaElementSource(null)).not.toThrow();
expect(loadedElements).toEqual([]);
});
});
describe('buffered ranges shorter than a second', () => {
it('ignores a sliver that would otherwise become the reported buffer', () => {
const media = mediaWithBufferedRanges({
duration: 100,
currentTime: 50,
ranges: [
[0, 10],
[60, 60.4],
],
});
expect(getBufferedPercentage(media)).toBe(10);
});
it('ignores a sliver even when the playhead sits inside it', () => {
const media = mediaWithBufferedRanges({
duration: 100,
currentTime: 60.2,
ranges: [
[0, 10],
[60, 60.4],
],
});
expect(getBufferedPercentage(media)).toBe(10);
});
});
describe('isAutoplayBlockedError', () => {
it('recognises only the browser refusing to start playback', () => {
expect(isAutoplayBlockedError(new DOMException('blocked', 'NotAllowedError'))).toBe(true);
expect(isAutoplayBlockedError(new DOMException('no source', 'NotSupportedError'))).toBe(false);
expect(isAutoplayBlockedError(new Error('NotAllowedError'))).toBe(false);
expect(isAutoplayBlockedError('NotAllowedError')).toBe(false);
expect(isAutoplayBlockedError(null)).toBe(false);
});
});
describe('collectBufferedSpanFractions', () => {
it('emits one normalised span per disjoint buffered island, so the bar can leave the gap between them unpainted', () => {
const media = mediaWithBufferedRanges({
duration: 120,
currentTime: 0,
ranges: [
[0, 30],
[60, 90],
],
});
expect(collectBufferedSpanFractions(media)).toStrictEqual([
{offset: 0, span: 0.25},
{offset: 0.5, span: 0.25},
]);
});
it('drops a buffered island shorter than the minimum, so a sliver never paints as an island of its own', () => {
const media = mediaWithBufferedRanges({
duration: 120,
currentTime: 0,
ranges: [
[0, 30],
[60, 60.5],
[90, 120],
],
});
expect(collectBufferedSpanFractions(media)).toStrictEqual([
{offset: 0, span: 0.25},
{offset: 0.75, span: 0.25},
]);
});
it('emits nothing while the duration is still unknown, so the bar does not paint against a NaN denominator', () => {
const media = mediaWithBufferedRanges({duration: Number.NaN, currentTime: 0, ranges: [[0, 30]]});
expect(collectBufferedSpanFractions(media)).toStrictEqual([]);
});
it('never lets a span run past the end of the track', () => {
const media = mediaWithBufferedRanges({duration: 100, currentTime: 0, ranges: [[80, 400]]});
const spans = collectBufferedSpanFractions(media);
expect(spans).toHaveLength(1);
expect(spans[0]!.offset + spans[0]!.span).toBe(1);
});
});
describe('areBufferedSpanFractionsEqual', () => {
it('reports two separately built span lists with the same contents as equal, so an unchanged buffer commits nothing', () => {
expect(
areBufferedSpanFractionsEqual(
[
{offset: 0, span: 0.25},
{offset: 0.5, span: 0.25},
],
[
{offset: 0, span: 0.25},
{offset: 0.5, span: 0.25},
],
),
).toBe(true);
});
it('reports a grown island as different, so real buffering progress still repaints', () => {
expect(areBufferedSpanFractionsEqual([{offset: 0, span: 0.25}], [{offset: 0, span: 0.3}])).toBe(false);
});
it('reports a moved island as different, so a span that slid along the track still repaints', () => {
expect(areBufferedSpanFractionsEqual([{offset: 0, span: 0.25}], [{offset: 0.1, span: 0.25}])).toBe(false);
});
it('reports a new island as different, so a second island appearing still repaints', () => {
expect(
areBufferedSpanFractionsEqual(
[{offset: 0, span: 0.25}],
[
{offset: 0, span: 0.25},
{offset: 0.5, span: 0.25},
],
),
).toBe(false);
});
});
describe('the pending seek latch', () => {
it('hands the armed target back to a reader that only holds the element', () => {
const media = document.createElement('audio');
armPendingSeekTarget(media, 42);
expect(readPendingSeekTarget(media)).toBe(42);
});
it('keeps each element on its own latch, so seeking one player does not suppress another', () => {
const seeking = document.createElement('audio');
const idle = document.createElement('audio');
armPendingSeekTarget(seeking, 42);
expect(readPendingSeekTarget(idle)).toBeNull();
});
it('reads back null once the latch is cleared, so a settled seek stops suppressing time updates', () => {
const media = document.createElement('audio');
armPendingSeekTarget(media, 42);
clearPendingSeekTarget(media);
expect(readPendingSeekTarget(media)).toBeNull();
});
it('treats a target of zero as armed rather than as an empty latch', () => {
const media = document.createElement('audio');
armPendingSeekTarget(media, 0);
expect(readPendingSeekTarget(media)).toBe(0);
});
});
describe('stepPlayheadPrediction', () => {
it('seeds the prediction from the element clock on the first frame, when there is nothing to predict from yet', () => {
expect(stepPlayheadPrediction({predictedSeconds: null, actualSeconds: 12.25, elapsedSeconds: 0})).toBe(12.25);
});
it('advances the prediction by the wall-clock delta between frames while the element clock stands still', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10, elapsedSeconds: 0.1})).toBeCloseTo(
10.09,
10,
);
});
it('scales the advance by the playback rate, so a double-speed frame predicts twice the wall-clock delta', () => {
expect(
stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10, elapsedSeconds: 0.1, playbackRate: 2}),
).toBeCloseTo(10.18, 10);
});
it('closes a tenth of a small error toward the element clock rather than jumping to it', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10.3, elapsedSeconds: 0.1})).toBeCloseTo(
10.12,
10,
);
});
it('hard-snaps to the element clock when a seek moves it further than the snap threshold', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 40, elapsedSeconds: 0.016})).toBe(40);
});
it('hard-snaps a drift just over half a second rather than converging on it', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10.6, elapsedSeconds: 0})).toBe(10.6);
});
it('converges on a drift just under half a second rather than snapping to it', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10.4, elapsedSeconds: 0})).toBeCloseTo(
10.04,
10,
);
});
it('converges on a drift sitting exactly on the snap threshold rather than snapping to it', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: 10.5, elapsedSeconds: 0})).toBeCloseTo(
10.05,
10,
);
});
it('hard-snaps backwards too, so a seek to the head of the media does not creep back over many frames', () => {
expect(stepPlayheadPrediction({predictedSeconds: 30, actualSeconds: 0, elapsedSeconds: 0.016})).toBe(0);
});
it('keeps predicting from the last prediction when the element clock reads a non-finite value', () => {
expect(stepPlayheadPrediction({predictedSeconds: 10, actualSeconds: Number.NaN, elapsedSeconds: 0.5})).toBeCloseTo(
10.5,
10,
);
});
it('never predicts a negative playhead', () => {
expect(stepPlayheadPrediction({predictedSeconds: null, actualSeconds: -5, elapsedSeconds: 0})).toBe(0);
});
});
describe('getEffectiveMediaDuration', () => {
it('reports the length the element itself knows, ignoring the supplied fallback', () => {
expect(getEffectiveMediaDuration({duration: 90} as HTMLMediaElement, 120)).toBe(90);
});
it('falls back to the supplied length while the element still reports none, so a fresh element still has a scale', () => {
expect(getEffectiveMediaDuration({duration: Number.NaN} as HTMLMediaElement, 120)).toBe(120);
});
it('reports no length at all when the fallback runs past every real value, rather than scaling against infinity', () => {
expect(getEffectiveMediaDuration(null, Number.POSITIVE_INFINITY)).toBe(0);
});
it('reports no length at all when the fallback is negative, rather than scaling against a backwards duration', () => {
expect(getEffectiveMediaDuration(null, -30)).toBe(0);
});
});
@@ -1,5 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const MIN_BUFFERED_RANGE_SECONDS = 1;
export const EMPTY_BUFFERED_SPANS: ReadonlyArray<BufferedSpanFraction> = Object.freeze([]);
export interface BufferedSpanFraction {
offset: number;
span: number;
}
const pendingSeekTargets = new WeakMap<HTMLMediaElement, number>();
export function armPendingSeekTarget(media: HTMLMediaElement, time: number): void {
pendingSeekTargets.set(media, time);
}
export function readPendingSeekTarget(media: HTMLMediaElement | null | undefined): number | null {
if (!media) return null;
const target = pendingSeekTargets.get(media);
return target === undefined ? null : target;
}
export function clearPendingSeekTarget(media: HTMLMediaElement | null | undefined): void {
if (!media) return;
pendingSeekTargets.delete(media);
}
export type SeekDirection = 'backward' | 'forward';
export interface SeekTapPoint {
@@ -19,6 +45,11 @@ export function clampMediaTime(time: number, duration: number): number {
return Math.max(0, Math.min(duration, time));
}
export function quantiseMediaTimeToSecond(time: number): number {
if (!Number.isFinite(time)) return 0;
return Math.trunc(time);
}
export function getFiniteMediaDuration(media: HTMLMediaElement | null | undefined): number {
if (!media || !Number.isFinite(media.duration) || media.duration <= 0) return 0;
return media.duration;
@@ -44,6 +75,9 @@ export function getBufferedPercentage(media: HTMLMediaElement): number {
for (let i = 0; i < media.buffered.length; i++) {
const start = media.buffered.start(i);
const end = media.buffered.end(i);
if (end - start < MIN_BUFFERED_RANGE_SECONDS) {
continue;
}
if (currentTime >= start && currentTime <= end) {
bufferedEnd = end;
break;
@@ -55,6 +89,69 @@ export function getBufferedPercentage(media: HTMLMediaElement): number {
return clampPercentage((bufferedEnd / duration) * 100);
}
export function collectBufferedSpanFractions(
media: HTMLMediaElement | null | undefined,
): ReadonlyArray<BufferedSpanFraction> {
const duration = getFiniteMediaDuration(media);
if (!media || duration <= 0) return EMPTY_BUFFERED_SPANS;
const ranges = media.buffered;
if (ranges == null || ranges.length === 0) return EMPTY_BUFFERED_SPANS;
const spans: Array<BufferedSpanFraction> = [];
for (let i = 0; i < ranges.length; i++) {
const start = ranges.start(i);
const end = ranges.end(i);
if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
if (end - start < MIN_BUFFERED_RANGE_SECONDS) continue;
const offset = Math.max(0, Math.min(1, start / duration));
const span = Math.max(0, Math.min(1 - offset, (end - start) / duration));
if (span <= 0) continue;
spans.push({offset, span});
}
return spans.length === 0 ? EMPTY_BUFFERED_SPANS : spans;
}
export function areBufferedSpanFractionsEqual(
a: ReadonlyArray<BufferedSpanFraction>,
b: ReadonlyArray<BufferedSpanFraction>,
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].offset !== b[i].offset || a[i].span !== b[i].span) return false;
}
return true;
}
function isElementOfKind(element: Element, constructorName: string): boolean {
const view = element.ownerDocument?.defaultView as unknown as Record<string, unknown> | null | undefined;
const elementConstructor = view?.[constructorName];
if (typeof elementConstructor !== 'function') return false;
return element instanceof (elementConstructor as new () => Element);
}
export function detachMediaElementSource(media: HTMLMediaElement | null | undefined): void {
if (!media) return;
try {
media.pause();
} catch {}
media.removeAttribute('src');
if ('srcObject' in media && media.srcObject != null) {
media.srcObject = null;
}
for (const child of Array.from(media.children)) {
if (isElementOfKind(child, 'HTMLSourceElement')) {
child.removeAttribute('src');
child.removeAttribute('type');
} else if (isElementOfKind(child, 'HTMLImageElement')) {
child.removeAttribute('src');
}
}
try {
media.load();
} catch {}
}
export function resolveDoubleTapSeekDirection(
previousTap: SeekTapPoint | null,
currentTap: SeekTapPoint,
@@ -74,3 +171,29 @@ export function resolveDoubleTapSeekDirection(
if (currentRatio >= 1 - sideZoneRatio && previousRatio >= 1 - sideZoneRatio) return 'forward';
return null;
}
export const PLAYHEAD_SNAP_THRESHOLD_SECONDS = 0.5;
export const PLAYHEAD_CONVERGENCE_RATE = 0.1;
export interface PlayheadPredictionStep {
predictedSeconds: number | null;
actualSeconds: number;
elapsedSeconds: number;
playbackRate?: number;
}
export function stepPlayheadPrediction(step: PlayheadPredictionStep): number {
const {predictedSeconds, actualSeconds, elapsedSeconds, playbackRate = 1} = step;
const hasActual = Number.isFinite(actualSeconds);
if (predictedSeconds === null || !Number.isFinite(predictedSeconds)) {
return hasActual ? Math.max(0, actualSeconds) : 0;
}
const rate = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1;
const elapsed = Number.isFinite(elapsedSeconds) && elapsedSeconds > 0 ? elapsedSeconds : 0;
const advancedSeconds = predictedSeconds + elapsed * rate;
if (!hasActual) return Math.max(0, advancedSeconds);
const error = actualSeconds - advancedSeconds;
if (Math.abs(error) > PLAYHEAD_SNAP_THRESHOLD_SECONDS) return Math.max(0, actualSeconds);
return Math.max(0, advancedSeconds + PLAYHEAD_CONVERGENCE_RATE * error);
}
@@ -1,59 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {makePersistent} from '@app/features/platform/utils/MobXPersistence';
import {makeAutoObservable} from 'mobx';
const DEFAULT_VOLUME = 1;
class AudioVolume {
volume = DEFAULT_VOLUME;
isMuted = false;
private previousVolume = DEFAULT_VOLUME;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'AudioVolume', ['volume', 'isMuted']);
if (this.volume > 0) {
this.previousVolume = this.volume;
}
}
setVolume(newVolume: number): void {
const clamped = Math.max(0, Math.min(1, newVolume));
this.volume = clamped;
if (clamped > 0) {
this.previousVolume = clamped;
}
if (this.isMuted && clamped > 0) {
this.isMuted = false;
}
}
toggleMute(): void {
if (this.isMuted) {
this.isMuted = false;
if (this.volume === 0) {
this.volume = this.previousVolume;
}
} else {
this.isMuted = true;
}
}
setMuted(muted: boolean): void {
this.isMuted = muted;
if (!muted && this.volume === 0) {
this.volume = this.previousVolume;
}
}
get effectiveVolume(): number {
return this.isMuted ? 0 : this.volume;
}
}
export default new AudioVolume();