diff --git a/CLAUDE.md b/CLAUDE.md index 38e975d01..1a878e706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ This file is an **index, not a manual**. It carries the map plus the one-line fo | --------------------------------------- | -------------------------------------------------------------------- | | Why a rule exists / how not to break it | [`docs/architecture/invariants.md`](docs/architecture/invariants.md) | | How a feature actually works | [`docs/features/`](docs/README.md#features) | +| The visual direction (tokens, shapes) | [`docs/design/fluid-editorial.md`](docs/design/fluid-editorial.md) | | Crate split, audio topology, DB layout | [`docs/architecture/`](docs/README.md#architecture) | | Design decisions not yet built | [`docs/rfcs/`](docs/README.md#rfcs) | | Release / packaging procedure | [`docs/RELEASING.md`](docs/RELEASING.md) | @@ -101,7 +102,7 @@ Names in `commands/`, `audio/` and `src/components/` are predictable — read th | Playlists | [`playlists.md`](docs/features/playlists.md) · [`smart-playlists.md`](docs/features/smart-playlists.md) | CRUD, sorting, auto-covers, M3U, Daily Mix + On Repeat generators, rule tree | | Integrations | [`integrations.md`](docs/features/integrations.md) | Deezer, Last.fm, TheAudioDB, lyrics providers + editor, artist overrides, Discord RPC, OS notifications, scrobbling | | Plugins | [`plugins.md`](docs/features/plugins.md) | WASM host + sandbox, store, options, `source` / `metadata` / `ui` / `canvas` worlds, Web Radio + offline catalogue | -| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 5 skins × 14 themes + custom / cover accent, suggestion of the day on Home, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | +| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 6 skins × 14 themes + custom / cover accent, suggestion of the day on Home, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | | LAN servers | [`dlna.md`](docs/features/dlna.md) · [`mpd.md`](docs/features/mpd.md) | opt-in MediaServer and MPD control surface | ## Conventions diff --git a/README.md b/README.md index b5acdc038..677723275 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ WaveFlow is a desktop music player for the audio files you already own. It scans ### 🎨 Interface · [docs](docs/features/ui.md) -- 5 skins × 14 themes, dark mode, a high-contrast mode +- 6 skins × 14 themes — **Fluid**, the cinematic editorial direction of 2.0, by default — dark mode, a high-contrast mode - Immersive now-playing with Canvas clips, animated covers and a cover slideshow - Always-on-top mini-player, system tray, Windows taskbar buttons - Statistics and **WaveFlow Wrapped**, all computed locally diff --git a/docs/README.md b/docs/README.md index 8f446c0bc..5ff830c25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,12 @@ User-facing references and per-feature deep dives. The top-level [README](../REA | [Audio architecture](architecture/audio.md) | 3-thread lock-free pipeline, ring buffer sizing, callback constraints | | [Database & paths](architecture/storage.md) | `app.db` vs per-profile `data.db`, on-disk layout, migration policy | +## Design + +| Doc | Scope | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Fluid Editorial](design/fluid-editorial.md) | The visual direction since 2.0: principles, colour / type / shape / motion tokens, the wave mark, every component's shape, and how to carry it to the web app, the Android app and the landing site | + ## RFCs Long-form design documents that lock in cross-cutting architectural decisions before implementation. New RFCs live under [`rfcs/`](rfcs/) and are numbered sequentially. diff --git a/docs/design/fluid-editorial.md b/docs/design/fluid-editorial.md new file mode 100644 index 000000000..413cdc593 --- /dev/null +++ b/docs/design/fluid-editorial.md @@ -0,0 +1,101 @@ +# Fluid Editorial — the design language + +WaveFlow's visual direction since 2.0, written down so the other surfaces — [waveflow-server](https://github.com/InstaZDLL/waveflow-server)'s web app, the Android app, the landing site — can speak it without reading the desktop app's stylesheet. The desktop implementation is the `fluid` skin ([`src/styles/skins/fluid.css`](../../src/styles/skins/fluid.css), [`src/lib/skins.ts`](../../src/lib/skins.ts)); this page is the intent and the values, not the CSS. + +The one-line brief: **cinematic, dark, music first — the cover lights the page, the type carries the editorial voice, the wave is the mark.** + +## Principles + +1. **The music is the picture.** The playing or last-played cover is the largest thing on a page, and its colour, blurred and darkened, is the page's light. Nothing else is decorated: no gradients of our own, no illustrations, no coloured icon boxes. +2. **Depth by tone, not by line.** Surfaces are steps of one dark, a hairline at most where two surfaces meet, no borders around cards, no drop shadows except under a cover. A raised element is a lighter tone; a pressed one, darker. +3. **One accent, spent carefully.** The accent (the theme's, the user's, or the cover's) appears on the primary action, the open item's edge, the sung word, the focus ring — and nowhere decorative. Everything else is ink on ground. +4. **Editorial typography.** A geometric sans at a settled weight for everything; a serif italic for one line per page at most (the greeting, the suggestion's kind), never for running text. Large sizes are tight and short; small sizes are tracked out and uppercase for labels only. +5. **Rhythm over rules.** Blocks are told apart by spacing, not separators. Groups breathe (1.75 rem between sections in a rail, 3.5 rem between sections on a page); items inside a group sit close. +6. **Calm motion.** One ease-out for everything, no springs, no bounces; a reveal rises a few pixels and fades; the hover is a tone change and a one-pixel lift at most. Reduced-motion turns all of it off, and the design still reads. +7. **Recompose, don't restyle.** Where the language calls for a different shape (a canvas instead of a banner and stat cards, tiles instead of rows, one tabbed panel instead of three), build that shape. A restyled legacy layout still reads as legacy. + +## Tokens + +### Colour + +| Token | Value | Role | +| -------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `bg` | `#0b0b0e` | The page. Never pure black: it has to hold the cover's light. | +| `surface` | `#121216` | Rails, panels, cards at rest. | +| `surface-2` | `#17171d` | Popovers, a card on hover, the second step. | +| `ink` | `#f3f1ec` | Titles, the open item, anything that must be read first. Warm, not pure white. | +| `ink-2` | `#b5b2aa` | Body text, names, the second line. | +| `ink-3` | `#8e8b83` | Labels, counts, timestamps, placeholders, icons at rest. | +| `hairline` | `white / 7 %` | Dividers, the edge of a rail, the ring of a field at rest. | +| `hairline-strong` | `white / 12 %` | A chip's ring, an input's ring. | +| `accent` | the theme's 400 stop (the default theme's: `oklch(0.765 0.177 163)`, emerald `#34d399`) | The primary action's fill, the open item's edge, the focus ring, the sung word. | +| `accent-ink` | `#0b0b0e` | Text on an accent fill. The accent is light enough to take dark ink. | +| `glow` / `glow-deep` | sampled from the cover; defaults `#1f5a48` / `#0f2b22` | The cover's light: two radial pools behind the page, the player bar's top gradient, the shadow under a hovered tile. | + +Tones over surfaces (hover, pressed, a tile's ground) are white at 4 %, 6 %, 8 % — never a new colour. Light theme: there is none; the direction is dark-only, and a light variant is an open question, not a planned one. + +### Typography + +| Role | Face | Size | Weight | Tracking | Notes | +| ------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --------------------------- | --------------------------------------------------------------- | +| Display | DM Sans (variable, optical sizing on) | `clamp(2rem, 2.6vw, 3.25rem)` on a canvas; `clamp(2.25rem, 3vw, 3.5rem)` on a page header; `clamp(3rem, 5vw, 5.25rem)` on an artist banner | 560–600 | −0.035 to −0.04 em | Line height 0.95–0.98, `text-wrap: balance`, two lines at most. | +| Title | DM Sans | 1.5 rem (section), 1.625 rem (band) | 540 | −0.022 em | | +| Editorial | Playfair Display italic | 1.375–2 rem | 400 | −0.01 em | One line per page: the greeting, the suggestion's kind. | +| Body | DM Sans | 0.875–0.9375 rem | 480–500 | 0 | `ink-2`. | +| Label | DM Sans | 0.6875–0.75 rem | 540 | +0.1 to +0.12 em, uppercase | `ink-3`. Kickers, section eyebrows, table headers. | +| Mono figures | DM Sans with `tnum` | inherits | | | Durations, counts, dates — tabular, never a monospace face. | + +Fallbacks: `system-ui, -apple-system, "Segoe UI", sans-serif` and `Georgia, serif`. On the web and Android, ship the two faces (DM Sans variable, Playfair Display italic) rather than relying on the system; the direction depends on DM Sans's low-contrast geometry. + +### Shape and space + +| Token | Value | +| -------------------- | ---------------------------------------------------------------------------------- | +| Card radius | 0.875 rem (14 px); a hero cover 1.25 rem; a small tile 0.625 rem | +| Button / pill radius | full | +| Input radius | 0.625 rem; the search field, full | +| Hairline | 1 px, as an inset shadow rather than a border, so it never changes the box | +| Rail width | 16 rem full, 5 rem collapsed | +| Page gutters | 2 / 3 / 3.5 rem at 1024 / 1280 / 1536 px; content column up to 1680 px | +| Section gap | 3.5 rem on a page, 1.75 rem in a rail | +| Density | rows 2.25–2.5 rem high; the top bar 3.5 rem; the player bar 4.25 rem plus the wave | + +### Motion + +| Token | Value | +| -------------- | --------------------------------------------------------------------------------------------------- | +| Ease | `cubic-bezier(0.22, 1, 0.36, 1)` — one ease-out, everywhere | +| Duration | 180 ms for colour and tone, 320 ms for position and shape, 900 ms for a light fading in | +| Reveal | `fe-rise`: 10 px up and fade over 640 ms, staggered 70 ms per block on a page | +| Hover | tone up one step; a tile lifts 3 px with the glow as its shadow; a CTA lifts 1 px and brightens 6 % | +| Reduced motion | no stagger, no drift, no lift, no blur transitions; opacity only | + +## The mark + +A short flowing line — one sine-like path, `M0.5 5 C 5 0.5, 9 0.5, 13.5 5 S 22 9.5, 26.5 5 S 35 0.5, 39.5 5` in a 40 × 10 box — drawn in the accent at 1.6 px. It is the underline of a section title, the marker of the open tab, the progress of the player (the played part of the line becomes a wave, the rest stays straight), the shape of the mood spectrum's bands. It is the one ornament the language allows, and it is always functional: it marks, it measures, it never fills. + +## Components + +- **Canvas** (the Home's head): a 12-column grid; text on 7, the cover on 5 from 1024 px; text · queue · cover in three columns from 1700 px. Greeting in the editorial italic with the name after it in sans; a kicker; the display title; artist · album · year; a pill CTA with the time left in tabular figures; a text link beside it. The cover's blurred copy sits behind the text at 20 % opacity, darkened to 70 % so a pale sleeve never pales the ground. +- **Band** (the suggestion of the day): a surface-toned strip, the cover left on its own light, label + editorial kind + title + artist + a quiet reason line, actions right (a text link, a pill CTA). +- **Spectrum** (Mood Radio): one continuous band whose axis is tempo; segments carry the mark as a wave at their own frequency; the chosen one opens with a description, a count and a CTA. +- **Tiles**: square cover, radius 14 px, the title and artist under it in ink and ink-2; a lead tile twice the size with a kicker. Tiles rise on hover with the glow underneath. +- **Rail** (sidebar): wordmark; profile as a small panel (avatar, name, the library's size); the pinned navigation as tiles two to a row (icon on a tone, name under it, the open one ringed in the accent); section labels; rows for the library's facets; liked / recently played as tiles; playlists as rows with a 40 px cover. The open row carries a 2 px accent edge with a faint glow. +- **Control bar** (top): transparent; three navigation squares in one cluster; the search 26 rem wide and 2.25 rem high on the bar's own middle — a tone, a hairline, and on focus the accent ring plus a soft halo of the cover's light; theme switch and profile as one cluster on a tone. +- **Panel** (right): one shell, tabs as text with the mark under the open one; Details = the cover edge to edge with the identity over its foot on a scrim, the album it plays from, two ghost pills, a glimpse of the lyrics on a quiet card; Lyrics = the sung line sharp and large, the others fading and blurring with their distance; Queue = rows. +- **Page header**: kicker, display title, one meta line (`ink-2`, counts in `ink-3`), the primary action as a pill in the accent, secondaries as ghost pills (white 6 %), icon actions as bare squares. An artist with a photo gets a full-width banner (22–25 rem tall) with the name at its foot over a scrim; no card around any of it. +- **Cards** (extensions, folders): surface tone, 14 px radius, no border, a tone up on hover; a title, a muted path or subtitle, chips for facts, actions in the top-right corner that appear on hover. +- **Tables**: header on the page's tone with a hairline under it, rows parted by white 5 %, the playing row on a tone with the accent's edge; no outer frame. +- **Menus and popovers**: `surface-2`, 14 px radius, no border, an inset top highlight and a deep soft shadow; rows with their own 8 px radius that take a tone on hover. +- **Player bar**: one surface, a top gradient from the glow, the wave as its top edge; the transport centred on the window. + +## Carrying it to another surface + +- **Web (waveflow-server)**: the colour and shape tokens map one to one onto CSS variables; the two faces ship as webfonts; the mark is one inline SVG path reused as a mask. Keep the dark-only rule; the server's light theme, if it has one, is a different direction and should not borrow these inks. +- **Android**: tokens as a Compose theme — the three inks and three surfaces, the accent from the album's palette through the same "400 stop" rule, DM Sans variable and Playfair italic as font families, 14 dp card radius, full-radius pills, the one ease. The canvas and the band translate as-is; the rail becomes a bottom bar whose active item carries the accent's edge. +- **Landing site**: the same page rhythm (display title, one quiet line, a pill), a cover-lit hero, the mark under section titles. No stock gradients, no device frames with coloured glows. +- **What not to carry**: Tailwind class names, the `data-*` landmarks (they are the desktop app's contract between its views and its skins), and the Studio skin's values — Studio is the desktop's safe-mode baseline, not a sibling direction. + +## Where it came from + +Prototyped as a dev-only page (#905), reviewed by the user and an external design review (8.5/10 on the first pass, then the second pass with a wider canvas, the cover's light behind the text and the edge progress), integrated as the sixth skin, then extended slice by slice to every page and recomposed where restyling fell short (the panel, the rail, the control bar, the extensions page). Made the default in 2.0. The history, with the reasons for each shape, is in the commits of the `feat/fluid-editorial-skin` branch and in [`ui.md`](../features/ui.md#skins). diff --git a/docs/features/library.md b/docs/features/library.md index c45ff8ce5..29be55746 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -216,7 +216,7 @@ The top-bar dropdown shows **sectioned results — Artists / Albums / Titles** ( ## Folder browsing -The Folders tab lists the configured **roots**, and used to stop there — which is folder _management_, not folder _browsing_. Someone who organises their music as `Artist/Album/` on disk lost that structure entirely once the library was scanned: reachable by album, artist, genre or playlist, but not by where the files actually live (#578). Clicking a root now descends into it. **The whole root row opens it, with a chevron at its end** (#733): at first only the path text did, with an underline on hover as its only sign, and a tester never found the browser at all. A click that starts on one of the row's own controls, or in its playlist popover, stays theirs; the path remains a button for the keyboard. +The Folders tab lists the configured **roots**, and used to stop there — which is folder _management_, not folder _browsing_. Someone who organises their music as `Artist/Album/` on disk lost that structure entirely once the library was scanned: reachable by album, artist, genre or playlist, but not by where the files actually live (#578). Clicking a root now descends into it. The roots are **cards** in a grid (the folder's name in the lead, its full path underneath, its track count and last scan at the foot, its actions in the corner on hover — except the watch eye, which stays while watching is on, so a watched folder says so at a glance), and **the whole card opens it** (#733): at first only the path text did, with an underline on hover as its only sign, and a tester never found the browser at all. A click that starts on one of the card's own controls, or in its playlist popover, stays theirs; the name remains a button for the keyboard. **The tree is derived at query time from `track.file_path`.** Nothing is materialised, and no migration was needed. Three commands in [`browse.rs`](../../src-tauri/crates/app/src/commands/browse.rs): diff --git a/docs/features/plugins.md b/docs/features/plugins.md index 9356d2ff2..24784fd0c 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -40,7 +40,7 @@ Installing an entry downloads that plugin's **pinned GitHub release**, then: Every registry fetch honours [`offline::is_offline()`](../../src-tauri/crates/app/src/offline.rs) and short-circuits when offline. Because the catalogue and each plugin live in **separate repos** ([`InstaZDLL/waveflow-plugins`](https://github.com/InstaZDLL/waveflow-plugins) + per-plugin repos), a grey-area plugin carries no liability for the signed core and a takedown is one registry commit. -UI: [`PluginStoreCard`](../../src/components/views/settings/PluginStoreCard.tsx) sits above [`PluginsCard`](../../src/components/views/settings/PluginsCard.tsx) under **Settings → Extensions**; i18n keys under `settings.pluginStore.*`. With nothing installed, `PluginsCard` offers an **Open folder** button (`open_plugins_folder`) that creates the sideload root if it is missing and reveals it in the file manager — the path is never spelled out in the copy, because it differs per platform and a user cannot type `` (issue #679). +UI: [`PluginStoreCard`](../../src/components/views/settings/PluginStoreCard.tsx) sits above [`PluginsCard`](../../src/components/views/settings/PluginsCard.tsx) on the **Extensions** page ([`ExtensionsView`](../../src/components/views/ExtensionsView.tsx), the `extensions` view), reached from the sidebar's entry of that name beside the entries plugins add (Web Radio, Release Radar). It used to be a category of Settings, where most never went and so never saw what the store holds; the two cards kept their files under `settings/` and their i18n keys under `settings.pluginStore.*` / `settings.plugins.*`. On the page they take `variant="grid"`: tiles two or three to a row instead of one list under another, the store showing only what is not installed (or has an update) since the installed list sits right beneath, a tile's permissions folded behind a summary, and a tile whose options are open taking the whole row — a dozen plugins fit a 1080p window without a scroll, where the lists ran to three. With nothing installed, `PluginsCard` offers an **Open folder** button (`open_plugins_folder`) that creates the sideload root if it is missing and reveals it in the file manager — the path is never spelled out in the copy, because it differs per platform and a user cannot type `` (issue #679). ## When a plugin cannot run @@ -74,7 +74,7 @@ A flag rather than a fourth type on purpose: an older host ignores an unknown fi ### Telling the user a credential was refused -A plugin whose credential was refused (an expired cookie, a revoked token) returns an error starting with **`auth-required:`**. [`plugin_attention`](../../src-tauri/crates/app/src/plugin_attention.rs) turns it into one toast ([`PluginAttentionToast`](../../src/components/common/PluginAttentionToast.tsx)) naming the plugin and pointing to Settings → Extensions — **once per launch and per plugin**, since the failure repeats on every track. Wired for the `canvas` and lyrics (`metadata/v2`) fan-outs. Every other error keeps its meaning (logged, skipped), and an older host logs the prefixed one the same way, so a plugin can adopt it without breaking there. Everything else a plugin cannot do is still `Ok(None)`: only "the user must act" is an error. +A plugin whose credential was refused (an expired cookie, a revoked token) returns an error starting with **`auth-required:`**. [`plugin_attention`](../../src-tauri/crates/app/src/plugin_attention.rs) turns it into one toast ([`PluginAttentionToast`](../../src/components/common/PluginAttentionToast.tsx)) naming the plugin and pointing to the Extensions page — **once per launch and per plugin**, since the failure repeats on every track. Wired for the `canvas` and lyrics (`metadata/v2`) fan-outs. Every other error keeps its meaning (logged, skipped), and an older host logs the prefixed one the same way, so a plugin can adopt it without breaking there. Everything else a plugin cannot do is still `Ok(None)`: only "the user must act" is an error. ## Localized manifest strings diff --git a/docs/features/ui.md b/docs/features/ui.md index 462fa7717..1e1911786 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -62,9 +62,10 @@ responds to its container, including space taken by the application's sidebar. Only the selected category is mounted. Existing category IDs remain valid for deep links and the remembered selection; a fresh visit defaults to General. -The ten categories are General, Library, Playback and audio, Appearance, -Images and lyrics, Connections, Extensions, Keyboard shortcuts, Storage and -backups, and Maintenance. Library owns scanning, duplicates and music analysis; +The nine categories are General, Library, Playback and audio, Appearance, +Images and lyrics, Connections, Keyboard shortcuts, Storage and backups, and +Maintenance. Extensions left Settings for a page of their own in the sidebar +([plugins.md](plugins.md)): most never went that far into Settings. Library owns scanning, duplicates and music analysis; Storage and backups owns profile exports, caches and artwork maintenance. Images and lyrics brings artwork retrieval and lyrics preferences together. @@ -354,7 +355,7 @@ Hovering the taskbar icon shows Previous / Play-Pause / Next under the window pr ## Suggestion of the day -Home suggests one track, over its blurred cover, with the reason it was picked ([`HomeSuggestionCard`](../../src/components/views/home/HomeSuggestionCard.tsx), [`get_home_suggestion`](../../src-tauri/crates/app/src/commands/home_suggestion.rs)). It replaced a "continue listening" card, which showed the track the player bar already showed right under it. There are three kinds, all read from the profile's own listening (`play_event`), and a play only **counts** when the track was played through or for 15 s, the recently-played list's rule. That rule is for counting (the plays of a favourite, of an artist); a track is **left out** for any play at all, a skip included, so one skipped two seconds in yesterday is neither offered as in your groove nor called never played: +Home suggests one track, over its blurred cover, with the reason it was picked ([`HomeSuggestionCard`](../../src/components/views/home/HomeSuggestionCard.tsx), [`get_home_suggestion`](../../src-tauri/crates/app/src/commands/home_suggestion.rs)). The draw, the wording and the two actions live in [`useHomeSuggestion`](../../src/components/views/home/useHomeSuggestion.ts); the card only draws them, and the Fluid skin's band ([`SuggestionOfTheDay`](../../src/components/views/home/fluid/SuggestionOfTheDay.tsx)) draws the same hook its own way, so the two never disagree on which track the day holds. It replaced a "continue listening" card, which showed the track the player bar already showed right under it. There are three kinds, all read from the profile's own listening (`play_event`), and a play only **counts** when the track was played through or for 15 s, the recently-played list's rule. That rule is for counting (the plays of a favourite, of an artist); a track is **left out** for any play at all, a skip included, so one skipped two seconds in yesterday is neither offered as in your groove nor called never played: - **You used to love this** — played at least 3 times, and not once in the last 60 days. The most played come first. The reason line gives the count and the month of the last play. - **In your current groove** — a track by one of the 5 artists played most in the last 14 days, or by an artist the similar-artists cache lists for one of them, not played in the last 30 days. Ranked by distance to the energy and tempo of those 14 days, where a half or double tempo counts as the tempo itself, since the estimator reads some tracks an octave out. The reason line names the recent artist it comes from. The cache is only read, never fetched: the card makes no network call. @@ -541,27 +542,29 @@ The first paint is handled by the bootstrap script in `index.html` alongside the ## Skins -Skins are an **orthogonal axis to the 14 colour themes**: a skin re-skins surfaces, typography, motion and signature elements (e.g. Editorial's drop caps, Pulse's vinyl-spin cover); the theme picks the OKLCH accent. Every skin × theme combination is valid → **5 × 14 = 70 visual identities**. +Skins are an **orthogonal axis to the 14 colour themes**: a skin re-skins surfaces, typography, motion and signature elements (e.g. Editorial's drop caps, Pulse's vinyl-spin cover); the theme picks the OKLCH accent. Every skin × theme combination is valid → **6 × 14 = 84 visual identities**. -| Skin | Direction | Signature | -| ----------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `studio` | Apple Music baseline | Inter / system-ui, soft shadows, rounded-12 — the calm default | -| `editorial` | Broadsheet "WaveFlow Gazette" | Playfair Display 900 + Lora body, `` with locale-aware `Intl.DateTimeFormat`, `::first-letter` drop caps, halftone N&B covers via `mix-blend-luminosity`, ruler-tick progress bar via `repeating-linear-gradient`, `counter-reset` page numbering ("P. 1", "P. 2") | -| `lounge` | "Listening Room" warm-burgundy glass | Inter, [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) blurs the cover (`filter: blur(140px) saturate(280%)`) into the body, white-overlay tint over the cover dominates the chrome | -| `pulse` | OLED "club control panel" | Space Grotesk + Space Mono, dual-neon magenta/cyan aurora blobs, `///` mono pills, vinyl-spin cover via [`SkinPlayingState`](../../src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`, floating pill PlayerBar | -| `liquid` | Apple Vibrancy material | DM Sans Variable (`opsz` axis 9..40), 8-layer inset `box-shadow` recipe `--liquid-glass` for "real glass" surfaces, aurora drift `body::before` (28 s), theme-aware light/dark token swap via `:not(.dark)`, `--liq-action` cyan/blue swap for contrast | +| Skin | Direction | Signature | +| ----------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `studio` | Apple Music baseline | Inter / system-ui, soft shadows, rounded-12 — the calm baseline, and the skin safe mode and the crash marker fall back to (`RECOVERY_SKIN_ID`) | +| `editorial` | Broadsheet "WaveFlow Gazette" | Playfair Display 900 + Lora body, `` with locale-aware `Intl.DateTimeFormat`, `::first-letter` drop caps, halftone N&B covers via `mix-blend-luminosity`, ruler-tick progress bar via `repeating-linear-gradient`, `counter-reset` page numbering ("P. 1", "P. 2") | +| `lounge` | "Listening Room" warm-burgundy glass | Inter, [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) blurs the cover (`filter: blur(140px) saturate(280%)`) into the body, white-overlay tint over the cover dominates the chrome | +| `pulse` | OLED "club control panel" | Space Grotesk + Space Mono, dual-neon magenta/cyan aurora blobs, `///` mono pills, vinyl-spin cover via [`SkinPlayingState`](../../src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`, floating pill PlayerBar | +| `liquid` | Apple Vibrancy material | DM Sans Variable (`opsz` axis 9..40), 8-layer inset `box-shadow` recipe `--liquid-glass` for "real glass" surfaces, aurora drift `body::before` (28 s), theme-aware light/dark token swap via `:not(.dark)`, `--liq-action` cyan/blue swap for contrast | +| `fluid` | Cinematic editorial, music first | **The default since 2.0** (`DEFAULT_SKIN_ID`; first in the picker, and the picker is the first card of Appearance). DM Sans Variable + Playfair italic on the greeting only; depth by tone (no borders); the playing cover's light behind the Home ([`useCoverGlow`](../../src/components/views/home/fluid/useCoverGlow.ts) → `--fe-glow`, the accent stays `--accent-400` so the #895 accent modes drive it); the flow-line mark under section titles; a **different Home** ([`FluidHome`](../../src/components/views/home/fluid/FluidHome.tsx): listening canvas, the suggestion of the day as a band, [`MoodSpectrum`](../../src/components/views/home/MoodSpectrum.tsx) — Mood Radio as a tempo band, editorial mosaic, shelf, history list) and the progress as the player bar's top edge (`ProgressBar variant="edge"`); the three right-edge panels as the tabs of one shell ([`FluidSidePanel`](../../src/components/layout/fluid/FluidSidePanel.tsx): Details with the cover edge to edge and the identity over it, the album it plays from, radio and artist as pills and a glimpse of the lyrics; Lyrics with the sung line sharp and the others fading with their distance; Queue); the immersive view in the same voice (the display face for the title, text tabs, the wave for the progress, the lyrics fading with their distance from the sung line) — the one place where `data-immersive`, which otherwise shields the view from every skin, is read on purpose; the sidebar as a navigation rail (the wordmark, the profile as a small panel with the library's size under the name, the open row on a tone with the accent's edge, the liked / recently played pair as two tiles, the playlists as rows with a larger cover) through the `data-sidebar-*` and `data-nav-item` landmarks the view and [`NavItem`](../../src/components/common/NavItem.tsx) carry; the top bar as a control rail (transparent, the navigation squares as one quiet cluster, the search shorter and on the bar's own middle with the accent and the cover's light on focus, the theme switch and the profile as one cluster, the popovers in the panel tone) through the `data-topbar-*` landmarks on [`TopBar`](../../src/components/layout/TopBar.tsx); every other page keeps its markup and gets the same header family from the stylesheet — kicker, display title, one meta line, a pill CTA and ghost secondaries — through a `data-page-hero` attribute on the header of the library, album, artist, playlist, genre, year, liked and history views. Dark-only like Lounge / Pulse | **Architecture** : - Token system + `SkinId` union live in [`src/lib/skins.ts`](../../src/lib/skins.ts); `applySkin()` writes `data-skin` on ``. -- Per-skin overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid}.css` (Studio = no overrides, baseline). The four files are imported from `src/app.css`. Three of them also carry a high-contrast block (see above) — a skin that adds a glass surface has to add it there too, or that surface becomes unreadable in the mode. -- [`src/app.css`](../../src/app.css) extends the Tailwind `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)` — Lounge/Pulse fire `dark:*` utilities automatically because they're always "dark" by design (Liquid stays theme-aware). +- Per-skin overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid,fluid}.css` (Studio = no overrides, baseline). The five files are imported from `src/app.css`. Four of them also carry a high-contrast block (see above) — a skin that adds a glass surface has to add it there too, or that surface becomes unreadable in the mode. +- [`src/app.css`](../../src/app.css) extends the Tailwind `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *, :root[data-skin="fluid"] *)` — Lounge/Pulse/Fluid fire `dark:*` utilities automatically because they're always "dark" by design (Liquid stays theme-aware). - **Local-first typography** — all skin fonts are bundled via `@fontsource` / `@fontsource-variable` (Playfair Display, Lora, Space Grotesk, Space Mono, DM Sans Variable) and imported at the top of [`src/main.tsx`](../../src/main.tsx). Zero network at runtime — no Google Fonts request. -- **Motion** — each skin declares its own `MotionConfig` spring in `SkinMotionWrapper`. Skins with strong identity (Pulse, Editorial) override the spring; calmer skins (Studio, Lounge, Liquid) inherit the soft default. +- **Motion** — each skin declares its own `MotionConfig` spring in `SkinMotionWrapper`. Skins with strong identity (Pulse, Editorial) override the spring; calmer skins (Studio, Lounge, Liquid, Fluid) inherit the soft default. +- **A skin may recompose a page, not only restyle it.** `HomeView` is a thin switch: under Fluid it renders `FluidHome`, which loads its own data (recent plays, albums by `added_at`, the all-time top album from `statsTopAlbums`, the listening total from `statsOverview`, the queue after the cursor from `playerGetQueue` for "up next"); every other skin keeps the classic view. Behaviour that both Homes share is a hook, not a copy: the [suggestion of the day](#suggestion-of-the-day) is one `useHomeSuggestion` under two cards. `MoodRadioGrid` and `PlayerBar` branch the same way, from `useSkin()`, and keep every action — the spectrum calls the same `start_mood_radio`, the edge progress is the same slider with the same keyboard contract. `AppLayout` branches once more for the right edge: under Fluid it mounts `FluidSidePanel` whenever a panel is open, and that shell renders `NowPlayingPanel`, `LyricsPanel` and `QueuePanel` with `embedded` set — the same components without their frame, header and close button, so a fix to the queue's drag order or the lyrics' word timing lands in both skins. The shell's tabs call `openRightPanel` / `closeRightPanel` on the player context, beside the three toggles the player bar keeps using; the chevron handle and the bar's buttons therefore keep their meaning under every skin. Where a skin only restyles, the view lends it a hook rather than a class to sniff: the `data-page-hero` attribute marks a view's header, so the Fluid stylesheet can hide the coloured icon box, size the title and reshape the actions without reading Tailwind class names that a refactor would rename; the playlist view also exposes its accent as `--playlist-accent`, a plain colour, beside the gradients it paints for the other skins, so Fluid can draw a pool of light from it instead. The sidebar marks its blocks the same way (`data-sidebar-brand`, `data-sidebar-profile`, `data-sidebar-nav`, `data-sidebar-section`, `data-sidebar-playlists`) and its rows with `data-nav-item` / `data-sidebar-row` plus `data-active`, so a skin that recomposes the column reads those rather than the `bg-emerald-50` an active row happens to carry; the earlier Fluid rules did read it, and a rename of that utility would have un-styled the rail in silence. The picker lives in Settings → Appearance → Theme and readability via [`SkinPickerCard`](../../src/components/views/settings/SkinPickerCard.tsx), alongside the theme picker and the contrast setting. View-Transitions API also drives skin swaps (radial reveal on click), with the same try/catch fallback used by the theme picker for WebKitGTK builds. @@ -621,7 +624,7 @@ Opt-in scheduled mirror of the manual export so the user's playlists / likes / r ## Settings categories -The ten categories, what each one houses and the settings search are described under [Settings navigation](#settings-navigation). +The nine categories, what each one houses and the settings search are described under [Settings navigation](#settings-navigation). ### App preferences diff --git a/index.html b/index.html index fee3d2801..15503f8f2 100644 --- a/index.html +++ b/index.html @@ -88,6 +88,7 @@ lounge: 1, pulse: 1, liquid: 1, + fluid: 1, }; // The native startup path adds this flag before creating the // webview. It must beat the cached skin on the very first paint. @@ -95,7 +96,9 @@ new URLSearchParams(location.search).get("safe-mode") === "1" ? "studio" : localStorage.getItem("waveflow.skin.id"); - if (!skin || !VALID_SKINS[skin]) skin = "studio"; + // Nothing cached (a first launch, a cleared store): the + // default skin, which the context will confirm from the profile. + if (!skin || !VALID_SKINS[skin]) skin = "fluid"; root.setAttribute("data-skin", skin); // Contrast bootstrap (#596) — stamp `data-contrast` before diff --git a/src-tauri/crates/app/src/skin_recovery.rs b/src-tauri/crates/app/src/skin_recovery.rs index 51ea68402..ff71b365b 100644 --- a/src-tauri/crates/app/src/skin_recovery.rs +++ b/src-tauri/crates/app/src/skin_recovery.rs @@ -221,7 +221,7 @@ fn path() -> Result<&'static Path, String> { /// launches from older versions become guarded too. #[tauri::command] pub fn record_skin(skin_id: String) -> Result<(), String> { - if !["studio", "editorial", "lounge", "pulse", "liquid"].contains(&skin_id.as_str()) { + if !["studio", "editorial", "lounge", "pulse", "liquid", "fluid"].contains(&skin_id.as_str()) { return Err("unknown skin".into()); } if !cfg!(target_os = "linux") { diff --git a/src/app.css b/src/app.css index 1639eb4e9..b0b713182 100644 --- a/src/app.css +++ b/src/app.css @@ -7,7 +7,7 @@ needed. Studio + Editorial stay theme-mode-driven (light or dark theme decides). The `data-skin` attribute is on documentElement so `[data-skin="lounge"] *` covers every element in the document. */ -@custom-variant dark (&:where(.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)); +@custom-variant dark (&:where(.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *, :root[data-skin="fluid"] *)); /* ============================================================ Layer scale (issue #390) @@ -144,7 +144,8 @@ :root[data-contrast="high"]:where( .dark, [data-skin="lounge"], - [data-skin="pulse"] + [data-skin="pulse"], + [data-skin="fluid"] ) { --wf-zinc-300: oklch(95% 0.004 286.286); --wf-zinc-400: oklch(89% 0.006 286.067); @@ -243,8 +244,17 @@ --track-row-hover: rgb(255 45 121 / 0.12); } +:root[data-skin="fluid"] { + --track-row-hover: rgb(255 255 255 / 0.045); +} + /* Override substring-based surface rules only for unselected, idle rows. */ -:root:is([data-skin="pulse"], [data-skin="liquid"], [data-skin="lounge"]) +:root:is( + [data-skin="pulse"], + [data-skin="liquid"], + [data-skin="lounge"], + [data-skin="fluid"] + ) [data-track-table] [data-track-row]:not([class*="bg-blue-500"]):not( [class*="bg-emerald-50"] @@ -252,7 +262,12 @@ background-color: var(--track-row-hover) !important; } -:root:is([data-skin="pulse"], [data-skin="liquid"], [data-skin="lounge"]) +:root:is( + [data-skin="pulse"], + [data-skin="liquid"], + [data-skin="lounge"], + [data-skin="fluid"] + ) [data-playlist-card] [data-playlist-caption] { padding: 0 0.875rem 0.875rem; @@ -405,6 +420,7 @@ @import "./styles/skins/lounge.css"; @import "./styles/skins/pulse.css"; @import "./styles/skins/liquid.css"; +@import "./styles/skins/fluid.css"; /* The desktop lyrics window (#582) is transparent at the OS level, and the document has to be too, or it paints an opaque box over the @@ -793,13 +809,15 @@ html.mini-player-window #root { tone (dark thumb on a dark glass body) = invisible. */ .dark ::-webkit-scrollbar-thumb, :root[data-skin="lounge"] ::-webkit-scrollbar-thumb, -:root[data-skin="pulse"] ::-webkit-scrollbar-thumb { +:root[data-skin="pulse"] ::-webkit-scrollbar-thumb, +:root[data-skin="fluid"] ::-webkit-scrollbar-thumb { background-color: rgba(244, 244, 245, 0.12); } .dark ::-webkit-scrollbar-thumb:hover, :root[data-skin="lounge"] ::-webkit-scrollbar-thumb:hover, -:root[data-skin="pulse"] ::-webkit-scrollbar-thumb:hover { +:root[data-skin="pulse"] ::-webkit-scrollbar-thumb:hover, +:root[data-skin="fluid"] ::-webkit-scrollbar-thumb:hover { background-color: rgba(244, 244, 245, 0.25); } @@ -811,7 +829,8 @@ html.mini-player-window #root { .dark *, :root[data-skin="lounge"] *, -:root[data-skin="pulse"] * { +:root[data-skin="pulse"] *, +:root[data-skin="fluid"] * { scrollbar-color: rgba(244, 244, 245, 0.12) transparent; } @@ -925,7 +944,8 @@ html.mini-player-window #root { :root[data-contrast="high"]:where( .dark, [data-skin="lounge"], - [data-skin="pulse"] + [data-skin="pulse"], + [data-skin="fluid"] ), :root[data-webkitgtk-safe][data-skin="liquid"].dark { --wf-contrast-surface: var(--color-surface-dark); diff --git a/src/components/common/NavItem.tsx b/src/components/common/NavItem.tsx index 39eb60204..03f8f6f57 100644 --- a/src/components/common/NavItem.tsx +++ b/src/components/common/NavItem.tsx @@ -11,6 +11,10 @@ export function NavItem({ }: NavItemProps) { return ( - )} + {localizations.any && ( - + )} + + + {!embedded && ( - + )} + - {/* Body */} -
- {currentTrack == null ? ( - } - text={t("lyrics.noTrack")} - /> - ) : isFetching && !payload ? ( - - ) : error ? ( - // Transient error (network, DB, profile pool) — distinct - // from a cached empty miss so the user knows to retry via - // the refetch button below rather than reaching for Import. - // The raw error stays in console.error; the panel surfaces - // a localized, action-oriented message. - } - text={t("lyrics.fetchError")} - /> - ) : !payload || payload.content.trim() === "" ? ( - } - text={ - excludedGenre - ? t("lyrics.excludedGenre", { genre: excludedGenre }) - : t("lyrics.notFound") - } - /> - ) : isSynced ? ( -
    - {activeInterlude?.afterIndex === -1 && ( - - )} - {lrcLines.map((line, index) => { - // Through the interlude after it, a line is sung and done. - const beforeInterlude = activeInterlude?.afterIndex === index; - // The other voice of a duet, still holding its line, stays lit - // beside the active one. - const isOverlap = index === overlapIndex; - const isActive = - (index === activeIndex && !beforeInterlude) || isOverlap; - const isPast = - (index < activeIndex && !isOverlap) || beforeInterlude; - const lineWordIndex = isOverlap - ? overlapWordIndex - : activeWordIndex; - const lineBackgroundWordIndex = isOverlap - ? overlapBackgroundWordIndex - : activeBackgroundWordIndex; - const hasWords = isActive && (line.words?.length ?? 0) > 0; - // A duet's second voice answers from the other side. - const align = line.side === "end" ? "text-right" : "text-left"; - return ( - -
  • { - lineRefs.current[index] = el; - }} + {/* Body */} +
    + {currentTrack == null ? ( + } text={t("lyrics.noTrack")} /> + ) : isFetching && !payload ? ( + + ) : error ? ( + // Transient error (network, DB, profile pool) — distinct + // from a cached empty miss so the user knows to retry via + // the refetch button below rather than reaching for Import. + // The raw error stays in console.error; the panel surfaces + // a localized, action-oriented message. + } + text={t("lyrics.fetchError")} + /> + ) : !payload || payload.content.trim() === "" ? ( + } + text={ + excludedGenre + ? t("lyrics.excludedGenre", { genre: excludedGenre }) + : t("lyrics.notFound") + } + /> + ) : isSynced ? ( +
      + {activeInterlude?.afterIndex === -1 && ( + + )} + {lrcLines.map((line, index) => { + // Through the interlude after it, a line is sung and done. + const beforeInterlude = activeInterlude?.afterIndex === index; + // The other voice of a duet, still holding its line, stays lit + // beside the active one. + const isOverlap = index === overlapIndex; + const isActive = + (index === activeIndex && !beforeInterlude) || isOverlap; + const isPast = + (index < activeIndex && !isOverlap) || beforeInterlude; + const lineWordIndex = isOverlap + ? overlapWordIndex + : activeWordIndex; + const lineBackgroundWordIndex = isOverlap + ? overlapBackgroundWordIndex + : activeBackgroundWordIndex; + const hasWords = isActive && (line.words?.length ?? 0) > 0; + // A duet's second voice answers from the other side. + const align = line.side === "end" ? "text-right" : "text-left"; + return ( + +
    • { + lineRefs.current[index] = el; + }} + > + -
    • - {beforeInterlude && ( - - )} -
      - ); - })} -
    - ) : ( -

    - {isRadio ? radioPlainText : payload.content} -

    - )} -
    + {line.companions?.map((companion, ci) => ( + + {companion} + + ))} + {localization === "translation" && line.translation && ( + + {line.translation} + + )} + +
  • + {beforeInterlude && ( + + )} +
    + ); + })} +
