mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): stop seeking and hidden tabs from stranding playback (#1874)
This commit is contained in:
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 {
|
||||
|
||||
+53
@@ -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]);
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -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}
|
||||
|
||||
+20
-7
@@ -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}
|
||||
|
||||
+96
-18
@@ -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}
|
||||
|
||||
+364
-248
@@ -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};
|
||||
}
|
||||
+325
-1
@@ -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();
|
||||
Reference in new issue
Block a user