Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import { DiscoverTrack, DiscoverPlaylist } from "../types";
|
||||
|
||||
export function useDiscoverActions(
|
||||
playlist: DiscoverPlaylist | null,
|
||||
onGenerationComplete?: () => void,
|
||||
isGenerating?: boolean,
|
||||
refreshBatchStatus?: () => Promise<any>,
|
||||
setPendingGeneration?: (pending: boolean) => void,
|
||||
updateTrackLiked?: (albumId: string, isLiked: boolean) => void
|
||||
) {
|
||||
const { playTracks, isPlaying, pause, resume } = useAudio();
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (isGenerating) {
|
||||
console.warn("Generation already in progress, ignoring request");
|
||||
toast.warning("Generation already in progress...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set optimistic state immediately to prevent double-clicks
|
||||
setPendingGeneration?.(true);
|
||||
|
||||
try {
|
||||
toast.info("Generating your Discover Weekly playlist...");
|
||||
await api.generateDiscoverWeekly();
|
||||
|
||||
// Immediately refresh batch status to start polling
|
||||
if (refreshBatchStatus) {
|
||||
await refreshBatchStatus();
|
||||
}
|
||||
|
||||
toast.success("Generation started! Downloading albums...");
|
||||
} catch (error: any) {
|
||||
console.error("Generation failed:", error);
|
||||
// Clear pending state on error
|
||||
setPendingGeneration?.(false);
|
||||
|
||||
if (error.status === 409) {
|
||||
toast.warning("A playlist is already being generated...");
|
||||
// Refresh status in case UI is out of sync
|
||||
if (refreshBatchStatus) {
|
||||
await refreshBatchStatus();
|
||||
}
|
||||
} else {
|
||||
toast.error(error.message || "Failed to generate playlist");
|
||||
}
|
||||
}
|
||||
}, [isGenerating, refreshBatchStatus, setPendingGeneration]);
|
||||
|
||||
const handleLike = useCallback(
|
||||
async (track: DiscoverTrack) => {
|
||||
const newLikedState = !track.isLiked;
|
||||
|
||||
// Optimistically update UI immediately
|
||||
updateTrackLiked?.(track.albumId, newLikedState);
|
||||
|
||||
try {
|
||||
if (track.isLiked) {
|
||||
await api.unlikeDiscoverAlbum(track.albumId);
|
||||
toast.success(`Unmarked ${track.album}`);
|
||||
} else {
|
||||
await api.likeDiscoverAlbum(track.albumId);
|
||||
toast.success(`${track.album} will be kept!`);
|
||||
}
|
||||
|
||||
// Reload to sync with server state
|
||||
onGenerationComplete?.();
|
||||
} catch (error: any) {
|
||||
console.error("Like failed:", error);
|
||||
// Revert optimistic update on error
|
||||
updateTrackLiked?.(track.albumId, track.isLiked);
|
||||
toast.error(error.message || "Failed to update");
|
||||
}
|
||||
},
|
||||
[onGenerationComplete, updateTrackLiked]
|
||||
);
|
||||
|
||||
const handlePlayPlaylist = useCallback(() => {
|
||||
if (!playlist || playlist.tracks.length === 0) return;
|
||||
|
||||
const formattedTracks = playlist.tracks.map((track) => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: { name: track.artist },
|
||||
album: {
|
||||
id: track.albumId,
|
||||
title: track.album,
|
||||
coverArt: track.coverUrl || undefined,
|
||||
},
|
||||
duration: 0,
|
||||
}));
|
||||
|
||||
playTracks(formattedTracks, 0);
|
||||
}, [playlist, playTracks]);
|
||||
|
||||
const handlePlayTrack = useCallback(
|
||||
(index: number) => {
|
||||
if (!playlist || playlist.tracks.length === 0) return;
|
||||
|
||||
const formattedTracks = playlist.tracks.map((track) => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: { name: track.artist },
|
||||
album: {
|
||||
id: track.albumId,
|
||||
title: track.album,
|
||||
coverArt: track.coverUrl || undefined,
|
||||
},
|
||||
duration: 0,
|
||||
}));
|
||||
|
||||
playTracks(formattedTracks, index);
|
||||
},
|
||||
[playlist, playTracks]
|
||||
);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [isPlaying, pause, resume]);
|
||||
|
||||
return {
|
||||
handleGenerate,
|
||||
handleLike,
|
||||
handlePlayPlaylist,
|
||||
handlePlayTrack,
|
||||
handleTogglePlay,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DiscoverPlaylist, DiscoverConfig } from '../types';
|
||||
|
||||
interface BatchStatus {
|
||||
active: boolean;
|
||||
status: "downloading" | "scanning" | null;
|
||||
batchId?: string;
|
||||
progress?: number;
|
||||
completed?: number;
|
||||
failed?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export function useDiscoverData() {
|
||||
const [playlist, setPlaylist] = useState<DiscoverPlaylist | null>(null);
|
||||
const [config, setConfig] = useState<DiscoverConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [batchStatus, setBatchStatus] = useState<BatchStatus | null>(null);
|
||||
const [pendingGeneration, setPendingGeneration] = useState(false);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasActiveRef = useRef(false);
|
||||
const pendingRef = useRef(false); // Track pending state for polling callback
|
||||
|
||||
// Keep pendingRef in sync with pendingGeneration
|
||||
useEffect(() => {
|
||||
pendingRef.current = pendingGeneration;
|
||||
}, [pendingGeneration]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [playlistData, configData] = await Promise.all([
|
||||
api.getCurrentDiscoverWeekly().catch(() => null),
|
||||
api.getDiscoverConfig().catch(() => null),
|
||||
]);
|
||||
|
||||
setPlaylist(playlistData);
|
||||
setConfig(configData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load discover data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkBatchStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await api.getDiscoverBatchStatus();
|
||||
setBatchStatus(status);
|
||||
|
||||
// Clear pending state once batch is confirmed active
|
||||
if (status.active) {
|
||||
setPendingGeneration(false);
|
||||
}
|
||||
|
||||
// If batch was active and now isn't, reload data
|
||||
if (wasActiveRef.current && !status.active) {
|
||||
console.log('[Discover] Batch completed, reloading playlist data...');
|
||||
wasActiveRef.current = false;
|
||||
setPendingGeneration(false);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
// Track if batch is currently active
|
||||
if (status.active) {
|
||||
wasActiveRef.current = true;
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error('Failed to check batch status:', error);
|
||||
setPendingGeneration(false);
|
||||
return null;
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
// Start polling for batch status
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollingRef.current) return; // Already polling
|
||||
|
||||
console.log('[Discover] Starting batch status polling...');
|
||||
pollingRef.current = setInterval(async () => {
|
||||
const status = await checkBatchStatus();
|
||||
|
||||
// Only stop polling if:
|
||||
// 1. Status is not active AND
|
||||
// 2. We're not waiting for generation to start (pendingRef) AND
|
||||
// 3. We previously had an active batch (wasActiveRef)
|
||||
// This ensures we keep polling while waiting for the batch to be created
|
||||
if (status && !status.active && !pendingRef.current && wasActiveRef.current) {
|
||||
console.log('[Discover] Batch completed, stopping polling');
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 3000); // Poll every 3 seconds
|
||||
}, [checkBatchStatus]);
|
||||
|
||||
// Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// Check batch status first
|
||||
const status = await checkBatchStatus();
|
||||
|
||||
// Load playlist data
|
||||
await loadData();
|
||||
|
||||
// Start polling if batch is active
|
||||
if (status?.active) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Start polling when batch becomes active OR when generation is pending
|
||||
// This ensures we catch the batch as soon as it's created
|
||||
useEffect(() => {
|
||||
if ((batchStatus?.active || pendingGeneration) && !pollingRef.current) {
|
||||
startPolling();
|
||||
}
|
||||
}, [batchStatus?.active, pendingGeneration, startPolling]);
|
||||
|
||||
// Optimistically update a track's liked status
|
||||
const updateTrackLiked = useCallback((albumId: string, isLiked: boolean) => {
|
||||
setPlaylist(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map(track =>
|
||||
track.albumId === albumId
|
||||
? { ...track, isLiked, likedAt: isLiked ? new Date().toISOString() : null }
|
||||
: track
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
playlist,
|
||||
config,
|
||||
setConfig,
|
||||
loading,
|
||||
reloadData: loadData,
|
||||
batchStatus,
|
||||
refreshBatchStatus: checkBatchStatus,
|
||||
setPendingGeneration,
|
||||
updateTrackLiked,
|
||||
isGenerating: pendingGeneration || batchStatus?.active || false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { howlerEngine } from "@/lib/howler-engine";
|
||||
|
||||
export function usePreviewPlayer() {
|
||||
const [currentPreview, setCurrentPreview] = useState<string | null>(null);
|
||||
const [previewAudios, setPreviewAudios] = useState<
|
||||
Map<string, HTMLAudioElement>
|
||||
>(new Map());
|
||||
const mainPlayerWasPausedRef = useRef(false);
|
||||
|
||||
// Cleanup audio on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewAudios.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
});
|
||||
// Resume main player if needed
|
||||
if (mainPlayerWasPausedRef.current) {
|
||||
howlerEngine.play();
|
||||
mainPlayerWasPausedRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [previewAudios]);
|
||||
|
||||
const handleTogglePreview = useCallback(
|
||||
(albumId: string, previewUrl: string) => {
|
||||
if (!previewUrl) {
|
||||
toast.error("No preview available for this album");
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop currently playing preview if any
|
||||
if (currentPreview && currentPreview !== albumId) {
|
||||
const audio = previewAudios.get(currentPreview);
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the clicked preview
|
||||
if (currentPreview === albumId) {
|
||||
const audio = previewAudios.get(albumId);
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
setCurrentPreview(null);
|
||||
// Resume main player if it was playing before
|
||||
if (mainPlayerWasPausedRef.current) {
|
||||
howlerEngine.play();
|
||||
mainPlayerWasPausedRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// Pause the main player if it's playing
|
||||
if (howlerEngine.isPlaying()) {
|
||||
howlerEngine.pause();
|
||||
mainPlayerWasPausedRef.current = true;
|
||||
}
|
||||
|
||||
let audio = previewAudios.get(albumId);
|
||||
if (!audio) {
|
||||
audio = new Audio(previewUrl);
|
||||
audio.onended = () => {
|
||||
setCurrentPreview(null);
|
||||
// Resume main player if it was playing before
|
||||
if (mainPlayerWasPausedRef.current) {
|
||||
howlerEngine.play();
|
||||
mainPlayerWasPausedRef.current = false;
|
||||
}
|
||||
};
|
||||
audio.onerror = () => {
|
||||
toast.error("Failed to load preview");
|
||||
setCurrentPreview(null);
|
||||
};
|
||||
const newMap = new Map(previewAudios);
|
||||
newMap.set(albumId, audio);
|
||||
setPreviewAudios(newMap);
|
||||
}
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
setCurrentPreview(albumId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error("Failed to play preview: " + error.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentPreview, previewAudios]
|
||||
);
|
||||
|
||||
return {
|
||||
currentPreview,
|
||||
handleTogglePreview,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user