+ ) : ( +

+ {isRadio ? radioPlainText : payload.content} +

+ )} +
- {/* Editor modal — sibling so it floats above the panel. */} - setIsEditing(false)} - trackId={trackId} - trackTitle={currentTrack?.title ?? null} - trackFilePath={currentTrack?.file_path ?? null} - initial={payload} - onSaved={(next) => applyPayload(next)} - /> + {/* Editor modal — sibling so it floats above the panel. */} + setIsEditing(false)} + trackId={trackId} + trackTitle={currentTrack?.title ?? null} + trackFilePath={currentTrack?.file_path ?? null} + initial={payload} + onSaved={(next) => applyPayload(next)} + /> - {/* Footer actions */} - {currentTrack != null && ( -
- - {/* Source label is a chip-button whenever the track has a + {/* Footer actions */} + {currentTrack != null && ( +
+ + {/* Source label is a chip-button whenever the track has a library row, so the user can pop the provider picker and ask one source about it — whatever answered before, if anything did: embedded or sidecar lyrics may be worse than what a plugin has, and a miss is exactly when one wants to try another source. Radio and remote streams have no row to cache against, so their label is text. */} - - {!noLibraryRow ? ( - - ) : ( + + {!noLibraryRow ? ( + - ); - })} -
- )} -
- {payload && - (payload.format === "enhanced_lrc" || - payload.format === "ttml") && ( - - {payload.format === "ttml" ? "TTML" : "WORD"} - - )} + + + ) : ( + + {payload ? sourceLabel(payload, t, pluginNames) : ""} + + )} + {pickerOpen && !noLibraryRow && ( +
{ + // Local Escape handler in addition to the + // document-level one in the picker useEffect. + // Redundant in practice (both fire on the same + // event) but keeps the WAI-ARIA menu pattern + // self-contained on the element itself + stops + // the event from bubbling further into ancestor + // panels that might also listen for Escape. + if (e.key === "Escape") { + e.stopPropagation(); + setPickerOpen(false); + } + }} + className="absolute left-0 bottom-full mb-1 z-20 min-w-44 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg p-1" + > + {[ + ...LYRICS_PROVIDERS.map((p) => ({ + value: p as LyricsProvider | PluginLyricsProvider, + label: t(`lyrics.provider.${p}`), + plugin: false, + })), + // The enabled lyrics plugins, after the built-in + // providers: picked here, one is asked about this + // track alone, which is also how to tell whether it + // answers at all. + ...lyricsPlugins.map((plugin) => ({ + value: + `${PLUGIN_PROVIDER_PREFIX}${plugin.id}` as PluginLyricsProvider, + label: plugin.name, + plugin: true, + })), + ].map((option, index, all) => { + const isActive = payload?.provider === option.value; + const firstPlugin = + option.plugin && !all[index - 1]?.plugin; + return ( + + ); + })} +
+ )} -
- {/* Import / refetch / clear operate on a library row + {payload && + (payload.format === "enhanced_lrc" || + payload.format === "ttml") && ( + + {payload.format === "ttml" ? "TTML" : "WORD"} + + )} + +
+ {/* Import / refetch / clear operate on a library row (track_id / file_hash); a radio session has neither, so they're hidden for radio — the lyrics auto-fetch by artist + title and there's nothing to import-to or clear-from. */} - {!noLibraryRow && ( - <> - + {!noLibraryRow && ( + <> + + + {payload && ( - {payload && ( - - )} - - )} -
+ )} + + )}
- )} -
+ + )} + + ); + + if (embedded) return
{body}
; + return ( + + {body} ); } diff --git a/src/components/layout/NowPlayingPanel.tsx b/src/components/layout/NowPlayingPanel.tsx index 3644ae7aa..9317ecf78 100644 --- a/src/components/layout/NowPlayingPanel.tsx +++ b/src/components/layout/NowPlayingPanel.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; -import { X, Music2, Radio } from "lucide-react"; +import { X, Music2, Radio, User } from "lucide-react"; +import { LyricsPreviewCard } from "./fluid/LyricsPreviewCard"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { usePlayer } from "../../hooks/usePlayer"; import { useArtistUpdated } from "../../hooks/useArtistUpdated"; @@ -46,6 +47,9 @@ interface NowPlayingPanelProps { /** Navigate to a remote artist (RFC-005) — used when the current track * is a remote stream, whose artist has a server id, not a local one. */ onNavigateToRemoteArtist: (artistId: string) => void; + /** Inside the Fluid skin's tabbed shell: no frame, no header, the + * cover edge to edge with the title over it, the actions as pills. */ + embedded?: boolean; } /** @@ -62,10 +66,16 @@ interface NowPlayingPanelProps { export function NowPlayingPanel({ onNavigateToArtist, onNavigateToRemoteArtist, + embedded = false, }: NowPlayingPanelProps) { const { t } = useTranslation(); - const { toggleNowPlaying, toggleQueue, currentTrack, isNowPlayingOpen } = - usePlayer(); + const { + toggleNowPlaying, + toggleQueue, + openRightPanel, + currentTrack, + isNowPlayingOpen, + } = usePlayer(); // Enrichment (picture + bio) for the current artist. Re-fetched // whenever the primary artist_id changes. @@ -346,7 +356,12 @@ export function NowPlayingPanel({ // cropped one nobody looks at. const coverClass = canvasTall ? "w-full h-full shadow-lg" - : "w-full aspect-square shadow-lg"; + : embedded + ? "w-full aspect-square" + : "w-full aspect-square shadow-lg"; + // Embedded, the sections under the cover carry the panel's inset + // themselves: the cover is the one thing that runs edge to edge. + const inset = embedded ? "mx-6" : ""; // Cover ↔ artist slideshow (issue #466) — the ambient fallback, one rung // below the motion cover (Canvas > motion > slideshow > cover). @@ -375,7 +390,8 @@ export function NowPlayingPanel({ // reference does it (#694). They differ only in the colours they can // afford: over a clip whose palette we cannot know, white on a scrim is // the only safe pair, so the panel's own zinc scale is out. - const identityTone = canvasTall + const identityOverCover = canvasTall || embedded; + const identityTone = identityOverCover ? { title: "text-white", artist: "text-white/85", @@ -423,17 +439,16 @@ export function NowPlayingPanel({ ) : null; - return ( - -
- {/* Header */} + {/* Header — the shell draws its own in the embedded form. */} + {!embedded && (

{t("nowPlaying.title")} @@ -461,56 +476,36 @@ export function NowPlayingPanel({

+ )} - {currentTrack ? ( -
- {/* Large artwork — keyboard-accessible lightbox trigger. The + {currentTrack ? ( +
+ {/* Large artwork — keyboard-accessible lightbox trigger. The Canvas overlay lives on a shared relative wrapper so it still renders for a track that has a Canvas but no cover art. */} - {/* The frame takes the clip's shape while a tall Canvas plays, + {/* The frame takes the clip's shape while a tall Canvas plays, and the cover's square back otherwise. `width` rather than a height cap so the aspect ratio survives the clamp: a definite width lets `aspect-ratio` derive the height, where a `max-height` would just squash the box out of ratio. */} -
- {currentTrack.artwork_path ? ( - - ) : ( +
+ {currentTrack.artwork_path ? ( + + ) : ( + - {/* Over the clip's lower part, the way the reference does it: + )} + setCanvasShape({ path, aspect })} + /> + + {/* Over the clip's lower part, the way the reference does it: with the square frame gone there is nothing else holding that space, and the scrim is what makes white text safe over colours we cannot know in advance. */} - {canvasTall && ( -
- {trackIdentity} -
- )} -
+ {identityOverCover && ( +
+ {trackIdentity} +
+ )} + {/* Embedded, the Canvas toggle sits on the cover's corner: + the shell's bar has no room for a per-tab control. */} + {embedded && canvasAvailable && ( + setCanvasEnabled(!canvasEnabled)} + size={16} + className={`absolute right-3 top-3 z-10 rounded-full p-2 transition-colors ${ + canvasEnabled + ? "bg-black/55 text-[var(--fe-accent)]" + : "bg-black/45 text-white/80 hover:text-white" + }`} + /> + )} +
+ + {/* Track info — under the cover, unless a tall Canvas took it + over the clip above (or the embedded form, always). */} + {!identityOverCover && trackIdentity} - {/* Track info — under the cover, unless a tall Canvas took it - over the clip above. */} - {!canvasTall && trackIdentity} + {/* Embedded: where the track comes from, the two actions as + pills, and a glimpse of the lyrics that opens their tab. */} + {embedded && currentTrack.album_title && ( +
+

{t("nowPlaying.fluid.fromAlbum")}

+

+ {currentTrack.album_title} +

+
+ )} - {/* Quick actions — Spotify's "Start radio" lives here too */} + {/* Quick actions — Spotify's "Start radio" lives here too */} +
+ {embedded && ( + + )} +
+ {embedded && ( +
+ openRightPanel("lyrics")} /> +
+ )} - {/* What plays next. While the next tracks are the rest of the + {/* What plays next. While the next tracks are the rest of the current record, the record: its title and every track still to come, in the order they will play (#423). Otherwise the one-track teaser. Above the artist, whose biography can run long and used to push this out of view. */} - {albumRest.length > 0 ? ( -
-
-
-

- {t("nowPlaying.restOfAlbum")} -

- {currentTrack.album_title && ( -
- {currentTrack.album_title} + {albumRest.length > 0 ? ( +
+
+
+

+ {t("nowPlaying.restOfAlbum")} +

+ {currentTrack.album_title && ( +
+ {currentTrack.album_title} +
+ )} +
+ +
+
    + {albumRest.slice(0, ALBUM_REST_SHOWN).map(({ item, index }) => ( +
  1. +
+ + {formatDuration(item.duration_ms)} + + + + ))} + + {albumRest.length > ALBUM_REST_SHOWN && ( +
+ {t("nowPlaying.moreTracks", { + count: albumRest.length - ALBUM_REST_SHOWN, + })} +
+ )} +
+ ) : ( + nextTrack && ( +
+
+

+ {t("nowPlaying.nextInQueue")} +

-
    - {albumRest - .slice(0, ALBUM_REST_SHOWN) - .map(({ item, index }) => ( -
  1. - -
  2. - ))} -
- {albumRest.length > ALBUM_REST_SHOWN && ( -
- {t("nowPlaying.moreTracks", { - count: albumRest.length - ALBUM_REST_SHOWN, - })} +
+ +
+
+ {nextTrack.title} +
+
+ {nextTrack.artist_name ?? "—"} +
+
+
+
+ ) + )} + + {/* About the artist */} + {primaryArtistName && ( +
+

+ {t("nowPlaying.aboutArtist")} +

+
+ {pictureSrc ? ( + {primaryArtistName} + ) : ( +
+ + {primaryArtistName.trim().charAt(0).toUpperCase() || "?"} +
)} +
- ) : ( - nextTrack && ( -
-
-

- {t("nowPlaying.nextInQueue")} -

+ {displayedBio ? ( +
+

+ {displayedBio} +

+ {canExpand && ( -
-
- -
-
- {nextTrack.title} -
-
- {nextTrack.artist_name ?? "—"} -
-
-
-
- ) - )} - - {/* About the artist */} - {primaryArtistName && ( -
-

- {t("nowPlaying.aboutArtist")} -

-
- {pictureSrc ? ( - {primaryArtistName} - ) : ( -
- - {primaryArtistName.trim().charAt(0).toUpperCase() || - "?"} - -
)} -
- {displayedBio ? ( -
-

- {displayedBio} -

- {canExpand && ( - - )} -
- ) : ( -

- {t("nowPlaying.noBio")} -

- )} -
- )} -
- ) : ( -
- -

{t("nowPlaying.empty")}

-
- )} -
+ ) : ( +

+ {t("nowPlaying.noBio")} +

+ )} +
+ )} +
+ ) : ( +
+ +

{t("nowPlaying.empty")}

+
+ )} +
+ ); + const lightbox = ( + setIsLightboxOpen(false)} + /> + ); - setIsLightboxOpen(false)} - /> + if (embedded) { + return ( +
+ {body} + {lightbox} +
+ ); + } + return ( + + {body} + {lightbox} ); } diff --git a/src/components/layout/QueuePanel.tsx b/src/components/layout/QueuePanel.tsx index d3fa20653..b36621356 100644 --- a/src/components/layout/QueuePanel.tsx +++ b/src/components/layout/QueuePanel.tsx @@ -59,7 +59,12 @@ import { isRemoteTrack } from "../../lib/playerSources"; * opens, and whenever a `player:queue-changed` event fires (the * backend emits this after fill_queue, advance, shuffle, etc). */ -export function QueuePanel() { +export function QueuePanel({ + embedded = false, +}: { + /** Inside the Fluid skin's tabbed shell: no frame, no title, no close. */ + embedded?: boolean; +} = {}) { const { t, i18n } = useTranslation(); const { isQueueOpen, toggleQueue, playbackState, currentTrack } = usePlayer(); // A remote play session has its own in-memory queue, shown by a @@ -213,41 +218,44 @@ export function QueuePanel() { [trackMenu], ); - return ( - -
- {trackMenu.render()} -
-
+ {trackMenu.render()} +
+
+ {!embedded && (

{t("queue.title")}

-

- {!isRemote && t("queue.count", { count: total })} - {!isActive && ( - - {t("queue.inactive")} - - )} -

-
-
- {!isRemote && total > 0 && ( - + )} +

+ {!isRemote && t("queue.count", { count: total })} + {!isActive && ( + + {t("queue.inactive")} + )} +

+
+
+ {!isRemote && total > 0 && ( + + )} + {!embedded && ( -
+ )}
- setIsSaveOpen(false)} - initialName={t("queue.defaultPlaylistName", { - date: new Date().toLocaleDateString(i18n.language), - })} - onCreate={async (data) => { - const created = await createFromModal(data); - // Library rows only: a negative id is a sentinel for a row - // with no local track, which no playlist can hold. - const ids = items.map((item) => item.id).filter((id) => id > 0); - if (created?.id != null && ids.length > 0) { - try { - await addTracksToPlaylist(created.id, ids); - } catch (err) { - // The playlist exists; returning it keeps the modal from - // offering a retry that would create a duplicate. - console.error("[QueuePanel] save queue tracks failed", err); - } +
+ setIsSaveOpen(false)} + initialName={t("queue.defaultPlaylistName", { + date: new Date().toLocaleDateString(i18n.language), + })} + onCreate={async (data) => { + const created = await createFromModal(data); + // Library rows only: a negative id is a sentinel for a row + // with no local track, which no playlist can hold. + const ids = items.map((item) => item.id).filter((id) => id > 0); + if (created?.id != null && ids.length > 0) { + try { + await addTracksToPlaylist(created.id, ids); + } catch (err) { + // The playlist exists; returning it keeps the modal from + // offering a retry that would create a duplicate. + console.error("[QueuePanel] save queue tracks failed", err); } - return created; - }} - /> + } + return created; + }} + /> - {isRemote ? ( - - ) : total === 0 ? ( -
-
-
-

{t("queue.emptyTitle")}

-

- {t("queue.emptyDescription")} -

+ {isRemote ? ( + + ) : total === 0 ? ( +
+
+
- ) : ( -
- {snapshot?.source_type === "radio" && items[0] && ( -
-
- -
-
-
- {t("queue.radio.label")} -
-
- {t("queue.radio.basedOn", { title: items[0].title })} -
-
+

{t("queue.emptyTitle")}

+

+ {t("queue.emptyDescription")} +

+
+ ) : ( +
+ {snapshot?.source_type === "radio" && items[0] && ( +
+
+
- )} - {nowPlaying && ( -
-
- {t("queue.nowPlaying")} +
+
+ {t("queue.radio.label")}
- handleRowContextMenu(e, nowPlaying)} - onMenuKey={ - hasTrackMenu(nowPlaying) - ? (e) => handleRowMenuKey(e, nowPlaying) - : undefined - } - /> -
- )} - {upNext.length > 0 && ( -
-
- {t("queue.upNext", { count: upNext.length })} +
+ {t("queue.radio.basedOn", { title: items[0].title })}
- -
- )} -
- )} -
+
+
+ )} + {nowPlaying && ( +
+
+ {t("queue.nowPlaying")} +
+ handleRowContextMenu(e, nowPlaying)} + onMenuKey={ + hasTrackMenu(nowPlaying) + ? (e) => handleRowMenuKey(e, nowPlaying) + : undefined + } + /> +
+ )} + {upNext.length > 0 && ( +
+
+ {t("queue.upNext", { count: upNext.length })} +
+ +
+ )} +
+ )} +
+ ); + + if (embedded) return
{body}
; + return ( + + {body} ); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 874d410ae..142a72d8a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { Upload, Sparkles, Radio, + Puzzle, } from "lucide-react"; import type { ViewId, LibraryTab } from "../../types"; import { NavItem } from "../common/NavItem"; @@ -22,6 +23,7 @@ import { WaveFlowLogo } from "../common/WaveFlowLogo"; import { CreatePlaylistModal } from "../common/CreatePlaylistModal"; import { SmartPlaylistEditorModal } from "../common/SmartPlaylistEditorModal"; import { useProfile } from "../../hooks/useProfile"; +import { useSkin } from "../../hooks/useSkin"; import { useLibrary } from "../../hooks/useLibrary"; import { usePlaylist } from "../../hooks/usePlaylist"; import { usePluginAvailability } from "../../hooks/usePluginAvailability"; @@ -96,6 +98,11 @@ export function Sidebar({ } = useLibrary(); const { playlists, refresh: refreshPlaylists } = usePlaylist(); const profileColor = getProfileColor(activeProfile?.color_id); + // The Fluid skin draws the profile block as a small panel, with the + // library's size under the name; the other skins keep the one line. + const { skin } = useSkin(); + const fluid = skin.id === "fluid"; + const totalTracks = libraries.reduce((sum, l) => sum + l.track_count, 0); const [isSmartEditorOpen, setIsSmartEditorOpen] = useState(false); const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false); @@ -291,6 +298,9 @@ export function Sidebar({ return (