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:
@@ -14,6 +14,7 @@ import { ActivityPanel } from "./ActivityPanel";
|
||||
import { GalaxyBackground } from "../ui/GalaxyBackground";
|
||||
import { GradientSpinner } from "../ui/GradientSpinner";
|
||||
import { PWAInstallPrompt } from "../PWAInstallPrompt";
|
||||
import { PullToRefresh } from "../ui/PullToRefresh";
|
||||
import { ReactNode } from "react";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
import { useIsTV } from "@/lib/tv-utils";
|
||||
@@ -84,6 +85,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
if (isTV) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<MediaControlsHandler />
|
||||
<TVLayout>{children}</TVLayout>
|
||||
</PlayerModeWrapper>
|
||||
@@ -94,6 +101,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div className="h-screen bg-black overflow-hidden flex flex-col">
|
||||
<MediaControlsHandler />
|
||||
<TopBar />
|
||||
@@ -110,18 +123,22 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
/>
|
||||
|
||||
{/* Main content area with rounded corners */}
|
||||
<main
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
|
||||
style={{
|
||||
marginTop: "58px",
|
||||
marginBottom:
|
||||
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{/* Padding at bottom for mini player floating above */}
|
||||
<div className="pb-24">{children}</div>
|
||||
</main>
|
||||
<PullToRefresh>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative focus:outline-none"
|
||||
style={{
|
||||
marginTop: "58px",
|
||||
marginBottom:
|
||||
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
|
||||
}}
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{/* Padding at bottom for mini player floating above */}
|
||||
<div className="pb-24">{children}</div>
|
||||
</main>
|
||||
</PullToRefresh>
|
||||
|
||||
{/* Mini Player - fixed, positioned above bottom nav */}
|
||||
<UniversalPlayer />
|
||||
@@ -137,6 +154,12 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
// Desktop Layout
|
||||
return (
|
||||
<PlayerModeWrapper>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:rounded-lg focus:font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div
|
||||
className="h-screen bg-black overflow-hidden flex flex-col"
|
||||
style={{ paddingTop: "64px" }}
|
||||
@@ -145,7 +168,11 @@ export function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
<TopBar />
|
||||
<div className="flex-1 flex gap-2 p-2 pt-0 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative">
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative focus:outline-none"
|
||||
>
|
||||
<GalaxyBackground />
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -43,9 +43,11 @@ export function BottomNavigation() {
|
||||
if (!isMobileOrTablet) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
|
||||
style={{
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
|
||||
}}
|
||||
>
|
||||
@@ -60,10 +62,12 @@ export function BottomNavigation() {
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
|
||||
isActive
|
||||
? "text-white"
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-gray-500 active:text-gray-300"
|
||||
)}
|
||||
aria-label={item.name}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
|
||||
@@ -72,6 +72,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sidebar Drawer */}
|
||||
@@ -109,7 +110,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Menu Content */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="flex-1 overflow-y-auto py-4" role="navigation" aria-label="Mobile menu">
|
||||
{/* Quick Links Section */}
|
||||
<div className="px-3 mb-6">
|
||||
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
|
||||
@@ -118,6 +119,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/discover"
|
||||
aria-current={pathname === "/discover" ? "page" : undefined}
|
||||
aria-label="Discover"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/discover"
|
||||
@@ -133,6 +136,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/radio"
|
||||
aria-current={pathname === "/radio" ? "page" : undefined}
|
||||
aria-label="Radio"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/radio"
|
||||
@@ -148,6 +153,8 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/releases"
|
||||
aria-current={pathname === "/releases" ? "page" : undefined}
|
||||
aria-label="Releases"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/releases"
|
||||
@@ -191,6 +198,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
|
||||
pathname === "/settings"
|
||||
|
||||
@@ -90,17 +90,9 @@ export function Sidebar() {
|
||||
|
||||
// Listen for playlist events to refresh playlists
|
||||
const handlePlaylistEvent = async () => {
|
||||
console.log(
|
||||
"[Sidebar] Playlist event received, refreshing playlists..."
|
||||
);
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const data = await api.getPlaylists();
|
||||
console.log(
|
||||
"[Sidebar] Playlists refreshed:",
|
||||
data.length,
|
||||
"playlists"
|
||||
);
|
||||
setPlaylists(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to reload playlists:", error);
|
||||
@@ -215,6 +207,7 @@ export function Sidebar() {
|
||||
? "bg-[#1DB954] text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
aria-label={isSyncing ? "Syncing library" : "Sync library"}
|
||||
title={isSyncing ? "Syncing..." : "Sync Library"}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -233,6 +226,7 @@ export function Sidebar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
|
||||
)}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -247,6 +241,8 @@ export function Sidebar() {
|
||||
"pt-6 space-y-1",
|
||||
isMobileOrTablet ? "px-6" : "px-3"
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
@@ -257,6 +253,7 @@ export function Sidebar() {
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
|
||||
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
|
||||
@@ -310,6 +307,7 @@ export function Sidebar() {
|
||||
href="/playlists"
|
||||
prefetch={false}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md bg-white/5 text-gray-400 hover:text-white hover:bg-gradient-to-br hover:from-purple-500 hover:to-pink-500 hover:scale-110 transition-all duration-300 shadow-lg shadow-transparent hover:shadow-purple-500/30 border border-white/5 hover:border-transparent"
|
||||
aria-label="Create playlist"
|
||||
title="Create Playlist"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { DPAD_KEYS } from "@/lib/tv-utils";
|
||||
import { useTVNavigation } from "@/hooks/useTVNavigation";
|
||||
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
|
||||
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
|
||||
|
||||
const tvNavigation = [
|
||||
@@ -99,12 +100,8 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// CRITICAL: Clamp currentTime to prevent display of invalid times
|
||||
const clampedCurrentTime = clampTime(currentTime, duration);
|
||||
|
||||
// Sync library
|
||||
const handleSync = async () => {
|
||||
@@ -260,7 +257,11 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
{/* Time counter */}
|
||||
<div className="tv-np-time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
{formatTime(clampedCurrentTime)} / {
|
||||
playbackType === "podcast" || playbackType === "audiobook"
|
||||
? formatTimeRemaining(Math.max(0, duration - clampedCurrentTime))
|
||||
: formatTime(duration)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Shuffle */}
|
||||
@@ -309,7 +310,7 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<main ref={contentRef} className="tv-content">
|
||||
<main id="main-content" tabIndex={-1} ref={contentRef} className="tv-content">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -44,8 +44,11 @@ export function TopBar() {
|
||||
|
||||
// Track download status from context (single source of truth)
|
||||
const { pendingDownloads, downloadStatus } = useDownloadContext();
|
||||
const hasActiveDownloads =
|
||||
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
|
||||
// Only use API-driven state for the icon
|
||||
// pendingDownloads is optimistic local state that can become stale
|
||||
const hasActiveDownloads = downloadStatus.hasActiveDownloads;
|
||||
const hasPendingUploads = pendingDownloads.length > 0 &&
|
||||
pendingDownloads.some(p => Date.now() - p.timestamp < 30000); // Only count recent pending
|
||||
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
|
||||
|
||||
const handleSync = async () => {
|
||||
@@ -167,6 +170,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
|
||||
)}
|
||||
aria-label="Home"
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
@@ -186,6 +190,7 @@ export function TopBar() {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="Search..."
|
||||
aria-label="Search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
@@ -241,6 +246,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
|
||||
)}
|
||||
aria-label="Home"
|
||||
title="Home"
|
||||
>
|
||||
<Home className="w-6 h-6" />
|
||||
@@ -262,6 +268,7 @@ export function TopBar() {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder="What do you want to play?"
|
||||
aria-label="Search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
tabIndex={0}
|
||||
@@ -276,6 +283,17 @@ export function TopBar() {
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isPolling}
|
||||
aria-label={
|
||||
isPolling
|
||||
? "Library scan in progress"
|
||||
: hasActiveDownloads
|
||||
? `${downloadStatus.activeDownloads.length} download(s) in progress`
|
||||
: hasPendingUploads
|
||||
? `${pendingDownloads.length} download(s) starting`
|
||||
: hasFailedDownloads
|
||||
? `${downloadStatus.failedDownloads.length} download(s) failed`
|
||||
: "Sync library"
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
|
||||
isPolling
|
||||
@@ -290,10 +308,9 @@ export function TopBar() {
|
||||
isPolling
|
||||
? "Library scan in progress..."
|
||||
: hasActiveDownloads
|
||||
? `${
|
||||
downloadStatus.activeDownloads
|
||||
.length + pendingDownloads.length
|
||||
} download(s) in progress`
|
||||
? `${downloadStatus.activeDownloads.length} download(s) in progress`
|
||||
: hasPendingUploads
|
||||
? `${pendingDownloads.length} download(s) starting...`
|
||||
: hasFailedDownloads
|
||||
? `${downloadStatus.failedDownloads.length} download(s) failed`
|
||||
: "Sync Library"
|
||||
@@ -316,6 +333,7 @@ export function TopBar() {
|
||||
? "bg-white text-black"
|
||||
: "text-white/60 hover:text-white"
|
||||
)}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
@@ -323,6 +341,7 @@ export function TopBar() {
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
|
||||
aria-label="Logout"
|
||||
title="Logout"
|
||||
>
|
||||
<Power className="w-5 h-5" />
|
||||
|
||||
Reference in New Issue
Block a user