Files
mcma-webui/src/hooks/useAudioPlayer.ts
T
Senko-san 44c8d1870f
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
2026-06-13 18:17:21 +03:00

162 lines
4.7 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
pause,
resume,
setPosition,
setDuration,
setVolume as setVolumeAction,
} from '../store/slices/player';
import { nextTrack, prevTrack } from '../store/slices/queue';
import { play } from '../store/slices/player';
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
let audioElement: HTMLAudioElement | null = null;
function getAudio(): HTMLAudioElement {
if (!audioElement) {
audioElement = new Audio();
audioElement.preload = 'metadata';
}
return audioElement;
}
export function useAudioPlayer() {
const dispatch = useAppDispatch();
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false);
// `ended` is registered once below; read the latest loop flag through a ref
// so the listener doesn't need to be re-bound on every queue change.
const loopRef = useRef(queue.loop);
loopRef.current = queue.loop;
useEffect(() => {
if (isSetup.current) return;
isSetup.current = true;
const audio = getAudio();
audio.addEventListener('timeupdate', () => {
dispatch(setPosition(audio.currentTime));
});
audio.addEventListener('durationchange', () => {
dispatch(setDuration(audio.duration || 0));
});
audio.addEventListener('ended', () => {
if (loopRef.current) {
audio.currentTime = 0;
void audio.play();
} else {
dispatch(nextTrack());
}
});
audio.addEventListener('pause', () => {
dispatch(pause());
});
audio.addEventListener('play', () => {
dispatch(resume());
});
}, [dispatch]);
useEffect(() => {
if (!player.currentTrackId || !accessToken) return;
const audio = getAudio();
const url = getStreamUrl(player.currentTrackId, accessToken);
if (audio.src !== url) {
audio.src = url;
audio.load();
}
if (player.isPlaying) {
void audio.play();
} else {
audio.pause();
}
}, [player.currentTrackId, player.isPlaying, accessToken]);
useEffect(() => {
const audio = getAudio();
audio.volume = player.muted ? 0 : player.volume;
}, [player.volume, player.muted]);
// MediaSession: system media controls + metadata (lock screen, OS, headset keys)
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const entry = queue.entries[queue.currentIndex];
if (!entry) {
navigator.mediaSession.metadata = null;
return;
}
const artUrl = getCoverUrl(entry.albumArtUrl);
navigator.mediaSession.metadata = new MediaMetadata({
title: entry.title,
artist: entry.artistName,
album: entry.albumTitle,
artwork: artUrl ? [{ src: artUrl }] : [],
});
}, [queue.entries, queue.currentIndex]);
useEffect(() => {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = player.isPlaying
? 'playing'
: 'paused';
}, [player.isPlaying]);
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => dispatch(resume()));
ms.setActionHandler('pause', () => dispatch(pause()));
ms.setActionHandler('previoustrack', () => dispatch(prevTrack()));
ms.setActionHandler('nexttrack', () => dispatch(nextTrack()));
ms.setActionHandler('seekto', (details) => {
if (typeof details.seekTime === 'number') {
getAudio().currentTime = details.seekTime;
dispatch(setPosition(details.seekTime));
}
});
return () => {
ms.setActionHandler('play', null);
ms.setActionHandler('pause', null);
ms.setActionHandler('previoustrack', null);
ms.setActionHandler('nexttrack', null);
ms.setActionHandler('seekto', null);
};
}, [dispatch]);
useEffect(() => {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry) return;
if (currentEntry.trackId !== player.currentTrackId) {
dispatch(play(currentEntry.trackId));
}
}, [queue.currentIndex, queue.entries, player.currentTrackId, dispatch]);
const seek = useCallback(
(seconds: number) => {
const audio = getAudio();
audio.currentTime = seconds;
dispatch(setPosition(seconds));
},
[dispatch],
);
const setPlayerVolume = useCallback(
(vol: number) => {
dispatch(setVolumeAction(vol));
},
[dispatch],
);
const playNext = useCallback(() => {
dispatch(nextTrack());
}, [dispatch]);
const playPrev = useCallback(() => {
dispatch(prevTrack());
}, [dispatch]);
return { seek, setVolume: setPlayerVolume, playNext, playPrev };
}