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": {