Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
@@ -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,
};
}