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
@@ -10,18 +10,6 @@ Kima is built for music lovers who want the convenience of streaming services wi
|
||||
|
||||

|
||||
|
||||
> **\* 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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 998 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 466 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 569 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 488 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 319 KiB |
@@ -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();
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ export function TopBar() {
|
||||
alt="Kima"
|
||||
width={32}
|
||||
height={32}
|
||||
priority
|
||||
className="group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||