mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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:
@@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "kima-backend",
|
||||
"version": "1.5.8",
|
||||
"version": "1.5.9",
|
||||
"description": "Kima backend API server",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function useAudio() {
|
||||
|
||||
// Playback
|
||||
isPlaying: playback.isPlaying,
|
||||
setIsPlaying: playback.setIsPlaying,
|
||||
currentTime: playback.currentTime,
|
||||
duration: playback.duration,
|
||||
isBuffering: playback.isBuffering,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "kima-frontend",
|
||||
"version": "1.5.8",
|
||||
"version": "1.5.9",
|
||||
"description": "Kima web frontend",
|
||||
"license": "GPL-3.0",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user