diff --git a/scripts/fluid-proto-fixtures.ts b/scripts/fluid-proto-fixtures.ts new file mode 100644 index 000000000..32570aec5 --- /dev/null +++ b/scripts/fluid-proto-fixtures.ts @@ -0,0 +1,403 @@ +/** + * Fluid Editorial prototype — fixture generator. + * + * Reads a real WaveFlow profile database (read-only, from a temp copy so + * the running app's WAL is never touched), picks a representative slice + * of the library and writes: + * + * - `prototypes/fluid-editorial/covers/.` — album art is + * the user's own files, so it lives in the gitignored `prototypes/` + * folder, outside `public/`, and a dev-only Vite middleware serves + * it at `/fluid-proto/covers/…` (see `vite.config.ts`). A production + * build never sees it. + * - `src/prototype/fluid-editorial/fixtures.generated.json` + * (titles, artists, play counts, a 3-stop palette per cover — safe + * to commit, lets the prototype render gradients when the covers + * are missing on another machine) + * + * Usage: + * bun run scripts/fluid-proto-fixtures.ts [profile-dir] + * + * `profile-dir` defaults to `%APPDATA%/app.waveflow/waveflow/profiles/2`. + * Prototype-only tooling: nothing in the app imports this file. + */ +import { Database } from "bun:sqlite"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + rmSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import sharp from "sharp"; + +const appData = process.env.APPDATA ?? join(process.env.HOME ?? "", ".config"); +const profileDir = resolve( + process.argv[2] ?? join(appData, "app.waveflow", "waveflow", "profiles", "2"), +); +const root = resolve(import.meta.dir, ".."); +const coversOut = join(root, "prototypes", "fluid-editorial", "covers"); +const fixturesOut = join( + root, + "src", + "prototype", + "fluid-editorial", + "fixtures.generated.json", +); + +// ── 1. Snapshot the DB so a live WAL is never opened in place ───────── +// The three files are copied one after the other, so a checkpoint +// landing in between leaves a triplet SQLite cannot open, or opens +// with a stale WAL. Copy, probe, and copy again on failure — the app +// is rarely writing twice in a row. +const snap = join(tmpdir(), `waveflow-fluid-proto-${process.pid}`); +function snapshot(): Database { + let lastError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt++) { + rmSync(snap, { recursive: true, force: true }); + mkdirSync(snap, { recursive: true }); + for (const f of ["data.db", "data.db-wal", "data.db-shm"]) { + const src = join(profileDir, f); + if (existsSync(src)) copyFileSync(src, join(snap, f)); + } + let candidate: Database | null = null; + try { + candidate = new Database(join(snap, "data.db"), { readonly: true }); + candidate.query("SELECT COUNT(*) FROM track").get(); + return candidate; + } catch (err) { + // Release the handle before the files underneath it are replaced. + candidate?.close(true); + lastError = err; + console.warn(`snapshot attempt ${attempt} unusable, retrying…`); + } + } + throw new Error( + `${profileDir}: could not take a consistent copy of data.db (${String(lastError)}) — retry when the app is idle`, + ); +} +const db = snapshot(); + +interface AlbumRow { + id: number; + title: string; + artist: string | null; + year: number | null; + hash: string | null; + format: string | null; + track_count: number; + plays: number; + last_played: number | null; + added_at: number; + genre: string | null; +} + +const albumSql = ` + SELECT al.id, al.title, + COALESCE(al.album_artist, ar.name) AS artist, + al.year, aw.hash, aw.format, + COUNT(DISTINCT t.id) AS track_count, + COUNT(pe.id) AS plays, + MAX(pe.played_at) AS last_played, + MIN(t.added_at) AS added_at, + (SELECT g.name FROM track_genre tg JOIN genre g ON g.id = tg.genre_id + WHERE tg.track_id IN (SELECT id FROM track WHERE album_id = al.id) + GROUP BY g.name ORDER BY COUNT(*) DESC LIMIT 1) AS genre + FROM album al + LEFT JOIN artist ar ON ar.id = al.artist_id + LEFT JOIN artwork aw ON aw.id = al.artwork_id + JOIN track t ON t.album_id = al.id AND t.is_available = 1 + LEFT JOIN play_event pe ON pe.track_id = t.id + WHERE aw.hash IS NOT NULL + GROUP BY al.id +`; + +const topAlbums = db + .query( + `${albumSql} HAVING plays > 0 ORDER BY plays DESC, last_played DESC LIMIT 18`, + ) + .all() as AlbumRow[]; +const recentAlbums = db + .query(`${albumSql} ORDER BY added_at DESC LIMIT 10`) + .all() as AlbumRow[]; +const lastPlayedAlbums = db + .query( + `${albumSql} HAVING last_played IS NOT NULL ORDER BY last_played DESC LIMIT 14`, + ) + .all() as AlbumRow[]; + +interface PlayRow { + track_id: number; + title: string; + artist: string | null; + album_id: number | null; + album: string | null; + duration_ms: number; + played_at: number; + hash: string | null; + format: string | null; + codec: string | null; + sample_rate: number | null; + bit_depth: number | null; + bpm: number | null; + energy: number | null; +} + +// A play whose track has no album row cannot be composed into the page +// (no cover, no album line), so it is excluded before the limit: the +// page gets sixteen usable plays, not sixteen minus the strays. +const recentPlays = db + .query( + `SELECT t.id AS track_id, t.title, ar.name AS artist, t.album_id, al.title AS album, + t.duration_ms, MAX(pe.played_at) AS played_at, aw.hash, aw.format, + t.codec, t.sample_rate, t.bit_depth, ta.bpm, ta.energy + FROM play_event pe + JOIN track t ON t.id = pe.track_id + LEFT JOIN artist ar ON ar.id = t.primary_artist + LEFT JOIN album al ON al.id = t.album_id + LEFT JOIN artwork aw ON aw.id = al.artwork_id + LEFT JOIN track_analysis ta ON ta.track_id = t.id + WHERE t.is_available = 1 AND al.id IS NOT NULL + GROUP BY t.id + ORDER BY played_at DESC + LIMIT 16`, + ) + .all() as PlayRow[]; + +const stats = { + tracks: ( + db.query("SELECT COUNT(*) c FROM track WHERE is_available = 1").get() as { + c: number; + } + ).c, + albums: (db.query("SELECT COUNT(*) c FROM album").get() as { c: number }).c, + artists: (db.query("SELECT COUNT(*) c FROM artist").get() as { c: number }).c, + liked: (db.query("SELECT COUNT(*) c FROM liked_track").get() as { c: number }) + .c, + plays: (db.query("SELECT COUNT(*) c FROM play_event").get() as { c: number }) + .c, + playlists: ( + db.query("SELECT COUNT(*) c FROM playlist WHERE is_smart = 0").get() as { + c: number; + } + ).c, + analysed: ( + db + .query("SELECT COUNT(*) c FROM track_analysis WHERE bpm IS NOT NULL") + .get() as { c: number } + ).c, + listened_ms: ( + db.query("SELECT COALESCE(SUM(listened_ms),0) s FROM play_event").get() as { + s: number; + } + ).s, +}; + +// Mood gates mirror `mood_radio` (tempo windows; Sleep adds a loudness cap). +const moodCounts = db + .query( + `SELECT + COALESCE(SUM(CASE WHEN bpm BETWEEN 60 AND 100 THEN 1 ELSE 0 END), 0) AS focus, + COALESCE(SUM(CASE WHEN bpm BETWEEN 70 AND 110 THEN 1 ELSE 0 END), 0) AS chill, + COALESCE(SUM(CASE WHEN bpm BETWEEN 120 AND 170 THEN 1 ELSE 0 END), 0) AS workout, + COALESCE(SUM(CASE WHEN bpm BETWEEN 110 AND 135 THEN 1 ELSE 0 END), 0) AS party, + COALESCE(SUM(CASE WHEN bpm < 80 AND (loudness_lufs IS NULL OR loudness_lufs < -14) THEN 1 ELSE 0 END), 0) AS sleep + FROM track_analysis WHERE bpm IS NOT NULL`, + ) + .get() as Record; + +interface PlaylistRow { + id: number; + name: string; + is_smart: number; + smart_rules: string | null; + color_id: string; + icon_id: string; + track_count: number; +} +const playlists = db + .query( + `SELECT p.id, p.name, p.is_smart, p.smart_rules, p.color_id, p.icon_id, + (SELECT COUNT(*) FROM playlist_track pt WHERE pt.playlist_id = p.id) AS track_count + FROM playlist p ORDER BY p.is_smart, p.position, p.id LIMIT 14`, + ) + .all() as PlaylistRow[]; + +// Four covers per playlist, from the albums of its first tracks. +const playlistCovers = db.query( + `SELECT DISTINCT aw.hash, aw.format + FROM playlist_track pt + JOIN track t ON t.id = pt.track_id + JOIN album al ON al.id = t.album_id + JOIN artwork aw ON aw.id = al.artwork_id + WHERE pt.playlist_id = ? + ORDER BY pt.position LIMIT 4`, +); + +// ── 2. Copy the covers + compute a palette per cover ────────────────── +rmSync(coversOut, { recursive: true, force: true }); +mkdirSync(coversOut, { recursive: true }); + +const palettes = new Map(); +// The album lists overlap (a record can be both "top" and "last +// played"), and they are mapped with `Promise.all`: without this, two +// callers would resize the same file at once and compute its palette +// twice. One promise per cover, whoever asks first. +const coverJobs = new Map>(); +function cover(hash: string | null, format: string | null) { + if (!hash || !format) return Promise.resolve(null); + const file = `${hash}.${format}`; + let job = coverJobs.get(file); + if (!job) { + job = coverOnce(hash, file); + coverJobs.set(file, job); + } + return job; +} +async function coverOnce(hash: string, file: string) { + const src = join(profileDir, "artwork", file); + if (!existsSync(src)) return null; + // Resized on the way in: the originals are up to 1 MB each and the + // prototype draws nothing larger than ~400 px. + const dst = join(coversOut, file); + if (!existsSync(dst)) { + await sharp(src) + .resize(640, 640, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 84, mozjpeg: true }) + .toFile(dst); + } + if (!palettes.has(hash)) palettes.set(hash, await palette(src)); + return `/fluid-proto/covers/${file}`; +} + +/** Three stops: a muted dominant, a deeper shade, a lighter accent. */ +async function palette(file: string): Promise { + const { data } = await sharp(file) + .resize(24, 24, { fit: "cover" }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let r = 0, + g = 0, + b = 0, + n = 0; + let vr = 0, + vg = 0, + vb = 0, + vs = 0; + for (let i = 0; i < data.length; i += 3) { + const pr = data[i], + pg = data[i + 1], + pb = data[i + 2]; + const max = Math.max(pr, pg, pb), + min = Math.min(pr, pg, pb); + if (max - min < 16 && (max < 40 || max > 220)) continue; + r += pr; + g += pg; + b += pb; + n++; + const sat = max === 0 ? 0 : (max - min) / max; + const w = sat * (max / 255); + vr += pr * w; + vg += pg * w; + vb += pb * w; + vs += w; + } + if (n === 0) return ["#3f3f46", "#18181b", "#71717a"]; + const avg = [r / n, g / n, b / n].map(Math.round); + const vib = vs > 0 ? [vr / vs, vg / vs, vb / vs].map(Math.round) : avg; + const hex = (c: number[]) => + "#" + + c + .map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")) + .join(""); + return [hex(avg), hex(avg.map((v) => Math.round(v * 0.45))), hex(vib)]; +} + +async function mapAlbum(a: AlbumRow) { + const art = await cover(a.hash, a.format); + return { + id: a.id, + title: a.title, + artist: a.artist ?? "Artiste inconnu", + year: a.year, + trackCount: a.track_count, + plays: a.plays, + lastPlayed: a.last_played, + addedAt: a.added_at, + genre: a.genre, + cover: art, + palette: a.hash ? (palettes.get(a.hash) ?? null) : null, + }; +} + +const fixtures = { + generatedAt: new Date().toISOString(), + stats, + moodCounts, + lastPlayed: await Promise.all(lastPlayedAlbums.map(mapAlbum)), + topAlbums: await Promise.all(topAlbums.map(mapAlbum)), + recentAlbums: await Promise.all(recentAlbums.map(mapAlbum)), + recentPlays: await Promise.all( + recentPlays.map(async (p) => ({ + trackId: p.track_id, + title: p.title, + artist: p.artist ?? "Artiste inconnu", + albumId: p.album_id, + album: p.album, + durationMs: p.duration_ms, + playedAt: p.played_at, + codec: p.codec, + sampleRate: p.sample_rate, + bitDepth: p.bit_depth, + bpm: p.bpm, + energy: p.energy, + cover: await cover(p.hash, p.format), + palette: p.hash ? (palettes.get(p.hash) ?? null) : null, + })), + ), + playlists: await Promise.all( + playlists.map(async (p) => { + const rows = playlistCovers.all(p.id) as { + hash: string; + format: string; + }[]; + const covers = ( + await Promise.all(rows.map((r) => cover(r.hash, r.format))) + ).filter((c): c is string => c != null); + return { + id: p.id, + name: p.name, + isSmart: p.is_smart === 1, + smartRules: p.smart_rules, + colorId: p.color_id, + iconId: p.icon_id, + trackCount: p.track_count, + covers, + }; + }), + ), +}; + +// The page is composed around the last play and the most played +// record: a profile with neither cannot drive it, and an empty JSON +// would only move the failure to the first render. +if (fixtures.recentPlays.length === 0 || fixtures.topAlbums.length === 0) { + db.close(); + rmSync(snap, { recursive: true, force: true }); + throw new Error( + `${profileDir}: no play history with artwork — pick a profile that has listened to something`, + ); +} + +await Bun.write(fixturesOut, JSON.stringify(fixtures, null, 2) + "\n"); +db.close(); +rmSync(snap, { recursive: true, force: true }); + +console.log(`covers → ${coversOut} (${readdirSync(coversOut).length} files)`); +console.log(`fixtures→ ${fixturesOut}`); +console.log( + `albums: top ${fixtures.topAlbums.length}, recent ${fixtures.recentAlbums.length}, last played ${fixtures.lastPlayed.length}; plays ${fixtures.recentPlays.length}; playlists ${fixtures.playlists.length}`, +); diff --git a/src/main.tsx b/src/main.tsx index 2c0156710..aab97193c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -42,6 +42,19 @@ import "@fontsource-variable/dm-sans/opsz.css"; import { i18nReady } from "./i18n"; import { markBundleReady, markI18nReady } from "./lib/startupTiming"; import { WINDOW_ROLE } from "./lib/windowRole"; +import { FluidEditorialPrototype } from "./prototype/fluid-editorial"; + +// Design prototypes (`?proto=`) — development builds only. Each one +// is a lazy chunk mounted *instead of* the app, so the production tree +// never imports it and `vite build` drops the branch entirely. +// The Tauri window loads the dev URL without a query string, so the +// flag can also be stored (`localStorage.setItem("waveflow.proto", +// "fluid-editorial")` from the devtools, then reload; `removeItem` to +// go back). Production builds never read either. +const PROTO = import.meta.env.DEV + ? (new URLSearchParams(window.location.search).get("proto") ?? + localStorage.getItem("waveflow.proto")) + : null; // First statement that runs once the entry module is executing: the // document and every static import above are in (#626). @@ -84,7 +97,11 @@ i18nReady ); root.render( - {WINDOW_ROLE === "mini" ? ( + {PROTO === "fluid-editorial" && FluidEditorialPrototype ? ( + + + + ) : WINDOW_ROLE === "mini" ? ( ) : WINDOW_ROLE === "lyrics" ? ( diff --git a/src/prototype/fluid-editorial/FluidEditorialApp.tsx b/src/prototype/fluid-editorial/FluidEditorialApp.tsx new file mode 100644 index 000000000..e5be97466 --- /dev/null +++ b/src/prototype/fluid-editorial/FluidEditorialApp.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +// DM Sans upright is bundled by `main.tsx` for the Liquid skin; the +// italic axis is loaded here only, for the prototype's emphasis. +import "@fontsource-variable/dm-sans/opsz-italic.css"; +import "./fluid-editorial.css"; +import { FluidHome } from "./home/FluidHome"; +import { Atmosphere } from "./shell/Atmosphere"; +import { FePlayerBar } from "./shell/FePlayerBar"; +import { FeSidebar } from "./shell/FeSidebar"; +import { FeTopBar } from "./shell/FeTopBar"; +import { FeStateProvider } from "./state"; +import { useFe } from "./useFe"; + +/** + * Fluid Editorial — an isolated Home prototype. + * + * Mounted by `main.tsx` when the URL carries `?proto=fluid-editorial`, + * in development builds only. It renders the whole desktop shell + * (sidebar, top bar, content column, player bar) on mock state so the + * direction can be judged as a screen, not a component — while the + * production `AppLayout`, `HomeView` and every provider stay untouched. + */ +export default function FluidEditorialApp() { + const { i18n } = useTranslation(); + useEffect(() => { + const previous = document.title; + document.title = "WaveFlow — Fluid Editorial (prototype)"; + return () => { + document.title = previous; + }; + }, []); + return ( + + + + ); +} + +function Shell() { + const { atmosphere } = useFe(); + const [collapsed, setCollapsed] = useState(false); + const vars = { + ["--fe-accent" as string]: atmosphere.accent, + ["--fe-accent-ink" as string]: atmosphere.accentInk, + ["--fe-glow" as string]: atmosphere.glow, + ["--fe-glow-deep" as string]: atmosphere.glowDeep, + transition: "--fe-accent 600ms, --fe-glow 600ms", + } as CSSProperties; + + return ( +
+
+ +
+ + setCollapsed((c) => !c)} + /> +
+ +
+
+
+ +
+ ); +} diff --git a/src/prototype/fluid-editorial/README.md b/src/prototype/fluid-editorial/README.md new file mode 100644 index 000000000..dd1fbc67a --- /dev/null +++ b/src/prototype/fluid-editorial/README.md @@ -0,0 +1,99 @@ +# Fluid Editorial — prototype de la page d'accueil + +Exploration visuelle, **pas une refonte approuvée**. Tout vit dans ce +dossier (plus un script, une entrée dans `main.tsx` et un middleware dev +dans `vite.config.ts`) : supprimer `src/prototype/`, `scripts/fluid-proto-fixtures.ts`, +`prototypes/` et les deux branches `?proto=` suffit à le retirer. + +## Ouvrir le prototype + +```bash +bun run scripts/fluid-proto-fixtures.ts # une fois : pochettes + fixtures depuis votre profil +bun run dev # Vite seul, pas de coquille Tauri +``` + +Puis ouvrir **http://localhost:1420/?proto=fluid-editorial** dans un navigateur. + +Dans la vraie coquille (`bun run tauri dev`), la fenêtre charge l'URL sans +paramètre : ouvrir les outils de développement et taper +`localStorage.setItem("waveflow.proto", "fluid-editorial")`, puis recharger +(`localStorage.removeItem("waveflow.proto")` pour revenir à l'accueil normal). + +- Le script lit `%APPDATA%/app.waveflow/waveflow/profiles/2` par défaut + (un autre dossier de profil se passe en argument). Il copie une soixantaine + de pochettes redimensionnées dans `prototypes/fluid-editorial/covers/` + (dossier **gitignoré**, servi uniquement par `vite dev` sous `/fluid-proto/`) + et écrit `fixtures.generated.json` (titres, artistes, compteurs, palette + par pochette — commité, sans image). +- Le script lit une **copie** de `data.db` + WAL + SHM (jamais la base en + place). Si l'app écrit pendant la copie, le triplet peut être incohérent : + le script sonde la copie et recommence jusqu'à trois fois, puis échoue + avec un message explicite — relancez-le quand l'app est au repos. +- Sans les pochettes, chaque tuile retombe sur un dégradé tiré de sa palette : + la composition tient, en plus plat. +- La langue suit celle de l'app (`waveflow-language` dans localStorage) : + français ou anglais, les 15 autres locales ne sont pas couvertes. +- Les captures de référence sont dans `prototypes/fluid-editorial/screenshots/` + (1280×720, 1600×900, 1920×1080), régénérables avec Chrome headless. + +## Ce qui change par rapport à l'accueil actuel + +| Aujourd'hui | Fluid Editorial | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bannière de bienvenue + 4 cartes de statistiques | **Listening Canvas** : salutation en italique éditoriale, les 4 compteurs sur une ligne de texte tertiaire, titre en cours en display 32–52 px, pochette 15–27 rem à droite avec son halo, les 5 derniers disques accrochés à son coin, la file « Ensuite » en 3 lignes denses. Aucune carte. | +| Fond uni | **Atmosphère** : deux nappes radiales de la couleur moyenne de la pochette + grain. L'accent (bouton, barre active, lignes d'onde, progression) est la teinte vive de la pochette, désaturée et pincée en luminosité pour rester lisible. Retombe sur l'émeraude de la marque quand la pochette est grise. | +| Radio par humeur : 5 blocs saturés | **Spectre d'humeur** : une bande continue dont l'axe horizontal est le tempo (40→170 BPM). Chaque humeur est un segment avec sa propre onde (plus lente et plate à gauche, plus rapide et haute à droite). Sélection = le segment s'élargit et révèle fenêtre de tempo, nombre de titres, bouton « Lancer la radio ». Radiogroup clavier (←/→, Home/End, Entrée), ondes figées en `prefers-reduced-motion`. | +| « Pour vous » : bannières 16:7 identiques | **Mosaïque éditoriale** : un album à la une (le plus écouté) en demi-largeur avec typographie magazine sur sa pochette, deux Daily Mix en portrait avec leur 2×2, les autres mixes en puces compactes. | +| « Récemment ajoutés » : grille uniforme | **Étagère** : le disque le plus récent prend 4 fois la surface et une légende complète ; les autres restent de petits carrés, sans boîte autour. | +| « Récemment joués » : grille de 160 px | **Journal** : liste sur deux colonnes (titre, artiste · album, il y a…, durée) — 12 lignes dans la hauteur où la grille en montrait 6. | +| Barre de lecture 96 px, bordure haute | **84 px, sans bordure**, dégradé haut dans la couleur de la pochette. Trois groupes : identité (pochette 56 px, titre 15 px, artiste 12 px, étiquette mono `FLAC · 24/44.1`), transport autour d'un seul bouton plein, panneaux en deux grappes discrètes. **Progression en onde** : la partie lue est une sinusoïde de 2 px d'amplitude, le reste un filet. Aucune commande retirée. | +| Barre latérale : blocs remplis, libellés gras | Même structure, chrome apaisé : pas de bordure, libellés 11 px médium espacés, état actif = barre d'accent de 2 px, compteurs à droite, **pochettes réelles** sur chaque playlist (2×2). | +| Barre supérieure vitrée avec bordure | Même ton que le contenu, 52 px, glyphes nus ; seule la recherche garde sa pilule. | + +### Itération 2 (retours de la première revue) + +- **Occupation de l'écran** : conteneur porté à 1 680 px et gouttières élargies au-delà de 1 536 px ; à 1 920 px le canvas tient en trois colonnes, titre · file · pochette. +- **Aura de pochette** : la pochette en cours, floutée à 96 px, désaturée et masquée en ellipse, éclaire le titre et la file derrière le canvas (opacité 0,2). Un seul filtre statique, fondu à l'arrivée, coupé en `prefers-reduced-motion`. +- **Lisibilité** : toutes les tailles secondaires montent d'un cran (0,625 → 0,6875, 0,6875 → 0,75, 0,75 → 0,8125 rem), l'encre tertiaire passe de `#7b7872` à `#8e8b83`, les libellés en capitales à 0,75 rem / 540. +- **La grande tuile a une raison** : « Dernier ajout · il y a 24 heures » au-dessus de son titre — la mise en avant est le plus récent ajout, pas le premier élément par hasard. +- **Barre de lecture** : la progression devient le bord supérieur de la barre, une onde sur toute la largeur avec les temps à ses deux extrémités (plus de course pour chercher, même hauteur à 2 px près : 88 px). Le centre ne garde que le transport. + +### Système visuel + +- **Typographie** : DM Sans Variable (déjà embarquée pour Liquid, axe optique) pour tout ; + Playfair Display italique — déjà embarquée pour Editorial — réservée à la salutation et + au chapeau de l'album à la une. Titres en 540–560, interlettrage −0,02 à −0,035 em. +- **Couleur** : fondation neutre `#0b0b0e` / `#121216` / `#17171d`, encre `#f3f1ec`, + un seul accent, dérivé de la pochette. La bande d'humeur est la seule exception : le + tempo est une dimension à part, sa teinte le dit. +- **Surfaces** : profondeur par ton et espacement, pas par bordures. Rayon 0,625–1,25 rem, + pilules pour les actions. +- **Motif** : la courbe d'onde — en-têtes de section, progression du lecteur, segments d'humeur. +- **Mouvement** : montée en cascade à l'arrivée (opacité + 10 px, 640 ms), lever de 3 px au survol, + élargissement du segment en 460 ms, dérive des ondes. Tout en `transform` / `opacity` sauf + le `flex-grow` du segment (5 éléments, 460 ms). `prefers-reduced-motion` coupe tout. + +## Ce qui est simulé + +- **Lecture** : état local (`state.tsx`). « Reprendre » fait avancer une horloge ; cliquer un + disque ou une ligne le met « en cours » et recolore la page. Rien ne parle au backend. +- **Radio par humeur** : « Lancer » choisit un titre dont le tempo tombe dans la fenêtre, au + lieu d'appeler `start_mood_radio`. Les compteurs viennent d'une requête équivalente sur + `track_analysis` au moment de la génération. +- **Navigation** : seule « Accueil » est active ; les autres entrées portent une infobulle. +- **Recherche, thème, profil, panneaux, volume, périphériques** : rendus, inertes. +- **Couleur de la pochette** : palette précalculée par `sharp` à la génération, raffinée à + l'exécution par `dominantColor()` (la fonction de l'app) quand l'image est décodée. +- Dans un navigateur sans Tauri, un module de l'app journalise une erreur `invoke` au + chargement (`useAccentChoice`) ; elle vient de l'arbre de production importé par `main.tsx`, + pas du prototype. + +## Pistes d'intégration + +Le prototype n'emprunte aux composants de production que `WaveFlowLogo`, `PlaylistIcon`, +`resolvePlaylistColor` et `dominantColor`. Une intégration passerait par : + +1. une skin ou un thème « Fluid » exposant `--fe-*` via `applySkin` ; +2. `HomeView` recomposé section par section (le canvas, la bande, la mosaïque, l'étagère, le journal) sur `PlayerContext` / `LibraryContext` ; +3. `ProgressBar` et `PlayerBar` recevant l'onde et le regroupement ; +4. les chaînes de `copy.ts` migrées dans les 17 locales. diff --git a/src/prototype/fluid-editorial/color.ts b/src/prototype/fluid-editorial/color.ts new file mode 100644 index 000000000..0c24e5800 --- /dev/null +++ b/src/prototype/fluid-editorial/color.ts @@ -0,0 +1,108 @@ +/** + * Palette → the two colours the page reads. + * + * `glow` is for atmosphere: the average tone of the cover, deepened so + * it sits behind text without competing. `accent` is for ink and + * controls: the cover's most vivid hue, with its saturation pulled + * down and its lightness pinned into a band that stays legible on the + * near-black foundation (and takes dark ink on a filled button). + */ + +interface Rgb { + r: number; + g: number; + b: number; +} + +export function hexToRgb(hex: string): Rgb { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!m) return { r: 63, g: 63, b: 70 }; + return { + r: parseInt(m[1], 16), + g: parseInt(m[2], 16), + b: parseInt(m[3], 16), + }; +} + +function rgbToHsl({ r, g, b }: Rgb): [number, number, number] { + const rn = r / 255, + gn = g / 255, + bn = b / 255; + const max = Math.max(rn, gn, bn), + min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h: number; + if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0); + else if (max === gn) h = (bn - rn) / d + 2; + else h = (rn - gn) / d + 4; + return [h / 6, s, l]; +} + +function hslToHex(h: number, s: number, l: number): string { + const f = (n: number) => { + const k = (n + h * 12) % 12; + const a = s * Math.min(l, 1 - l); + const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + return Math.round(c * 255) + .toString(16) + .padStart(2, "0"); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +export interface Atmosphere { + accent: string; + /** Ink that sits on a filled `accent` surface. */ + accentInk: string; + glow: string; + glowDeep: string; +} + +const BRAND: Atmosphere = { + accent: "#5ed3a5", + accentInk: "#0b0b0e", + glow: "#1f5a48", + glowDeep: "#0f2b22", +}; + +export function atmosphereFromPalette( + palette: string[] | null | undefined, +): Atmosphere { + if (!palette || palette.length < 3) return BRAND; + const [avg, , vivid] = palette; + const [h, s] = rgbToHsl(hexToRgb(vivid)); + const [gh, gs, gl] = rgbToHsl(hexToRgb(avg)); + // A grey cover gives no hue worth painting with: stay on brand. + if (s < 0.08 && gs < 0.08) return BRAND; + const accent = hslToHex(h, Math.min(0.62, s * 0.85 + 0.12), 0.66); + return { + accent, + accentInk: "#0b0b0e", + glow: hslToHex( + gh, + Math.min(0.5, gs * 0.9 + 0.08), + Math.min(0.34, Math.max(0.2, gl * 0.7)), + ), + glowDeep: hslToHex(gh, Math.min(0.45, gs * 0.9), 0.14), + }; +} + +/** Same shaping, from a live `dominantColor()` sample. */ +export function atmosphereFromRgb(vivid: Rgb, base: Atmosphere): Atmosphere { + const [h, s] = rgbToHsl(vivid); + if (s < 0.1) return base; + return { + ...base, + accent: hslToHex(h, Math.min(0.62, s * 0.85 + 0.12), 0.66), + }; +} + +/** A soft two-stop gradient used wherever a cover is missing. */ +export function fallbackGradient(palette: string[] | null | undefined): string { + const [a, b] = + palette && palette.length >= 2 ? palette : ["#2a2a31", "#141418"]; + return `linear-gradient(135deg, ${a} 0%, ${b} 100%)`; +} diff --git a/src/prototype/fluid-editorial/copy.ts b/src/prototype/fluid-editorial/copy.ts new file mode 100644 index 000000000..bc1b1b838 --- /dev/null +++ b/src/prototype/fluid-editorial/copy.ts @@ -0,0 +1,267 @@ +/** + * Prototype copy, French + English only. + * + * The production app ships 17 locales through i18next; a design + * prototype that may be thrown away should not add keys to 17 files. + * The strings below are the ones a real integration would move into + * `src/i18n/locales/*.json` — the existing keys they mirror are named + * in the comments. + */ + +export type Lang = "fr" | "en"; + +export function pickLang(language: string | undefined): Lang { + return language?.toLowerCase().startsWith("fr") ? "fr" : "en"; +} + +const FR = { + // home.greeting.* + greeting: { + morning: "Bonjour", + afternoon: "Bonjour", + evening: "Bonsoir", + night: "Bonne nuit", + }, + canvas: { + resumeKicker: "Reprendre là où vous étiez", + resume: "Reprendre", + pause: "Pause", + playAlbum: "Lire l’album", + upNext: "Ensuite", + recentStrip: "Dernières écoutes", + remaining: "{{time}} restantes", + radio: "Radio", + }, + // home.stats.* folded into one line + stats: { + tracks: "titres", + albums: "albums", + liked: "coups de cœur", + listened: "d’écoute", + }, + // home.moodRadio.* + mood: { + title: "Radio par humeur", + subtitle: + "Un spectre de tempo : choisissez une humeur, WaveFlow compose la radio.", + start: "Lancer la radio", + starting: "Démarrage…", + tracks: "{{count}} titres", + empty: "Pas assez de titres analysés", + bpm: "BPM", + tempoAxis: "Tempo", + moods: { + sleep: { + title: "Sommeil", + subtitle: "Très lent et discret pour s’endormir", + }, + focus: { + title: "Concentration", + subtitle: "Tempos calmes pour rester concentré", + }, + chill: { title: "Détente", subtitle: "Ambiance posée, fins de journée" }, + party: { title: "Soirée", subtitle: "Tempo dansant, énergie haute" }, + workout: { + title: "Entraînement", + subtitle: "Cadence soutenue pour bouger", + }, + }, + }, + // home.dailyMix.* + home.onRepeat.* … + forYou: { + title: "Pour vous", + featuredKicker: "Vous y revenez sans cesse", + featuredCta: "Réécouter", + plays: "{{count}} écoutes", + mixes: "Vos mixes", + regenerate: "Régénérer", + labels: { + daily_mix: "Daily Mix", + on_repeat: "On Repeat", + artist_mix: "Mix d’artiste", + genre_mix: "Mix de genre", + decade_mix: "Mix de décennie", + }, + }, + // home.recentlyAdded.* / home.recentlyPlayed.* / home.seeAll + recentlyAdded: { title: "Récemment ajoutés", leadKicker: "Dernier ajout" }, + history: { title: "Récemment joués", listened: "écouté" }, + seeAll: "Tout voir", + // sidebar.* + sidebar: { + home: "Accueil", + webRadio: "Web Radio", + myMusic: "Ma musique", + tracks: "Titres", + albums: "Albums", + artists: "Artistes", + genres: "Genres", + years: "Années", + folders: "Dossiers", + playlists: "Playlists", + liked: "Coups de cœur", + recent: "Écoutés récemment", + trackCount: "{{count}} titres", + newPlaylist: "Nouvelle playlist", + }, + // topbar.* + topbar: { + search: "Rechercher des titres, albums, artistes…", + back: "Précédent", + forward: "Suivant", + sidebar: "Réduire la barre latérale", + theme: "Thème clair", + profile: "Profil", + }, + // player.* / playerBar.* + player: { + noTrack: "Aucun titre", + play: "Lire", + pause: "Pause", + previous: "Titre précédent", + next: "Titre suivant", + shuffle: "Lecture aléatoire", + repeat: "Répéter", + like: "Ajouter aux coups de cœur", + lyrics: "Paroles", + queue: "File d’attente", + devices: "Périphériques de sortie", + volume: "Volume", + more: "Plus d’actions", + immersive: "Vue immersive", + miniPlayer: "Mini-lecteur", + seek: "Position", + bitPerfect: "Bit-perfect", + }, + proto: { + badge: "Prototype · Fluid Editorial", + hint: "Navigation désactivée dans le prototype", + }, +}; + +type Copy = typeof FR; + +const EN: Copy = { + greeting: { + morning: "Good morning", + afternoon: "Good afternoon", + evening: "Good evening", + night: "Good night", + }, + canvas: { + resumeKicker: "Pick up where you left off", + resume: "Resume", + pause: "Pause", + playAlbum: "Play the album", + upNext: "Up next", + recentStrip: "Last played", + remaining: "{{time}} left", + radio: "Radio", + }, + stats: { + tracks: "tracks", + albums: "albums", + liked: "liked", + listened: "listened", + }, + mood: { + title: "Mood radio", + subtitle: "A tempo spectrum: pick a mood, WaveFlow composes the radio.", + start: "Start the radio", + starting: "Starting…", + tracks: "{{count}} tracks", + empty: "Not enough analysed tracks", + bpm: "BPM", + tempoAxis: "Tempo", + moods: { + sleep: { + title: "Sleep", + subtitle: "Very slow and quiet for falling asleep", + }, + focus: { title: "Focus", subtitle: "Calm tempos for deep work" }, + chill: { title: "Chill", subtitle: "Easy listening for winding down" }, + party: { title: "Party", subtitle: "Danceable tempos, high energy" }, + workout: { title: "Workout", subtitle: "Steady pace to keep moving" }, + }, + }, + forYou: { + title: "For you", + featuredKicker: "You keep coming back to it", + featuredCta: "Play again", + plays: "{{count}} plays", + mixes: "Your mixes", + regenerate: "Regenerate", + labels: { + daily_mix: "Daily Mix", + on_repeat: "On Repeat", + artist_mix: "Artist mix", + genre_mix: "Genre mix", + decade_mix: "Decade mix", + }, + }, + recentlyAdded: { title: "Recently added", leadKicker: "Latest addition" }, + history: { title: "Recently played", listened: "played" }, + seeAll: "See all", + sidebar: { + home: "Home", + webRadio: "Web Radio", + myMusic: "My music", + tracks: "Tracks", + albums: "Albums", + artists: "Artists", + genres: "Genres", + years: "Years", + folders: "Folders", + playlists: "Playlists", + liked: "Liked tracks", + recent: "Recently played", + trackCount: "{{count}} tracks", + newPlaylist: "New playlist", + }, + topbar: { + search: "Search tracks, albums, artists…", + back: "Back", + forward: "Forward", + sidebar: "Collapse the sidebar", + theme: "Light theme", + profile: "Profile", + }, + player: { + noTrack: "No track", + play: "Play", + pause: "Pause", + previous: "Previous track", + next: "Next track", + shuffle: "Shuffle", + repeat: "Repeat", + like: "Add to liked tracks", + lyrics: "Lyrics", + queue: "Queue", + devices: "Output devices", + volume: "Volume", + more: "More actions", + immersive: "Immersive view", + miniPlayer: "Mini player", + seek: "Position", + bitPerfect: "Bit-perfect", + }, + proto: { + badge: "Prototype · Fluid Editorial", + hint: "Navigation is disabled in the prototype", + }, +}; + +export function copyFor(lang: Lang): Copy { + return lang === "fr" ? FR : EN; +} + +/** `{{count}}`-style fill, the shape i18next uses, so moving a string + * into the locale files later is a cut and paste. */ +export function fill( + template: string, + vars: Record, +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => + String(vars[k] ?? ""), + ); +} diff --git a/src/prototype/fluid-editorial/fixtures.generated.json b/src/prototype/fluid-editorial/fixtures.generated.json new file mode 100644 index 000000000..b56a0a8d6 --- /dev/null +++ b/src/prototype/fluid-editorial/fixtures.generated.json @@ -0,0 +1,1271 @@ +{ + "generatedAt": "2026-10-18T18:13:26.861Z", + "stats": { + "tracks": 1293, + "albums": 1198, + "artists": 1295, + "liked": 13, + "plays": 1464, + "playlists": 4, + "analysed": 1338, + "listened_ms": 163928539 + }, + "moodCounts": { + "focus": 674, + "chill": 525, + "workout": 294, + "party": 301, + "sleep": 89 + }, + "lastPlayed": [ + { + "id": 1158, + "title": "Nothing's Gonna End Us Till The Aliens Do It", + "artist": "PLAIINS", + "year": 2023, + "trackCount": 2, + "plays": 39, + "lastPlayed": 1792340142209, + "addedAt": 1789826750799, + "genre": "Alternative", + "cover": "/fluid-proto/covers/1ef1f033a8350610da93ce5128038447928902c5215eacb0b17f3ab0342416c0.jpg", + "palette": [ + "#231b7a", + "#100c37", + "#241c80" + ] + }, + { + "id": 1216, + "title": "new trick", + "artist": "ROSÉ", + "year": 2026, + "trackCount": 1, + "plays": 9, + "lastPlayed": 1792337915702, + "addedAt": 1791521875969, + "genre": "Pop", + "cover": "/fluid-proto/covers/f13a49d803c5ff341e8d1bf7b38cd286f096305f9a8571402039af9daaadf37f.jpg", + "palette": [ + "#a28095", + "#493a43", + "#9c778e" + ] + }, + { + "id": 850, + "title": "Waterfalls (feat. Sam Harper & Bobby Harvey)", + "artist": "James Hype", + "year": 2025, + "trackCount": 1, + "plays": 7, + "lastPlayed": 1792337861944, + "addedAt": 1779556664197, + "genre": null, + "cover": "/fluid-proto/covers/61bd910b8141b7ccbf19d6e5524a855c4a17c7a6a66b7f4c625106e6ce55f2d8.jpg", + "palette": [ + "#58492f", + "#282115", + "#674b2f" + ] + }, + { + "id": 1215, + "title": "Hypnotic Hills", + "artist": "Mujo", + "year": 2023, + "trackCount": 1, + "plays": 20, + "lastPlayed": 1792337801667, + "addedAt": 1791521662841, + "genre": "Rap/Hip Hop", + "cover": "/fluid-proto/covers/65057ef3a56cd10e764fc80e1cc9f9e276da9e5a5ad34acabb711f0e4b166036.jpg", + "palette": [ + "#82b5c7", + "#3b515a", + "#6eb8ce" + ] + }, + { + "id": 1155, + "title": "Anthethu kondapai koluvainava Durgamma - Single", + "artist": "A. Ramadevi", + "year": 2026, + "trackCount": 1, + "plays": 29, + "lastPlayed": 1792337592319, + "addedAt": 1789826750799, + "genre": "MZGenre.Music.DevotionalSpiritual", + "cover": "/fluid-proto/covers/d26e800ee5d2da09adb8af7895b5e0938093acdac3da1d76d70590f4b309937f.jpg", + "palette": [ + "#6f4433", + "#321f17", + "#834f30" + ] + }, + { + "id": 1222, + "title": "Whispers of the Shore", + "artist": "Mama Aiuto, Dan Gregory", + "year": 2022, + "trackCount": 1, + "plays": 2, + "lastPlayed": 1792337567649, + "addedAt": 1792267160833, + "genre": "Electro", + "cover": "/fluid-proto/covers/54d3b180eefad0ca6769a7dd65c0449a409dde44d333df41f1a4a7b1e155e103.jpg", + "palette": [ + "#93a7ba", + "#424b54", + "#7599c0" + ] + }, + { + "id": 1162, + "title": "Step It Up", + "artist": "Diskopunk", + "year": 2025, + "trackCount": 2, + "plays": 49, + "lastPlayed": 1792337495545, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/865bec1bf9c7202881324065a69872767fa20c66fffddbc18471083aa7a88d68.jpg", + "palette": [ + "#63451c", + "#2d1f0d", + "#855e25" + ] + }, + { + "id": 1164, + "title": "Tell Me I'm Wrong", + "artist": "Beach Riot", + "year": 2023, + "trackCount": 2, + "plays": 35, + "lastPlayed": 1792337459582, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg", + "palette": [ + "#988d8a", + "#443f3e", + "#9f7e74" + ] + }, + { + "id": 1160, + "title": "Otherside", + "artist": "Spyres", + "year": 2019, + "trackCount": 2, + "plays": 30, + "lastPlayed": 1792273098596, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/58d2d358cfbfd31f393f9becf65100b80d7b51031b915d00a806f83ecb6b014b.jpg", + "palette": [ + "#768279", + "#353b36", + "#76877b" + ] + }, + { + "id": 292, + "title": "LOVE, PEACE & FIRE", + "artist": "Superfly", + "year": 2017, + "trackCount": 31, + "plays": 24, + "lastPlayed": 1792270602362, + "addedAt": 1779068421740, + "genre": "J-Rock", + "cover": "/fluid-proto/covers/73e2c16ede225323314fa82ae6143404734ea04d8819b4236c2283c76829b78a.jpg", + "palette": [ + "#967f72", + "#443933", + "#a27961" + ] + }, + { + "id": 287, + "title": "U -Special Edition-", + "artist": "NiziU", + "year": 2021, + "trackCount": 4, + "plays": 2, + "lastPlayed": 1792270541501, + "addedAt": 1779068421740, + "genre": null, + "cover": "/fluid-proto/covers/cabf81b80a7eb83238c9cd88b6f6051742ed6a9316cb979ecac5a311a0c03909.jpg", + "palette": [ + "#b19e99", + "#504745", + "#af8973" + ] + }, + { + "id": 334, + "title": "Man And Beast", + "artist": "Thomas Vent", + "year": 2024, + "trackCount": 3, + "plays": 3, + "lastPlayed": 1792270490191, + "addedAt": 1779068421740, + "genre": null, + "cover": "/fluid-proto/covers/6a00e6bc6cb40600b2385c658df0c611a714d093531a263b7f4f068c234e0eea.jpg", + "palette": [ + "#6e6865", + "#322f2d", + "#af846d" + ] + }, + { + "id": 1218, + "title": "Comin' Home Baby (from Palm Royale Season 2)", + "artist": "Ricky Martin", + "year": 2025, + "trackCount": 1, + "plays": 5, + "lastPlayed": 1792262952323, + "addedAt": 1791521875969, + "genre": "Latin Music", + "cover": "/fluid-proto/covers/7d528548cf1abe401466684af10c436064a55c62b973f62bf12788182ff4ef2d.jpg", + "palette": [ + "#785e49", + "#362a21", + "#8c684e" + ] + }, + { + "id": 1156, + "title": "Heaven", + "artist": "swim school", + "year": 2025, + "trackCount": 2, + "plays": 33, + "lastPlayed": 1792262893572, + "addedAt": 1789826750799, + "genre": "Rock", + "cover": "/fluid-proto/covers/09b51456b774dd25b6876dea36848c5e5454319455bcfd625fecec63ae0b5d23.jpg", + "palette": [ + "#a5493a", + "#4a211a", + "#a64a3a" + ] + } + ], + "topAlbums": [ + { + "id": 1162, + "title": "Step It Up", + "artist": "Diskopunk", + "year": 2025, + "trackCount": 2, + "plays": 49, + "lastPlayed": 1792337495545, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/865bec1bf9c7202881324065a69872767fa20c66fffddbc18471083aa7a88d68.jpg", + "palette": [ + "#63451c", + "#2d1f0d", + "#855e25" + ] + }, + { + "id": 1214, + "title": "bona fide", + "artist": "Civin", + "year": 2026, + "trackCount": 1, + "plays": 45, + "lastPlayed": 1791553467968, + "addedAt": 1791521547925, + "genre": "Rap/Hip Hop", + "cover": "/fluid-proto/covers/157437ca8cff3d75d0d9625beca5e8eee7a4e4efdd1c178b22d57ada20605470.jpg", + "palette": [ + "#ad6059", + "#4e2b28", + "#b45e58" + ] + }, + { + "id": 1158, + "title": "Nothing's Gonna End Us Till The Aliens Do It", + "artist": "PLAIINS", + "year": 2023, + "trackCount": 2, + "plays": 39, + "lastPlayed": 1792340142209, + "addedAt": 1789826750799, + "genre": "Alternative", + "cover": "/fluid-proto/covers/1ef1f033a8350610da93ce5128038447928902c5215eacb0b17f3ab0342416c0.jpg", + "palette": [ + "#231b7a", + "#100c37", + "#241c80" + ] + }, + { + "id": 870, + "title": "Motto", + "artist": "ITZY", + "year": 2026, + "trackCount": 8, + "plays": 39, + "lastPlayed": 1791274962639, + "addedAt": 1781204847677, + "genre": null, + "cover": "/fluid-proto/covers/7c7be2fc446eef0436df3c85dff8eda45825fb488a2bdc956095d1c068180719.jpg", + "palette": [ + "#d0d2d3", + "#5e5f5f", + "#c4cdd2" + ] + }, + { + "id": 1164, + "title": "Tell Me I'm Wrong", + "artist": "Beach Riot", + "year": 2023, + "trackCount": 2, + "plays": 35, + "lastPlayed": 1792337459582, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg", + "palette": [ + "#988d8a", + "#443f3e", + "#9f7e74" + ] + }, + { + "id": 1156, + "title": "Heaven", + "artist": "swim school", + "year": 2025, + "trackCount": 2, + "plays": 33, + "lastPlayed": 1792262893572, + "addedAt": 1789826750799, + "genre": "Rock", + "cover": "/fluid-proto/covers/09b51456b774dd25b6876dea36848c5e5454319455bcfd625fecec63ae0b5d23.jpg", + "palette": [ + "#a5493a", + "#4a211a", + "#a64a3a" + ] + }, + { + "id": 1160, + "title": "Otherside", + "artist": "Spyres", + "year": 2019, + "trackCount": 2, + "plays": 30, + "lastPlayed": 1792273098596, + "addedAt": 1789826750799, + "genre": "Indie Rock", + "cover": "/fluid-proto/covers/58d2d358cfbfd31f393f9becf65100b80d7b51031b915d00a806f83ecb6b014b.jpg", + "palette": [ + "#768279", + "#353b36", + "#76877b" + ] + }, + { + "id": 1155, + "title": "Anthethu kondapai koluvainava Durgamma - Single", + "artist": "A. Ramadevi", + "year": 2026, + "trackCount": 1, + "plays": 29, + "lastPlayed": 1792337592319, + "addedAt": 1789826750799, + "genre": "MZGenre.Music.DevotionalSpiritual", + "cover": "/fluid-proto/covers/d26e800ee5d2da09adb8af7895b5e0938093acdac3da1d76d70590f4b309937f.jpg", + "palette": [ + "#6f4433", + "#321f17", + "#834f30" + ] + }, + { + "id": 1212, + "title": "Monaco", + "artist": "Mujo", + "year": null, + "trackCount": 1, + "plays": 27, + "lastPlayed": 1791553583695, + "addedAt": 1791521547925, + "genre": null, + "cover": "/fluid-proto/covers/3833b27ad3b281012314eddc6af2073ec66fa0fd76d4718f9d0aa9f469501fcd.bin", + "palette": [ + "#988ca0", + "#443f48", + "#948e9d" + ] + }, + { + "id": 1167, + "title": "Sub Atomic Party Cool", + "artist": "Beach Riot", + "year": 2021, + "trackCount": 2, + "plays": 27, + "lastPlayed": 1791529652783, + "addedAt": 1789967126904, + "genre": "Alternative", + "cover": "/fluid-proto/covers/99249e5aab898da18745e944fc0bef9eeb87ce4be1174fd086dbf50371b55a5a.jpg", + "palette": [ + "#a8a18c", + "#4c483f", + "#a79273" + ] + }, + { + "id": 1165, + "title": "Tune in, Drop Out", + "artist": "Beach Riot", + "year": 2020, + "trackCount": 2, + "plays": 27, + "lastPlayed": 1791419477395, + "addedAt": 1789826750799, + "genre": "Indie Pop", + "cover": "/fluid-proto/covers/773e8fa5fe87a6fdc0ee68761931f45ab236f352e5f7483f22d143a04f70df9a.jpg", + "palette": [ + "#aaced9", + "#4d5d62", + "#8dcadf" + ] + }, + { + "id": 1163, + "title": "Kanga Ngai Photo", + "artist": "José Nzita", + "year": 2014, + "trackCount": 2, + "plays": 25, + "lastPlayed": 1791530155694, + "addedAt": 1789826750799, + "genre": null, + "cover": "/fluid-proto/covers/28358afa2c226a94ea7b2bfb45800bc83c5d9e023e3dc8ea0a3714c0aa2fb82c.jpg", + "palette": [ + "#618aa1", + "#2c3e48", + "#4a92b8" + ] + }, + { + "id": 1157, + "title": "Comin' Home Baby", + "artist": "Ricky Martin", + "year": null, + "trackCount": 1, + "plays": 25, + "lastPlayed": 1791421502260, + "addedAt": 1789826750799, + "genre": null, + "cover": "/fluid-proto/covers/580b818b8222c98457efbea3b30277bd83ff2b06f9840ec819f32a6619d1bda3.jpg", + "palette": [ + "#775d48", + "#362a20", + "#8b674d" + ] + }, + { + "id": 292, + "title": "LOVE, PEACE & FIRE", + "artist": "Superfly", + "year": 2017, + "trackCount": 31, + "plays": 24, + "lastPlayed": 1792270602362, + "addedAt": 1779068421740, + "genre": "J-Rock", + "cover": "/fluid-proto/covers/73e2c16ede225323314fa82ae6143404734ea04d8819b4236c2283c76829b78a.jpg", + "palette": [ + "#967f72", + "#443933", + "#a27961" + ] + }, + { + "id": 1213, + "title": "ERRATIC.BAS", + "artist": "Dualizm", + "year": 2025, + "trackCount": 1, + "plays": 24, + "lastPlayed": 1791553670666, + "addedAt": 1791521547925, + "genre": "Rap/Hip Hop", + "cover": "/fluid-proto/covers/4be0f321f4e280f73e6b03eab25047103696450ebfe6c663c51610f81ef66bcf.jpg", + "palette": [ + "#c69964", + "#59452d", + "#ca9f55" + ] + }, + { + "id": 1168, + "title": "Slow", + "artist": "Airways, Jake Daniels, Dom Craik, Jim Irvin", + "year": 2021, + "trackCount": 1, + "plays": 22, + "lastPlayed": 1791420945902, + "addedAt": 1789967126904, + "genre": null, + "cover": "/fluid-proto/covers/bd8ace294668b5882ccfc6fba7b99f018d5af469865ac52c6c8b44c5835aa7ad.jpg", + "palette": [ + "#758599", + "#353c45", + "#3e6390" + ] + }, + { + "id": 590, + "title": "Espresso", + "artist": "Sabrina Carpenter", + "year": 2024, + "trackCount": 2, + "plays": 21, + "lastPlayed": 1792262761735, + "addedAt": 1779068421740, + "genre": null, + "cover": "/fluid-proto/covers/6d97e5ac7cd128cf82bf52ee23861485f198ddcc1044adc2458ef6eafbd22291.jpg", + "palette": [ + "#a8a09d", + "#4c4847", + "#b9a194" + ] + }, + { + "id": 628, + "title": "Sweetener", + "artist": "Ariana Grande", + "year": 2018, + "trackCount": 2, + "plays": 21, + "lastPlayed": 1792261975809, + "addedAt": 1779068421740, + "genre": null, + "cover": "/fluid-proto/covers/f34e844695e8c1a6610b948a6fb11c21f20aee4451c9263d9933fa62be2dde4b.jpg", + "palette": [ + "#deb599", + "#645145", + "#dfb193" + ] + } + ], + "recentAlbums": [ + { + "id": 1222, + "title": "Whispers of the Shore", + "artist": "Mama Aiuto, Dan Gregory", + "year": 2022, + "trackCount": 1, + "plays": 2, + "lastPlayed": 1792337567649, + "addedAt": 1792267160833, + "genre": "Electro", + "cover": "/fluid-proto/covers/54d3b180eefad0ca6769a7dd65c0449a409dde44d333df41f1a4a7b1e155e103.jpg", + "palette": [ + "#93a7ba", + "#424b54", + "#7599c0" + ] + }, + { + "id": 843, + "title": "LOVE CATCHER", + "artist": "YENA", + "year": 2026, + "trackCount": 1, + "plays": 0, + "lastPlayed": null, + "addedAt": 1791791957073, + "genre": null, + "cover": "/fluid-proto/covers/3959db84624500f392e4909121e8889f1c447896a3446f5bd29d714ae1eda8f5.jpg", + "palette": [ + "#cb8554", + "#5b3c26", + "#c87b45" + ] + }, + { + "id": 1216, + "title": "new trick", + "artist": "ROSÉ", + "year": 2026, + "trackCount": 1, + "plays": 9, + "lastPlayed": 1792337915702, + "addedAt": 1791521875969, + "genre": "Pop", + "cover": "/fluid-proto/covers/f13a49d803c5ff341e8d1bf7b38cd286f096305f9a8571402039af9daaadf37f.jpg", + "palette": [ + "#a28095", + "#493a43", + "#9c778e" + ] + }, + { + "id": 1217, + "title": "Twistin’ The Rock, Vol. 9", + "artist": "Gillian Hills", + "year": 2002, + "trackCount": 1, + "plays": 1, + "lastPlayed": 1791530175074, + "addedAt": 1791521875969, + "genre": "Rock", + "cover": "/fluid-proto/covers/1f99931401665e7decb116d0e479b77c7a553e48e40edb838cffc9fee30fdad5.jpg", + "palette": [ + "#d98c90", + "#623f41", + "#f08489" + ] + }, + { + "id": 1218, + "title": "Comin' Home Baby (from Palm Royale Season 2)", + "artist": "Ricky Martin", + "year": 2025, + "trackCount": 1, + "plays": 5, + "lastPlayed": 1792262952323, + "addedAt": 1791521875969, + "genre": "Latin Music", + "cover": "/fluid-proto/covers/7d528548cf1abe401466684af10c436064a55c62b973f62bf12788182ff4ef2d.jpg", + "palette": [ + "#785e49", + "#362a21", + "#8c684e" + ] + }, + { + "id": 1219, + "title": "Bright Moments", + "artist": "Flamingosis", + "year": 2016, + "trackCount": 1, + "plays": 0, + "lastPlayed": null, + "addedAt": 1791521875969, + "genre": "Dance", + "cover": "/fluid-proto/covers/3a96619b462471104f03bd2563d490873507b635498711c5464ad7239622be8b.jpg", + "palette": [ + "#5eb9a9", + "#2a534c", + "#5bbaaa" + ] + }, + { + "id": 1220, + "title": "yes, and?", + "artist": "Ariana Grande", + "year": 2024, + "trackCount": 1, + "plays": 5, + "lastPlayed": 1792147503399, + "addedAt": 1791521875969, + "genre": "Pop", + "cover": "/fluid-proto/covers/fc67cb46d48abf0e6e09e703a12cd8a9b628e674d9315e2f4f00339e1385299c.jpg", + "palette": [ + "#673014", + "#2e1609", + "#833f1e" + ] + }, + { + "id": 1221, + "title": "Slow", + "artist": "Airways", + "year": 2021, + "trackCount": 1, + "plays": 2, + "lastPlayed": 1792147707760, + "addedAt": 1791521875969, + "genre": "Rock", + "cover": "/fluid-proto/covers/a4581d8a5b8b8fc5a385d19805fa6a0c56e49e7e5019ad94b936fb6f52108a19.jpg", + "palette": [ + "#76869a", + "#353c45", + "#406490" + ] + }, + { + "id": 1215, + "title": "Hypnotic Hills", + "artist": "Mujo", + "year": 2023, + "trackCount": 1, + "plays": 20, + "lastPlayed": 1792337801667, + "addedAt": 1791521662841, + "genre": "Rap/Hip Hop", + "cover": "/fluid-proto/covers/65057ef3a56cd10e764fc80e1cc9f9e276da9e5a5ad34acabb711f0e4b166036.jpg", + "palette": [ + "#82b5c7", + "#3b515a", + "#6eb8ce" + ] + }, + { + "id": 1211, + "title": "Jazz Wizzard", + "artist": "Mujo", + "year": 2026, + "trackCount": 1, + "plays": 3, + "lastPlayed": 1791529207823, + "addedAt": 1791521547925, + "genre": null, + "cover": "/fluid-proto/covers/024295dda21336f650f1e85806a36e2cad0fe6f8b45135344162daba7095e300.jpg", + "palette": [ + "#799194", + "#364143", + "#7b939c" + ] + } + ], + "recentPlays": [ + { + "trackId": 1461, + "title": "Nothing's Gonna End Us Till The Aliens Do It", + "artist": "PLAIINS", + "albumId": 1158, + "album": "Nothing's Gonna End Us Till The Aliens Do It", + "durationMs": 181681, + "playedAt": 1792340142209, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 16, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/1ef1f033a8350610da93ce5128038447928902c5215eacb0b17f3ab0342416c0.jpg", + "palette": [ + "#231b7a", + "#100c37", + "#241c80" + ] + }, + { + "trackId": 1448, + "title": "new trick", + "artist": "ROSÉ", + "albumId": 1216, + "album": "new trick", + "durationMs": 182297, + "playedAt": 1792337915702, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": 67.99958881578948, + "energy": null, + "cover": "/fluid-proto/covers/f13a49d803c5ff341e8d1bf7b38cd286f096305f9a8571402039af9daaadf37f.jpg", + "palette": [ + "#a28095", + "#493a43", + "#9c778e" + ] + }, + { + "trackId": 1012, + "title": "Waterfalls (feat. Sam Harper & Bobby Harvey)", + "artist": "James Hype", + "albumId": 850, + "album": "Waterfalls (feat. Sam Harper & Bobby Harvey)", + "durationMs": 120577, + "playedAt": 1792337861944, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": 135.99917763157896, + "energy": null, + "cover": "/fluid-proto/covers/61bd910b8141b7ccbf19d6e5524a855c4a17c7a6a66b7f4c625106e6ce55f2d8.jpg", + "palette": [ + "#58492f", + "#282115", + "#674b2f" + ] + }, + { + "trackId": 1447, + "title": "Valentino Land", + "artist": "Mujo", + "albumId": 1215, + "album": "Hypnotic Hills", + "durationMs": 95203, + "playedAt": 1792337801667, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": 61.5234375, + "energy": null, + "cover": "/fluid-proto/covers/65057ef3a56cd10e764fc80e1cc9f9e276da9e5a5ad34acabb711f0e4b166036.jpg", + "palette": [ + "#82b5c7", + "#3b515a", + "#6eb8ce" + ] + }, + { + "trackId": 1273, + "title": "Anthethu kondapai koluvainava Durgamma", + "artist": "A. Ramadevi", + "albumId": 1155, + "album": "Anthethu kondapai koluvainava Durgamma - Single", + "durationMs": 331302, + "playedAt": 1792337592319, + "codec": "AAC", + "sampleRate": 44100, + "bitDepth": null, + "bpm": 92.28515625, + "energy": null, + "cover": "/fluid-proto/covers/d26e800ee5d2da09adb8af7895b5e0938093acdac3da1d76d70590f4b309937f.jpg", + "palette": [ + "#6f4433", + "#321f17", + "#834f30" + ] + }, + { + "trackId": 1506, + "title": "Whispers of the Shore", + "artist": "Mama Aiuto", + "albumId": 1222, + "album": "Whispers of the Shore", + "durationMs": 158250, + "playedAt": 1792337567649, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": 87.92199938278607, + "energy": 0.15508731556985675, + "cover": "/fluid-proto/covers/54d3b180eefad0ca6769a7dd65c0449a409dde44d333df41f1a4a7b1e155e103.jpg", + "palette": [ + "#93a7ba", + "#424b54", + "#7599c0" + ] + }, + { + "trackId": 1462, + "title": "Step It Up", + "artist": "Diskopunk", + "albumId": 1162, + "album": "Step It Up", + "durationMs": 208847, + "playedAt": 1792337495545, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/865bec1bf9c7202881324065a69872767fa20c66fffddbc18471083aa7a88d68.jpg", + "palette": [ + "#63451c", + "#2d1f0d", + "#855e25" + ] + }, + { + "trackId": 1460, + "title": "Tell Me I'm Wrong", + "artist": "Beach Riot", + "albumId": 1164, + "album": "Tell Me I'm Wrong", + "durationMs": 201781, + "playedAt": 1792337459582, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 16, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg", + "palette": [ + "#988d8a", + "#443f3e", + "#9f7e74" + ] + }, + { + "trackId": 1282, + "title": "Tell Me I'm Wrong", + "artist": "Beach Riot", + "albumId": 1164, + "album": "Tell Me I'm Wrong", + "durationMs": 202292, + "playedAt": 1792337395320, + "codec": "MP3", + "sampleRate": 44100, + "bitDepth": null, + "bpm": 129.19921875, + "energy": null, + "cover": "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg", + "palette": [ + "#988d8a", + "#443f3e", + "#9f7e74" + ] + }, + { + "trackId": 1463, + "title": "Otherside", + "artist": "Spyres", + "albumId": 1160, + "album": "Otherside", + "durationMs": 221999, + "playedAt": 1792273098596, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/58d2d358cfbfd31f393f9becf65100b80d7b51031b915d00a806f83ecb6b014b.jpg", + "palette": [ + "#768279", + "#353b36", + "#76877b" + ] + }, + { + "trackId": 1192, + "title": "Alright!!", + "artist": "Superfly", + "albumId": 292, + "album": "LOVE, PEACE & FIRE", + "durationMs": 260665, + "playedAt": 1792270602362, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": 71.77734375, + "energy": null, + "cover": "/fluid-proto/covers/73e2c16ede225323314fa82ae6143404734ea04d8819b4236c2283c76829b78a.jpg", + "palette": [ + "#967f72", + "#443933", + "#a27961" + ] + }, + { + "trackId": 327, + "title": "Wonder Dream", + "artist": "NiziU", + "albumId": 287, + "album": "U -Special Edition-", + "durationMs": 190764, + "playedAt": 1792270541501, + "codec": "AAC", + "sampleRate": 48000, + "bitDepth": null, + "bpm": 65.40697674418605, + "energy": null, + "cover": "/fluid-proto/covers/cabf81b80a7eb83238c9cd88b6f6051742ed6a9316cb979ecac5a311a0c03909.jpg", + "palette": [ + "#b19e99", + "#504745", + "#af8973" + ] + }, + { + "trackId": 768, + "title": "Like I Do", + "artist": "Thomas Vent", + "albumId": 334, + "album": "Man And Beast", + "durationMs": 99662, + "playedAt": 1792270490191, + "codec": "AAC", + "sampleRate": 48000, + "bitDepth": null, + "bpm": 61.141304347826086, + "energy": null, + "cover": "/fluid-proto/covers/6a00e6bc6cb40600b2385c658df0c611a714d093531a263b7f4f068c234e0eea.jpg", + "palette": [ + "#6e6865", + "#322f2d", + "#af846d" + ] + }, + { + "trackId": 1454, + "title": "Comin' Home Baby", + "artist": "Ricky Martin", + "albumId": 1218, + "album": "Comin' Home Baby (from Palm Royale Season 2)", + "durationMs": 234416, + "playedAt": 1792262952323, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/7d528548cf1abe401466684af10c436064a55c62b973f62bf12788182ff4ef2d.jpg", + "palette": [ + "#785e49", + "#362a21", + "#8c684e" + ] + }, + { + "trackId": 1459, + "title": "Heaven", + "artist": "swim school", + "albumId": 1156, + "album": "Heaven", + "durationMs": 166826, + "playedAt": 1792262893572, + "codec": "FLAC", + "sampleRate": 44100, + "bitDepth": 24, + "bpm": null, + "energy": null, + "cover": "/fluid-proto/covers/09b51456b774dd25b6876dea36848c5e5454319455bcfd625fecec63ae0b5d23.jpg", + "palette": [ + "#a5493a", + "#4a211a", + "#a64a3a" + ] + }, + { + "trackId": 908, + "title": "Taste", + "artist": "Sabrina Carpenter", + "albumId": 763, + "album": "Short n' Sweet", + "durationMs": 157322, + "playedAt": 1792262850801, + "codec": "AAC", + "sampleRate": 48000, + "bitDepth": null, + "bpm": 76.01351351351352, + "energy": null, + "cover": "/fluid-proto/covers/b70f04a2ff178579f25505e9a38f2f6e31448309407b4a4d4c1a0b31404791a4.jpg", + "palette": [ + "#5c5364", + "#29252d", + "#5d576f" + ] + } + ], + "playlists": [ + { + "id": 9, + "name": "Motto", + "isSmart": false, + "smartRules": null, + "colorId": "rose", + "iconId": "flame", + "trackCount": 8, + "covers": [ + "/fluid-proto/covers/7c7be2fc446eef0436df3c85dff8eda45825fb488a2bdc956095d1c068180719.jpg" + ] + }, + { + "id": 11, + "name": "Radio Music", + "isSmart": false, + "smartRules": null, + "colorId": "orange", + "iconId": "gift", + "trackCount": 78, + "covers": [ + "/fluid-proto/covers/2184b9164c372b28f6199cdd2bd99fe5aa7f311fd0e97769b41d647024c2c880.jpg", + "/fluid-proto/covers/e7856850225d1bda7e1038ed11dee8b7476a94880cea884309571c97266743b0.jpg", + "/fluid-proto/covers/a3fb423645e2a5f62e02981e4b5a25bdcf42d06350aa66d06bb7d42d9966e2b6.jpg", + "/fluid-proto/covers/e3f03fb85e282129431a143d545830bdd11600d25d99adcf06030d39334ebfab.jpg" + ] + }, + { + "id": 12, + "name": "lofi Base", + "isSmart": false, + "smartRules": null, + "colorId": "violet", + "iconId": "music", + "trackCount": 136, + "covers": [ + "/fluid-proto/covers/9aa287f5467ed0560d4a612c304da45e1890a78e85ce392bf16acb51e30dfbde.jpg", + "/fluid-proto/covers/0280d1e2d70a40a515b18cd81d69158457baf58294dfc50fc002aa1d115d9211.jpg", + "/fluid-proto/covers/5e234d5cfaff3f9b70d0a600fdf4c7a24ce4632799ed9a42e857fadcc58f9509.jpg", + "/fluid-proto/covers/3564f3f8078823842478800ab6cb6e0b8592ba928d9b8fd5d085c036f0efe87b.jpg" + ] + }, + { + "id": 13, + "name": "Triage", + "isSmart": false, + "smartRules": null, + "colorId": "violet", + "iconId": "plane", + "trackCount": 16, + "covers": [ + "/fluid-proto/covers/f13a49d803c5ff341e8d1bf7b38cd286f096305f9a8571402039af9daaadf37f.jpg", + "/fluid-proto/covers/773e8fa5fe87a6fdc0ee68761931f45ab236f352e5f7483f22d143a04f70df9a.jpg", + "/fluid-proto/covers/8fd1d46d48e9977fd059dee386e7f030ec7f1b508d90e47a81eef8a5385469a4.jpg", + "/fluid-proto/covers/4856b384f3511df4a12b847b0eb0fde260ca97ffa45384e8d57b54622796df5d.jpg" + ] + }, + { + "id": 7, + "name": "On Repeat", + "isSmart": true, + "smartRules": "{\"kind\":\"on_repeat\"}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 30, + "covers": [ + "/fluid-proto/covers/865bec1bf9c7202881324065a69872767fa20c66fffddbc18471083aa7a88d68.jpg", + "/fluid-proto/covers/157437ca8cff3d75d0d9625beca5e8eee7a4e4efdd1c178b22d57ada20605470.jpg", + "/fluid-proto/covers/1ef1f033a8350610da93ce5128038447928902c5215eacb0b17f3ab0342416c0.jpg", + "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg" + ] + }, + { + "id": 3, + "name": "Daily Mix 1", + "isSmart": true, + "smartRules": "{\"kind\":\"daily_mix\",\"slot\":1,\"album_mode\":false}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 27, + "covers": [ + "/fluid-proto/covers/58d2d358cfbfd31f393f9becf65100b80d7b51031b915d00a806f83ecb6b014b.jpg", + "/fluid-proto/covers/8fd1d46d48e9977fd059dee386e7f030ec7f1b508d90e47a81eef8a5385469a4.jpg", + "/fluid-proto/covers/f34e844695e8c1a6610b948a6fb11c21f20aee4451c9263d9933fa62be2dde4b.jpg", + "/fluid-proto/covers/580b818b8222c98457efbea3b30277bd83ff2b06f9840ec819f32a6619d1bda3.jpg" + ] + }, + { + "id": 4, + "name": "Daily Mix 2", + "isSmart": true, + "smartRules": "{\"kind\":\"daily_mix\",\"slot\":2,\"album_mode\":false}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 36, + "covers": [ + "/fluid-proto/covers/6a00e6bc6cb40600b2385c658df0c611a714d093531a263b7f4f068c234e0eea.jpg", + "/fluid-proto/covers/cabf81b80a7eb83238c9cd88b6f6051742ed6a9316cb979ecac5a311a0c03909.jpg", + "/fluid-proto/covers/6d97e5ac7cd128cf82bf52ee23861485f198ddcc1044adc2458ef6eafbd22291.jpg", + "/fluid-proto/covers/87714c7e0c07db386ebdc90720bfef1a345d938d3333a5a8067b50e28b0e5c80.jpg" + ] + }, + { + "id": 5, + "name": "Daily Mix 3", + "isSmart": true, + "smartRules": "{\"kind\":\"daily_mix\",\"slot\":3,\"album_mode\":false}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 9, + "covers": [ + "/fluid-proto/covers/28358afa2c226a94ea7b2bfb45800bc83c5d9e023e3dc8ea0a3714c0aa2fb82c.jpg", + "/fluid-proto/covers/2e865905ea2f6d2dc371478a04561bd227ca8fd8fad672b7307c08f7dd70822d.jpg", + "/fluid-proto/covers/61bd910b8141b7ccbf19d6e5524a855c4a17c7a6a66b7f4c625106e6ce55f2d8.jpg", + "/fluid-proto/covers/1ef1f033a8350610da93ce5128038447928902c5215eacb0b17f3ab0342416c0.jpg" + ] + }, + { + "id": 17, + "name": "Beach Riot Mix", + "isSmart": true, + "smartRules": "{\"kind\":\"artist_mix\",\"artist_id\":796}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 16, + "covers": [ + "/fluid-proto/covers/f34e844695e8c1a6610b948a6fb11c21f20aee4451c9263d9933fa62be2dde4b.jpg", + "/fluid-proto/covers/90d461d9ace95284fa8782e8d9d0d8269a641a9a340f8cd06f985f12cf9edf9e.jpg", + "/fluid-proto/covers/fb523a4d33950b24d2db139f724eaeeffe4aa2883e01733600578a421a162409.jpg", + "/fluid-proto/covers/773e8fa5fe87a6fdc0ee68761931f45ab236f352e5f7483f22d143a04f70df9a.jpg" + ] + }, + { + "id": 18, + "name": "Ariana Grande Mix", + "isSmart": true, + "smartRules": "{\"kind\":\"artist_mix\",\"artist_id\":262}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 18, + "covers": [ + "/fluid-proto/covers/98adce345b81b8803cdd829d9ffc0a80155d8b97b159d11170142313317c294b.jpg", + "/fluid-proto/covers/6d97e5ac7cd128cf82bf52ee23861485f198ddcc1044adc2458ef6eafbd22291.jpg", + "/fluid-proto/covers/865bec1bf9c7202881324065a69872767fa20c66fffddbc18471083aa7a88d68.jpg", + "/fluid-proto/covers/b515c74901eccab546bd0bf40ea910258a3b222a003302bf075f94a6e01461e0.jpg" + ] + }, + { + "id": 19, + "name": "Mujo Mix", + "isSmart": true, + "smartRules": "{\"kind\":\"artist_mix\",\"artist_id\":3}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 16, + "covers": [ + "/fluid-proto/covers/086a38866a4ca73d1be31e540dc5566b9ccb22d9ece3301541df9ff065088c76.jpg", + "/fluid-proto/covers/1a0c1b0a8cf7c966b3ffcfdf294179567b170278eb0ae09e025e8c6ce72d7164.jpg", + "/fluid-proto/covers/157437ca8cff3d75d0d9625beca5e8eee7a4e4efdd1c178b22d57ada20605470.jpg", + "/fluid-proto/covers/5e234d5cfaff3f9b70d0a600fdf4c7a24ce4632799ed9a42e857fadcc58f9509.jpg" + ] + }, + { + "id": 14, + "name": "The 2020s", + "isSmart": true, + "smartRules": "{\"kind\":\"decade_mix\",\"decade\":2020}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 50, + "covers": [ + "/fluid-proto/covers/9e34ab5fab2670d62fbafd6954866846d2fa1f93bbb5142dca9e680377c2fd1e.jpg", + "/fluid-proto/covers/69758662f19786f8f69c9d58e0f98c95ffee86c79122185c86f502c9c7c152ad.jpg", + "/fluid-proto/covers/886e9963ca286147f99f30248f483b070c979fc9fca53ce7a205082f3fd1ac0c.jpg", + "/fluid-proto/covers/31c03cbcb32f51a4dae7b159d1c57b36b9c647b84286b9dcb2574bcd3f58b6e7.jpg" + ] + }, + { + "id": 15, + "name": "The 2010s", + "isSmart": true, + "smartRules": "{\"kind\":\"decade_mix\",\"decade\":2010}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 50, + "covers": [ + "/fluid-proto/covers/2d8083792003d142923fe1c5e32d548c8e99a8db8c39e712031f9e6874075bdc.jpg", + "/fluid-proto/covers/67528f31569941c14e347c272a844be1b46f9cb6cb165e0ee09113f20a4e9b0b.jpg", + "/fluid-proto/covers/d3e1f555988c5ddc9832dee0658fac08f1f7528e51978b4c4e50219babbf2562.jpg", + "/fluid-proto/covers/54cb59b5d54697863095048ee20d06f01d471689d8998b61a9d81ffe2eecbe5b.jpg" + ] + }, + { + "id": 16, + "name": "The 2000s", + "isSmart": true, + "smartRules": "{\"kind\":\"decade_mix\",\"decade\":2000}", + "colorId": "violet", + "iconId": "sparkles", + "trackCount": 50, + "covers": [ + "/fluid-proto/covers/46fe114220dea1c9928c3f54771ab39cf9d34a7cdf746331aebb02172d6c1a92.jpg", + "/fluid-proto/covers/65daa66d2bc049827cce1695f73c7b349f10e5bf77a729f6c6b7db941b5049ab.jpg", + "/fluid-proto/covers/8cb7fe0de43a3b8b1888b7cb77162468a1e9eec5f9138edb5b1f078aacb8b566.jpg", + "/fluid-proto/covers/01fc87d007b316849f4ff1415a3b34c57eb708330f3a5c210644f89e3e3995bf.jpg" + ] + } + ] +} diff --git a/src/prototype/fluid-editorial/fixtures.ts b/src/prototype/fluid-editorial/fixtures.ts new file mode 100644 index 000000000..e2089ef2c --- /dev/null +++ b/src/prototype/fluid-editorial/fixtures.ts @@ -0,0 +1,178 @@ +/** + * Typed view over `fixtures.generated.json`, produced by + * `scripts/fluid-proto-fixtures.ts` from a real profile database. + * + * The covers it references live under `prototypes/fluid-editorial/covers/`, + * a gitignored folder (album art is the user's). Every consumer has a + * palette to fall back on, so the page still composes on a machine + * without the images — flatter, but laid out the same. + */ +import raw from "./fixtures.generated.json"; + +export interface FeAlbum { + id: number; + title: string; + artist: string; + year: number | null; + trackCount: number; + plays: number; + lastPlayed: number | null; + addedAt: number; + genre: string | null; + cover: string | null; + palette: string[] | null; +} + +export interface FeTrack { + trackId: number; + title: string; + artist: string; + albumId: number | null; + album: string | null; + durationMs: number; + playedAt: number; + codec: string | null; + sampleRate: number | null; + bitDepth: number | null; + bpm: number | null; + energy: number | null; + cover: string | null; + palette: string[] | null; +} + +export type SmartKind = + "daily_mix" | "on_repeat" | "artist_mix" | "genre_mix" | "decade_mix"; + +export interface FePlaylist { + id: number; + name: string; + isSmart: boolean; + smartKind: SmartKind | null; + colorId: string; + iconId: string; + trackCount: number; + covers: string[]; +} + +export interface FeStats { + tracks: number; + albums: number; + artists: number; + liked: number; + plays: number; + playlists: number; + analysed: number; + listenedMs: number; +} + +export type MoodId = "sleep" | "focus" | "chill" | "party" | "workout"; + +interface RawFixtures { + stats: { + tracks: number; + albums: number; + artists: number; + liked: number; + plays: number; + playlists: number; + analysed: number; + listened_ms: number; + }; + moodCounts: Record<"focus" | "chill" | "workout" | "party" | "sleep", number>; + lastPlayed: FeAlbum[]; + topAlbums: FeAlbum[]; + recentAlbums: FeAlbum[]; + recentPlays: FeTrack[]; + playlists: Array< + Omit & { smartRules: string | null } + >; +} + +const data = raw as unknown as RawFixtures; + +function smartKind(rules: string | null): SmartKind | null { + if (!rules) return null; + try { + const kind = (JSON.parse(rules) as { kind?: string }).kind; + switch (kind) { + case "daily_mix": + case "on_repeat": + case "artist_mix": + case "genre_mix": + case "decade_mix": + return kind; + default: + return null; + } + } catch { + return null; + } +} + +/** Drop a track that has no album row at all — it cannot be composed + * into anything on this page (the untitled WAV in the sample). */ +const plays = data.recentPlays.filter((p) => p.album != null); +if (plays.length === 0) { + // The generator refuses to write such a file; a hand-edited one + // should fail here, not on `recentPlays[0].durationMs` in `state.tsx`. + throw new Error( + "fixtures.generated.json has no play with an album — regenerate it with scripts/fluid-proto-fixtures.ts", + ); +} + +export const fixtures = { + stats: { + tracks: data.stats.tracks, + albums: data.stats.albums, + artists: data.stats.artists, + liked: data.stats.liked, + plays: data.stats.plays, + playlists: data.stats.playlists, + analysed: data.stats.analysed, + listenedMs: data.stats.listened_ms, + } satisfies FeStats, + moodCounts: data.moodCounts, + /** Most recent first. */ + recentPlays: plays, + lastPlayedAlbums: data.lastPlayed, + topAlbums: data.topAlbums, + recentAlbums: data.recentAlbums, + playlists: data.playlists.map((p) => ({ + id: p.id, + name: p.name, + isSmart: p.isSmart, + smartKind: smartKind(p.smartRules), + colorId: p.colorId, + iconId: p.iconId, + trackCount: p.trackCount, + covers: p.covers, + })), +}; + +export const albumById = new Map( + [...data.topAlbums, ...data.recentAlbums, ...data.lastPlayed].map((a) => [ + a.id, + a, + ]), +); + +/** A playable stand-in built from an album row, for tiles that start + * an album rather than a track. */ +export function trackFromAlbum(album: FeAlbum): FeTrack { + return { + trackId: -album.id, + title: album.title, + artist: album.artist, + albumId: album.id, + album: album.title, + durationMs: 214_000, + playedAt: Date.now() / 1000, + codec: "FLAC", + sampleRate: 44_100, + bitDepth: 16, + bpm: null, + energy: null, + cover: album.cover, + palette: album.palette, + }; +} diff --git a/src/prototype/fluid-editorial/fluid-editorial.css b/src/prototype/fluid-editorial/fluid-editorial.css new file mode 100644 index 000000000..0ab03d4b7 --- /dev/null +++ b/src/prototype/fluid-editorial/fluid-editorial.css @@ -0,0 +1,638 @@ +/* ============================================================ + Fluid Editorial — prototype tokens + ============================================================ + Scoped under `.fe` so nothing here can leak into the production + shell. The prototype mounts in its own document branch + (`?proto=fluid-editorial`), never alongside AppLayout. + + One neutral foundation, slightly warm, and a single accent that is + *derived from the playing artwork* at runtime (`--fe-accent`, + `--fe-glow`). Everything coloured on the page reads one of those + two variables — the mood spectrum is the one deliberate exception, + since tempo is a dimension of its own. */ + +.fe { + --fe-bg: #0b0b0e; + --fe-bg-raised: #10101499; + --fe-surface: #121216; + --fe-surface-2: #17171d; + --fe-ink: #f3f1ec; + --fe-ink-2: #b5b2aa; + --fe-ink-3: #8e8b83; + --fe-hairline: rgb(255 255 255 / 0.07); + --fe-hairline-strong: rgb(255 255 255 / 0.12); + + /* Artwork-driven. Written by `Atmosphere` from the current track's + palette; the defaults are the brand emerald, so a cover with no + colour of its own still reads as WaveFlow. */ + --fe-accent: #5ed3a5; + --fe-accent-ink: #0b0b0e; + --fe-glow: #1f5a48; + --fe-glow-deep: #0f2b22; + + --fe-ease: cubic-bezier(0.22, 1, 0.36, 1); + --fe-ease-soft: cubic-bezier(0.32, 0.72, 0, 1); + + --fe-font: + "DM Sans Variable", system-ui, -apple-system, "Segoe UI", sans-serif; + --fe-font-editorial: "Playfair Display", Georgia, serif; + + color: var(--fe-ink); + background-color: var(--fe-bg); + font-family: var(--fe-font); + font-optical-sizing: auto; + font-feature-settings: + "ss01" 0, + "kern" 1; + -webkit-font-smoothing: antialiased; +} + +/* ── Typographic roles ─────────────────────────────────────── */ +.fe-display { + font-weight: 560; + letter-spacing: -0.035em; + line-height: 0.98; + text-wrap: balance; +} +.fe-title { + font-weight: 540; + letter-spacing: -0.022em; + line-height: 1.1; +} +.fe-editorial { + font-family: var(--fe-font-editorial); + font-style: italic; + font-weight: 400; + letter-spacing: -0.01em; +} +.fe-label { + font-size: 0.75rem; + font-weight: 540; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fe-ink-3); +} +.fe-mono { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} + +.fe-ink-2 { + color: var(--fe-ink-2); +} +.fe-ink-3 { + color: var(--fe-ink-3); +} +.fe-accent { + color: var(--fe-accent); +} + +/* ── Surfaces: depth through tone, not borders ─────────────── */ +.fe-surface { + background-color: var(--fe-surface); +} +.fe-surface-2 { + background-color: var(--fe-surface-2); +} +.fe-hairline-t { + box-shadow: inset 0 1px 0 var(--fe-hairline); +} + +/* ── Interactive baseline ──────────────────────────────────── */ +.fe button, +.fe [role="button"] { + transition: + color 180ms var(--fe-ease), + background-color 180ms var(--fe-ease), + opacity 180ms var(--fe-ease), + transform 320ms var(--fe-ease), + box-shadow 320ms var(--fe-ease); +} +.fe + :where( + button, + [role="button"], + [role="slider"], + [role="radio"], + a, + input + ):focus-visible { + outline: 2px solid var(--fe-accent); + outline-offset: 3px; + border-radius: 0.5rem; +} +.fe-press:active { + transform: scale(0.985); +} + +/* Primary pill — accent fill, dark ink. */ +.fe-cta { + display: inline-flex; + align-items: center; + gap: 0.75rem; + height: 3rem; + padding: 0 1.25rem 0 1rem; + border-radius: 9999px; + background-color: var(--fe-accent); + color: var(--fe-accent-ink); + font-weight: 560; + font-size: 0.9375rem; + letter-spacing: -0.01em; + box-shadow: 0 18px 40px -18px + color-mix(in srgb, var(--fe-accent) 70%, transparent); +} +.fe-cta:hover { + filter: brightness(1.06); + transform: translateY(-1px); +} +.fe-cta:active { + transform: translateY(0) scale(0.985); +} +.fe-cta__icon { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 9999px; + background-color: rgb(0 0 0 / 0.14); +} + +/* Quiet text link — underline that draws in. */ +.fe-link { + position: relative; + color: var(--fe-ink-2); + font-weight: 500; +} +.fe-link::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -2px; + height: 1px; + background: currentColor; + transform: scaleX(0); + transform-origin: left; + transition: transform 320ms var(--fe-ease); +} +.fe-link:hover { + color: var(--fe-ink); +} +.fe-link:hover::after { + transform: scaleX(1); +} + +/* Icon button — no box until hovered. */ +.fe-icon-btn { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.625rem; + color: var(--fe-ink-3); +} +.fe-icon-btn:hover { + color: var(--fe-ink); + background-color: rgb(255 255 255 / 0.06); +} +.fe-icon-btn[aria-pressed="true"], +.fe-icon-btn[data-active="true"] { + color: var(--fe-accent); +} + +/* ── Artwork tiles ─────────────────────────────────────────── */ +.fe-tile { + position: relative; + border-radius: 0.875rem; + overflow: hidden; + background-color: var(--fe-surface-2); + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.04) inset, + 0 10px 30px -18px rgb(0 0 0 / 0.9); + transition: + transform 420ms var(--fe-ease), + box-shadow 420ms var(--fe-ease); +} +.fe-tile-hover:hover .fe-tile, +.fe-tile-hover:focus-visible .fe-tile { + transform: translateY(-3px); + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.06) inset, + 0 26px 44px -22px color-mix(in srgb, var(--fe-glow) 60%, rgb(0 0 0 / 0.8)); +} +.fe-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* The big cover in the Listening Canvas: a colour shadow that comes + from the artwork itself, so the picture seems to light the page. */ +.fe-hero-cover { + border-radius: 1.25rem; + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.08) inset, + 0 50px 90px -40px color-mix(in srgb, var(--fe-glow) 85%, transparent), + 0 30px 60px -30px rgb(0 0 0 / 0.9); +} + +/* ── Atmosphere (fixed, pointer-events none) ───────────────── */ +.fe-atmosphere { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} +.fe-atmosphere__pool { + position: absolute; + inset: -20%; + background: + radial-gradient( + ellipse 42% 38% at 72% 8%, + color-mix(in srgb, var(--fe-glow) 40%, transparent), + transparent 70% + ), + radial-gradient( + ellipse 36% 30% at 18% 92%, + color-mix(in srgb, var(--fe-glow-deep) 70%, transparent), + transparent 72% + ); + animation: fe-pool-in 1200ms var(--fe-ease) both; +} +.fe-atmosphere__grain { + position: absolute; + inset: 0; + opacity: 0.045; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 160px 160px; +} +@keyframes fe-pool-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ── Entry motion: a staggered rise, once, GPU-only ────────── */ +.fe-rise { + animation: fe-rise 640ms var(--fe-ease) both; + animation-delay: calc(var(--i, 0) * 70ms); +} +@keyframes fe-rise { + from { + opacity: 0; + transform: translate3d(0, 10px, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +/* ── Listening Canvas grid ─────────────────────────────────── */ +/* Plain CSS rather than stacked Tailwind variants: the three-column + layout starts at 1700 px, between the `xl` and `2xl` breakpoints, and + an arbitrary `min-[…]` variant does not reliably outrank `lg:` in the + generated order. Two tiers: text over queue beside the artwork from + 1024 px; text · queue · artwork in one row from 1700 px. */ +.fe-canvas { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + column-gap: 2rem; + align-items: start; +} +/* The cover's light. A blurred copy of the artwork, oversized and + masked to an ellipse that favours the text column, under everything + in the canvas. One static filter on one element: nothing animates + but its fade-in. */ +.fe-canvas__aura { + position: absolute; + inset: -7rem -6rem -4rem -10rem; + z-index: -1; + overflow: hidden; + pointer-events: none; + opacity: 0.2; + background: radial-gradient( + ellipse 50% 60% at 55% 45%, + color-mix(in srgb, var(--fe-glow) 70%, transparent), + transparent 70% + ); + mask-image: radial-gradient( + ellipse 62% 70% at 52% 40%, + #000 20%, + transparent 70% + ); + -webkit-mask-image: radial-gradient( + ellipse 62% 70% at 52% 40%, + #000 20%, + transparent 70% + ); + animation: fe-pool-in 900ms var(--fe-ease) both; +} +.fe-canvas__aura img { + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(96px) saturate(0.95); + transform: scale(1.12); +} +.fe-canvas__text, +.fe-canvas__queue, +.fe-canvas__art { + grid-column: span 12; +} +.fe-canvas__resume { + margin-top: 1.75rem; +} +.fe-canvas__queue { + margin-top: 2rem; +} +.fe-canvas__art { + margin-top: 2.5rem; + justify-self: start; +} +@media (min-width: 1024px) { + .fe-canvas__text, + .fe-canvas__queue { + grid-column: span 7; + } + .fe-canvas__art { + grid-column: 8 / span 5; + grid-row: 1 / span 2; + margin-top: 0; + justify-self: end; + } +} +@media (min-width: 1700px) { + .fe-canvas { + grid-template-columns: minmax(0, 5fr) minmax(0, 3.4fr) auto; + column-gap: 3.5rem; + } + .fe-canvas__text { + grid-column: 1; + } + .fe-canvas__resume { + margin-top: 2.5rem; + } + .fe-canvas__queue { + grid-column: 2; + grid-row: 1; + margin-top: 0; + align-self: end; + padding-bottom: 0.25rem; + } + .fe-canvas__art { + grid-column: 3; + grid-row: 1; + } +} + +/* ── Mood spectrum ─────────────────────────────────────────── */ +.fe-spectrum { + position: relative; + display: flex; + height: 10.5rem; + border-radius: 1.125rem; + overflow: hidden; + background-color: var(--fe-surface); + isolation: isolate; +} +/* One continuous tempo gradient behind the five segments. Muted on + purpose: the hue says "which mood", the artwork says everything + else on this page. */ +.fe-spectrum__band { + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + #3a3e78 0%, + #335f87 22%, + #3f8a7c 46%, + #a35a7a 70%, + #c0653f 100% + ); + opacity: 0.55; +} +.fe-spectrum__band::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgb(11 11 14 / 0.1), + rgb(11 11 14 / 0.72) + ); +} +/* A segment is a wrapper (the flex item that widens) holding the radio + button, which fills it, and — when selected — the start button beside + it. Two controls, two siblings. */ +.fe-spectrum__seg { + position: relative; + flex: 1 1 0; + min-width: 0; + transition: + flex-grow 460ms var(--fe-ease), + background-color 300ms var(--fe-ease); +} +.fe-spectrum__seg + .fe-spectrum__seg { + box-shadow: inset 1px 0 0 rgb(255 255 255 / 0.08); +} +.fe-spectrum__seg:hover { + background-color: rgb(255 255 255 / 0.04); +} +.fe-spectrum__seg[data-checked="true"] { + flex-grow: 2.6; + background-color: rgb(255 255 255 / 0.06); +} +.fe-spectrum__radio { + position: absolute; + inset: 0; + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 1rem 1.125rem 1rem; + text-align: left; + color: var(--fe-ink); +} +.fe-spectrum__radio:focus-visible { + outline-offset: -3px; + border-radius: 0; +} +.fe-spectrum__radio:disabled { + color: var(--fe-ink-3); + cursor: not-allowed; +} +.fe-spectrum__cta { + display: inline-flex; + align-items: center; + gap: 0.5rem; + height: 2rem; + padding: 0 0.875rem 0 0.625rem; + border-radius: 9999px; + font-size: 0.8125rem; + font-weight: 500; + color: #fff; +} +.fe-spectrum__wave { + position: absolute; + left: 0; + right: 0; + top: 0.75rem; + height: 3.75rem; + overflow: hidden; + opacity: 0.55; + transition: opacity 300ms var(--fe-ease); +} +.fe-spectrum__seg[data-checked="true"] .fe-spectrum__wave { + opacity: 1; +} +.fe-spectrum__wave svg { + width: 200%; + height: 100%; + animation: fe-wave-drift var(--fe-wave-period, 9s) linear infinite; +} +.fe-spectrum__seg[data-disabled] .fe-spectrum__wave svg { + animation-play-state: paused; +} +@keyframes fe-wave-drift { + to { + transform: translate3d(-50%, 0, 0); + } +} +.fe-spectrum__detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 420ms var(--fe-ease); +} +.fe-spectrum__detail > div { + overflow: hidden; +} +.fe-spectrum__seg[data-checked="true"] .fe-spectrum__detail { + grid-template-rows: 1fr; +} +/* The band's ruler: tempo in BPM along the bottom edge. */ +.fe-spectrum__ruler { + display: flex; + justify-content: space-between; + padding: 0.5rem 0.25rem 0; +} + +/* ── Wave progress (player) ────────────────────────────────── */ +.fe-progress { + position: relative; + height: 1.25rem; + cursor: pointer; +} +.fe-progress:focus-visible { + outline-offset: -2px; + border-radius: 0.25rem; +} +.fe-progress svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} +.fe-progress__thumb { + opacity: 0; + transition: opacity 180ms var(--fe-ease); +} +.fe-progress:hover .fe-progress__thumb, +.fe-progress:focus-visible .fe-progress__thumb { + opacity: 1; +} + +/* ── Sidebar ───────────────────────────────────────────────── */ +.fe-nav-item { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + height: 2.25rem; + padding: 0 0.75rem; + border-radius: 0.625rem; + color: var(--fe-ink-2); + font-size: 0.875rem; + font-weight: 480; + text-align: left; +} +.fe-nav-item:hover { + color: var(--fe-ink); + background-color: rgb(255 255 255 / 0.045); +} +.fe-nav-item[aria-current="page"] { + color: var(--fe-ink); + font-weight: 540; +} +/* The active mark is a short accent bar at the left edge — the one + place the artwork colour appears in the navigation. */ +.fe-nav-item[aria-current="page"]::before { + content: ""; + position: absolute; + left: -0.75rem; + top: 0.625rem; + bottom: 0.625rem; + width: 2px; + border-radius: 2px; + background-color: var(--fe-accent); +} +.fe-nav-item svg { + color: var(--fe-ink-3); + transition: color 180ms var(--fe-ease); +} +.fe-nav-item:hover svg, +.fe-nav-item[aria-current="page"] svg { + color: var(--fe-ink); +} + +/* ── Scrollbars inside the prototype ───────────────────────── */ +.fe ::-webkit-scrollbar { + width: 10px; +} +.fe ::-webkit-scrollbar-thumb { + background: rgb(255 255 255 / 0.1); + border-radius: 10px; + border: 3px solid transparent; + background-clip: padding-box; +} +.fe ::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 0.18); + background-clip: padding-box; + border: 3px solid transparent; +} + +/* ── Reduced motion: no drift, no stagger, no lift ─────────── */ +@media (prefers-reduced-motion: reduce) { + .fe-rise, + .fe-atmosphere__pool, + .fe-canvas__aura { + animation: none; + } + .fe-spectrum__wave svg { + animation: none; + } + .fe button, + .fe [role="button"], + .fe-spectrum__seg, + .fe-spectrum__detail, + .fe-tile, + .fe-cta, + .fe-link::after { + transition: none; + } + .fe-tile-hover:hover .fe-tile, + .fe-tile-hover:focus-visible .fe-tile, + .fe-press:active, + .fe-cta:hover, + .fe-cta:active { + transform: none; + } +} diff --git a/src/prototype/fluid-editorial/format.ts b/src/prototype/fluid-editorial/format.ts new file mode 100644 index 000000000..ce0e681b3 --- /dev/null +++ b/src/prototype/fluid-editorial/format.ts @@ -0,0 +1,53 @@ +import type { Lang } from "./copy"; + +export function duration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, "0")}`; +} + +export function hours(ms: number, lang: Lang): string { + const h = Math.round(ms / 3_600_000); + return `${new Intl.NumberFormat(lang).format(h)} h`; +} + +export function integer(n: number, lang: Lang): string { + return new Intl.NumberFormat(lang).format(n); +} + +export function relativeTime(unix: number, lang: Lang): string { + // `play_event.played_at` is stored in milliseconds; older rows and the + // album `added_at` are in seconds. Tell them apart by magnitude. + const ms = unix > 1e12 ? unix : unix * 1000; + const diff = ms - Date.now(); + const abs = Math.abs(diff); + const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" }); + if (abs < 3_600_000) return rtf.format(Math.round(diff / 60_000), "minute"); + if (abs < 86_400_000) return rtf.format(Math.round(diff / 3_600_000), "hour"); + if (abs < 30 * 86_400_000) + return rtf.format(Math.round(diff / 86_400_000), "day"); + return rtf.format(Math.round(diff / (30 * 86_400_000)), "month"); +} + +export function longDate(lang: Lang): string { + return new Intl.DateTimeFormat(lang, { + weekday: "long", + day: "numeric", + month: "long", + }).format(new Date()); +} + +/** "FLAC · 24/44.1" — the quality tag the player bar wears. */ +export function qualityTag( + codec: string | null, + sampleRate: number | null, + bitDepth: number | null, +): string | null { + if (!codec) return null; + const rate = sampleRate + ? (sampleRate / 1000).toFixed(1).replace(/\.0$/, "") + : null; + if (bitDepth && rate) return `${codec} · ${bitDepth}/${rate}`; + if (rate) return `${codec} · ${rate} kHz`; + return codec; +} diff --git a/src/prototype/fluid-editorial/home/FluidHome.tsx b/src/prototype/fluid-editorial/home/FluidHome.tsx new file mode 100644 index 000000000..be2feaea4 --- /dev/null +++ b/src/prototype/fluid-editorial/home/FluidHome.tsx @@ -0,0 +1,36 @@ +import type { CSSProperties } from "react"; +import { ForYouMosaic } from "./ForYouMosaic"; +import { ListeningCanvas } from "./ListeningCanvas"; +import { ListeningHistory } from "./ListeningHistory"; +import { MoodSpectrum } from "./MoodSpectrum"; +import { RecentlyAdded } from "./RecentlyAdded"; + +const rise = (i: number): CSSProperties => ({ ["--i" as string]: i }); + +/** + * The Home page, top to bottom: what you were listening to, what mood + * you are in, what we picked for you, what is new, what you played. + * Five sections, five layout families — a split canvas, a band, a + * mosaic, a shelf, a list. + */ +export function FluidHome() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/prototype/fluid-editorial/home/ForYouMosaic.tsx b/src/prototype/fluid-editorial/home/ForYouMosaic.tsx new file mode 100644 index 000000000..0f56e7002 --- /dev/null +++ b/src/prototype/fluid-editorial/home/ForYouMosaic.tsx @@ -0,0 +1,160 @@ +import { Play, RefreshCw } from "lucide-react"; +import { fixtures, trackFromAlbum, type FePlaylist } from "../fixtures"; +import { fill } from "../copy"; +import { integer } from "../format"; +import { useFe } from "../useFe"; +import { Cover, Mosaic } from "../ui/Cover"; +import { SectionHeader } from "../ui/SectionHeader"; + +/** + * "Pour vous" as an editorial mosaic instead of a row of identical + * 16:7 banners. One featured record (the most played) takes half the + * width with magazine-sized type over its own artwork; two portrait + * Daily Mix cards stand beside it with their 2×2 covers; every other + * mix becomes a compact chip — the place where density helps. + */ +export function ForYouMosaic() { + const { copy, lang, play } = useFe(); + const featured = fixtures.topAlbums[0]; + const smart = fixtures.playlists.filter((p) => p.isSmart); + const daily = smart.filter((p) => p.smartKind === "daily_mix").slice(0, 2); + const rest = smart.filter((p) => !daily.includes(p)); + + return ( +
+ + + {copy.forYou.regenerate} + + } + /> + + {copy.forYou.title} + + +
+ {/* Featured */} + + + {/* Portrait mix cards */} + {daily.map((p) => ( + + ))} +
+ + {rest.length > 0 && ( +
+

{copy.forYou.mixes}

+
    + {rest.map((p) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} + +function MixCard({ playlist }: { playlist: FePlaylist }) { + const { copy, lang } = useFe(); + const label = playlist.smartKind + ? copy.forYou.labels[playlist.smartKind] + : ""; + return ( + + ); +} diff --git a/src/prototype/fluid-editorial/home/ListeningCanvas.tsx b/src/prototype/fluid-editorial/home/ListeningCanvas.tsx new file mode 100644 index 000000000..00c8100ad --- /dev/null +++ b/src/prototype/fluid-editorial/home/ListeningCanvas.tsx @@ -0,0 +1,245 @@ +import { Heart, Pause, Play } from "lucide-react"; +import { fixtures, trackFromAlbum, albumById } from "../fixtures"; +import { fill } from "../copy"; +import { duration, hours, integer, longDate } from "../format"; +import { useFe } from "../useFe"; +import { Cover } from "../ui/Cover"; + +/** "dimanche 18 octobre" → "Dimanche 18 octobre": only the first letter. */ +function sentence(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function greetingKey(): "morning" | "afternoon" | "evening" | "night" { + const h = new Date().getHours(); + if (h >= 5 && h < 12) return "morning"; + if (h >= 12 && h < 18) return "afternoon"; + if (h >= 18 && h < 23) return "evening"; + return "night"; +} + +/** + * The Listening Canvas replaces the greeting banner and the four stat + * cards. Composition: a 7/5 split, text column left, artwork right, no + * card around any of it — the atmosphere behind the page *is* the + * container. The four counters survive as one line of tertiary ink + * under the greeting; the last five covers hang off the artwork's + * corner and switch the whole page's colour when clicked. + */ +export function ListeningCanvas() { + const { + copy, + lang, + current, + isPlaying, + togglePlay, + positionMs, + queue, + play, + liked, + toggleLike, + radioMood, + } = useFe(); + const stats = fixtures.stats; + const album = + current.albumId != null ? albumById.get(current.albumId) : undefined; + const remaining = Math.max(0, current.durationMs - positionMs); + const strip = fixtures.lastPlayedAlbums.slice(0, 5); + + return ( +
+ {/* The cover's own light, spread behind the title and the queue: + the artwork, blurred wide and masked, so the record lights the + whole canvas rather than sitting in its own rectangle. Keyed on + the cover for a cross-fade on track change. */} + +
+

+ + {copy.greeting[greetingKey()]} + + , Probu +

+

+ {sentence(longDate(lang))} + {[ + `${integer(stats.tracks, lang)} ${copy.stats.tracks}`, + `${integer(stats.albums, lang)} ${copy.stats.albums}`, + `${integer(stats.liked, lang)} ${copy.stats.liked}`, + `${hours(stats.listenedMs, lang)} ${copy.stats.listened}`, + ].map((item) => ( + + · + {item} + + ))} +

+ +
+

+ {radioMood + ? `${copy.canvas.radio} · ${copy.mood.moods[radioMood].title}` + : copy.canvas.resumeKicker} +

+

+ {current.title} +

+

+ {current.artist} + {current.album && current.album !== current.title && ( + <> + · + {current.album} + + )} + {album?.year && ( + <> + · + {album.year} + + )} +

+ +
+ + {album && ( + + )} + +
+
+
+ + {/* Up next: three rows, dense, the only list in the canvas. Below + the text on narrow windows; from xl it takes the middle column, + its last row on the baseline of the actions. */} +
+
+

{copy.canvas.upNext}

+
    + {queue.slice(0, 3).map((t) => ( +
  1. + +
  2. + ))} +
+
+
+ + {/* Artwork: the largest thing on the page, sitting on its own glow, + with the last five records hanging off its corner. */} +
+
+ +
+ {current.album ?? current.title} +
+
    + {strip.map((a, i) => { + const active = a.id === current.albumId; + return ( +
  • + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/src/prototype/fluid-editorial/home/ListeningHistory.tsx b/src/prototype/fluid-editorial/home/ListeningHistory.tsx new file mode 100644 index 000000000..4f28459f1 --- /dev/null +++ b/src/prototype/fluid-editorial/home/ListeningHistory.tsx @@ -0,0 +1,86 @@ +import { fixtures } from "../fixtures"; +import { duration, relativeTime } from "../format"; +import { useFe } from "../useFe"; +import { Cover } from "../ui/Cover"; +import { SectionHeader } from "../ui/SectionHeader"; + +/** + * Recently played as a two-column list, not a grid of 160 px tiles: + * the history is a log, and a log wants rows — title, artist, album, + * when, how long — twelve of them in the height the old grid spent on + * six. Clicking a row plays it. + */ +export function ListeningHistory() { + const { copy, lang, play, current } = useFe(); + const rows = fixtures.recentPlays.slice(0, 12); + const half = Math.ceil(rows.length / 2); + const columns = [rows.slice(0, half), rows.slice(half)]; + + return ( +
+ + {copy.seeAll} + + } + /> + + {copy.history.title} + +
+ {columns.map((col, ci) => ( +
    + {col.map((t) => { + const active = t.trackId === current.trackId; + return ( +
  1. + +
  2. + ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/prototype/fluid-editorial/home/MoodSpectrum.tsx b/src/prototype/fluid-editorial/home/MoodSpectrum.tsx new file mode 100644 index 000000000..f7fc0a260 --- /dev/null +++ b/src/prototype/fluid-editorial/home/MoodSpectrum.tsx @@ -0,0 +1,252 @@ +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { Play } from "lucide-react"; +import { fixtures, type MoodId } from "../fixtures"; +import { fill } from "../copy"; +import { integer } from "../format"; +import { useFe } from "../useFe"; +import { SectionHeader } from "../ui/SectionHeader"; + +interface MoodDef { + id: MoodId; + /** Tempo window, in BPM — the same gates `mood_radio` applies. */ + bpm: [number, number]; + /** Cycles of the abstract wave across one segment width. */ + cycles: number; + /** 0–1, how tall the wave is. */ + amplitude: number; + /** Seconds for one drift of the wave; slower moods drift slower. */ + period: number; +} + +/** Ordered by tempo, which is what the horizontal axis means. */ +const MOODS: MoodDef[] = [ + { id: "sleep", bpm: [40, 80], cycles: 1.5, amplitude: 0.22, period: 16 }, + { id: "focus", bpm: [60, 100], cycles: 2.5, amplitude: 0.36, period: 12 }, + { id: "chill", bpm: [70, 110], cycles: 3, amplitude: 0.48, period: 10 }, + { id: "party", bpm: [110, 135], cycles: 5, amplitude: 0.72, period: 7 }, + { id: "workout", bpm: [120, 170], cycles: 7, amplitude: 0.9, period: 5 }, +]; + +/** A seamless sine over two segment widths, so translating by −50 % loops. */ +function wavePath(cycles: number, amplitude: number): string { + const W = 400; + const H = 60; + const mid = H / 2; + const amp = (H / 2 - 4) * amplitude; + const steps = 96; + const pts: string[] = []; + for (let i = 0; i <= steps; i++) { + const x = (i / steps) * W; + // Two "cycles" spans per 400 units so the second half repeats the first. + const y = mid + Math.sin((x / (W / 2)) * cycles * Math.PI * 2) * amp; + pts.push(`${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(2)}`); + } + return pts.join(" "); +} + +/** + * Mood Radio as a spectrum: one continuous band whose horizontal axis + * is tempo. Each mood is a segment on that axis with its own abstract + * waveform (slower, flatter on the left; faster, taller on the right). + * Selecting a mood widens its segment and reveals what the radio is + * made of — tempo window, how many tracks qualify — plus the one + * button that starts it. The five moods stay named and clickable; + * nothing here depends on reading the gradient. + * + * Keyboard: a radiogroup with roving focus over the moods that have + * tracks (←/→, Home/End); Enter or Space on the focused segment starts + * the radio, as does the start button beside it. + */ +export function MoodSpectrum() { + const { copy, lang, play, radioMood } = useFe(); + const counts = fixtures.moodCounts; + // Only a mood with tracks can be selected: the playing radio's mood + // if it has any, else Focus, else the first one that does. + const enabled = MOODS.filter((m) => counts[m.id] > 0); + const [selected, setSelected] = useState(() => { + const prefer = [radioMood, "focus" as MoodId]; + return ( + prefer.find((id) => id != null && counts[id] > 0) ?? + enabled[0]?.id ?? + null + ); + }); + const [starting, setStarting] = useState(null); + const refs = useRef>({}); + const paths = useMemo( + () => + Object.fromEntries( + MOODS.map((m) => [m.id, wavePath(m.cycles, m.amplitude)]), + ), + [], + ); + + const start = (mood: MoodDef) => { + if (starting) return; + setStarting(mood.id); + // Stand-in for `start_mood_radio`: a track whose tempo sits in the + // window, or the most recent one when none is analysed. + const pick = + fixtures.recentPlays.find( + (t) => t.bpm != null && t.bpm >= mood.bpm[0] && t.bpm <= mood.bpm[1], + ) ?? + fixtures.recentPlays[1] ?? + fixtures.recentPlays[0]; + window.setTimeout(() => { + play(pick, { radio: mood.id }); + setStarting(null); + }, 420); + }; + + // Arrow keys walk the enabled moods only, so focus never lands on a + // segment that cannot start a radio. + const onKeyDown = (e: KeyboardEvent, mood: MoodDef) => { + const index = enabled.indexOf(mood); + let to: number | null = null; + if (e.key === "ArrowRight") to = (index + 1) % enabled.length; + else if (e.key === "ArrowLeft") + to = (index - 1 + enabled.length) % enabled.length; + else if (e.key === "Home") to = 0; + else if (e.key === "End") to = enabled.length - 1; + else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + start(mood); + return; + } + if (to == null || enabled.length === 0) return; + e.preventDefault(); + const m = enabled[to]; + setSelected(m.id); + refs.current[m.id]?.focus(); + }; + + return ( +
+ +
+ + {copy.mood.title} + + + +
+ ); +} diff --git a/src/prototype/fluid-editorial/home/RecentlyAdded.tsx b/src/prototype/fluid-editorial/home/RecentlyAdded.tsx new file mode 100644 index 000000000..7f1fa317d --- /dev/null +++ b/src/prototype/fluid-editorial/home/RecentlyAdded.tsx @@ -0,0 +1,98 @@ +import { fixtures, trackFromAlbum, type FeAlbum } from "../fixtures"; +import { fill } from "../copy"; +import { integer, relativeTime } from "../format"; +import { useFe } from "../useFe"; +import { Cover } from "../ui/Cover"; +import { SectionHeader } from "../ui/SectionHeader"; + +/** + * Square tiles, as before, but the newest record is given four times + * the area and a fuller caption — one thing on the row has presence, + * the rest are a shelf. No padding box around each tile: the caption + * sits straight under the artwork. + */ +export function RecentlyAdded() { + const { copy } = useFe(); + const albums = fixtures.recentAlbums.slice(0, 9); + const [lead, ...rest] = albums; + + return ( +
+ + {copy.seeAll} + + } + /> + + {copy.recentlyAdded.title} + +
+ {lead && } + {rest.map((a) => ( + + ))} +
+
+ ); +} + +function AlbumTile({ album, lead }: { album: FeAlbum; lead?: boolean }) { + const { copy, lang, play } = useFe(); + return ( + + ); +} diff --git a/src/prototype/fluid-editorial/index.ts b/src/prototype/fluid-editorial/index.ts new file mode 100644 index 000000000..f23fd4a8e --- /dev/null +++ b/src/prototype/fluid-editorial/index.ts @@ -0,0 +1,10 @@ +import { lazy } from "react"; + +/** + * Entry for `main.tsx`. `import.meta.env.DEV` is a build-time constant, + * so in a production build this is `null` and the dynamic import — the + * whole prototype chunk — is dead code the bundler drops. + */ +export const FluidEditorialPrototype = import.meta.env.DEV + ? lazy(() => import("./FluidEditorialApp")) + : null; diff --git a/src/prototype/fluid-editorial/shell/Atmosphere.tsx b/src/prototype/fluid-editorial/shell/Atmosphere.tsx new file mode 100644 index 000000000..1533de175 --- /dev/null +++ b/src/prototype/fluid-editorial/shell/Atmosphere.tsx @@ -0,0 +1,17 @@ +import { useFe } from "../useFe"; + +/** + * Two soft pools of the cover's colour behind the content column, plus + * a grain so the dark never reads as a flat digital slab. Keyed on the + * glow so a track change cross-fades through React's mount/unmount + * (the production `SkinAmbientBackdrop` uses the same trick). + */ +export function Atmosphere() { + const { atmosphere } = useFe(); + return ( +