Files
lidify/frontend/features/album/hooks/useTrackPreview.ts
2025-12-25 18:58:06 -06:00

177 lines
6.1 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { api } from "@/lib/api";
import { toast } from "sonner";
import { Track } from "../types";
import { howlerEngine } from "@/lib/howler-engine";
export function useTrackPreview() {
const [previewTrack, setPreviewTrack] = useState<string | null>(null);
const [previewPlaying, setPreviewPlaying] = useState(false);
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const mainPlayerWasPausedRef = useRef(false);
const previewRequestIdRef = useRef(0);
const noPreviewTrackIdsRef = useRef<Set<string>>(new Set());
const toastShownForNoPreviewRef = useRef<Set<string>>(new Set());
const inFlightTrackIdRef = useRef<string | null>(null);
const isAbortError = (err: unknown) => {
if (!err || typeof err !== "object") return false;
const e = err as Record<string, unknown>;
const name = typeof e.name === "string" ? e.name : "";
const code = typeof e.code === "number" ? e.code : undefined;
const message = typeof e.message === "string" ? e.message : "";
return (
name === "AbortError" ||
code === 20 ||
message.includes("interrupted by a call to pause")
);
};
const showNoPreviewToast = (trackId: string) => {
if (toastShownForNoPreviewRef.current.has(trackId)) return;
toastShownForNoPreviewRef.current.add(trackId);
// Small, out-of-the-way notification (not an "error" state)
toast("No Deezer preview available", { duration: 1500 });
};
const handlePreview = async (
track: Track,
artistName: string,
e: React.MouseEvent
) => {
e.stopPropagation();
// If the same track is playing, pause it
if (previewTrack === track.id && previewPlaying) {
if (previewAudioRef.current) {
previewAudioRef.current.pause();
setPreviewPlaying(false);
// Don't auto-resume main player - let user manually click play
// This prevents the "pop in" effect when spam-clicking preview
}
return;
}
// If a different track is playing, stop it first
if (previewAudioRef.current) {
previewAudioRef.current.pause();
previewAudioRef.current = null;
}
try {
if (inFlightTrackIdRef.current === track.id) return;
if (noPreviewTrackIdsRef.current.has(track.id)) {
showNoPreviewToast(track.id);
return;
}
const requestId = ++previewRequestIdRef.current;
inFlightTrackIdRef.current = track.id;
// Fetch preview URL
const response = await api.getTrackPreview(artistName, track.title);
if (requestId !== previewRequestIdRef.current) return;
if (!response.previewUrl) {
noPreviewTrackIdsRef.current.add(track.id);
showNoPreviewToast(track.id);
return;
}
// Pause the main player if it's playing
if (howlerEngine.isPlaying()) {
howlerEngine.pause();
mainPlayerWasPausedRef.current = true;
}
// Create new audio element
const audio = new Audio(response.previewUrl);
previewAudioRef.current = audio;
// Set up event handlers
audio.onended = () => {
setPreviewPlaying(false);
setPreviewTrack(null);
// Don't auto-resume main player - let user manually click play
mainPlayerWasPausedRef.current = false;
};
audio.onerror = () => {
toast.error("Failed to play preview");
setPreviewPlaying(false);
setPreviewTrack(null);
};
// Play audio
try {
await audio.play();
} catch (err: unknown) {
if (isAbortError(err)) return;
throw err;
}
setPreviewTrack(track.id);
setPreviewPlaying(true);
} catch (error: unknown) {
if (isAbortError(error)) return;
if (
typeof error === "object" &&
error !== null &&
(((error as Record<string, unknown>).error as unknown) ===
"Preview not found" ||
/preview not found/i.test(
String((error as Record<string, unknown>).message || "")
))
) {
noPreviewTrackIdsRef.current.add(track.id);
showNoPreviewToast(track.id);
return;
}
console.error("Failed to play preview:", error);
toast.error("Failed to play preview");
setPreviewPlaying(false);
setPreviewTrack(null);
} finally {
if (inFlightTrackIdRef.current === track.id) {
inFlightTrackIdRef.current = null;
}
}
};
// Stop preview when main player starts playing
useEffect(() => {
const stopPreview = () => {
if (previewAudioRef.current) {
previewAudioRef.current.pause();
previewAudioRef.current = null;
setPreviewPlaying(false);
setPreviewTrack(null);
// Don't resume main player - it's already playing
mainPlayerWasPausedRef.current = false;
}
};
howlerEngine.on("play", stopPreview);
return () => {
howlerEngine.off("play", stopPreview);
};
}, []);
// Cleanup effect: Stop audio on unmount
useEffect(() => {
return () => {
if (previewAudioRef.current) {
previewAudioRef.current.pause();
previewAudioRef.current = null;
}
// Don't auto-resume main player on unmount
mainPlayerWasPausedRef.current = false;
};
}, []);
return {
previewTrack,
previewPlaying,
handlePreview,
};
}