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

183 lines
6.9 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 usePreviewPlayer() {
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 });
};
async function handlePreview(
track: Track,
artistName: string,
e: React.MouseEvent
) {
e.stopPropagation();
// If clicking the same track that's playing, pause it
if (previewTrack === track.id && previewPlaying) {
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 clicking a different track, stop current and play new
if (previewTrack !== track.id) {
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;
const response = await api.getTrackPreview(
artistName,
track.title
);
if (requestId !== previewRequestIdRef.current) return;
if (response.previewUrl) {
// Stop current preview if any
if (previewAudioRef.current) {
previewAudioRef.current.pause();
previewAudioRef.current = null;
}
// 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;
setPreviewTrack(track.id);
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 load preview");
setPreviewPlaying(false);
setPreviewTrack(null);
};
try {
await audio.play();
} catch (err: unknown) {
// Common when user clicks quickly and we pause/replace audio.
// Treat AbortError as non-fatal and avoid console spam.
if (isAbortError(err)) return;
throw err;
}
setPreviewPlaying(true);
} else {
noPreviewTrackIdsRef.current.add(track.id);
showNoPreviewToast(track.id);
}
} 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;
}
toast.error("Failed to load preview");
console.error("Preview error:", error);
} finally {
if (inFlightTrackIdRef.current === track.id) {
inFlightTrackIdRef.current = null;
}
}
} else {
// Resume paused preview
try {
await previewAudioRef.current?.play();
} catch (err: unknown) {
if (isAbortError(err)) return;
console.error("Preview error:", err);
}
setPreviewPlaying(true);
}
}
// 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 preview on unmount
useEffect(() => {
return () => {
if (previewAudioRef.current) {
previewAudioRef.current.pause();
previewAudioRef.current = null;
}
};
}, []);
return { previewTrack, previewPlaying, handlePreview };
}