174 lines
4.7 KiB
TypeScript
174 lines
4.7 KiB
TypeScript
import { create } from "zustand";
|
|
|
|
interface ActiveTrack {
|
|
src: string;
|
|
title: string;
|
|
}
|
|
|
|
interface AudioPlayerState {
|
|
track: ActiveTrack | null;
|
|
audioEl: HTMLAudioElement | null;
|
|
isPlaying: boolean;
|
|
volume: number;
|
|
playbackRate: number;
|
|
duration: number;
|
|
position: number;
|
|
playTrack: (track: ActiveTrack) => Promise<void>;
|
|
togglePlay: () => Promise<void>;
|
|
seekTo: (seconds: number) => void;
|
|
seekBy: (secondsDelta: number) => void;
|
|
setVolume: (volume: number) => void;
|
|
setPlaybackRate: (rate: number) => void;
|
|
cyclePlaybackRate: () => void;
|
|
stop: () => void;
|
|
}
|
|
|
|
let globalAudioEl: HTMLAudioElement | null = null;
|
|
let isBound = false;
|
|
const PLAYBACK_RATE_KEY = "bm_audio_playback_rate";
|
|
|
|
function getInitialPlaybackRate(): number {
|
|
if (typeof window === "undefined") {
|
|
return 1;
|
|
}
|
|
const raw = Number(window.localStorage.getItem(PLAYBACK_RATE_KEY) || "1");
|
|
if (raw === 1 || raw === 1.5 || raw === 2) {
|
|
return raw;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
function ensureAudio(): HTMLAudioElement {
|
|
if (globalAudioEl) {
|
|
return globalAudioEl;
|
|
}
|
|
globalAudioEl = new Audio();
|
|
globalAudioEl.preload = "metadata";
|
|
return globalAudioEl;
|
|
}
|
|
|
|
function getPlayableDuration(audio: HTMLAudioElement): number {
|
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
return audio.duration;
|
|
}
|
|
try {
|
|
if (audio.seekable && audio.seekable.length > 0) {
|
|
const end = audio.seekable.end(audio.seekable.length - 1);
|
|
if (Number.isFinite(end) && end > 0) {
|
|
return end;
|
|
}
|
|
}
|
|
} catch {
|
|
return 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
|
|
track: null,
|
|
audioEl: ensureAudio(),
|
|
isPlaying: false,
|
|
volume: 1,
|
|
playbackRate: getInitialPlaybackRate(),
|
|
duration: 0,
|
|
position: 0,
|
|
playTrack: async (track) => {
|
|
const audio = ensureAudio();
|
|
if (!isBound) {
|
|
audio.addEventListener("timeupdate", () => {
|
|
set({ position: audio.currentTime || 0, duration: getPlayableDuration(audio) });
|
|
});
|
|
audio.addEventListener("loadedmetadata", () => {
|
|
set({ duration: getPlayableDuration(audio) });
|
|
});
|
|
audio.addEventListener("durationchange", () => {
|
|
set({ duration: getPlayableDuration(audio) });
|
|
});
|
|
audio.addEventListener("play", () => set({ isPlaying: true }));
|
|
audio.addEventListener("pause", () => set({ isPlaying: false }));
|
|
audio.addEventListener("ended", () => set({ isPlaying: false, position: 0 }));
|
|
isBound = true;
|
|
}
|
|
const current = get().track;
|
|
if (current?.src !== track.src) {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
audio.src = track.src;
|
|
set({ track, position: 0, duration: 0 });
|
|
} else {
|
|
set({ track });
|
|
}
|
|
audio.volume = get().volume;
|
|
audio.playbackRate = get().playbackRate;
|
|
try {
|
|
await audio.play();
|
|
set({ isPlaying: true, audioEl: audio });
|
|
} catch {
|
|
set({ isPlaying: false, audioEl: audio });
|
|
}
|
|
},
|
|
togglePlay: async () => {
|
|
const audio = ensureAudio();
|
|
if (!audio.src) {
|
|
return;
|
|
}
|
|
if (!audio) {
|
|
return;
|
|
}
|
|
if (audio.paused) {
|
|
await audio.play();
|
|
set({ isPlaying: true });
|
|
return;
|
|
}
|
|
audio.pause();
|
|
set({ isPlaying: false });
|
|
},
|
|
seekTo: (seconds) => {
|
|
const audio = ensureAudio();
|
|
if (!audio.src) {
|
|
return;
|
|
}
|
|
const target = Math.max(0, Math.min(Number.isFinite(audio.duration) ? audio.duration : seconds, seconds));
|
|
audio.currentTime = target;
|
|
set({ position: target });
|
|
},
|
|
seekBy: (secondsDelta) => {
|
|
const audio = ensureAudio();
|
|
if (!audio.src) {
|
|
return;
|
|
}
|
|
const duration = Number.isFinite(audio.duration) ? audio.duration : Number.MAX_SAFE_INTEGER;
|
|
const target = Math.max(0, Math.min(duration, (audio.currentTime || 0) + secondsDelta));
|
|
audio.currentTime = target;
|
|
set({ position: target });
|
|
},
|
|
setVolume: (volume) => {
|
|
const audio = ensureAudio();
|
|
const normalized = Math.max(0, Math.min(1, volume));
|
|
audio.volume = normalized;
|
|
set({ volume: normalized });
|
|
},
|
|
setPlaybackRate: (rate) => {
|
|
const normalized = rate === 2 ? 2 : rate === 1.5 ? 1.5 : 1;
|
|
const audio = ensureAudio();
|
|
audio.playbackRate = normalized;
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(PLAYBACK_RATE_KEY, String(normalized));
|
|
}
|
|
set({ playbackRate: normalized });
|
|
},
|
|
cyclePlaybackRate: () => {
|
|
const current = get().playbackRate;
|
|
const next = current === 1 ? 1.5 : current === 1.5 ? 2 : 1;
|
|
get().setPlaybackRate(next);
|
|
},
|
|
stop: () => {
|
|
const audio = ensureAudio();
|
|
if (audio) {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
}
|
|
set({ audioEl: audio, track: null, isPlaying: false, position: 0, duration: 0 });
|
|
},
|
|
}));
|