162 lines
4.7 KiB
TypeScript
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 };
|
|
}
|