/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useCallback, useEffect, useRef, useState} from 'react'; import {DEFAULT_VOLUME, MUTE_STORAGE_KEY, PLAYBACK_RATE_STORAGE_KEY, VOLUME_STORAGE_KEY} from '../utils/mediaConstants'; export interface MediaPlayerState { isPlaying: boolean; isPaused: boolean; isEnded: boolean; isBuffering: boolean; isSeeking: boolean; currentTime: number; duration: number; bufferedRanges: TimeRanges | null; volume: number; isMuted: boolean; playbackRate: number; error: Error | null; } export interface UseMediaPlayerOptions { autoPlay?: boolean; loop?: boolean; muted?: boolean; initialVolume?: number; initialPlaybackRate?: number; persistVolume?: boolean; persistPlaybackRate?: boolean; onEnded?: () => void; onError?: (error: Error) => void; onPlay?: () => void; onPause?: () => void; onTimeUpdate?: (currentTime: number) => void; onLoadedMetadata?: (duration: number) => void; } export interface UseMediaPlayerReturn { mediaRef: React.RefObject; state: MediaPlayerState; play: () => Promise; pause: () => void; toggle: () => Promise; 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 = localStorage.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 localStorage.getItem(MUTE_STORAGE_KEY) === 'true'; } catch { return false; } } function getStoredPlaybackRate(): number { try { const stored = localStorage.getItem(PLAYBACK_RATE_STORAGE_KEY); if (stored !== null) { const value = parseFloat(stored); if (Number.isFinite(value) && value >= 0.25 && value <= 4) { return value; } } } catch {} return 1; } function storeVolume(volume: number): void { try { localStorage.setItem(VOLUME_STORAGE_KEY, volume.toString()); } catch {} } function storeMuted(muted: boolean): void { try { localStorage.setItem(MUTE_STORAGE_KEY, muted.toString()); } catch {} } function storePlaybackRate(rate: number): void { try { localStorage.setItem(PLAYBACK_RATE_STORAGE_KEY, rate.toString()); } catch {} } export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPlayerReturn { const { autoPlay = false, loop = false, muted: initialMuted, initialVolume, initialPlaybackRate, persistVolume = true, persistPlaybackRate = true, onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata, } = options; const mediaRef = useRef(null); const previousVolumeRef = useRef(DEFAULT_VOLUME); const callbacksRef = useRef({onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata}); callbacksRef.current = {onEnded, onError, onPlay, onPause, onTimeUpdate, onLoadedMetadata}; const [state, setState] = useState(() => ({ isPlaying: false, isPaused: true, isEnded: false, isBuffering: false, isSeeking: false, currentTime: 0, duration: 0, bufferedRanges: null, volume: initialVolume ?? (persistVolume ? getStoredVolume() : DEFAULT_VOLUME), isMuted: initialMuted ?? (persistVolume ? getStoredMuted() : false), playbackRate: initialPlaybackRate ?? (persistPlaybackRate ? getStoredPlaybackRate() : 1), error: null, })); useEffect(() => { const media = mediaRef.current; if (!media) return; media.volume = state.volume; media.muted = state.isMuted; media.playbackRate = state.playbackRate; media.loop = loop; }, []); useEffect(() => { const media = mediaRef.current; if (!media) return; const handlePlay = () => { setState((prev) => ({ ...prev, isPlaying: true, isPaused: false, isEnded: false, })); callbacksRef.current.onPlay?.(); }; const handlePause = () => { setState((prev) => ({ ...prev, isPlaying: false, isPaused: true, })); callbacksRef.current.onPause?.(); }; const handleEnded = () => { setState((prev) => ({ ...prev, isPlaying: false, isPaused: true, isEnded: true, })); callbacksRef.current.onEnded?.(); }; const handleTimeUpdate = () => { const currentTime = media.currentTime; setState((prev) => ({ ...prev, currentTime, bufferedRanges: media.buffered, })); callbacksRef.current.onTimeUpdate?.(currentTime); }; const handleLoadedMetadata = () => { const duration = media.duration; setState((prev) => ({ ...prev, duration: Number.isFinite(duration) ? duration : 0, })); callbacksRef.current.onLoadedMetadata?.(duration); }; 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})); }; const handleSeeking = () => { setState((prev) => ({...prev, isSeeking: true})); }; const handleSeeked = () => { setState((prev) => ({...prev, isSeeking: false})); }; const handleVolumeChange = () => { setState((prev) => ({ ...prev, volume: media.volume, isMuted: media.muted, })); }; const handleRateChange = () => { setState((prev) => ({ ...prev, playbackRate: media.playbackRate, })); }; const handleError = () => { const error = media.error; 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, })); }; media.addEventListener('play', handlePlay); media.addEventListener('pause', handlePause); media.addEventListener('ended', handleEnded); media.addEventListener('timeupdate', handleTimeUpdate); media.addEventListener('loadedmetadata', handleLoadedMetadata); media.addEventListener('durationchange', handleDurationChange); media.addEventListener('waiting', handleWaiting); media.addEventListener('canplay', handleCanPlay); media.addEventListener('seeking', handleSeeking); media.addEventListener('seeked', handleSeeked); media.addEventListener('volumechange', handleVolumeChange); media.addEventListener('ratechange', handleRateChange); media.addEventListener('error', handleError); media.addEventListener('progress', handleProgress); if (media.readyState >= 1) { handleLoadedMetadata(); } return () => { media.removeEventListener('play', handlePlay); media.removeEventListener('pause', handlePause); media.removeEventListener('ended', handleEnded); media.removeEventListener('timeupdate', handleTimeUpdate); media.removeEventListener('loadedmetadata', handleLoadedMetadata); media.removeEventListener('durationchange', handleDurationChange); media.removeEventListener('waiting', handleWaiting); media.removeEventListener('canplay', handleCanPlay); media.removeEventListener('seeking', handleSeeking); media.removeEventListener('seeked', handleSeeked); media.removeEventListener('volumechange', handleVolumeChange); media.removeEventListener('ratechange', handleRateChange); media.removeEventListener('error', handleError); media.removeEventListener('progress', handleProgress); }; }, []); useEffect(() => { const media = mediaRef.current; if (!media || !autoPlay) return; media.play().catch((error) => { console.debug('Autoplay prevented:', error); }); }, [autoPlay]); const play = useCallback(async () => { const media = mediaRef.current; if (!media) return; try { await media.play(); } catch (error) { console.error('Play failed:', error); throw error; } }, []); const pause = useCallback(() => { const media = mediaRef.current; if (!media) return; media.pause(); }, []); const toggle = useCallback(async () => { const media = mediaRef.current; if (!media) return; if (media.paused) { await play(); } else { pause(); } }, [play, pause]); const seek = useCallback((time: number) => { const media = mediaRef.current; if (!media) return; const clampedTime = Math.max(0, Math.min(time, media.duration || 0)); media.currentTime = clampedTime; }, []); const seekRelative = useCallback( (delta: number) => { const media = mediaRef.current; if (!media) return; seek(media.currentTime + delta); }, [seek], ); const seekPercentage = useCallback((percentage: number) => { const media = mediaRef.current; if (!media || !Number.isFinite(media.duration)) return; const clampedPercentage = Math.max(0, Math.min(100, percentage)); const time = (clampedPercentage / 100) * media.duration; 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.playbackRate = clampedRate; if (persistPlaybackRate) { storePlaybackRate(clampedRate); } }, [persistPlaybackRate], ); return { mediaRef, state, play, pause, toggle, seek, seekRelative, seekPercentage, setVolume, toggleMute, setPlaybackRate, }; }