diff --git a/.env.example b/.env.example index 7bf47b3..a3121c4 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,13 @@ VERSION=latest # AUDIO_MODEL_IDLE_TIMEOUT=300 # Seconds before unloading ML models when idle (default: 300) # # Set to 0 to keep models loaded permanently +# CLAP Audio Analyzer (AI vibe/mood matching) +# Set to 'true' to disable the CLAP embedding analyzer on startup. +# Useful for low-memory deployments or when AI vibe matching is not needed. +# Only applies to the all-in-one container (docker-compose.prod.yml). +# For split containers (docker-compose.yml), simply don't start the audio-analyzer-clap service. +# DISABLE_CLAP=true + # ============================================================================== # OPTIONAL: Lidarr Webhook Configuration # ============================================================================== diff --git a/CHANGELOG.md b/CHANGELOG.md index b3974b3..e8b6fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to Kima will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.9] - 2026-02-27 + +### Added + +- **#122** `DISABLE_CLAP=true` environment variable to disable the CLAP audio embedding analyzer on startup in the all-in-one container (useful for low-memory deployments) +- **#123** Foobar2000-style track title formatting in Settings > Playback -- configure a format string with `%field%`, `[conditional blocks]`, `$if2()`, `$filepart()` syntax; applied in playlist view +- **#124** Cancelling a playlist import now creates a partial playlist from all tracks already matched to your library, instead of discarding progress + +### Fixed + +- **#124** Cancel button previously promised "Playlist will be created with tracks downloaded so far" but discarded all progress -- now delivers on that promise +- **iOS lock screen controls inverted**: MediaSession `playbackState` was driven by React `useEffect` on `isPlaying` state, which fires asynchronously after render -- not synchronously with the actual audio state change. This caused lock screen controls to show the opposite state (play when playing, pause when paused). Rewrote MediaSession to drive `playbackState` directly from `audioEngine` events, call the engine directly from action handlers to preserve iOS user-gesture context, and use ref-based one-time handler registration to avoid re-registration churn. +- **Favicon showing old Lidify icon or wrong Kima logo**: Browser tab showed the pre-rebrand Lidify favicon. Replaced with the waveform-only icon generated from `kima-black.webp` as a proper multi-size ICO (16/32/48/64/128/256px) with tight cropping so the waveform fills the tab space. +- **Enrichment pipeline: no periodic vibe sweep**: The enrichment cycle had no phase for queueing vibe/CLAP embedding jobs. The only automatic path was a lossy pub/sub event from Essentia completion -- if missed (crash, restart, migration wipe), tracks were orphaned forever. Added Phase 5 that sweeps for tracks with completed audio but missing embedding rows via LEFT JOIN. +- **Enrichment pipeline: crash recovery dead end**: Crash recovery reset `vibeAnalysisStatus` from `processing` to `null`, which nothing in the regular cycle re-queued. Changed to reset to `pending` so the periodic sweep picks them up. +- **Enrichment pipeline: CLAP analyzer permanent death**: When enrichment was stopped, the backend sent a stop command causing the CLAP analyzer to exit cleanly (code 0). Supervisor's `autorestart=unexpected` treated this as expected and never restarted. Changed to `autorestart=true` and removed the stop signal entirely -- the analyzer has its own idle timeout. +- **Enrichment pipeline: completion never triggers**: `isFullyComplete` required `clapCompleted + clapFailed >= trackTotal`, which was impossible after `track_embeddings` was wiped by migration. Now checks for actual un-embedded tracks via LEFT JOIN. +- **Enrichment pipeline: "Reset Vibe Embeddings" incomplete**: `reRunVibeEmbeddingsOnly()` reset `vibeAnalysisStatus` but did not delete existing `track_embeddings` rows, so the re-queue query (which uses LEFT JOIN) silently skipped tracks that already had embeddings. Now deletes all embeddings first for full regeneration. +- **Feature detection: CLAP reported available when disabled**: When `DISABLE_CLAP=true` was set, `checkCLAP()` skipped the file-existence check but still fell through to heartbeat and data checks. If old embeddings existed in the database, it returned `true`, causing the vibe sweep to queue jobs that no CLAP worker would ever process. Now returns `false` immediately when disabled. +- **docker-compose.server.yml healthcheck using removed tool**: Healthcheck used `wget` which is removed from the production image during security hardening. Changed to `node /app/healthcheck.js` to match docker-compose.prod.yml. + ## [1.5.8] - 2026-02-26 ### Fixed diff --git a/Dockerfile b/Dockerfile index 05216e5..741319a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -276,7 +276,7 @@ priority=50 [program:audio-analyzer-clap] command=/bin/bash -c "/app/wait-for-db.sh 120 && cd /app/audio-analyzer-clap && python3 analyzer.py" autostart=true -autorestart=unexpected +autorestart=true startretries=3 startsecs=30 stdout_logfile=/dev/stdout @@ -490,8 +490,25 @@ TRANSCODE_CACHE_PATH=/data/cache/transcodes SESSION_SECRET=$SESSION_SECRET SETTINGS_ENCRYPTION_KEY=$SETTINGS_ENCRYPTION_KEY INTERNAL_API_SECRET=kima-internal-aio +DISABLE_CLAP=${DISABLE_CLAP:-} ENVEOF +# Optionally disable CLAP audio analyzer (for low-memory deployments) +if [ "${DISABLE_CLAP:-false}" = "true" ] || [ "${DISABLE_CLAP:-0}" = "1" ]; then + python3 -c " +import re +conf = open('/etc/supervisor/conf.d/kima.conf').read() +conf = re.sub( + r'(\[program:audio-analyzer-clap\][^\[]*autostart=)true', + r'\g<1>false', + conf, + flags=re.DOTALL +) +open('/etc/supervisor/conf.d/kima.conf', 'w').write(conf) +" + echo "CLAP audio analyzer disabled (DISABLE_CLAP=${DISABLE_CLAP})" +fi + echo "Starting Kima..." exec env \ NODE_ENV=production \ diff --git a/backend/package.json b/backend/package.json index 57e2896..8a87c9c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "kima-backend", - "version": "1.5.8", + "version": "1.5.9", "description": "Kima backend API server", "license": "GPL-3.0", "repository": { diff --git a/backend/src/services/featureDetection.ts b/backend/src/services/featureDetection.ts index 276994b..f38e48d 100644 --- a/backend/src/services/featureDetection.ts +++ b/backend/src/services/featureDetection.ts @@ -68,7 +68,12 @@ class FeatureDetectionService { private async checkCLAP(): Promise { try { - // Analyzer script bundled in image = feature is available + // If explicitly disabled via env var, CLAP is not available + const disabled = process.env.DISABLE_CLAP; + if (disabled === "true" || disabled === "1") { + return false; + } + if (existsSync(CLAP_ANALYZER_PATH)) { return true; } diff --git a/backend/src/services/spotifyImport.ts b/backend/src/services/spotifyImport.ts index 05d6829..adead99 100644 --- a/backend/src/services/spotifyImport.ts +++ b/backend/src/services/spotifyImport.ts @@ -2644,16 +2644,57 @@ class SpotifyImportService { }, }); - // Mark job as cancelled - do NOT create a playlist + // Collect tracks already matched to the library before cancellation + const matchedTrackIds = [ + ...new Set( + (job.pendingTracks || []) + .map((t) => t.preMatchedTrackId) + .filter((id): id is string => !!id), + ), + ]; + + let createdPlaylistId: string | null = null; + + if (matchedTrackIds.length > 0) { + try { + const playlist = await prisma.playlist.create({ + data: { + userId: job.userId, + name: job.playlistName, + isPublic: false, + spotifyPlaylistId: job.spotifyPlaylistId, + items: { + create: matchedTrackIds.map((trackId, index) => ({ + trackId, + sort: index, + })), + }, + }, + }); + createdPlaylistId = playlist.id; + logger?.info( + `Partial playlist created with ${matchedTrackIds.length} tracks: ${playlist.id}`, + ); + } catch (err: any) { + logger?.warn( + `Failed to create partial playlist on cancel: ${err?.message}`, + ); + } + } + job.status = "cancelled"; + job.createdPlaylistId = createdPlaylistId; + job.tracksMatched = matchedTrackIds.length; job.updatedAt = new Date(); await saveImportJob(job); - logger?.info(`Import cancelled by user - no playlist created`); + logger?.info( + `Import cancelled by user — ${createdPlaylistId ? "partial playlist created" : "no tracks matched"}`, + ); return { - playlistCreated: false, - playlistId: null, - tracksMatched: 0, + playlistCreated: !!createdPlaylistId, + playlistId: createdPlaylistId, + tracksMatched: matchedTrackIds.length, }; } diff --git a/backend/src/workers/unifiedEnrichment.ts b/backend/src/workers/unifiedEnrichment.ts index 26685fa..0300c68 100644 --- a/backend/src/workers/unifiedEnrichment.ts +++ b/backend/src/workers/unifiedEnrichment.ts @@ -221,8 +221,9 @@ async function setupControlChannel() { "[Enrichment] Stopping gracefully - completing current item...", ); // DO NOT override state - let enrichmentStateService.stop() handle it - // Signal CLAP Python container to stop draining its queue - getRedis().publish("audio:clap:control", JSON.stringify({ command: "stop" })).catch(() => {}); + // DO NOT kill the CLAP analyzer — it has its own idle timeout (MODEL_IDLE_TIMEOUT=300s) + // and will unload the model when the vibe queue is empty. Killing it caused + // permanent death because supervisor's autorestart didn't revive clean exits. } } }); @@ -248,7 +249,7 @@ export async function startUnifiedEnrichmentWorker() { }); const orphanedVibe = await prisma.track.updateMany({ where: { vibeAnalysisStatus: "processing" }, - data: { vibeAnalysisStatus: null, vibeAnalysisStartedAt: null }, + data: { vibeAnalysisStatus: "pending", vibeAnalysisStartedAt: null }, }); if (orphanedAudio.count > 0 || orphanedVibe.count > 0) { logger.info( @@ -496,6 +497,9 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{ // Podcast refresh phase -- only runs if subscriptions exist await runPhase("podcasts", executePodcastRefreshPhase); + // Vibe embedding sweep — catches tracks missed by the event-driven subscriber + await runPhase("vibe", executeVibePhase); + // Orphaned failure cleanup -- runs at most once per hour, never during stop/pause const ONE_HOUR_MS = 60 * 60 * 1000; if (!isStopping && !isPaused && (!lastOrphanedFailuresCleanup || Date.now() - lastOrphanedFailuresCleanup.getTime() > ONE_HOUR_MS)) { @@ -853,7 +857,7 @@ async function shouldHaltCycle(): Promise { * Run a phase and return result. Returns null if cycle should halt. */ async function runPhase( - phaseName: "artists" | "tracks" | "audio" | "podcasts", + phaseName: "artists" | "tracks" | "audio" | "podcasts" | "vibe", executor: () => Promise, ): Promise { await enrichmentStateService.updateState({ @@ -1014,6 +1018,69 @@ async function executeAudioPhase(): Promise { return queueAudioAnalysis(); } +const VIBE_SWEEP_BATCH_SIZE = 100; + +async function executeVibePhase(): Promise { + const features = await featureDetection.getFeatures(); + if (!features.vibeEmbeddings) { + return 0; + } + + // Find tracks with completed audio analysis but no embedding row. + // This catches: + // - Tracks orphaned by migration wiping track_embeddings + // - Tracks whose pub/sub completion event was missed (crash, restart) + // - Tracks reset to null/pending by crash recovery + // - Tracks with vibeAnalysisStatus='completed' but no actual embedding (stale status) + const tracks = await prisma.$queryRaw<{ id: string; filePath: string }[]>` + SELECT t.id, t."filePath" + FROM "Track" t + LEFT JOIN track_embeddings te ON t.id = te.track_id + WHERE te.track_id IS NULL + AND t."analysisStatus" = 'completed' + AND t."filePath" IS NOT NULL + AND (t."vibeAnalysisStatus" IS NULL + OR t."vibeAnalysisStatus" = 'pending' + OR t."vibeAnalysisStatus" = 'completed') + AND (t."vibeAnalysisStatus" IS DISTINCT FROM 'processing') + LIMIT ${VIBE_SWEEP_BATCH_SIZE} + `; + + if (tracks.length === 0) { + return 0; + } + + // Reset stale vibeAnalysisStatus for these tracks before queuing + const trackIds = tracks.map((t) => t.id); + await prisma.track.updateMany({ + where: { id: { in: trackIds } }, + data: { + vibeAnalysisStatus: "pending", + vibeAnalysisError: null, + }, + }); + + let queued = 0; + for (const track of tracks) { + try { + await vibeQueue.add( + "embed", + { trackId: track.id, filePath: track.filePath }, + { jobId: `vibe-${track.id}` }, + ); + queued++; + } catch (err) { + // jobId dedup: if already queued, BullMQ throws — that's fine + } + } + + if (queued > 0) { + logger.debug(`[Enrichment] Vibe sweep: queued ${queued} tracks for embedding`); + } + + return queued; +} + async function executePodcastRefreshPhase(): Promise { const podcastCount = await prisma.podcast.count(); if (podcastCount === 0) return 0; @@ -1094,7 +1161,7 @@ export async function getEnrichmentProgress() { }); // CLAP embedding progress (for vibe similarity) - const [clapEmbeddingCount, clapProcessing, clapQueueCounts, clapFailedCount] = await Promise.all([ + const [clapEmbeddingCount, clapProcessing, clapQueueCounts, clapFailedCount, clapUnembeddedCount] = await Promise.all([ prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) as count FROM track_embeddings `, @@ -1105,10 +1172,21 @@ export async function getEnrichmentProgress() { prisma.track.count({ where: { vibeAnalysisStatus: "failed" }, }), + // Tracks with completed audio but no embedding and not failed + prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM "Track" t + LEFT JOIN track_embeddings te ON t.id = te.track_id + WHERE te.track_id IS NULL + AND t."analysisStatus" = 'completed' + AND t."filePath" IS NOT NULL + AND (t."vibeAnalysisStatus" IS DISTINCT FROM 'failed') + `, ]); const clapQueueLength = (clapQueueCounts.active ?? 0) + (clapQueueCounts.waiting ?? 0) + (clapQueueCounts.delayed ?? 0); const clapCompleted = Number(clapEmbeddingCount[0]?.count || 0); const clapFailed = clapFailedCount; + const clapUnembedded = Number(clapUnembeddedCount[0]?.count || 0); // Core enrichment is complete when artists and track tags are done // Audio analysis is separate - it runs in background and doesn't block @@ -1175,7 +1253,7 @@ export async function getEnrichmentProgress() { audioProcessing === 0 && clapProcessing === 0 && clapQueueLength === 0 && - clapCompleted + clapFailed >= trackTotal, + clapUnembedded === 0, }; } @@ -1293,12 +1371,14 @@ export async function triggerEnrichmentNow(): Promise<{ return 0; } + // Delete all existing embeddings so tracks get fully regenerated + const deleted = await prisma.trackEmbedding.deleteMany({}); + logger.debug(`[Enrichment] Deleted ${deleted.count} existing embeddings for full regeneration`); + // Reset all tracks so they can be re-embedded. - // Only reset tracks whose audio analysis is complete (no point embedding incomplete audio). await prisma.track.updateMany({ where: { analysisStatus: "completed", - vibeAnalysisStatus: { not: null }, }, data: { vibeAnalysisStatus: null, @@ -1311,9 +1391,7 @@ export async function triggerEnrichmentNow(): Promise<{ const tracks = await prisma.$queryRaw<{ id: string; filePath: string }[]>` SELECT t.id, t."filePath" FROM "Track" t - LEFT JOIN track_embeddings te ON t.id = te.track_id - WHERE te.track_id IS NULL - AND t."analysisStatus" = 'completed' + WHERE t."analysisStatus" = 'completed' AND t."filePath" IS NOT NULL LIMIT 5000 `; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6c2d0ab..e7a7959 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,6 +29,7 @@ services: # Default uses host.docker.internal which works on most setups with extra_hosts below # Override if using custom Docker networks: e.g., http://192.168.0.20:3030 - KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3030} + - DISABLE_CLAP=${DISABLE_CLAP:-} # Makes host.docker.internal work on Linux (already works on Docker Desktop) extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docker-compose.server.yml b/docker-compose.server.yml index fb05116..d464bee 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -27,6 +27,7 @@ services: # Default uses host.docker.internal which works on most setups with extra_hosts below # Override if using custom Docker networks: e.g., http://192.168.0.20:3030 - KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3030} + - DISABLE_CLAP=${DISABLE_CLAP:-} # Makes host.docker.internal work on Linux (already works on Docker Desktop) extra_hosts: - "host.docker.internal:host-gateway" @@ -35,15 +36,7 @@ services: - vm.overcommit_memory=1 restart: unless-stopped healthcheck: - test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://localhost:3030", - ] + test: ["CMD", "node", "/app/healthcheck.js"] interval: 30s timeout: 10s retries: 3 diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico index 718d6fe..124ba35 100644 Binary files a/frontend/app/favicon.ico and b/frontend/app/favicon.ico differ diff --git a/frontend/app/import/spotify/page.tsx b/frontend/app/import/spotify/page.tsx index dfdcd92..15ab951 100644 --- a/frontend/app/import/spotify/page.tsx +++ b/frontend/app/import/spotify/page.tsx @@ -336,7 +336,7 @@ function SpotifyImportPageContent() { setIsCancelling(true); try { - await api.post<{ + const cancelResult = await api.post<{ message: string; playlistId: string | null; tracksMatched: number; @@ -347,8 +347,8 @@ function SpotifyImportPageContent() { ? { ...prev, status: "cancelled", - createdPlaylistId: null, - tracksMatched: 0, + createdPlaylistId: cancelResult.playlistId ?? null, + tracksMatched: cancelResult.tracksMatched ?? 0, } : prev ); @@ -967,7 +967,9 @@ function SpotifyImportPageContent() {

) : importJob.status === "cancelled" ? (

- Import was cancelled. No playlist was created. + {importJob.createdPlaylistId + ? `Import cancelled. A playlist was created with ${importJob.tracksMatched} matched track${importJob.tracksMatched === 1 ? "" : "s"}.` + : "Import cancelled. No tracks had been matched yet."}

) : ( <> diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 26fd648..9f52ae9 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -35,7 +35,6 @@ export const metadata: Metadata = { description: "Self-hosted music streaming platform", manifest: "/manifest.webmanifest", icons: { - icon: "/assets/images/Kima__favicon.ico", apple: [ { url: "/assets/images/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, ], diff --git a/frontend/app/playlist/[id]/page.tsx b/frontend/app/playlist/[id]/page.tsx index 23f68de..96c562d 100644 --- a/frontend/app/playlist/[id]/page.tsx +++ b/frontend/app/playlist/[id]/page.tsx @@ -30,6 +30,8 @@ import { Loader2, ArrowLeft, } from "lucide-react"; +import { useTrackFormat } from "@/hooks/useTrackFormat"; +import { formatTrackDisplay } from "@/lib/track-format"; interface Track { id: string; @@ -72,6 +74,7 @@ export default function PlaylistDetailPage() { const queryClient = useQueryClient(); const { toast } = useToast(); const { currentTrack } = useAudioState(); + const { format: trackFormat } = useTrackFormat(); const { isPlaying } = useAudioPlayback(); const { playTracks, addToQueue, pause, resumeWithGesture } = useAudioControls(); const playlistId = params.id as string; @@ -790,7 +793,14 @@ export default function PlaylistDetailPage() { : "text-white" )} > - {playlistItem.track.title} + {formatTrackDisplay( + { + title: playlistItem.track.title, + artist: playlistItem.track.album.artist.name, + album: playlistItem.track.album.title, + }, + trackFormat, + )}

{playlistItem.track.album.artist.name} diff --git a/frontend/features/settings/components/sections/PlaybackSection.tsx b/frontend/features/settings/components/sections/PlaybackSection.tsx index 7807585..99e9a96 100644 --- a/frontend/features/settings/components/sections/PlaybackSection.tsx +++ b/frontend/features/settings/components/sections/PlaybackSection.tsx @@ -1,7 +1,9 @@ "use client"; -import { SettingsSection, SettingsRow, SettingsSelect } from "../ui"; +import { SettingsSection, SettingsRow, SettingsSelect, SettingsInput } from "../ui"; import { UserSettings } from "../../types"; +import { useTrackFormat } from "@/hooks/useTrackFormat"; +import { formatTrackDisplay } from "@/lib/track-format"; interface PlaybackSectionProps { value: UserSettings["playbackQuality"]; @@ -15,10 +17,20 @@ const qualityOptions = [ { value: "low", label: "Low (128 kbps)" }, ]; +const SAMPLE_TRACK = { + title: "Midnight Rain", + artist: "Taylor Swift", + album: "Midnights", + filename: "/music/Taylor Swift/Midnights/04 Midnight Rain.flac", +}; + export function PlaybackSection({ value, onChange }: PlaybackSectionProps) { + const { format, setFormat } = useTrackFormat(); + const preview = formatTrackDisplay(SAMPLE_TRACK, format); + return ( - @@ -28,7 +40,23 @@ export function PlaybackSection({ value, onChange }: PlaybackSectionProps) { options={qualityOptions} /> + +

+ + {format && ( +

+ Preview: {preview} +

+ )} +
+ ); } - diff --git a/frontend/hooks/useMediaSession.ts b/frontend/hooks/useMediaSession.ts index 829d80f..c6f0df5 100644 --- a/frontend/hooks/useMediaSession.ts +++ b/frontend/hooks/useMediaSession.ts @@ -1,16 +1,15 @@ import { useEffect, useCallback, useRef } from "react"; import { useAudio } from "@/lib/audio-context"; +import { audioEngine } from "@/lib/audio-engine"; +import { silenceKeepalive } from "@/lib/silence-keepalive"; import { api } from "@/lib/api"; /** * Media Session API integration for OS-level media controls * - * Features: - * - Lock screen controls (iOS/Android) - * - Media keys (play/pause, next, previous) - * - Now playing notification - * - Album art display - * - Seek controls (on supported platforms) + * playbackState is driven by audio engine events (the source of truth), + * not React state, to avoid async timing issues that cause inverted + * lock screen controls on iOS. */ export function useMediaSession() { const { @@ -19,8 +18,8 @@ export function useMediaSession() { currentPodcast, playbackType, isPlaying, + setIsPlaying, pause, - resumeWithGesture, next, previous, seek, @@ -28,71 +27,102 @@ export function useMediaSession() { } = useAudio(); const currentTimeRef = useRef(currentTime); - const isPlayingRef = useRef(isPlaying); + const pauseRef = useRef(pause); + const nextRef = useRef(next); + const previousRef = useRef(previous); + const seekRef = useRef(seek); + const setIsPlayingRef = useRef(setIsPlaying); + const playbackTypeRef = useRef(playbackType); + const currentTrackRef = useRef(currentTrack); + const currentAudiobookRef = useRef(currentAudiobook); + const currentPodcastRef = useRef(currentPodcast); - useEffect(() => { - currentTimeRef.current = currentTime; - }, [currentTime]); - - useEffect(() => { - isPlayingRef.current = isPlaying; - }, [isPlaying]); + useEffect(() => { currentTimeRef.current = currentTime; }, [currentTime]); + useEffect(() => { pauseRef.current = pause; }, [pause]); + useEffect(() => { nextRef.current = next; }, [next]); + useEffect(() => { previousRef.current = previous; }, [previous]); + useEffect(() => { seekRef.current = seek; }, [seek]); + useEffect(() => { setIsPlayingRef.current = setIsPlaying; }, [setIsPlaying]); + useEffect(() => { playbackTypeRef.current = playbackType; }, [playbackType]); + useEffect(() => { currentTrackRef.current = currentTrack; }, [currentTrack]); + useEffect(() => { currentAudiobookRef.current = currentAudiobook; }, [currentAudiobook]); + useEffect(() => { currentPodcastRef.current = currentPodcast; }, [currentPodcast]); // Track if this device has initiated playback locally - // Prevents cross-device media session interference from state sync const hasPlayedLocallyRef = useRef(false); + // Track if action handlers have been registered (one-time gate) + const handlersRegisteredRef = useRef(false); - // Set flag when playback starts on this device useEffect(() => { if (isPlaying) { hasPlayedLocallyRef.current = true; } }, [isPlaying]); - // Reset flag when all media is cleared useEffect(() => { if (!currentTrack && !currentAudiobook && !currentPodcast) { hasPlayedLocallyRef.current = false; + handlersRegisteredRef.current = false; } }, [currentTrack, currentAudiobook, currentPodcast]); - // Convert relative URLs to absolute (required for iOS) const getAbsoluteUrl = useCallback((url: string): string => { if (!url) return ""; if (url.startsWith("http://") || url.startsWith("https://")) { return url; } - // Construct absolute URL if (typeof window !== "undefined") { return `${window.location.origin}${url}`; } return url; }, []); + // Drive playbackState from audio engine events, not React state. + // This fires synchronously when the audio element actually starts/stops, + // eliminating the async gap that caused inverted lock screen controls. useEffect(() => { - // Check if Media Session API is supported - if (!("mediaSession" in navigator)) { - console.warn("[MediaSession] Media Session API not supported"); - return; - } + if (!("mediaSession" in navigator)) return; + + const handlePlay = () => { + if (hasPlayedLocallyRef.current) { + navigator.mediaSession.playbackState = "playing"; + } + }; + + const handlePause = () => { + if (hasPlayedLocallyRef.current) { + navigator.mediaSession.playbackState = "paused"; + } + }; + + audioEngine.on("play", handlePlay); + audioEngine.on("pause", handlePause); + audioEngine.on("ended", handlePause); + + return () => { + audioEngine.off("play", handlePlay); + audioEngine.off("pause", handlePause); + audioEngine.off("ended", handlePause); + }; + }, []); + + // Metadata updates -- still driven by React state since metadata + // changes are infrequent and not timing-sensitive like playbackState. + useEffect(() => { + if (!("mediaSession" in navigator)) return; - // Only set metadata if this device has initiated playback - // Prevents cross-device interference from state sync if (!hasPlayedLocallyRef.current) { navigator.mediaSession.metadata = null; return; } - // Update metadata when track/audiobook/podcast changes const fallbackArtwork = [ { src: getAbsoluteUrl("/assets/icons/icon-512.webp"), sizes: "512x512", type: "image/webp" }, ]; if (playbackType === "track" && currentTrack) { const coverUrl = currentTrack.album?.coverArt - ? getAbsoluteUrl( - api.getCoverArtUrl(currentTrack.album.coverArt, 512) - ) + ? getAbsoluteUrl(api.getCoverArtUrl(currentTrack.album.coverArt, 512)) : undefined; navigator.mediaSession.metadata = new MediaMetadata({ @@ -102,39 +132,17 @@ export function useMediaSession() { artwork: coverUrl ? [ { src: coverUrl, sizes: "96x96", type: "image/jpeg" }, - { - src: coverUrl, - sizes: "128x128", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "192x192", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "256x256", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "384x384", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "512x512", - type: "image/jpeg", - }, + { src: coverUrl, sizes: "128x128", type: "image/jpeg" }, + { src: coverUrl, sizes: "192x192", type: "image/jpeg" }, + { src: coverUrl, sizes: "256x256", type: "image/jpeg" }, + { src: coverUrl, sizes: "384x384", type: "image/jpeg" }, + { src: coverUrl, sizes: "512x512", type: "image/jpeg" }, ] : fallbackArtwork, }); } else if (playbackType === "audiobook" && currentAudiobook) { const coverUrl = currentAudiobook.coverUrl - ? getAbsoluteUrl( - api.getCoverArtUrl(currentAudiobook.coverUrl, 512) - ) + ? getAbsoluteUrl(api.getCoverArtUrl(currentAudiobook.coverUrl, 512)) : undefined; navigator.mediaSession.metadata = new MediaMetadata({ @@ -146,39 +154,17 @@ export function useMediaSession() { artwork: coverUrl ? [ { src: coverUrl, sizes: "96x96", type: "image/jpeg" }, - { - src: coverUrl, - sizes: "128x128", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "192x192", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "256x256", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "384x384", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "512x512", - type: "image/jpeg", - }, + { src: coverUrl, sizes: "128x128", type: "image/jpeg" }, + { src: coverUrl, sizes: "192x192", type: "image/jpeg" }, + { src: coverUrl, sizes: "256x256", type: "image/jpeg" }, + { src: coverUrl, sizes: "384x384", type: "image/jpeg" }, + { src: coverUrl, sizes: "512x512", type: "image/jpeg" }, ] : fallbackArtwork, }); } else if (playbackType === "podcast" && currentPodcast) { const coverUrl = currentPodcast.coverUrl - ? getAbsoluteUrl( - api.getCoverArtUrl(currentPodcast.coverUrl, 512) - ) + ? getAbsoluteUrl(api.getCoverArtUrl(currentPodcast.coverUrl, 512)) : undefined; navigator.mediaSession.metadata = new MediaMetadata({ @@ -188,41 +174,17 @@ export function useMediaSession() { artwork: coverUrl ? [ { src: coverUrl, sizes: "96x96", type: "image/jpeg" }, - { - src: coverUrl, - sizes: "128x128", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "192x192", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "256x256", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "384x384", - type: "image/jpeg", - }, - { - src: coverUrl, - sizes: "512x512", - type: "image/jpeg", - }, + { src: coverUrl, sizes: "128x128", type: "image/jpeg" }, + { src: coverUrl, sizes: "192x192", type: "image/jpeg" }, + { src: coverUrl, sizes: "256x256", type: "image/jpeg" }, + { src: coverUrl, sizes: "384x384", type: "image/jpeg" }, + { src: coverUrl, sizes: "512x512", type: "image/jpeg" }, ] : fallbackArtwork, }); } else { - // Clear metadata when nothing is playing navigator.mediaSession.metadata = null; } - - // Update playback state - navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused"; }, [ currentTrack, currentAudiobook, @@ -232,110 +194,74 @@ export function useMediaSession() { getAbsoluteUrl, ]); + // Register action handlers once when first playback occurs. Uses refs + // so handlers always access current values without re-registration. useEffect(() => { if (!("mediaSession" in navigator)) return; + if (!hasPlayedLocallyRef.current) return; + if (handlersRegisteredRef.current) return; + handlersRegisteredRef.current = true; - // Only register handlers if this device has initiated playback - // Prevents cross-device interference from state sync - if (!hasPlayedLocallyRef.current) { - return; - } - - // Register action handlers + // Play handler: call audioEngine directly to preserve iOS user gesture + // context, then sync React state from the audio element event. navigator.mediaSession.setActionHandler("play", () => { - resumeWithGesture(); + silenceKeepalive.prime(); + audioEngine.tryResume().then((started) => { + if (started) { + setIsPlayingRef.current(true); + } + }); }); navigator.mediaSession.setActionHandler("pause", () => { - pause(); + audioEngine.pause(); + setIsPlayingRef.current(false); }); navigator.mediaSession.setActionHandler("previoustrack", () => { - if (playbackType === "track") { - previous(); + if (playbackTypeRef.current === "track") { + previousRef.current(); } else { - // For audiobooks/podcasts, seek backward 30s - seek(Math.max(currentTimeRef.current - 30, 0)); + seekRef.current(Math.max(currentTimeRef.current - 30, 0)); } }); navigator.mediaSession.setActionHandler("nexttrack", () => { - if (playbackType === "track") { - next(); + if (playbackTypeRef.current === "track") { + nextRef.current(); } else { - // For audiobooks/podcasts, seek forward 30s const duration = - currentAudiobook?.duration || currentPodcast?.duration || 0; - seek(Math.min(currentTimeRef.current + 30, duration)); + currentAudiobookRef.current?.duration || + currentPodcastRef.current?.duration || 0; + seekRef.current(Math.min(currentTimeRef.current + 30, duration)); } }); - // Seek controls (may not be supported on all platforms) try { - navigator.mediaSession.setActionHandler( - "seekbackward", - (details) => { - const skipTime = details.seekOffset || 10; - seek(Math.max(currentTimeRef.current - skipTime, 0)); - } - ); + navigator.mediaSession.setActionHandler("seekbackward", (details) => { + const skipTime = details.seekOffset || 10; + seekRef.current(Math.max(currentTimeRef.current - skipTime, 0)); + }); - navigator.mediaSession.setActionHandler( - "seekforward", - (details) => { - const skipTime = details.seekOffset || 10; - const duration = - currentTrack?.duration || - currentAudiobook?.duration || - currentPodcast?.duration || - 0; - seek(Math.min(currentTimeRef.current + skipTime, duration)); - } - ); + navigator.mediaSession.setActionHandler("seekforward", (details) => { + const skipTime = details.seekOffset || 10; + const duration = + currentTrackRef.current?.duration || + currentAudiobookRef.current?.duration || + currentPodcastRef.current?.duration || 0; + seekRef.current(Math.min(currentTimeRef.current + skipTime, duration)); + }); navigator.mediaSession.setActionHandler("seekto", (details) => { if (details.seekTime !== undefined) { - seek(details.seekTime); + seekRef.current(details.seekTime); } }); } catch { // Seek actions not supported on this platform } - // Cleanup - return () => { - if ("mediaSession" in navigator) { - navigator.mediaSession.setActionHandler("play", null); - navigator.mediaSession.setActionHandler("pause", null); - navigator.mediaSession.setActionHandler("previoustrack", null); - navigator.mediaSession.setActionHandler("nexttrack", null); - try { - navigator.mediaSession.setActionHandler( - "seekbackward", - null - ); - navigator.mediaSession.setActionHandler( - "seekforward", - null - ); - navigator.mediaSession.setActionHandler("seekto", null); - } catch { - // Ignore cleanup errors - } - } - }; - }, [ - pause, - resumeWithGesture, - next, - previous, - seek, - playbackType, - isPlaying, - currentTrack, - currentAudiobook, - currentPodcast, - ]); + }, [isPlaying]); // Update position state for scrubbing on lock screen useEffect(() => { @@ -355,23 +281,19 @@ export function useMediaSession() { position: Math.min(currentTime, duration), }); } catch (error) { - // Some browsers may not support position state - console.warn( - "[MediaSession] Failed to set position state:", - error - ); + console.warn("[MediaSession] Failed to set position state:", error); } } }, [currentTime, currentTrack, currentAudiobook, currentPodcast]); - // Re-sync MediaSession playbackState when the app comes back to the foreground. - // iOS may have shown stale controls while the app was backgrounded. + // Re-sync playbackState on foreground restore. + // Uses audioEngine.isPlaying() (ground truth) instead of React ref. useEffect(() => { if (!("mediaSession" in navigator)) return; const handleVisibilityChange = () => { - if (!document.hidden) { - navigator.mediaSession.playbackState = isPlayingRef.current + if (!document.hidden && hasPlayedLocallyRef.current) { + navigator.mediaSession.playbackState = audioEngine.isPlaying() ? "playing" : "paused"; } @@ -379,9 +301,6 @@ export function useMediaSession() { document.addEventListener("visibilitychange", handleVisibilityChange); return () => - document.removeEventListener( - "visibilitychange", - handleVisibilityChange - ); + document.removeEventListener("visibilitychange", handleVisibilityChange); }, []); } diff --git a/frontend/hooks/useTrackFormat.ts b/frontend/hooks/useTrackFormat.ts new file mode 100644 index 0000000..aca339e --- /dev/null +++ b/frontend/hooks/useTrackFormat.ts @@ -0,0 +1,28 @@ +import { useState, useCallback } from "react"; + +const STORAGE_KEY = "kima:trackDisplayFormat"; + +function getStoredFormat(): string { + if (typeof window === "undefined") return ""; + try { + return localStorage.getItem(STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +export function useTrackFormat() { + const [format, setFormatState] = useState(getStoredFormat); + + const setFormat = useCallback((value: string) => { + setFormatState(value); + try { + if (value) localStorage.setItem(STORAGE_KEY, value); + else localStorage.removeItem(STORAGE_KEY); + } catch { + // storage unavailable + } + }, []); + + return { format, setFormat } as const; +} diff --git a/frontend/lib/audio-hooks.tsx b/frontend/lib/audio-hooks.tsx index 162f8d4..751d1ab 100644 --- a/frontend/lib/audio-hooks.tsx +++ b/frontend/lib/audio-hooks.tsx @@ -39,6 +39,7 @@ export function useAudio() { // Playback isPlaying: playback.isPlaying, + setIsPlaying: playback.setIsPlaying, currentTime: playback.currentTime, duration: playback.duration, isBuffering: playback.isBuffering, diff --git a/frontend/lib/track-format.ts b/frontend/lib/track-format.ts new file mode 100644 index 0000000..ab8ac7d --- /dev/null +++ b/frontend/lib/track-format.ts @@ -0,0 +1,133 @@ +/** + * Foobar2000-style title formatting for track display. + * + * Supported syntax: + * %field% — substitute field value (empty string if missing) + * [block] — hide entire block if any %field% inside resolved empty + * $if2(a, b) — return a if non-empty, else b + * $filepart(path) — filename without directory or extension + * + * Available fields: title, artist, album, filename + * + * Reference: https://wiki.hydrogenaudio.org/index.php?title=Foobar2000:Title_Formatting_Reference + */ + +export interface FormatTrack { + title?: string | null; + artist?: string | null; + album?: string | null; + filename?: string | null; +} + +function filepart(path: string | null | undefined): string { + if (!path) return ""; + const base = path.split(/[\\/]/).pop() ?? ""; + const dot = base.lastIndexOf("."); + return dot > 0 ? base.slice(0, dot) : base; +} + +function resolveField(name: string, track: FormatTrack): string { + switch (name.toLowerCase()) { + case "title": return track.title ?? ""; + case "artist": return track.artist ?? ""; + case "album": return track.album ?? ""; + case "filename": return filepart(track.filename); + default: return ""; + } +} + +function findMatchingClose(str: string, start: number, open: string, close: string): number { + let depth = 1; + for (let i = start; i < str.length; i++) { + if (str[i] === open) depth++; + else if (str[i] === close) { depth--; if (depth === 0) return i; } + } + return -1; +} + +function splitAtTopLevelComma(str: string): [string, string] | null { + let depth = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === "(") depth++; + else if (str[i] === ")") depth--; + else if (str[i] === "," && depth === 0) { + return [str.slice(0, i), str.slice(i + 1)]; + } + } + return null; +} + +function evalFormat(fmt: string, track: FormatTrack): string { + let result = ""; + let i = 0; + + while (i < fmt.length) { + // [conditional block] + if (fmt[i] === "[") { + const close = findMatchingClose(fmt, i + 1, "[", "]"); + if (close === -1) { result += fmt[i++]; continue; } + const inner = fmt.slice(i + 1, close); + const fieldRefs = [...inner.matchAll(/%([^%]+)%/g)]; + const anyEmpty = fieldRefs.some((m) => resolveField(m[1], track) === ""); + if (!anyEmpty) result += evalFormat(inner, track); + i = close + 1; + continue; + } + + // $function(...) + if (fmt[i] === "$") { + const fnMatch = fmt.slice(i).match(/^\$(\w+)\(/); + if (fnMatch) { + const bodyStart = i + fnMatch[0].length; + const bodyEnd = findMatchingClose(fmt, bodyStart, "(", ")"); + if (bodyEnd === -1) { result += fmt[i++]; continue; } + const body = fmt.slice(bodyStart, bodyEnd); + const fnName = fnMatch[1].toLowerCase(); + + if (fnName === "if2") { + const parts = splitAtTopLevelComma(body); + if (parts) { + const a = evalFormat(parts[0].trim(), track); + result += a !== "" ? a : evalFormat(parts[1].trim(), track); + } else { + result += evalFormat(body, track); + } + } else if (fnName === "filepart") { + result += filepart(evalFormat(body.trim(), track)); + } + + i = bodyEnd + 1; + continue; + } + result += fmt[i++]; + continue; + } + + // %field% + if (fmt[i] === "%") { + const end = fmt.indexOf("%", i + 1); + if (end === -1) { result += fmt[i++]; continue; } + result += resolveField(fmt.slice(i + 1, end), track); + i = end + 1; + continue; + } + + // literal character + result += fmt[i++]; + } + + return result; +} + +/** + * Format a track title using a fb2k-style format string. + * Returns track.title when format is empty/null. + */ +export function formatTrackDisplay( + track: FormatTrack, + format: string | null | undefined, +): string { + if (!format?.trim()) return track.title ?? ""; + const result = evalFormat(format, track); + return result || (track.title ?? ""); +} diff --git a/frontend/package.json b/frontend/package.json index 7c81e0a..6d35154 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kima-frontend", - "version": "1.5.8", + "version": "1.5.9", "description": "Kima web frontend", "license": "GPL-3.0", "repository": {