release: v1.5.9 — iOS MediaSession fix, enrichment pipeline hardening, track formatting

Features:
- DISABLE_CLAP env var for low-memory deployments
- Foobar2000-style track title formatting in Settings > Playback
- Partial playlist creation on cancelled Spotify imports

Fixes:
- iOS lock screen controls showing inverted state (play/pause swapped)
- Enrichment pipeline: 7 fixes covering vibe sweep, crash recovery,
  CLAP supervisor, completion detection, embedding reset, feature detection
- Favicon replaced with waveform-only multi-size ICO
- docker-compose.server.yml healthcheck using removed wget
This commit is contained in:
Your Name
2026-02-27 12:30:11 -06:00
parent caf3caef86
commit 51c761715c
19 changed files with 523 additions and 240 deletions
+7
View File
@@ -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
# ==============================================================================
+21
View File
@@ -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
+18 -1
View File
@@ -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 \
+1 -1
View File
@@ -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": {
+6 -1
View File
@@ -68,7 +68,12 @@ class FeatureDetectionService {
private async checkCLAP(): Promise<boolean> {
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;
}
+46 -5
View File
@@ -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,
};
}
+89 -11
View File
@@ -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<boolean> {
* 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<number>,
): Promise<number | null> {
await enrichmentStateService.updateState({
@@ -1014,6 +1018,69 @@ async function executeAudioPhase(): Promise<number> {
return queueAudioAnalysis();
}
const VIBE_SWEEP_BATCH_SIZE = 100;
async function executeVibePhase(): Promise<number> {
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<number> {
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
`;
+1
View File
@@ -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"
+2 -9
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+6 -4
View File
@@ -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() {
</p>
) : importJob.status === "cancelled" ? (
<p className="text-sm text-gray-400">
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."}
</p>
) : (
<>
-1
View File
@@ -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" },
],
+11 -1
View File
@@ -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,
)}
</p>
<p className="text-[10px] font-mono text-white/40 truncate uppercase tracking-wider">
{playlistItem.track.album.artist.name}
@@ -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 (
<SettingsSection id="playback" title="Playback">
<SettingsRow
<SettingsRow
label="Streaming quality"
description="Higher quality uses more bandwidth"
>
@@ -28,7 +40,23 @@ export function PlaybackSection({ value, onChange }: PlaybackSectionProps) {
options={qualityOptions}
/>
</SettingsRow>
<SettingsRow
label="Track title format"
description="Foobar2000-style format string. Leave empty for default."
>
<div className="flex flex-col gap-1.5 w-full max-w-sm">
<SettingsInput
value={format}
onChange={setFormat}
placeholder="[%artist% - ]$if2(%title%,$filepart(%filename%))"
/>
{format && (
<p className="text-[11px] text-white/30 font-mono truncate">
Preview: {preview}
</p>
)}
</div>
</SettingsRow>
</SettingsSection>
);
}
+121 -202
View File
@@ -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);
}, []);
}
+28
View File
@@ -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<string>(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;
}
+1
View File
@@ -39,6 +39,7 @@ export function useAudio() {
// Playback
isPlaying: playback.isPlaying,
setIsPlaying: playback.setIsPlaying,
currentTime: playback.currentTime,
duration: playback.duration,
isBuffering: playback.isBuffering,
+133
View File
@@ -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 ?? "");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-frontend",
"version": "1.5.8",
"version": "1.5.9",
"description": "Kima web frontend",
"license": "GPL-3.0",
"repository": {