chore: v1.7.0 predeploy sweep -- vibe UI cleanup, fresh screenshots, dead code removal

- Strip legacy MusicCNN sections from NowPlayingTab (radar chart, mood spectrum bars,
  audio features grid, match score badge) -- audioFeatures no longer populated since
  vibe system moved to CLAP; all sections showed "--"
- Remove recharts dependency (zero usages after NowPlayingTab cleanup)
- Add priority prop to all 4 kima.webp logo Images so headless Playwright screenshots
  capture the logo (lazy loading doesn't trigger in headless Chromium)
- Retake all 19 README screenshots at 1440x900 desktop / 390x844 mobile
- Add vibe section to README with Map, Galaxy, Drift, Blend screenshots
- Remove orphaned dev artifacts: backend/test_dedup_manual.ts, backend/src/scripts/testDataCleanup.ts
- Add scripts/take-screenshots.js for future screenshot updates
This commit is contained in:
Your Name
2026-03-16 22:44:21 -05:00
parent 73272a9093
commit a72705c6de
29 changed files with 381 additions and 500 deletions
+77 -47
View File
@@ -10,18 +10,6 @@ Kima is built for music lovers who want the convenience of streaming services wi
![Kima Home Screen](assets/screenshots/desktop-home.png)
> **\* Upgrading from Lidify?** Kima v1.5.0+ renamed the internal database from `lidify` to `kima`. If you upgraded from a Lidify install and v1.5.0 ran before this fix was available, the container created an empty `kima` database while your data remained in the old `lidify` database. **Your data is safe.** Update to v1.5.1+ and restart -- the migration is now automatic. If you already started v1.5.0 and see an empty library, run these commands to fix it:
>
> ```bash
> docker exec -it kima-hub supervisorctl stop backend frontend audio-analyzer audio-analyzer-clap
> docker exec -it kima-hub gosu postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'kima' AND pid <> pg_backend_pid();"
> docker exec -it kima-hub gosu postgres psql -c "DROP DATABASE kima;"
> docker exec -it kima-hub gosu postgres psql -c "ALTER DATABASE lidify RENAME TO kima;"
> docker exec -it kima-hub gosu postgres psql -c "ALTER USER lidify RENAME TO kima;"
> docker exec -it kima-hub gosu postgres psql -c "ALTER USER kima WITH PASSWORD 'kima';"
> docker restart kima-hub
> ```
---
## A Note on Native Apps
@@ -45,6 +33,7 @@ Thanks for your patience while I work through this.
- [Integrations](#integrations)
- [Native Apps (Subsonic)](#native-apps-subsonic)
- [Using Kima](#using-kima)
- [Using the Vibe System](#using-the-vibe-system)
- [Administration](#administration)
- [Architecture](#architecture)
- [Roadmap](#roadmap)
@@ -112,18 +101,41 @@ Thanks for your patience while I work through this.
### The Vibe System
Kima's standout feature for music discovery. While playing any track, activate vibe mode to find similar music in your library.
The centerpiece of music discovery in Kima. Your entire library is analyzed by a CLAP neural network and projected into a 2D/3D space where similar-sounding tracks cluster together. The result is a living map of your music collection you can explore, search, and navigate.
- **Vibe Button** - Tap while playing any track to activate vibe mode
- **Audio Analysis** - Real-time radar chart showing Energy, Mood, Groove, and Tempo
- **Keep The Vibe Going** - Automatically queues tracks that match your current vibe
- **Match Scoring** - See how well each track matches with percentage scores
- **ML Mood Detection** - Tracks are classified across 7 moods: Happy, Sad, Relaxed, Aggressive, Party, Acoustic, Electronic
- **Mood Mixer** - Create custom playlists by adjusting mood sliders or using presets like Workout, Chill, or Focus
**Music Map** -- the default 2D view. Every track in your library is a point on the map, colored by mood cluster. Zoom and pan to explore. Click any track to inspect it; double-click to play it immediately.
<p align="center">
<img src="assets/screenshots/vibe-overlay.png" alt="Vibe Overlay" width="800">
<img src="assets/screenshots/vibe-map.png" alt="Vibe Music Map" width="800">
</p>
**Galaxy View** -- the same data rendered as a 3D star field. Orbit, zoom, and fly through your library. Switch between Map and Galaxy with the toggle in the top-left corner.
<p align="center">
<img src="assets/screenshots/vibe-galaxy.png" alt="Vibe Galaxy" width="800">
</p>
**Drift** -- pick any two tracks as start and end points and Kima plots a smooth path through the audio space between them. The resulting queue travels gradually from one sonic neighborhood to the other.
<p align="center">
<img src="assets/screenshots/vibe-drift.png" alt="Vibe Drift -- Song Path" width="800">
</p>
**Blend** -- add multiple tracks and let Kima find the centroid in audio space. The result is a queue of tracks that blend all of the inputs together into something new.
<p align="center">
<img src="assets/screenshots/vibe-blend.png" alt="Vibe Blend" width="800">
</p>
**Additional features:**
- **Text search** - Type any descriptor ("loud and fast", "rainy day piano") to highlight matching tracks on the map
- **Right-click context menu** - Vibe from any track (similar-track queue), find similar (highlight on map), or start a Drift
- **Labels** - Toggle track/artist labels on the map
- **Keep The Vibe Going** - From the player, activate vibe mode to continuously queue tracks that match what's playing
**Mood Mixer** -- pick a mood preset (Happy, Energetic, Chill, Focus, Party, Acoustic, Melancholy, Sad, Aggressive) to instantly generate a playlist calibrated to that sound. Moods are derived from audio analysis of your actual library, not genre tags.
<p align="center">
<img src="assets/screenshots/mood-mixer.png" alt="Mood Mixer" width="800">
</p>
@@ -150,7 +162,7 @@ Import playlists from Spotify, Deezer, and YouTube, or browse and discover new m
### Native Apps
- **OpenSubsonic API** - Use any Subsonic-compatible client (Symfonium, DSub, Ultrasonic, Finamp, etc.) to stream your Kima library
- **Standard Subsonic auth** - MD5 token auth supported; enter your API token as the password works with Amperfy, Symfonium, DSub, and any standard Subsonic client
- **Standard Subsonic auth** - MD5 token auth supported; enter your API token as the password -- works with Amperfy, Symfonium, DSub, and any standard Subsonic client
- **Per-client tokens** - Generate named API tokens in Settings > Native Apps; revoke them individually when a device is lost or replaced
- **Enrichment-aware** - Genres and artist biographies exposed to clients come from Last.fm enrichment, not just file tags
- **Lyrics, bookmarks, and play queue** - getLyrics, bookmarks, and savePlayQueue/getPlayQueue for cross-device resume
@@ -665,15 +677,15 @@ Kima implements the [OpenSubsonic](https://opensubsonic.netlify.app/) REST API,
1. Go to Settings > Native Apps in Kima
2. Enter a client name (e.g. "Amperfy on iPhone") and click **Generate Token**
3. Copy and save the token it is only shown once
3. Copy and save the token -- it is only shown once
4. In your client app, configure:
- **Server URL** your Kima server address (e.g. `http://192.168.1.10:3030`)
- **Username** your Kima username
- **Password** the token you just generated
- **Server URL** -- your Kima server address (e.g. `http://192.168.1.10:3030`)
- **Username** -- your Kima username
- **Password** -- the token you just generated
**Notes:**
- Standard MD5 token auth is supported clients that hash their password automatically will work correctly when you enter an API token as the password
- Standard MD5 token auth is supported -- clients that hash their password automatically will work correctly when you enter an API token as the password
- Each client should have its own token so you can revoke access per device
- Genres and biographies surfaced to clients come from Last.fm enrichment, not just file tags
- DISCOVER-location albums are excluded from all library views
@@ -682,17 +694,17 @@ Kima implements the [OpenSubsonic](https://opensubsonic.netlify.app/) REST API,
**Subsonic route module layout (backend):**
- `backend/src/routes/subsonic/index.ts` top-level router composition, auth/rate-limit, system endpoints
- `library.ts` artists/albums/tracks browsing and directory traversal
- `search.ts` `search`/`search2`/`search3`, genre/top/similar discovery
- `playback.ts` stream/download/cover-art/scrobble/now-playing plus `hls`/`getTranscodeStream`
- `playlists.ts` playlist list/read/create/update/delete
- `queue.ts` play queue get/save (ID-based and index-based)
- `starred.ts` star/unstar, starred lists, `setRating`
- `artistInfo.ts` / `lyrics.ts` artist metadata and lyric endpoints
- `userManagement.ts` / `profile.ts` user admin endpoints and `getUser`
- `podcasts.ts` podcast subscription and episode endpoints
- `compat.ts` compatibility/stub endpoints for clients that expect optional APIs
- `backend/src/routes/subsonic/index.ts` -- top-level router composition, auth/rate-limit, system endpoints
- `library.ts` -- artists/albums/tracks browsing and directory traversal
- `search.ts` -- `search`/`search2`/`search3`, genre/top/similar discovery
- `playback.ts` -- stream/download/cover-art/scrobble/now-playing plus `hls`/`getTranscodeStream`
- `playlists.ts` -- playlist list/read/create/update/delete
- `queue.ts` -- play queue get/save (ID-based and index-based)
- `starred.ts` -- star/unstar, starred lists, `setRating`
- `artistInfo.ts` / `lyrics.ts` -- artist metadata and lyric endpoints
- `userManagement.ts` / `profile.ts` -- user admin endpoints and `getUser`
- `podcasts.ts` -- podcast subscription and episode endpoints
- `compat.ts` -- compatibility/stub endpoints for clients that expect optional APIs
**When adding a Subsonic endpoint:**
@@ -764,18 +776,36 @@ Your listening progress is saved automatically, so you can pause on one device a
### Using the Vibe System
1. Start playing any track from your library
2. Click the **vibe button** (waveform icon) in the player controls
3. Kima analyzes the track and finds matching songs based on energy, mood, and tempo
4. Matching tracks are automatically queued - just keep listening
5. The vibe overlay shows a radar chart comparing your current track to the source
**Exploring the map:**
**Using the Mood Mixer:**
1. Navigate to **Vibe** in the sidebar
2. Your library loads as a 2D music map -- similar-sounding tracks cluster together
3. Click any point to inspect the track; double-click to play it
4. Switch to **Galaxy** view for a 3D star-field perspective
5. Use the **Search** bar to highlight tracks matching a text description
1. Open the Mood Mixer from the home screen or player
2. Choose a quick mood preset (Happy, Energetic, Chill, Focus, Workout) or create a custom mix
3. Adjust sliders for happiness, energy, danceability, and tempo
4. Kima generates a playlist of matching tracks from your library
**Drift -- journey between two tracks:**
1. Click **Drift** in the toolbar
2. Search for and select a start track, then an end track
3. Click **Generate Path** -- Kima queues a smooth sonic journey between them
**Blend -- find the space between multiple tracks:**
1. Click **Blend** in the toolbar
2. Add tracks you want to blend together
3. Kima finds the centroid in audio space and queues tracks from that neighborhood
**Keep The Vibe Going (from the player):**
1. Start playing any track
2. Right-click it on the vibe map and select **Vibe** to queue similar tracks continuously
**Mood Mixer:**
1. On the home screen, click **Mood Mixer** next to the Made For You section
2. Select a mood preset -- Kima instantly generates a playlist from your library calibrated to that mood
3. Each preset count shows how many tracks in your library match that mood
### Importing Playlists
Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 319 KiB

-82
View File
@@ -1,82 +0,0 @@
/**
* Manual test script for data cleanup functionality
* Run with: npx ts-node src/scripts/testDataCleanup.ts
*/
import { runDataCleanup } from "../workers/dataCleanup";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
async function main() {
logger.debug("Starting data cleanup test...");
logger.debug("Current time:", new Date().toLocaleString());
try {
// Show current counts before cleanup
const beforeCounts = {
downloadJobs: await prisma.downloadJob.count({
where: {
status: { in: ["completed", "failed"] },
},
}),
webhookEvents: await prisma.webhookEvent.count({
where: {
processed: true,
},
}),
discoveryBatches: await prisma.discoveryBatch.count({
where: {
status: "completed",
},
}),
};
logger.debug("\nBefore cleanup:");
logger.debug(` Download jobs (completed/failed): ${beforeCounts.downloadJobs}`);
logger.debug(` Webhook events (processed): ${beforeCounts.webhookEvents}`);
logger.debug(` Discovery batches (completed): ${beforeCounts.discoveryBatches}`);
// Run cleanup
logger.debug("\nRunning cleanup...");
const result = await runDataCleanup();
logger.debug("\nCleanup results:");
logger.debug(` Download jobs deleted: ${result.downloadJobs}`);
logger.debug(` Webhook events deleted: ${result.webhookEvents}`);
logger.debug(` Discovery batches deleted: ${result.discoveryBatches}`);
logger.debug(` Total records deleted: ${result.total}`);
// Show counts after cleanup
const afterCounts = {
downloadJobs: await prisma.downloadJob.count({
where: {
status: { in: ["completed", "failed"] },
},
}),
webhookEvents: await prisma.webhookEvent.count({
where: {
processed: true,
},
}),
discoveryBatches: await prisma.discoveryBatch.count({
where: {
status: "completed",
},
}),
};
logger.debug("\nAfter cleanup:");
logger.debug(` Download jobs (completed/failed): ${afterCounts.downloadJobs}`);
logger.debug(` Webhook events (processed): ${afterCounts.webhookEvents}`);
logger.debug(` Discovery batches (completed): ${afterCounts.discoveryBatches}`);
logger.debug("\nData cleanup test completed successfully!");
} catch (error) {
logger.error("Data cleanup test failed:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();
-103
View File
@@ -1,103 +0,0 @@
import { acquisitionService } from './src/services/acquisitionService';
import { prisma } from './src/utils/db';
import { redisClient } from './src/utils/redis';
async function test() {
console.log('\n=== Testing Download Job Deduplication ===\n');
const testUserId = 'test-user-dedup-manual';
const testBatchId = 'test-batch-dedup-manual';
const albumMbid = 'test-mbid-dedup-manual';
try {
// Cleanup any existing test data
await prisma.downloadJob.deleteMany({ where: { userId: testUserId } });
// Create test user
await prisma.user.upsert({
where: { id: testUserId },
create: {
id: testUserId,
username: `testuser-${Date.now()}`,
passwordHash: 'test-hash',
role: 'user'
},
update: {},
});
// Create test batch
await prisma.discoveryBatch.upsert({
where: { id: testBatchId },
create: {
id: testBatchId,
userId: testUserId,
weekStart: new Date(),
targetSongCount: 40,
status: 'downloading'
},
update: {},
});
console.log('✓ Test data setup complete\n');
// Test 1: Concurrent job creation
console.log('Test 1: Creating two jobs concurrently...');
const createJob = () => {
return (acquisitionService as any).createDownloadJob(
{ albumTitle: 'Test Album', artistName: 'Test Artist', mbid: albumMbid },
{ userId: testUserId, discoveryBatchId: testBatchId }
);
};
const start = Date.now();
const [job1, job2] = await Promise.all([createJob(), createJob()]);
const elapsed = Date.now() - start;
console.log(` Job 1 ID: ${job1.id}`);
console.log(` Job 2 ID: ${job2.id}`);
console.log(` Same job returned: ${job1.id === job2.id ? '✓' : '✗'}`);
console.log(` Time elapsed: ${elapsed}ms\n`);
const jobs = await prisma.downloadJob.findMany({
where: { targetMbid: albumMbid, userId: testUserId, discoveryBatchId: testBatchId },
});
console.log(` Jobs in database: ${jobs.length}`);
console.log(` Test 1 Result: ${jobs.length === 1 && job1.id === job2.id ? '✓ PASSED' : '✗ FAILED'}\n`);
// Test 2: Creating job after first is completed should create new job
console.log('Test 2: Creating new job after previous completes...');
await prisma.downloadJob.update({
where: { id: job1.id },
data: { status: 'completed' },
});
const job3 = await createJob();
console.log(` New job ID: ${job3.id}`);
console.log(` Different from first: ${job3.id !== job1.id ? '✓' : '✗'}`);
const allJobs = await prisma.downloadJob.findMany({
where: { targetMbid: albumMbid, userId: testUserId, discoveryBatchId: testBatchId },
});
console.log(` Total jobs now: ${allJobs.length}`);
console.log(` Test 2 Result: ${allJobs.length === 2 && job3.id !== job1.id ? '✓ PASSED' : '✗ FAILED'}\n`);
// Cleanup
await prisma.downloadJob.deleteMany({ where: { userId: testUserId } });
await prisma.discoveryBatch.delete({ where: { id: testBatchId } });
await prisma.user.delete({ where: { id: testUserId } });
console.log('=== All Tests Completed Successfully ===\n');
} catch (error) {
console.error('\n✗ Test failed with error:', error);
throw error;
} finally {
await redisClient.quit();
await prisma.$disconnect();
}
}
test().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -99,6 +99,7 @@ export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
alt="Kima"
width={32}
height={32}
priority
className="flex-shrink-0"
/>
<span className="text-lg font-bold text-white tracking-tight">
+1
View File
@@ -171,6 +171,7 @@ export function Sidebar() {
alt="Kima Logo"
width={48}
height={48}
priority
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
+1 -1
View File
@@ -213,7 +213,7 @@ export function TVLayout({ children }: { children: React.ReactNode }) {
{/* Nav */}
<header className="tv-nav">
<Link href="/" className="tv-logo">
<Image src="/assets/images/kima.webp" alt="Kima" width={24} height={24} />
<Image src="/assets/images/kima.webp" alt="Kima" width={24} height={24} priority />
<span>Kima</span>
</Link>
+1
View File
@@ -271,6 +271,7 @@ export function TopBar() {
alt="Kima"
width={32}
height={32}
priority
className="group-hover:scale-105 transition-transform"
/>
</Link>
+2 -265
View File
@@ -1,99 +1,15 @@
"use client";
import { useMemo } from "react";
import { useAudioState, AudioFeatures } from "@/lib/audio-state-context";
import { useAudioState } from "@/lib/audio-state-context";
import { useAudioPlayback } from "@/lib/audio-playback-context";
import { cn } from "@/utils/cn";
import { Music } from "lucide-react";
import { motion } from "framer-motion";
import Image from "next/image";
import { api } from "@/lib/api";
import { computeVibeMatchScore } from "@/utils/vibeMatchScore";
import {
ResponsiveContainer,
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
} from "recharts";
const RADAR_FEATURES = [
{ key: "energy", label: "Energy", min: 0, max: 1 },
{ key: "valence", label: "Mood", min: 0, max: 1 },
{ key: "arousal", label: "Arousal", min: 0, max: 1 },
{ key: "danceability", label: "Dance", min: 0, max: 1 },
{ key: "bpm", label: "Tempo", min: 60, max: 200 },
{ key: "moodHappy", label: "Happy", min: 0, max: 1 },
{ key: "moodSad", label: "Sad", min: 0, max: 1 },
{ key: "moodRelaxed", label: "Relaxed", min: 0, max: 1 },
{ key: "moodAggressive", label: "Aggressive", min: 0, max: 1 },
{ key: "moodParty", label: "Party", min: 0, max: 1 },
{ key: "moodAcoustic", label: "Acoustic", min: 0, max: 1 },
{ key: "moodElectronic", label: "Electronic", min: 0, max: 1 },
];
const ML_MOODS = [
{ key: "moodHappy", label: "Happy", color: "#ecb200" },
{ key: "moodSad", label: "Sad", color: "#5c8dd6" },
{ key: "moodRelaxed", label: "Relaxed", color: "#1db954" },
{ key: "moodAggressive", label: "Aggressive", color: "#e35656" },
{ key: "moodParty", label: "Party", color: "#e056a0" },
{ key: "moodAcoustic", label: "Acoustic", color: "#d4a656" },
{ key: "moodElectronic", label: "Electronic", color: "#a056e0" },
];
function normalizeValue(value: number | null | undefined, min: number, max: number): number {
if (value == null) return 0;
return Math.max(0, Math.min(1, (value - min) / (max - min)));
}
function getFeatureValue(features: AudioFeatures | null | undefined, key: string): number | null {
if (!features) return null;
const value = (features as Record<string, unknown>)[key];
if (typeof value === "number") return value;
return null;
}
export function NowPlayingTab() {
const { currentTrack, activeOperation } = useAudioState();
const { currentTrack } = useAudioState();
const { isPlaying } = useAudioPlayback();
const currentFeatures = currentTrack?.audioFeatures as AudioFeatures | null | undefined;
const sourceFeatures = useMemo(() => {
if (activeOperation.type !== "idle" && "sourceFeatures" in activeOperation) {
return activeOperation.sourceFeatures as AudioFeatures;
}
return null;
}, [activeOperation]);
const displayFeatures = currentFeatures || sourceFeatures;
const hasOperation = activeOperation.type !== "idle";
const radarData = useMemo(() => {
return RADAR_FEATURES.map((feature) => {
const sourceVal = getFeatureValue(
sourceFeatures,
feature.key,
);
const currentVal = getFeatureValue(
displayFeatures,
feature.key,
);
return {
feature: feature.label,
source: normalizeValue(sourceVal, feature.min, feature.max) * 100,
current: normalizeValue(currentVal, feature.min, feature.max) * 100,
fullMark: 100,
};
});
}, [sourceFeatures, displayFeatures]);
const matchScore = useMemo(
() => computeVibeMatchScore(sourceFeatures, currentFeatures),
[sourceFeatures, currentFeatures],
);
const coverUrl = currentTrack?.album?.coverArt
? api.getCoverArtUrl(currentTrack.album.coverArt, 300)
: null;
@@ -109,28 +25,6 @@ export function NowPlayingTab() {
);
}
const bpmValue = getFeatureValue(
displayFeatures,
"bpm",
);
const keyValue = displayFeatures?.keyScale ?? null;
const energyValue = getFeatureValue(
displayFeatures,
"energy",
);
const danceValue = getFeatureValue(
displayFeatures,
"danceability",
);
const valenceValue = getFeatureValue(
displayFeatures,
"valence",
);
const arousalValue = getFeatureValue(
displayFeatures,
"arousal",
);
return (
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
@@ -167,164 +61,7 @@ export function NowPlayingTab() {
{currentTrack.album?.title}
</p>
</div>
{/* Match score badge */}
{hasOperation && matchScore !== null && (
<div className="flex justify-center">
<span
className={cn(
"text-xs font-bold px-3 py-1 rounded-full",
matchScore >= 80
? "bg-[#1db954]/20 text-[#1db954]"
: matchScore >= 60
? "bg-[#ecb200]/20 text-[#ecb200]"
: "bg-[#e35656]/20 text-[#e35656]",
)}
>
{matchScore}% Match
</span>
</div>
)}
{/* Radar chart */}
{displayFeatures && (
<div className="h-[240px] w-full bg-[#181818] rounded-lg p-2">
<ResponsiveContainer width="100%" height="100%">
<RadarChart
data={radarData}
margin={{ top: 20, right: 30, bottom: 20, left: 30 }}
>
<PolarGrid stroke="#282828" strokeDasharray="3 3" />
<PolarAngleAxis
dataKey="feature"
tick={{ fill: "#b3b3b3", fontSize: 9, fontWeight: 500 }}
tickLine={false}
/>
{hasOperation && sourceFeatures && (
<Radar
name="Source"
dataKey="source"
stroke="#ecb200"
fill="#ecb200"
fillOpacity={0.1}
strokeWidth={2}
strokeDasharray="5 5"
/>
)}
<Radar
name="Current"
dataKey="current"
stroke="#ffffff"
fill="#ffffff"
fillOpacity={0.15}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
{hasOperation && (
<div className="flex items-center justify-center gap-6 pb-1">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#ecb200] border-dashed" />
<span className="text-[10px] text-[#b3b3b3]">Source</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-2 rounded-sm bg-white/30" />
<span className="text-[10px] text-[#b3b3b3]">Current</span>
</div>
</div>
)}
</div>
)}
{/* Mood spectrum bars */}
{displayFeatures && (
<div className="bg-[#181818] rounded-lg p-4">
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-3">
Mood Spectrum
</div>
<div className="space-y-3">
{ML_MOODS.map((mood) => {
const value = getFeatureValue(
displayFeatures,
mood.key,
);
const percentage = value != null ? Math.round(value * 100) : 0;
const hasValue = value != null;
return (
<div key={mood.key} className="flex items-center gap-3">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: mood.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs text-[#b3b3b3]">
{mood.label}
</span>
<span className="text-xs font-medium tabular-nums text-white">
{hasValue ? `${percentage}%` : "--"}
</span>
</div>
<div className="w-full bg-[#282828] rounded-full h-1 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width: hasValue
? `${Math.max(percentage, 2)}%`
: "0%",
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="h-full rounded-full"
style={{ backgroundColor: mood.color }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Audio features grid */}
{displayFeatures && (
<div className="grid grid-cols-2 gap-2">
<FeatureCard
label="BPM"
value={bpmValue != null ? `${Math.round(bpmValue)}` : "--"}
/>
<FeatureCard label="Key" value={keyValue || "--"} />
<FeatureCard
label="Energy"
value={energyValue != null ? `${Math.round(energyValue * 100)}%` : "--"}
/>
<FeatureCard
label="Danceability"
value={danceValue != null ? `${Math.round(danceValue * 100)}%` : "--"}
/>
<FeatureCard
label="Valence"
value={valenceValue != null ? `${Math.round(valenceValue * 100)}%` : "--"}
/>
<FeatureCard
label="Arousal"
value={arousalValue != null ? `${Math.round(arousalValue * 100)}%` : "--"}
/>
</div>
)}
</div>
</div>
);
}
function FeatureCard({ label, value }: { label: string; value: string }) {
return (
<div className="bg-[#181818] rounded-lg p-3">
<div className="text-[10px] text-[#b3b3b3] uppercase tracking-wider mb-1">
{label}
</div>
<div className="text-sm font-semibold text-white">{value}</div>
</div>
);
}
-1
View File
@@ -43,7 +43,6 @@
"qrcode.react": "^4.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "^3.6.0",
"tailwind-merge": "^3.5.0",
"three": "^0.183.2"
},
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* Takes README screenshots against a running Kima instance.
*
* Usage:
* export KIMA_TEST_USERNAME=your_username
* export KIMA_TEST_PASSWORD=your_password
* export KIMA_UI_BASE_URL=http://127.0.0.1:3030 # optional, this is the default
* cd /mnt/storage/Projects/lidify/frontend
* node ../scripts/take-screenshots.js
*/
const { chromium } = require("playwright");
const path = require("path");
const BASE_URL = process.env.KIMA_UI_BASE_URL || "http://127.0.0.1:3030";
const USERNAME = process.env.KIMA_TEST_USERNAME;
const PASSWORD = process.env.KIMA_TEST_PASSWORD;
const OUT_DIR = path.resolve(__dirname, "../assets/screenshots");
if (!USERNAME || !PASSWORD) {
console.error("Set KIMA_TEST_USERNAME and KIMA_TEST_PASSWORD before running.");
process.exit(1);
}
async function shot(page, filename) {
const outPath = path.join(OUT_DIR, filename);
await page.screenshot({ path: outPath, fullPage: false });
console.log(` [ok] ${filename}`);
}
async function netIdle(page, ms = 5000) {
try {
await page.waitForLoadState("networkidle", { timeout: ms });
} catch { /* ok */ }
}
async function login(page) {
await page.goto(`${BASE_URL}/login`);
await page.locator("#username").fill(USERNAME);
await page.locator("#password").fill(PASSWORD);
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForURL(/\/($|\?|home)/, { timeout: 20_000 });
console.log(` logged in as ${USERNAME}`);
}
async function startPlaying(page) {
await page.goto(`${BASE_URL}/collection?tab=albums`);
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(2000);
const firstAlbum = page.locator('a[href^="/album/"]').first();
await firstAlbum.waitFor({ timeout: 8000 });
await firstAlbum.click();
await page.waitForURL(/\/album\//, { timeout: 8000 });
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(1500);
await page.getByLabel("Play all").click();
await page.getByTitle("Pause", { exact: true }).waitFor({ timeout: 10_000 });
await page.waitForTimeout(1500);
}
async function takeDesktopShots(browser) {
console.log("\nDesktop (1440x900)...");
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await login(p);
// Home
await p.goto(`${BASE_URL}/`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "desktop-home.png");
// Library - albums tab
await p.goto(`${BASE_URL}/collection?tab=albums`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "desktop-library.png");
// Album page
try {
const link = p.locator('a[href^="/album/"]').first();
await link.waitFor({ timeout: 8000 });
await link.click();
await p.waitForURL(/\/album\//, { timeout: 8000 });
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 4000);
await p.waitForTimeout(1500);
await shot(p, "desktop-album.png");
} catch (e) {
console.warn(" [skip] desktop-album.png:", e.message);
}
// Artist page
await p.goto(`${BASE_URL}/collection?tab=artists`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
try {
const link = p.locator('a[href^="/artist/"]').first();
await link.waitFor({ timeout: 8000 });
await link.click();
await p.waitForURL(/\/artist\//, { timeout: 8000 });
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 4000);
await p.waitForTimeout(1500);
await shot(p, "desktop-artist.png");
} catch (e) {
console.warn(" [skip] desktop-artist.png:", e.message);
}
// Podcasts
await p.goto(`${BASE_URL}/podcasts`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "desktop-podcasts.png");
// Audiobooks
await p.goto(`${BASE_URL}/audiobooks`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "desktop-audiobooks.png");
// Player (start music then screenshot)
try {
await startPlaying(p);
await shot(p, "desktop-player.png");
} catch (e) {
console.warn(" [skip] desktop-player.png:", e.message);
}
// Settings
await p.goto(`${BASE_URL}/settings`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 4000);
await p.waitForTimeout(1500);
await shot(p, "desktop-settings.png");
// Deezer browse
await p.goto(`${BASE_URL}/browse/playlists`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 8000);
await p.waitForTimeout(2000);
await shot(p, "deezer-browse.png");
// Import / playlist
await p.goto(`${BASE_URL}/import/playlist`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 4000);
await p.waitForTimeout(1500);
await shot(p, "spotify-import-preview.png");
// Mood Mixer (home page, scroll to it)
await p.goto(`${BASE_URL}/`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
try {
// Look for mood mixer heading or the MoodMixer component
const moodSection = p.locator("text=Mood Mixer, text=Create Your Vibe, text=Mood").first();
if (await moodSection.isVisible({ timeout: 3000 })) {
await moodSection.scrollIntoViewIfNeeded();
await p.waitForTimeout(500);
} else {
// Scroll down ~600px from the top to reveal sections below the fold
await p.evaluate(() => window.scrollBy(0, 600));
await p.waitForTimeout(500);
}
} catch { /* ignore */ }
await shot(p, "mood-mixer.png");
// --- Vibe ---
// Vibe Map (2D)
await p.goto(`${BASE_URL}/vibe`);
await p.waitForLoadState("domcontentloaded");
console.log(" waiting for vibe canvas (up to 40s)...");
const canvas = p.locator("canvas").first();
const noData = p.locator("text=/No tracks with vibe|Computing music map/i").first();
try {
await Promise.race([
canvas.waitFor({ timeout: 40_000 }),
noData.waitFor({ timeout: 40_000 }),
]);
} catch { /* ok */ }
await p.waitForTimeout(3000); // let map settle
await shot(p, "vibe-map.png");
await shot(p, "vibe-overlay.png"); // replace old vibe-overlay slot with current map view
// Vibe Galaxy (3D)
const galaxyRendered = await canvas.count() > 0;
if (galaxyRendered) {
try {
await p.getByRole("button", { name: "Galaxy" }).click();
await p.waitForTimeout(4000); // WebGL scene load
await shot(p, "vibe-galaxy.png");
// Back to Map view for subsequent shots
await p.getByRole("button", { name: "Map" }).click();
await p.waitForTimeout(1500);
} catch (e) {
console.warn(" [skip] vibe-galaxy.png:", e.message);
}
// Drift panel
try {
await p.locator('[title="Drift -- journey between two tracks"]').click();
await p.waitForTimeout(1000);
await shot(p, "vibe-drift.png");
// Close
const closeBtn = p.locator('[aria-label="Close"], button:has-text("Cancel")').first();
if (await closeBtn.isVisible({ timeout: 1000 })) await closeBtn.click();
else await p.keyboard.press("Escape");
await p.waitForTimeout(500);
} catch (e) {
console.warn(" [skip] vibe-drift.png:", e.message);
}
// Blend / Alchemy panel
try {
await p.locator('[title="Blend -- mix tracks to find new vibes"]').click();
await p.waitForTimeout(1500);
await shot(p, "vibe-blend.png");
const closeBtn = p.locator('[aria-label="Close alchemy"]').first();
if (await closeBtn.isVisible({ timeout: 1000 })) await closeBtn.click();
} catch (e) {
console.warn(" [skip] vibe-blend.png:", e.message);
}
} else {
console.warn(" [skip] vibe galaxy/drift/blend -- canvas not rendered");
}
await ctx.close();
}
async function takeMobileShots(browser) {
console.log("\nMobile (390x844)...");
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const p = await ctx.newPage();
await login(p);
// Mobile Home
await p.goto(`${BASE_URL}/`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "mobile-home.png");
// Mobile Library
await p.goto(`${BASE_URL}/collection?tab=albums`);
await p.waitForLoadState("domcontentloaded");
await netIdle(p, 6000);
await p.waitForTimeout(1500);
await shot(p, "mobile-library.png");
// Mobile Player
try {
await startPlaying(p);
await shot(p, "mobile-player.png");
} catch (e) {
console.warn(" [skip] mobile-player.png:", e.message);
}
await ctx.close();
}
async function main() {
console.log(`Taking screenshots against ${BASE_URL}`);
console.log(`Saving to ${OUT_DIR}\n`);
const browser = await chromium.launch({
headless: true,
args: [
"--enable-webgl",
"--ignore-gpu-blocklist",
"--disable-dev-shm-usage",
],
});
try {
await takeDesktopShots(browser);
await takeMobileShots(browser);
} finally {
await browser.close();
}
console.log("\nDone.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});