Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default function ArtistPage() {
|
||||
const { isPendingByMbid } = useDownloadContext();
|
||||
|
||||
// Data hook
|
||||
const { artist, albums, loading, error, source, reloadArtist } = useArtistData();
|
||||
const { artist, albums, loading, error, source, sortBy, setSortBy, reloadArtist } = useArtistData();
|
||||
|
||||
// Action hooks
|
||||
const { playAll, shufflePlay } = useArtistActions();
|
||||
@@ -209,6 +209,8 @@ export default function ArtistPage() {
|
||||
albums={ownedAlbums}
|
||||
colors={colors}
|
||||
onPlayAlbum={handlePlayAlbum}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
/>
|
||||
|
||||
{/* Available Albums to Download */}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Route error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
{error.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Global error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div className="flex h-screen items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Application Error
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
{error.message || 'A critical error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Reload Application
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,21 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Accessibility - Reduced Motion Support
|
||||
===================================================== */
|
||||
|
||||
/* Respect user's motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Android TV - Navigation Bar & Now Playing Only
|
||||
===================================================== */
|
||||
|
||||
+17
-14
@@ -8,6 +8,7 @@ import { ConditionalAudioProvider } from "@/components/providers/ConditionalAudi
|
||||
import { AuthenticatedLayout } from "@/components/layout/AuthenticatedLayout";
|
||||
import { QueryProvider } from "@/lib/query-client";
|
||||
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||
import { GlobalErrorBoundary } from "@/components/providers/GlobalErrorBoundary";
|
||||
|
||||
const montserrat = Montserrat({
|
||||
weight: ["300", "400", "500", "600", "700", "800"],
|
||||
@@ -55,20 +56,22 @@ export default function RootLayout({
|
||||
className={`${montserrat.variable} antialiased`}
|
||||
style={{ fontFamily: "var(--font-montserrat)" }}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<DownloadProvider>
|
||||
<ConditionalAudioProvider>
|
||||
<ToastProvider>
|
||||
<AuthenticatedLayout>
|
||||
{children}
|
||||
</AuthenticatedLayout>
|
||||
</ToastProvider>
|
||||
</ConditionalAudioProvider>
|
||||
</DownloadProvider>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
<GlobalErrorBoundary>
|
||||
<ServiceWorkerRegistration />
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
<DownloadProvider>
|
||||
<ConditionalAudioProvider>
|
||||
<ToastProvider>
|
||||
<AuthenticatedLayout>
|
||||
{children}
|
||||
</AuthenticatedLayout>
|
||||
</ToastProvider>
|
||||
</ConditionalAudioProvider>
|
||||
</DownloadProvider>
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
</GlobalErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -27,23 +27,32 @@ export default function LibraryPage() {
|
||||
// Get active tab from URL params, default to "artists"
|
||||
const activeTab = (searchParams.get("tab") as Tab) || "artists";
|
||||
|
||||
// Read page from URL params
|
||||
const urlPage = parseInt(searchParams.get("page") || "1", 10);
|
||||
|
||||
// Filter state (owned = your library, discovery = discovery weekly artists)
|
||||
const [filter, setFilter] = useState<LibraryFilter>("owned");
|
||||
|
||||
// Sort and pagination state
|
||||
const [sortBy, setSortBy] = useState<SortOption>("name");
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(50);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(40);
|
||||
const [currentPage, setCurrentPage] = useState(urlPage);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Sync currentPage with URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
setCurrentPage(urlPage);
|
||||
}, [urlPage]);
|
||||
|
||||
// Use custom hooks with server-side pagination
|
||||
const { artists, albums, tracks, isLoading, reloadData, pagination } = useLibraryData({
|
||||
activeTab,
|
||||
filter,
|
||||
sortBy,
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
});
|
||||
const { artists, albums, tracks, isLoading, reloadData, pagination } =
|
||||
useLibraryData({
|
||||
activeTab,
|
||||
filter,
|
||||
sortBy,
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
});
|
||||
const {
|
||||
playArtist,
|
||||
playAlbum,
|
||||
@@ -85,6 +94,15 @@ export default function LibraryPage() {
|
||||
router.push(`/library?tab=${tab}`, { scroll: false });
|
||||
};
|
||||
|
||||
// Update page with URL state and scroll to top
|
||||
const updatePage = (page: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("tab", activeTab);
|
||||
params.set("page", String(page));
|
||||
router.push(`/library?${params.toString()}`, { scroll: false });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Helper to convert library Track to audio context Track format
|
||||
const formatTracksForAudio = (libraryTracks: typeof tracks) => {
|
||||
return libraryTracks.map((track) => ({
|
||||
@@ -248,7 +266,9 @@ export default function LibraryPage() {
|
||||
{/* Sort Dropdown */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
onChange={(e) =>
|
||||
setSortBy(e.target.value as SortOption)
|
||||
}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
|
||||
>
|
||||
<option value="name">Name (A-Z)</option>
|
||||
@@ -264,13 +284,15 @@ export default function LibraryPage() {
|
||||
{/* Items per page */}
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
setItemsPerPage(Number(e.target.value))
|
||||
}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
|
||||
>
|
||||
<option value={25}>25 per page</option>
|
||||
<option value={50}>50 per page</option>
|
||||
<option value={100}>100 per page</option>
|
||||
<option value={250}>250 per page</option>
|
||||
<option value={24}>24 per page</option>
|
||||
<option value={40}>40 per page</option>
|
||||
<option value={80}>80 per page</option>
|
||||
<option value={200}>200 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@@ -330,7 +352,7 @@ export default function LibraryPage() {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8 pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
onClick={() => updatePage(1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -338,7 +360,7 @@ export default function LibraryPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.max(1, p - 1))
|
||||
updatePage(Math.max(1, currentPage - 1))
|
||||
}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -350,8 +372,8 @@ export default function LibraryPage() {
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(totalPages, p + 1)
|
||||
updatePage(
|
||||
Math.min(totalPages, currentPage + 1)
|
||||
)
|
||||
}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
@@ -360,7 +382,7 @@ export default function LibraryPage() {
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
onClick={() => updatePage(totalPages)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
+10
-4
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, lazy, Suspense } from "react";
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
import { RefreshCw, AudioWaveform } from "lucide-react";
|
||||
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
||||
@@ -16,7 +16,9 @@ import { PodcastsGrid } from "@/features/home/components/PodcastsGrid";
|
||||
import { AudiobooksGrid } from "@/features/home/components/AudiobooksGrid";
|
||||
import { FeaturedPlaylistsGrid } from "@/features/home/components/FeaturedPlaylistsGrid";
|
||||
import { LibraryRadioStations } from "@/features/home/components/LibraryRadioStations";
|
||||
import { MoodMixer } from "@/components/MoodMixer";
|
||||
|
||||
// Lazy load MoodMixer - only loads when user opens it
|
||||
const MoodMixer = lazy(() => import("@/components/MoodMixer").then(mod => ({ default: mod.MoodMixer })));
|
||||
|
||||
// Loading skeleton for playlist cards
|
||||
function PlaylistSkeleton() {
|
||||
@@ -163,8 +165,12 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mood Mixer Modal */}
|
||||
<MoodMixer isOpen={showMoodMixer} onClose={() => setShowMoodMixer(false)} />
|
||||
{/* Mood Mixer Modal - Lazy loaded */}
|
||||
{showMoodMixer && (
|
||||
<Suspense fallback={null}>
|
||||
<MoodMixer isOpen={showMoodMixer} onClose={() => setShowMoodMixer(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Pause,
|
||||
Trash2,
|
||||
Shuffle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ListPlus,
|
||||
ListMusic,
|
||||
@@ -195,10 +196,18 @@ export default function PlaylistDetailPage() {
|
||||
} else {
|
||||
await api.hidePlaylist(playlistId);
|
||||
}
|
||||
|
||||
// Update local state immediately
|
||||
queryClient.setQueryData(["playlist", playlistId], (old: any) => ({
|
||||
...old,
|
||||
isHidden: !playlist.isHidden,
|
||||
}));
|
||||
|
||||
// Dispatch event to update sidebar and other components
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playlist-updated", { detail: { playlistId } })
|
||||
);
|
||||
|
||||
// Optionally navigate away if hiding
|
||||
if (!playlist.isHidden) {
|
||||
router.push("/playlists");
|
||||
@@ -532,7 +541,11 @@ export default function PlaylistDetailPage() {
|
||||
: "Hide playlist"
|
||||
}
|
||||
>
|
||||
<EyeOff className="w-5 h-5" />
|
||||
{playlist.isHidden ? (
|
||||
<Eye className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function PodcastDetailPage() {
|
||||
// Extract colors from the proxied image URL
|
||||
const { colors } = useImageColor(heroImage);
|
||||
|
||||
// Action hooks
|
||||
// Action hooks - pass sortedEpisodes for queue building
|
||||
const {
|
||||
isSubscribing,
|
||||
showDeleteConfirm,
|
||||
@@ -43,9 +43,10 @@ export default function PodcastDetailPage() {
|
||||
handleRemovePodcast,
|
||||
handlePlayEpisode,
|
||||
handlePlayPauseEpisode,
|
||||
handleMarkEpisodeComplete,
|
||||
isEpisodePlaying,
|
||||
isPlaying,
|
||||
} = usePodcastActions(podcastId);
|
||||
} = usePodcastActions(podcastId, sortedEpisodes);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
@@ -154,6 +155,7 @@ export default function PodcastDetailPage() {
|
||||
handlePlayPauseEpisode(episode, podcast)
|
||||
}
|
||||
onPlay={(episode) => handlePlayEpisode(episode, podcast)}
|
||||
onMarkComplete={handleMarkEpisodeComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -150,7 +150,8 @@ export default function QueuePage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-purple-400 truncate">
|
||||
{currentTrack.title}
|
||||
{currentTrack.displayTitle ??
|
||||
currentTrack.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 truncate">
|
||||
{currentTrack.artist?.name}
|
||||
@@ -218,7 +219,8 @@ export default function QueuePage() {
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white truncate">
|
||||
{track.title}
|
||||
{track.displayTitle ??
|
||||
track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 truncate">
|
||||
{track.artist?.name}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
+110
-34
@@ -10,6 +10,7 @@ import { TopResult } from "@/features/search/components/TopResult";
|
||||
import { EmptyState } from "@/features/search/components/EmptyState";
|
||||
import { LibraryAlbumsGrid } from "@/features/search/components/LibraryAlbumsGrid";
|
||||
import { LibraryPodcastsGrid } from "@/features/search/components/LibraryPodcastsGrid";
|
||||
import { LibraryAudiobooksGrid } from "@/features/search/components/LibraryAudiobooksGrid";
|
||||
import { LibraryTracksList } from "@/features/search/components/LibraryTracksList";
|
||||
import { SimilarArtistsGrid } from "@/features/search/components/SimilarArtistsGrid";
|
||||
import { SoulseekSongsList } from "@/features/search/components/SoulseekSongsList";
|
||||
@@ -58,6 +59,16 @@ export default function SearchPage() {
|
||||
const showDiscover = filterTab === "all" || filterTab === "discover";
|
||||
const showSoulseek = filterTab === "all" || filterTab === "soulseek";
|
||||
|
||||
// Determine if we should show the 2-column layout
|
||||
const hasTopResult = libraryResults?.artists?.[0] || topArtist;
|
||||
const hasTracks =
|
||||
libraryResults?.tracks?.length > 0 || soulseekResults.length > 0;
|
||||
const show2ColumnLayout =
|
||||
hasSearched &&
|
||||
hasTopResult &&
|
||||
hasTracks &&
|
||||
(showLibrary || showDiscover);
|
||||
|
||||
// Handle TV search
|
||||
const handleTVSearch = (searchQuery: string) => {
|
||||
setQuery(searchQuery);
|
||||
@@ -157,41 +168,88 @@ export default function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Result */}
|
||||
{hasSearched &&
|
||||
(showDiscover || showLibrary) &&
|
||||
(libraryResults?.artists?.[0] || topArtist) && (
|
||||
<TopResult
|
||||
libraryArtist={libraryResults?.artists?.[0]}
|
||||
discoveryArtist={topArtist}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Soulseek Songs */}
|
||||
{hasSearched && showSoulseek && soulseekResults.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Songs
|
||||
</h2>
|
||||
<SoulseekSongsList
|
||||
soulseekResults={soulseekResults}
|
||||
downloadingFiles={downloadingFiles}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Library Songs */}
|
||||
{hasSearched &&
|
||||
showLibrary &&
|
||||
libraryResults?.tracks?.length > 0 && (
|
||||
<section>
|
||||
{/* 2-Column Layout: Top Result (left) + Songs (right) */}
|
||||
{show2ColumnLayout ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column: Top Result */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Songs in Your Library
|
||||
Top Result
|
||||
</h2>
|
||||
<LibraryTracksList tracks={libraryResults.tracks} />
|
||||
</section>
|
||||
)}
|
||||
<TopResult
|
||||
libraryArtist={libraryResults?.artists?.[0]}
|
||||
discoveryArtist={topArtist}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Songs */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
{showSoulseek && soulseekResults.length > 0
|
||||
? "Songs"
|
||||
: "Songs in Your Library"}
|
||||
</h2>
|
||||
{showSoulseek && soulseekResults.length > 0 ? (
|
||||
<SoulseekSongsList
|
||||
soulseekResults={soulseekResults}
|
||||
downloadingFiles={downloadingFiles}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
) : showLibrary &&
|
||||
libraryResults?.tracks?.length > 0 ? (
|
||||
<LibraryTracksList
|
||||
tracks={libraryResults.tracks}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Original single-column layout when not showing 2-column */}
|
||||
{hasSearched &&
|
||||
(showDiscover || showLibrary) &&
|
||||
hasTopResult && (
|
||||
<div>
|
||||
<TopResult
|
||||
libraryArtist={
|
||||
libraryResults?.artists?.[0]
|
||||
}
|
||||
discoveryArtist={topArtist}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Soulseek Songs */}
|
||||
{hasSearched &&
|
||||
showSoulseek &&
|
||||
soulseekResults.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Songs
|
||||
</h2>
|
||||
<SoulseekSongsList
|
||||
soulseekResults={soulseekResults}
|
||||
downloadingFiles={downloadingFiles}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Library Songs */}
|
||||
{hasSearched &&
|
||||
showLibrary &&
|
||||
libraryResults?.tracks?.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Songs in Your Library
|
||||
</h2>
|
||||
<LibraryTracksList
|
||||
tracks={libraryResults.tracks}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Library Albums */}
|
||||
{hasSearched &&
|
||||
@@ -219,6 +277,21 @@ export default function SearchPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Library Audiobooks */}
|
||||
{hasSearched &&
|
||||
showLibrary &&
|
||||
libraryResults?.audiobooks &&
|
||||
libraryResults.audiobooks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Audiobooks
|
||||
</h2>
|
||||
<LibraryAudiobooksGrid
|
||||
audiobooks={libraryResults.audiobooks}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Similar Artists */}
|
||||
{hasSearched &&
|
||||
showDiscover &&
|
||||
@@ -235,7 +308,10 @@ export default function SearchPage() {
|
||||
(!libraryResults ||
|
||||
(!libraryResults.artists?.length &&
|
||||
!libraryResults.albums?.length &&
|
||||
!libraryResults.tracks?.length)) && (
|
||||
!libraryResults.tracks?.length &&
|
||||
!libraryResults.podcasts?.length &&
|
||||
!libraryResults.audiobooks?.length &&
|
||||
!libraryResults.episodes?.length)) && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<SearchIcon className="w-16 h-16 text-gray-700 mb-4" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingScreen } from "@/components/ui/LoadingScreen";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import { dispatchQueryEvent } from "@/lib/query-events";
|
||||
|
||||
export default function SyncPage() {
|
||||
const router = useRouter();
|
||||
@@ -58,6 +59,9 @@ export default function SyncPage() {
|
||||
// Enrichment runs on-demand from Settings page
|
||||
// Artists get images from Deezer/Fanart when first viewed
|
||||
|
||||
// Dispatch event to update Recently Added section
|
||||
dispatchQueryEvent("library-updated");
|
||||
|
||||
setProgress(100);
|
||||
setMessage("All set! Redirecting...");
|
||||
redirectTimeout = setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user