diff --git a/CLAUDE.md b/CLAUDE.md index 01241797..6b3066a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Scanner with parallel BLAKE3 extraction + transactional commit + fs-watcher sile ### UI ([`docs/features/ui.md`](docs/features/ui.md)) -**5 skins** ([`SkinId` in `src/lib/skins.ts`](src/lib/skins.ts)) — `studio` (Apple Music baseline) · `editorial` (broadsheet "WaveFlow Gazette" with Playfair Display + Lora, ``, `::first-letter` drop caps, halftone covers via `mix-blend-luminosity`) · `lounge` (warm-burgundy glass over a blurred cover backdrop via [`SkinAmbientBackdrop`](src/components/layout/SkinAmbientBackdrop.tsx)) · `pulse` (OLED neon club with dual-magenta/cyan aurora, vinyl-spin cover driven by [`SkinPlayingState`](src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`) · `liquid` (Apple Vibrancy with 8-layer inset `box-shadow` recipe `--liquid-glass`, theme-aware via `:not(.dark)` token swap). Skins are an **orthogonal axis** to the theme system — every skin × theme combination is valid (5 × 14 = 70 identities). Per-skin CSS overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid}.css` (Studio = baseline, no overrides). `applySkin()` writes `data-skin` on ``; [`src/app.css`](src/app.css) extends Tailwind's `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)` so always-dark skins fire `dark:*` utilities automatically without per-utility flips (Liquid stays theme-aware on purpose). Skin typography is **local-first** — Playfair Display, Lora, Space Grotesk, Space Mono and DM Sans Variable are bundled via `@fontsource` / `@fontsource-variable` and imported from [`src/main.tsx`](src/main.tsx); zero network at runtime, no Google Fonts request. Motion is dispatched via one global [`SkinMotionWrapper`](src/components/layout/SkinMotionWrapper.tsx) feeding `MotionConfig` — Pulse uses a bouncy spring `cubic-bezier(0.34, 1.56, 0.64, 1)`, calmer skins inherit the soft default. **14-preset theme system** ([`THEME_PRESETS`](src/lib/themes.ts)) — 6 light (Émeraude · Midnight · Sunset · Lavender · Crimson · Ocean) + 8 dark (Émeraude · OLED · Midnight · Sunset · Lavender · Crimson · Ocean · Neon), each declaring an OKLCH accent palette + ambient bg + optional `surfaceDark` / `surfaceDarkElevated` overrides. `applyTheme` writes CSS vars on ``; Tailwind v4's `@theme inline` block remaps `bg-emerald-*` and `bg-surface-dark*` utilities to those vars so a swap re-tints the entire app without component edits. Inline bootstrap script in `index.html` paints the right `dark` class + `data-theme` + ambient bg before React mounts so a dark default doesn't flash white. View-Transitions radial reveal on click, with try/catch fallback to plain `setTheme` for WebKitGTK builds that throw synchronously. Picker lives in Settings → Appearance alongside [`PlayerBarLayoutCard`](src/components/views/settings/PlayerBarLayoutCard.tsx). Player bar with per-button visibility + cover-click action all driven from one Settings → Appearance panel ([`PlayerBarLayoutCard`](src/components/views/settings/PlayerBarLayoutCard.tsx)) backed by [`usePlayerBarLayout`](src/hooks/usePlayerBarLayout.ts) — Mini-player, Immersive, Lyrics, Queue, Device, A-B, Sleep, EQ presets, Audio-quality strip all toggleable, plus the cover thumbnail can be set to `immersive` (default, opens fullscreen) / `now_playing` (Spotify-style: toggles the right panel) / `none`. EQ presets share an `EqPresetPanel` body between the primary popover (when pinned) and the "⋯" overflow menu inline. Audio-quality footer ([`AudioQualityFooter`](src/components/player/AudioQualityFooter.tsx)) renders a compact source-specs strip with a Hi-Res pill; hover opens [`AudioPipelinePopover`](src/components/player/AudioPipelinePopover.tsx) — Source / Processing chips (`DSD → PCM` / `Resample` / `Downmix` / `EQ` / `ReplayGain` / `Normalize` / `Mono` / `Speed`) / Output, with a `Bit-perfect` pill when nothing is touching the stream · immersive view ([`ImmersiveView`](src/components/player/ImmersiveView.tsx) — issue #328: now-playing + lyrics merged into one two-column fullscreen view, native OS fullscreen, both toggleable per-profile via [`ImmersiveViewCard`](src/components/views/settings/ImmersiveViewCard.tsx); lyrics state shared with the side panel through [`useTrackLyrics`](src/hooks/useTrackLyrics.ts)) · **track Canvas** (issue #442 — a short muted looping mp4 behind the now-playing view, Spotify-Canvas-style: [`CanvasStage`](src/components/player/CanvasStage.tsx) cleanly replaces the static cover in ImmersiveNowPlaying + NowPlayingPanel, **per-track** (not per-album like motion artwork) and taking precedence over the plugin motion cover; sourced either from a **manual `.mp4`** picked via the immersive "⋯" → [`CanvasPickerModal`](src/components/common/CanvasPickerModal.tsx) (hash-addressed + atomically `hard_link`-published into the never-evicted per-profile `canvas/` dir via the shared [`media_file::store_hash_addressed_mp4`](src-tauri/crates/app/src/commands/media_file.rs) helper — same as the #408 motion cover — keyed by `track_canvas`) **or** a **`canvas`-world plugin** ([`waveflow:canvas/v1`](src-tauri/crates/plugin-sdk/wit/canvas/plugin.wit), issue #473) via [`fetch_track_canvas`](src-tauri/crates/app/src/commands/canvas.rs) which fans out to enabled canvas plugins for a **remote** mp4 URL (fail-soft: any plugin error/timeout → fall through) — backdrop precedence **manual Canvas > plugin Canvas > motion > slideshow > cover**, [`CanvasStage`](src/components/player/CanvasStage.tsx) tells a local path from a remote URL by the `http(s)` prefix; a "Show Canvas" toggle ([`useCanvasEnabled`](src/hooks/useCanvasEnabled.ts), **default OFF**) shows only when the track has one, `prefers-reduced-motion`/radio/Spotify excluded; [`useTrackCanvas`](src/hooks/useTrackCanvas.ts) takes the track (manual local lookup → plugin fallback), guards by `{id, profileId}` + a monotonic profile-generation token. Deep dive: [`docs/features/ui.md`](docs/features/ui.md#track-canvas)) · **cover slideshow** (issue #466 — gently crossfades the album cover with the artist photo behind the now-playing view, built from images already in the library, no plugin: [`CoverSlideshow`](src/components/player/CoverSlideshow.tsx) in ImmersiveNowPlaying + NowPlayingPanel, one rung below the motion cover in the backdrop precedence **Canvas > motion > slideshow > cover**; per-profile toggle [`useCoverSlideshow`](src/hooks/useCoverSlideshow.ts) `ui.cover_slideshow` **default OFF** under Settings → Appearance, artist photo via [`useArtistImage`](src/hooks/useArtistImage.ts) at `"full"` res, `prefers-reduced-motion` + missing-photo fallbacks. Deep dive: [`docs/features/ui.md`](docs/features/ui.md#cover-slideshow)) · mini-player (`?mini=1` second webview, 280×380 default, always-on-top, cover-derived gradient background, position + size restored on next launch from `app_setting['mini_player.bounds']` with offscreen-after-monitor-disconnect fallback to the bottom-right anchor; includes an up-next overlay toggle reusing `player_get_queue` + `player:queue-changed`; Web Radio shows there too — a webview that mounts mid-stream hydrates the current station via `get_current_radio_metadata`, and for a live stream the ♥ like swaps to a ★ that favorites the station via [`useWebRadioFavorites`](src/hooks/useWebRadioFavorites.ts), shared with the PlayerBar + WebRadioView so the three never drift. **[`player_get_state`](src-tauri/crates/app/src/commands/player.rs) returns `current_track = null` while a radio session owns the engine** (`current_track_id` is the negative sentinel < 0) — without that explicit branch it would surface the persisted last _library_ track, and a webview mounting mid-stream would render that stale row instead of taking the radio-hydration path. The station identity (name + genre/country) is surfaced as a third line under the now-playing ICY song in the PlayerBar + immersive view ([`ImmersiveNowPlaying`](src/components/player/ImmersiveNowPlaying.tsx)) + mini-player — song stays the title, station rides below behind a `Radio` glyph. The seek bar + timestamps are hidden for radio across all three surfaces ([`ProgressBar`](src/components/player/ProgressBar.tsx) returns null for `isRadioTrack`; the mini-player gates its own bar) — a live stream has no seekable timeline) · karaoke fullscreen lyrics · lyrics editor (plain + Musicolet-style synced) · first-run onboarding (Lokal-style multi-step wizard: welcome → language → profile → localOnly → folder → Last.fm → scan → done; profile step renames the auto-created "Default" profile in place via [`rename_profile`](src-tauri/crates/app/src/commands/profile.rs); modal is `flex flex-col max-h-[calc(100vh-2rem)]` with the body scrollable so the progress bar and action bar stay visible on 1080p displays; latched per-profile, re-evaluates against the new profile's libraries on switch via `LibraryContext.loadedProfileId`) · profile create + rename + delete (`ProfileSelectorModal` Netflix-style "Manage" toggle, atomic `DELETE … AND (SELECT COUNT(*) FROM profile) > 1` guard against losing the last profile, active profile refused to delete; rename is safe against the active profile since only `app.db` is touched) · Settings split into seven ARIA tablist categories (library / playback / integrations / appearance / data / shortcuts / diagnostics) — only the active panel mounts · WaveFlow Wrapped (year-in-review story overlay + shareable PNG; Home banner gated by [`useWrappedBannerVisibility`](src/hooks/useWrappedBannerVisibility.ts) — three modes `auto` (default — visible only during Wrapped season, Dec 1 → Jan 31, matching Spotify's release window) / `always` / `never`, plus a per-year dismiss button on the banner that writes `profile_setting['wrapped.dismissed_year']`; toggle UI in [`WrappedBannerCard`](src/components/views/settings/WrappedBannerCard.tsx) under Settings → Appearance) · Now Playing share card · configurable keyboard shortcuts · persistent zoom (VS Code style shortcuts) · full-width music views (no `max-w-*` cap on listing views) · single-instance lock ([`tauri-plugin-single-instance`](https://crates.io/crates/tauri-plugin-single-instance) first plugin in `lib.rs`). +**5 skins** ([`SkinId` in `src/lib/skins.ts`](src/lib/skins.ts)) — `studio` (Apple Music baseline) · `editorial` (broadsheet "WaveFlow Gazette" with Playfair Display + Lora, ``, `::first-letter` drop caps, halftone covers via `mix-blend-luminosity`) · `lounge` (warm-burgundy glass over a blurred cover backdrop via [`SkinAmbientBackdrop`](src/components/layout/SkinAmbientBackdrop.tsx)) · `pulse` (OLED neon club with dual-magenta/cyan aurora, vinyl-spin cover driven by [`SkinPlayingState`](src/components/layout/SkinPlayingState.tsx) mirroring `usePlayer().isPlaying` to `[data-is-playing]`) · `liquid` (Apple Vibrancy with 8-layer inset `box-shadow` recipe `--liquid-glass`, theme-aware via `:not(.dark)` token swap). Skins are an **orthogonal axis** to the theme system — every skin × theme combination is valid (5 × 14 = 70 identities). Per-skin CSS overrides live in `src/styles/skins/{editorial,lounge,pulse,liquid}.css` (Studio = baseline, no overrides). `applySkin()` writes `data-skin` on ``; [`src/app.css`](src/app.css) extends Tailwind's `dark` variant via `@custom-variant dark (.dark, .dark *, :root[data-skin="lounge"] *, :root[data-skin="pulse"] *)` so always-dark skins fire `dark:*` utilities automatically without per-utility flips (Liquid stays theme-aware on purpose). Skin typography is **local-first** — Playfair Display, Lora, Space Grotesk, Space Mono and DM Sans Variable are bundled via `@fontsource` / `@fontsource-variable` and imported from [`src/main.tsx`](src/main.tsx); zero network at runtime, no Google Fonts request. Motion is dispatched via one global [`SkinMotionWrapper`](src/components/layout/SkinMotionWrapper.tsx) feeding `MotionConfig` — Pulse uses a bouncy spring `cubic-bezier(0.34, 1.56, 0.64, 1)`, calmer skins inherit the soft default. **14-preset theme system** ([`THEME_PRESETS`](src/lib/themes.ts)) — 6 light (Émeraude · Midnight · Sunset · Lavender · Crimson · Ocean) + 8 dark (Émeraude · OLED · Midnight · Sunset · Lavender · Crimson · Ocean · Neon), each declaring an OKLCH accent palette + ambient bg + optional `surfaceDark` / `surfaceDarkElevated` overrides. `applyTheme` writes CSS vars on ``; Tailwind v4's `@theme inline` block remaps `bg-emerald-*` and `bg-surface-dark*` utilities to those vars so a swap re-tints the entire app without component edits. Inline bootstrap script in `index.html` paints the right `dark` class + `data-theme` + ambient bg before React mounts so a dark default doesn't flash white. View-Transitions radial reveal on click, with try/catch fallback to plain `setTheme` for WebKitGTK builds that throw synchronously. Picker lives in Settings → Appearance alongside [`PlayerBarLayoutCard`](src/components/views/settings/PlayerBarLayoutCard.tsx). Player bar with per-button visibility + cover-click action all driven from one Settings → Appearance panel ([`PlayerBarLayoutCard`](src/components/views/settings/PlayerBarLayoutCard.tsx)) backed by [`usePlayerBarLayout`](src/hooks/usePlayerBarLayout.ts) — Mini-player, Immersive, Lyrics, Queue, Device, A-B, Sleep, EQ presets, Audio-quality strip all toggleable, plus the cover thumbnail can be set to `immersive` (default, opens fullscreen) / `now_playing` (Spotify-style: toggles the right panel) / `none`. EQ presets share an `EqPresetPanel` body between the primary popover (when pinned) and the "⋯" overflow menu inline. Audio-quality footer ([`AudioQualityFooter`](src/components/player/AudioQualityFooter.tsx)) renders a compact source-specs strip with a Hi-Res pill; hover opens [`AudioPipelinePopover`](src/components/player/AudioPipelinePopover.tsx) — Source / Processing chips (`DSD → PCM` / `Resample` / `Downmix` / `EQ` / `ReplayGain` / `Normalize` / `Mono` / `Speed`) / Output, with a `Bit-perfect` pill when nothing is touching the stream · immersive view ([`ImmersiveView`](src/components/player/ImmersiveView.tsx) — issue #328: now-playing + lyrics merged into one two-column fullscreen view, native OS fullscreen, both toggleable per-profile via [`ImmersiveViewCard`](src/components/views/settings/ImmersiveViewCard.tsx); lyrics state shared with the side panel through [`useTrackLyrics`](src/hooks/useTrackLyrics.ts)) · **track Canvas** (issue #442 — a short muted looping mp4 behind the now-playing view, Spotify-Canvas-style: [`CanvasStage`](src/components/player/CanvasStage.tsx) cleanly replaces the static cover in ImmersiveNowPlaying + NowPlayingPanel, **per-track** (not per-album like motion artwork) and taking precedence over the plugin motion cover; sourced either from a **manual `.mp4`** picked via the immersive "⋯" → [`CanvasPickerModal`](src/components/common/CanvasPickerModal.tsx) (hash-addressed + atomically `hard_link`-published into the never-evicted per-profile `canvas/` dir via the shared [`media_file::store_hash_addressed_mp4`](src-tauri/crates/app/src/commands/media_file.rs) helper — same as the #408 motion cover — keyed by `track_canvas`) **or** a **`canvas`-world plugin** ([`waveflow:canvas/v1`](src-tauri/crates/plugin-sdk/wit/canvas/plugin.wit), issue #473) via [`fetch_track_canvas`](src-tauri/crates/app/src/commands/canvas.rs) which fans out to enabled canvas plugins for a **remote** mp4 URL (fail-soft: any plugin error/timeout → fall through) — backdrop precedence **manual Canvas > plugin Canvas > motion > slideshow > cover**, [`CanvasStage`](src/components/player/CanvasStage.tsx) tells a local path from a remote URL by the `http(s)` prefix; a "Show Canvas" toggle ([`useCanvasEnabled`](src/hooks/useCanvasEnabled.ts), **default OFF**) shows only when the track has one, `prefers-reduced-motion`/radio/Spotify excluded; [`useTrackCanvas`](src/hooks/useTrackCanvas.ts) takes the track (manual local lookup → plugin fallback), guards by `{id, profileId}` + a monotonic profile-generation token. Deep dive: [`docs/features/ui.md`](docs/features/ui.md#track-canvas)) · **cover slideshow** (issue #466 — gently crossfades the album cover with the artist photo behind the now-playing view, built from images already in the library, no plugin: [`CoverSlideshow`](src/components/player/CoverSlideshow.tsx) in ImmersiveNowPlaying + NowPlayingPanel, one rung below the motion cover in the backdrop precedence **Canvas > motion > slideshow > cover**; per-profile toggle [`useCoverSlideshow`](src/hooks/useCoverSlideshow.ts) `ui.cover_slideshow` **default OFF** under Settings → Appearance, artist photo via [`useArtistImage`](src/hooks/useArtistImage.ts) at `"full"` res, `prefers-reduced-motion` + missing-photo fallbacks. Deep dive: [`docs/features/ui.md`](docs/features/ui.md#cover-slideshow)) · **artist hero** (issue #482 — Spotify-style full-bleed backdrop behind the artist detail header via [`ArtistHeroBackdrop`](src/components/common/ArtistHeroBackdrop.tsx), two tiers **real wide TheAudioDB fanart (nearly crisp) > blurred square photo (Deezer / local `artist.jpg`, works offline) > flat header**; [`ArtistDetailView`](src/components/views/ArtistDetailView.tsx) breaks out of `
`'s `p-8` with `-mx-8 -mt-8`, the header copy is forced **white in every theme** over a dark scrim (Spotify's header is dark-on-image in light mode too) and the bottom edge fades through a CSS **mask** rather than a hard-coded colour stop so it dissolves into any theme × skin; per-profile toggle [`useArtistHero`](src/hooks/useArtistHero.ts) `ui.artist_hero` **default ON** under Settings → Appearance, `prefers-reduced-motion` skips the cross-fade only (the image is static), the enrichment response only ever *sets* the fanart so an offline refresh never blanks a hero the cache produced. Deep dive: [`docs/features/ui.md`](docs/features/ui.md#artist-hero)) · mini-player (`?mini=1` second webview, 280×380 default, always-on-top, cover-derived gradient background, position + size restored on next launch from `app_setting['mini_player.bounds']` with offscreen-after-monitor-disconnect fallback to the bottom-right anchor; includes an up-next overlay toggle reusing `player_get_queue` + `player:queue-changed`; Web Radio shows there too — a webview that mounts mid-stream hydrates the current station via `get_current_radio_metadata`, and for a live stream the ♥ like swaps to a ★ that favorites the station via [`useWebRadioFavorites`](src/hooks/useWebRadioFavorites.ts), shared with the PlayerBar + WebRadioView so the three never drift. **[`player_get_state`](src-tauri/crates/app/src/commands/player.rs) returns `current_track = null` while a radio session owns the engine** (`current_track_id` is the negative sentinel < 0) — without that explicit branch it would surface the persisted last _library_ track, and a webview mounting mid-stream would render that stale row instead of taking the radio-hydration path. The station identity (name + genre/country) is surfaced as a third line under the now-playing ICY song in the PlayerBar + immersive view ([`ImmersiveNowPlaying`](src/components/player/ImmersiveNowPlaying.tsx)) + mini-player — song stays the title, station rides below behind a `Radio` glyph. The seek bar + timestamps are hidden for radio across all three surfaces ([`ProgressBar`](src/components/player/ProgressBar.tsx) returns null for `isRadioTrack`; the mini-player gates its own bar) — a live stream has no seekable timeline) · karaoke fullscreen lyrics · lyrics editor (plain + Musicolet-style synced) · first-run onboarding (Lokal-style multi-step wizard: welcome → language → profile → localOnly → folder → Last.fm → scan → done; profile step renames the auto-created "Default" profile in place via [`rename_profile`](src-tauri/crates/app/src/commands/profile.rs); modal is `flex flex-col max-h-[calc(100vh-2rem)]` with the body scrollable so the progress bar and action bar stay visible on 1080p displays; latched per-profile, re-evaluates against the new profile's libraries on switch via `LibraryContext.loadedProfileId`) · profile create + rename + delete (`ProfileSelectorModal` Netflix-style "Manage" toggle, atomic `DELETE … AND (SELECT COUNT(*) FROM profile) > 1` guard against losing the last profile, active profile refused to delete; rename is safe against the active profile since only `app.db` is touched) · Settings split into seven ARIA tablist categories (library / playback / integrations / appearance / data / shortcuts / diagnostics) — only the active panel mounts · WaveFlow Wrapped (year-in-review story overlay + shareable PNG; Home banner gated by [`useWrappedBannerVisibility`](src/hooks/useWrappedBannerVisibility.ts) — three modes `auto` (default — visible only during Wrapped season, Dec 1 → Jan 31, matching Spotify's release window) / `always` / `never`, plus a per-year dismiss button on the banner that writes `profile_setting['wrapped.dismissed_year']`; toggle UI in [`WrappedBannerCard`](src/components/views/settings/WrappedBannerCard.tsx) under Settings → Appearance) · Now Playing share card · configurable keyboard shortcuts · persistent zoom (VS Code style shortcuts) · full-width music views (no `max-w-*` cap on listing views) · single-instance lock ([`tauri-plugin-single-instance`](https://crates.io/crates/tauri-plugin-single-instance) first plugin in `lib.rs`). ### Playlists ([`docs/features/playlists.md`](docs/features/playlists.md), [`docs/features/smart-playlists.md`](docs/features/smart-playlists.md)) @@ -112,7 +112,7 @@ Playlist sort dropdown (custom / title / artist / album / recently added / durat ### Integrations ([`docs/features/integrations.md`](docs/features/integrations.md)) -Deezer enrichment (pictures, covers, fans — cached 30 days in `metadata_artist` / `metadata_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline; **Web Radio now-playing artwork** is the one Deezer path that is NOT disk-cached — [`fetch_radio_artwork`](src-tauri/crates/app/src/commands/deezer.rs) resolves an album cover URL from the ICY `Artist - Title` via `search_track` and returns the remote CDN link directly because a radio now-playing line is ephemeral; `PlayerContext` swaps it into `currentTrack.artwork_path` over the station favicon, token + `isRadioTrack`-guarded so a stale fetch or a library track that started meanwhile is never clobbered) · artist bio source selector (Settings → Integrations: **Last.fm** default vs **TheAudioDB** multi-language via [`metadata::theaudiodb`](src-tauri/crates/core/src/metadata/theaudiodb.rs), `app_setting['metadata.bio_source']` + `['metadata.bio_language']`; `enrich_artist_deezer` branches on it and stores `bio_source`/`bio_language` in `metadata_artist` so a switch invalidates the cached bio) · Last.fm (bios, similar artists with Deezer picture backfill — Last.fm's `artist.getSimilar` returns generic star placeholders for every image since their artist-image API was killed in 2019, so [`similar::enrich_with_deezer_pictures`](src-tauri/crates/app/src/commands/similar.rs) joins the result against `app.metadata_artist` and fans out parallel Deezer `search_artist` calls for any cache miss before responding; scrobbler) · **per-artist offline overrides** (issue #323: `artist.custom_bio` + library-scoped `artist_similar_custom` table, both per-profile; [`commands/artist_overrides.rs`](src-tauri/crates/app/src/commands/artist_overrides.rs) write/read commands, edited from Artist Detail → "Edit info" [`ArtistMetadataEditorModal`](src/components/common/ArtistMetadataEditorModal.tsx); `enrich_artist_deezer` swaps the custom bio onto the returned payload and `get_similar_artists` short-circuits to the curated list before any cache/network, so both work offline and survive enrichment passes) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/crates/app/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)) · MPD protocol server ([`docs/features/mpd.md`](docs/features/mpd.md) — opt-in LAN control surface for existing MPD clients; control + queue mutation only, library browsing deliberately out of scope for v1) · **offline Web Radio catalogue** ([`commands/web_radio_catalogue.rs`](src-tauri/crates/app/src/commands/web_radio_catalogue.rs)) — the `web-radio` WASM plugin queries radio-browser live and can't host SQLite, so the offline catalogue is a NATIVE side path: `download_radio_catalogue` snapshots the ~35k-station directory into an app.db `radio_station` table + contentless FTS5 index (user-triggered from Settings → Data), and `resolve_radio_catalogue` answers the SAME opaque query tokens as the plugin (`top` / `tag:x` / `country:xx` / free text) returning the SAME `PluginTrack` shape. [`WebRadioView`](src/components/views/WebRadioView.tsx) routes browse/search through it when `offline_mode` is on OR the `radio.catalogue.local_first` setting is enabled with a catalogue present; the stream url rides inside the track id (`url:`) so `plugin_stream_url` + playback stay network-free regardless. +Deezer enrichment (pictures, covers, fans — cached 30 days in `metadata_artist` / `metadata_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline; **Web Radio now-playing artwork** is the one Deezer path that is NOT disk-cached — [`fetch_radio_artwork`](src-tauri/crates/app/src/commands/deezer.rs) resolves an album cover URL from the ICY `Artist - Title` via `search_track` and returns the remote CDN link directly because a radio now-playing line is ephemeral; `PlayerContext` swaps it into `currentTrack.artwork_path` over the station favicon, token + `isRadioTrack`-guarded so a stale fetch or a library track that started meanwhile is never clobbered) · artist bio source selector (Settings → Integrations: **Last.fm** default vs **TheAudioDB** multi-language via [`metadata::theaudiodb`](src-tauri/crates/core/src/metadata/theaudiodb.rs), `app_setting['metadata.bio_source']` + `['metadata.bio_language']`; `enrich_artist_deezer` branches on it and stores `bio_source`/`bio_language` in `metadata_artist` so a switch invalidates the cached bio) · **wide artist fanart** (issue #482 — the same TheAudioDB `search.php` response carries `strArtistFanart*` / `strArtistWideThumb` / `strArtistBanner`, so `TheAudioDbClient::artist_info` returns bio **and** `fanart_url` from ONE lookup; `enrich_artist_deezer` calls it **regardless of `metadata.bio_source`** because Last.fm has no equivalent image and gating it would leave Last.fm users with no artist hero at all, cached in `metadata_artist.background_{url,hash}` + `background_fetched_at` — the "we already looked" marker without which a NULL hash is indistinguishable from "never queried" and every fanart-less artist would re-hit a rate-limited API per page visit; stamped when the API was *reached*, left NULL on a transport error so a blip retries instead of caching for the 30-day TTL) · Last.fm (bios, similar artists with Deezer picture backfill — Last.fm's `artist.getSimilar` returns generic star placeholders for every image since their artist-image API was killed in 2019, so [`similar::enrich_with_deezer_pictures`](src-tauri/crates/app/src/commands/similar.rs) joins the result against `app.metadata_artist` and fans out parallel Deezer `search_artist` calls for any cache miss before responding; scrobbler) · **per-artist offline overrides** (issue #323: `artist.custom_bio` + library-scoped `artist_similar_custom` table, both per-profile; [`commands/artist_overrides.rs`](src-tauri/crates/app/src/commands/artist_overrides.rs) write/read commands, edited from Artist Detail → "Edit info" [`ArtistMetadataEditorModal`](src/components/common/ArtistMetadataEditorModal.tsx); `enrich_artist_deezer` swaps the custom bio onto the returned payload and `get_similar_artists` short-circuits to the curated list before any cache/network, so both work offline and survive enrichment passes) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/crates/app/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)) · MPD protocol server ([`docs/features/mpd.md`](docs/features/mpd.md) — opt-in LAN control surface for existing MPD clients; control + queue mutation only, library browsing deliberately out of scope for v1) · **offline Web Radio catalogue** ([`commands/web_radio_catalogue.rs`](src-tauri/crates/app/src/commands/web_radio_catalogue.rs)) — the `web-radio` WASM plugin queries radio-browser live and can't host SQLite, so the offline catalogue is a NATIVE side path: `download_radio_catalogue` snapshots the ~35k-station directory into an app.db `radio_station` table + contentless FTS5 index (user-triggered from Settings → Data), and `resolve_radio_catalogue` answers the SAME opaque query tokens as the plugin (`top` / `tag:x` / `country:xx` / free text) returning the SAME `PluginTrack` shape. [`WebRadioView`](src/components/views/WebRadioView.tsx) routes browse/search through it when `offline_mode` is on OR the `radio.catalogue.local_first` setting is enabled with a catalogue present; the stream url rides inside the track id (`url:`) so `plugin_stream_url` + playback stay network-free regardless. ### Preferences & maintenance diff --git a/docs/features/library.md b/docs/features/library.md index 14d268c2..d55b1901 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -130,3 +130,15 @@ The pencil overlay on the artist photo in [`ArtistDetailView`](../../src/compone - **Remove image** → `clear_artist_artwork` sets `artist.artwork_id = NULL` so the next render falls back through the resolution chain (Deezer cache → live fetch). Both `set_artist_artwork_from_*` overwrite `artwork_id` unconditionally — an explicit user pick beats any automatic resolution. + +### Wide artist fanart (hero) + +Everything above carries the **square** artist photo. The [artist hero](ui.md#artist-hero) needs a **wide** one, and the only source in the stack that has one is TheAudioDB (issue #482). + +[`metadata::theaudiodb`](../../src-tauri/crates/core/src/metadata/theaudiodb.rs) already queried `search.php` for multi-language bios; the same response carries `strArtistFanart` (+ `2/3/4`), `strArtistWideThumb` and `strArtistBanner`. `TheAudioDbClient::artist_info` now returns bio **and** `fanart_url` from one lookup, picking the first non-blank image widest-and-cleanest first (fanart → alternates → wide thumb → logo banner last, since baked-in text can clash with the header copy). It returns `Some` for any name match even with neither bio nor fanart, so the caller can cache the "looked, nothing there" outcome. + +[`enrich_artist_deezer`](../../src-tauri/crates/app/src/commands/deezer.rs) calls it **independently of the `metadata.bio_source` setting**: Last.fm has no equivalent image, so gating the fanart on the bio source would leave every Last.fm user with no hero at all. One request serves both consumers (the bio half is used only when TheAudioDB *is* the selected source) — TheAudioDB's shared free key is rate-limited, so it's one call, cached hard. The URL is downloaded through the usual `metadata_artwork::download_and_cache` (BLAKE3-addressed, shared across profiles) and kept at **full resolution** — no `_1x` / `_2x` tier, downscaling a full-bleed banner would only soften it. Offline mode short-circuits before any of this, and the blurred-photo tier still works. + +Cached in `app.metadata_artist` next to the picture pair: `background_url` + `background_hash`, plus **`background_fetched_at`** — the "we already looked" marker (migration `20260802120000_metadata_artist_background.sql`). Without it a NULL hash can't be told apart from "never queried", so every artist without fanart would re-hit a rate-limited API on each page visit. It is stamped whenever the API was *reached* (match or not) and left NULL on a transport error, so a network blip retries instead of caching as "this artist has no fanart" for the row's whole 30-day TTL. Rows written before the migration have NULL there and are treated as a background cache miss on their next refresh, which backfills them once. + +Both `get_artist_detail` (first paint, straight from the cache) and `enrich_artist_deezer` (refresh) return `background_url` / `background_path`. diff --git a/docs/features/ui.md b/docs/features/ui.md index c9ce4cbc..cb666145 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -90,6 +90,20 @@ It sits one rung below the motion cover in the backdrop precedence — **Canvas **Toggle + guardrails** — a per-profile preference [`useCoverSlideshow`](../../src/hooks/useCoverSlideshow.ts) (`ui.cover_slideshow`, **default OFF**), toggled in Settings → Appearance via [`CoverSlideshowCard`](../../src/components/views/settings/CoverSlideshowCard.tsx); the write machinery (serialized writes, profile-switch guards, rollback, broadcast) mirrors [`useScrollLongTitles`](../../src/hooks/useScrollLongTitles.ts). `prefers-reduced-motion` suppresses the alternation, and a missing artist photo falls back to the static cover — so the feature is purely additive. The artist image is resolved through [`useArtistImage`](../../src/hooks/useArtistImage.ts) at **`"full"`** resolution (matching the artist detail page — a 1x thumbnail would upscale blurry in the large cover slot), and the enrichment fetch is gated on the toggle so it costs nothing while off. i18n under `settings.coverSlideshow.*`. +## Artist hero + +[`ArtistHeroBackdrop`](../../src/components/common/ArtistHeroBackdrop.tsx) paints a **full-bleed backdrop behind the artist detail header** (issue #482), the Spotify artist-banner look — replacing the flat surface that only carried a circular avatar + name. Mounted by [`ArtistDetailView`](../../src/components/views/ArtistDetailView.tsx), which wraps its header in a `-mx-8 -mt-8` block to break out of `
`'s `p-8` so the image reaches the column edges (and shrinks with the column when a right panel opens). + +**Two image tiers plus a no-image case, in precedence order:** + +1. **Real wide fanart** — `strArtistFanart` (or its alternates / `strArtistWideThumb` / `strArtistBanner`) from TheAudioDB, downloaded into the shared `metadata_artwork/` cache. Shown nearly crisp: `blur(2px)` only, enough to keep JPEG artefacts from crawling under the header copy. See [the backend pipeline](library.md#wide-artist-fanart-hero). +2. **The square artist photo** — Deezer picture or a local `artist.jpg`, heavily blurred + upscaled (`blur(56px) saturate(190%)`, `scale(1.35)`), the same colour-field treatment [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) uses. Always available and **works offline**, which is why it's the universal fallback — a 1:1 image stretched across a banner would be unreadable unblurred. +3. **Nothing** — an artist with no image at all keeps today's flat header. + +**Legibility** is not left to the theme: the image always carries a dark scrim (`from-black/85 via-black/60 to-black/35`) and the header copy (eyebrow / name / stats) is forced **white in every theme**, matching Spotify — whose artist header is dark-on-image in light mode too. The secondary buttons swap to a translucent white treatment over the hero. The bottom edge fades out through a **mask** (`linear-gradient(to bottom, black 68%, transparent)`) rather than a hard-coded colour stop, so the hero dissolves into whatever the current theme × skin paints behind it. + +**Toggle + guardrails** — per-profile preference [`useArtistHero`](../../src/hooks/useArtistHero.ts) (`ui.artist_hero`, **default ON** — it's a baseline visual, not extra motion), toggled in Settings → Appearance via [`ArtistHeroCard`](../../src/components/views/settings/ArtistHeroCard.tsx); the write machinery (serialized writes, profile-switch guards, rollback, broadcast) mirrors [`useCoverSlideshow`](../../src/hooks/useCoverSlideshow.ts). `prefers-reduced-motion` skips the `artistHeroFadeIn` cross-fade only — the image itself is static, so there is nothing else to suppress. The fanart source is seeded from `get_artist_detail` (metadata cache, first frame) and refined by the later `enrich_artist_deezer` response, which only ever *sets* it: a refresh that comes back empty (offline, TheAudioDB down) must not blank a hero the cache already produced. i18n under `settings.artistHero.*`. + ## Mini-player [`MiniPlayerApp`](../../src/MiniPlayerApp.tsx) + [`MiniPlayer`](../../src/components/views/MiniPlayer.tsx) ship a Spotify-style always-on-top widget. Launched from the picture-in-picture button in the PlayerBar via [`lib/miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts). diff --git a/src-tauri/crates/app/src/commands/browse.rs b/src-tauri/crates/app/src/commands/browse.rs index fb3059d2..38701d7a 100644 --- a/src-tauri/crates/app/src/commands/browse.rs +++ b/src-tauri/crates/app/src/commands/browse.rs @@ -1212,6 +1212,11 @@ pub struct ArtistDetail { pub fans_count: Option, pub bio_short: Option, pub bio_full: Option, + /// Wide TheAudioDB fanart backing the artist hero (issue #482). + /// Served straight from the metadata cache so the hero paints on the + /// first frame instead of waiting for `enrich_artist_deezer`. + pub background_url: Option, + pub background_path: Option, pub track_count: i64, pub album_count: i64, pub albums: Vec, @@ -1228,6 +1233,8 @@ struct ArtistDetailRaw { fans_count: Option, bio_short: Option, bio_full: Option, + background_url: Option, + background_hash: Option, track_count: i64, album_count: i64, } @@ -1274,6 +1281,8 @@ pub async fn get_artist_detail( da.fans_count AS fans_count, da.bio_short AS bio_short, da.bio_full AS bio_full, + da.background_url AS background_url, + da.background_hash AS background_hash, COUNT(DISTINCT t.id) AS track_count, COUNT(DISTINCT t.album_id) AS album_count FROM artist ar @@ -1361,6 +1370,10 @@ pub async fn get_artist_detail( Some(h) => crate::thumbnails::thumbnail_paths_for(metadata_dir, h), None => (None, None), }; + let background_path = header + .background_hash + .as_deref() + .and_then(|h| crate::metadata_artwork::existing_path(metadata_dir, h)); Ok(ArtistDetail { id: header.id, @@ -1375,6 +1388,8 @@ pub async fn get_artist_detail( fans_count: header.fans_count, bio_short: header.bio_short, bio_full: header.bio_full, + background_url: header.background_url, + background_path, track_count: header.track_count, album_count: header.album_count, albums, diff --git a/src-tauri/crates/app/src/commands/deezer.rs b/src-tauri/crates/app/src/commands/deezer.rs index 0e708a84..bc16c049 100644 --- a/src-tauri/crates/app/src/commands/deezer.rs +++ b/src-tauri/crates/app/src/commands/deezer.rs @@ -263,6 +263,13 @@ pub struct DeezerArtistEnrichment { pub bio_short: Option, /// Full biography from Last.fm. HTML stripped. pub bio_full: Option, + /// Remote TheAudioDB URL of the wide artist fanart (issue #482) — + /// fallback when the local download failed. + pub background_url: Option, + /// Absolute filesystem path to the locally-cached fanart. Feeds the + /// artist hero; `None` means the artist has no wide image and the + /// frontend falls back to blurring the square photo. + pub background_path: Option, } impl DeezerArtistEnrichment { @@ -276,6 +283,8 @@ impl DeezerArtistEnrichment { fans_count: None, bio_short: None, bio_full: None, + background_url: None, + background_path: None, } } } @@ -367,10 +376,14 @@ async fn enrich_artist_deezer_inner( Option, Option, Option, + Option, + Option, + Option, i64, )> = sqlx::query_as( "SELECT picture_url, picture_hash, fans_count, bio_short, bio_full, - bio_source, bio_language, expires_at + bio_source, bio_language, + background_url, background_hash, background_fetched_at, expires_at FROM app.metadata_artist WHERE deezer_id = ?", ) .bind(did) @@ -385,6 +398,9 @@ async fn enrich_artist_deezer_inner( bio_full, cached_bio_source, cached_bio_language, + background_url, + background_hash, + background_fetched_at, expires_at, )) = cached { @@ -394,7 +410,13 @@ async fn enrich_artist_deezer_inner( let bio_fresh = BioSource::parse(cached_bio_source.as_deref()) == active_source && (active_source != BioSource::TheAudioDb || cached_bio_language.as_deref() == Some(active_lang.as_str())); - if expires_at > now && bio_fresh { + // A row written before issue #482 never looked for fanart — + // `background_fetched_at IS NULL` is the marker, and a NULL + // hash alone can't say it apart from "looked, found nothing". + // Falling through backfills it once, then this stays true + // for the rest of the row's TTL. + let background_fresh = background_fetched_at.is_some(); + if expires_at > now && bio_fresh && background_fresh { // A row cached before #406 may hold a Deezer placeholder // URL (and a grey-blob hash). Drop both so we surface the // initial-letter avatar instead of the grey box; the row's @@ -416,6 +438,9 @@ async fn enrich_artist_deezer_inner( Some(h) => crate::thumbnails::thumbnail_paths_for(&artwork_dir, h), None => (None, None), }; + let background_path = background_hash + .as_deref() + .and_then(|h| metadata_artwork::existing_path(&artwork_dir, h)); return Ok(DeezerArtistEnrichment { deezer_id: Some(did), picture_url, @@ -425,6 +450,8 @@ async fn enrich_artist_deezer_inner( fans_count, bio_short, bio_full, + background_url, + background_path, }); } } @@ -473,7 +500,30 @@ async fn enrich_artist_deezer_inner( return Ok(DeezerArtistEnrichment::empty()); }; - // 4. Fetch the bio from the selected source (issue #295). Network + // 4. TheAudioDB lookup — one call, two consumers. The wide fanart + // backing the artist hero (issue #482) is fetched whatever the + // selected bio source is: Last.fm has no equivalent image, so + // gating this on `bio_source` would leave every Last.fm user + // with no hero at all. The bio half of the same response is only + // used when TheAudioDB IS the selected source — one request + // instead of two, which matters on their rate-limited free key. + let audiodb_result = TheAudioDbClient::new() + .artist_info(&artist_name, &active_lang) + .await; + // A *reached* API — match or not — is what licenses stamping + // `background_fetched_at` below. A transport error leaves it NULL so + // the next visit retries instead of caching a network blip as "this + // artist has no fanart" for the whole 30-day TTL. + let audiodb_reached = audiodb_result.is_ok(); + let audiodb = match audiodb_result { + Ok(info) => info, + Err(err) => { + tracing::warn!(?err, "TheAudioDB artist_info failed"); + None + } + }; + + // 5. Fetch the bio from the selected source (issue #295). Network // failures and missing matches are non-fatal — we still persist // the Deezer portion so the next refresh doesn't spam the // network. The source/language we used is stored alongside so a @@ -493,22 +543,16 @@ async fn enrich_artist_deezer_inner( } None => (None, None), }, - BioSource::TheAudioDb => { - let client = TheAudioDbClient::new(); - match client.artist_bio(&artist_name, &active_lang).await { - Ok(Some(info)) => (info.bio_short, info.bio_full), - Ok(None) => (None, None), - Err(err) => { - tracing::warn!(?err, "TheAudioDB artist_bio failed"); - (None, None) - } - } - } + BioSource::TheAudioDb => match audiodb.as_ref() { + Some(info) => (info.bio_short.clone(), info.bio_full.clone()), + None => (None, None), + }, }; let picture_url = hit.best_picture(); + let background_url = audiodb.and_then(|info| info.fanart_url); - // 5. Download artwork into the shared cache (best-effort). + // 6. Download artwork into the shared cache (best-effort). let picture_hash = match picture_url.as_deref() { Some(url) => metadata_artwork::download_and_cache(url, &artwork_dir).await, None => None, @@ -520,8 +564,19 @@ async fn enrich_artist_deezer_inner( Some(h) => crate::thumbnails::thumbnail_paths_for(&artwork_dir, h), None => (None, None), }; + // The hero paints the fanart full-bleed behind the header, so it's + // the one image we deliberately keep at full resolution — hence the + // `_full_res` variant, which skips the `_1x` / `_2x` thumbnail job: + // downscaling would only soften the crop, and nothing reads the tiers. + let background_hash = match background_url.as_deref() { + Some(url) => metadata_artwork::download_and_cache_full_res(url, &artwork_dir).await, + None => None, + }; + let background_path = background_hash + .as_deref() + .and_then(|h| metadata_artwork::existing_path(&artwork_dir, h)); - // 6. Upsert into the metadata cache (Deezer + bio fields land in the + // 7. Upsert into the metadata cache (Deezer + bio fields land in the // unified `metadata_artist` table in app.db so every profile // shares the same cache). `bio_source` / `bio_language` record // which provider produced the bio so a later switch invalidates @@ -533,8 +588,9 @@ async fn enrich_artist_deezer_inner( sqlx::query( "INSERT INTO app.metadata_artist (deezer_id, name, picture_url, picture_hash, fans_count, bio_short, bio_full, - bio_source, bio_language, fetched_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + bio_source, bio_language, background_url, background_hash, background_fetched_at, + fetched_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deezer_id) DO UPDATE SET name = excluded.name, picture_url = excluded.picture_url, @@ -544,6 +600,9 @@ async fn enrich_artist_deezer_inner( bio_full = excluded.bio_full, bio_source = excluded.bio_source, bio_language = excluded.bio_language, + background_url = excluded.background_url, + background_hash = excluded.background_hash, + background_fetched_at = excluded.background_fetched_at, fetched_at = excluded.fetched_at, expires_at = excluded.expires_at", ) @@ -556,12 +615,18 @@ async fn enrich_artist_deezer_inner( .bind(bio_full.as_deref()) .bind(active_source.as_str()) .bind(stored_lang) + .bind(background_url.as_deref()) + .bind(background_hash.as_deref()) + // Stamped even when the lookup came back empty — that's the whole + // point of the column: "we asked, TheAudioDB has nothing". Left NULL + // when the API couldn't be reached at all, so that retries. + .bind(audiodb_reached.then_some(now)) .bind(now) .bind(expires) .execute(&pool) .await?; - // 7. Link deezer_id on the local artist. + // 8. Link deezer_id on the local artist. if existing_deezer_id.is_none() { sqlx::query("UPDATE artist SET deezer_id = ? WHERE id = ?") .bind(hit.id) @@ -579,6 +644,8 @@ async fn enrich_artist_deezer_inner( fans_count: hit.nb_fan, bio_short, bio_full, + background_url, + background_path, }) } diff --git a/src-tauri/crates/core/src/artwork/metadata.rs b/src-tauri/crates/core/src/artwork/metadata.rs index 27dbe356..d60350a8 100644 --- a/src-tauri/crates/core/src/artwork/metadata.rs +++ b/src-tauri/crates/core/src/artwork/metadata.rs @@ -65,12 +65,30 @@ pub fn resolve_local_or_cached_path( } /// Download `url`, blake3-hash the bytes and write the file to -/// `/.jpg` if missing. Returns the hex hash on success. +/// `/.jpg` if missing, then queue the `_1x` / `_2x` thumbnail +/// job. Returns the hex hash on success. /// /// All failures (network, http != 2xx, oversize body, write error) are logged /// at WARN level and surfaced as `None`. Enrichment is best-effort: the /// caller should fall back to the remote URL. pub async fn download_and_cache(url: &str, dir: &Path) -> Option { + download_and_cache_inner(url, dir, true).await +} + +/// Same as [`download_and_cache`] but **skips thumbnail generation**. +/// +/// For images only ever consumed at full size — the artist hero fanart +/// (#482) is painted full-bleed, so a downscaled tier would only soften +/// it — the `_1x` / `_2x` job is pure CPU + disk for files nothing reads. +pub async fn download_and_cache_full_res(url: &str, dir: &Path) -> Option { + download_and_cache_inner(url, dir, false).await +} + +async fn download_and_cache_inner( + url: &str, + dir: &Path, + generate_thumbnails: bool, +) -> Option { let client = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)) @@ -108,7 +126,9 @@ pub async fn download_and_cache(url: &str, dir: &Path) -> Option { return None; } } - crate::artwork::thumbnails::spawn_thumbnail_job(out, dir.to_path_buf(), hash.clone()); + if generate_thumbnails { + crate::artwork::thumbnails::spawn_thumbnail_job(out, dir.to_path_buf(), hash.clone()); + } Some(hash) } diff --git a/src-tauri/crates/core/src/metadata/theaudiodb.rs b/src-tauri/crates/core/src/metadata/theaudiodb.rs index 4da3ef88..5abae791 100644 --- a/src-tauri/crates/core/src/metadata/theaudiodb.rs +++ b/src-tauri/crates/core/src/metadata/theaudiodb.rs @@ -1,4 +1,5 @@ -//! TheAudioDB API client — multi-language artist biographies. +//! TheAudioDB API client — multi-language artist biographies + wide +//! artist fanart. //! //! TheAudioDB is a community-maintained music database with a free v1 //! JSON API. We use it as an opt-in alternative to Last.fm for artist @@ -6,6 +7,14 @@ //! languages, so users who don't run a Last.fm account can still get a //! localized bio. //! +//! The same `search.php` response also carries the **wide** artist +//! images (`strArtistFanart*` / `strArtistWideThumb` / +//! `strArtistBanner`) that back the Spotify-style artist hero (issue +//! #482) — the rest of the pipeline only ever carried the square +//! Deezer photo. One lookup therefore serves both, which is why +//! [`TheAudioDbClient::artist_info`] returns bio *and* fanart instead +//! of a bio-only payload. +//! //! `search.php?s=` returns an `artists` array. The English bio is //! the suffixless `strBiography`; other languages are `strBiography{XX}` //! (e.g. `strBiographyFR`). We pick the requested language and fall back @@ -32,7 +41,10 @@ struct SearchResponse { /// Only the fields we use. TheAudioDB returns every value as a JSON /// string or null, so `Option` is the honest type throughout. -#[derive(Debug, Deserialize)] +/// +/// `Default` is derived so tests can build a payload from the one or +/// two fields they exercise instead of spelling out every language. +#[derive(Debug, Default, Deserialize)] struct ArtistPayload { #[serde(rename = "strArtist")] name: Option, @@ -56,6 +68,24 @@ struct ArtistPayload { bio_jp: Option, #[serde(rename = "strBiographyCN")] bio_cn: Option, + /// Wide 16:9 backdrops (1920×1080-ish). `strArtistFanart` is the + /// primary one; 2/3/4 are alternates uploaded by the community. + #[serde(rename = "strArtistFanart")] + fanart: Option, + #[serde(rename = "strArtistFanart2")] + fanart2: Option, + #[serde(rename = "strArtistFanart3")] + fanart3: Option, + #[serde(rename = "strArtistFanart4")] + fanart4: Option, + /// ~1000×185 wide thumbnail — narrower than fanart but still a + /// usable hero strip when no fanart exists. + #[serde(rename = "strArtistWideThumb")] + wide_thumb: Option, + /// 1000×185 banner, usually carrying the artist's logo. Last + /// resort: the text baked into it can clash with the header copy. + #[serde(rename = "strArtistBanner")] + banner: Option, } impl ArtistPayload { @@ -77,15 +107,32 @@ impl ArtistPayload { }; non_blank(primary).or_else(|| non_blank(&self.bio_en)) } + + /// First non-blank wide image, widest-and-cleanest first: real + /// fanart, then its community alternates, then the wide thumb, and + /// the logo banner only as a last resort. + fn fanart_url(&self) -> Option { + non_blank(&self.fanart) + .or_else(|| non_blank(&self.fanart2)) + .or_else(|| non_blank(&self.fanart3)) + .or_else(|| non_blank(&self.fanart4)) + .or_else(|| non_blank(&self.wide_thumb)) + .or_else(|| non_blank(&self.banner)) + } } -/// Cleaned artist bio returned to callers. `bio_short` is a truncated -/// lead-in for the collapsed UI; `bio_full` is the whole text. +/// Cleaned artist payload returned to callers. `bio_short` is a +/// truncated lead-in for the collapsed UI; `bio_full` is the whole +/// text; `fanart_url` is the wide hero image (issue #482). +/// +/// Every field is optional independently: an artist row can carry +/// fanart with no biography in any language, and vice-versa. #[derive(Debug, Clone)] -pub struct TheAudioDbArtistBio { +pub struct TheAudioDbArtist { pub name: String, pub bio_short: Option, pub bio_full: Option, + pub fanart_url: Option, } pub struct TheAudioDbClient { @@ -108,14 +155,17 @@ impl TheAudioDbClient { Self { http } } - /// Look up an artist bio by name in `lang`. Returns `Ok(None)` when - /// nothing matches or the matched artist has no biography in the - /// requested language nor English. - pub async fn artist_bio( + /// Look up an artist by name, returning its bio in `lang` (English + /// fallback) and its wide fanart URL. Returns `Ok(None)` only when + /// nothing matches the name — a match with neither bio nor fanart + /// still comes back as `Some` with both fields empty, so the caller + /// can cache the "looked it up, nothing there" outcome instead of + /// re-querying a rate-limited API on every visit. + pub async fn artist_info( &self, name: &str, lang: &str, - ) -> reqwest::Result> { + ) -> reqwest::Result> { let url = format!("{BASE_URL}/{FREE_API_KEY}/search.php"); let resp: SearchResponse = self .http @@ -134,17 +184,17 @@ impl TheAudioDbClient { return Ok(None); }; - let Some(full) = artist.bio_for_lang(lang).map(clean_text) else { - return Ok(None); - }; - if full.is_empty() { - return Ok(None); - } + let fanart_url = artist.fanart_url(); + let full = artist + .bio_for_lang(lang) + .map(clean_text) + .filter(|full| !full.is_empty()); - Ok(Some(TheAudioDbArtistBio { + Ok(Some(TheAudioDbArtist { name: artist.name.unwrap_or_default(), - bio_short: Some(make_summary(&full)), - bio_full: Some(full), + bio_short: full.as_deref().map(make_summary), + bio_full: full, + fanart_url, })) } } @@ -220,17 +270,38 @@ mod tests { let payload = ArtistPayload { name: Some("X".into()), bio_en: Some("English bio".into()), - bio_fr: None, bio_de: Some(" ".into()), // blank → ignored - bio_es: None, - bio_it: None, - bio_pt: None, - bio_nl: None, - bio_ru: None, - bio_jp: None, - bio_cn: None, + ..Default::default() }; assert_eq!(payload.bio_for_lang("fr").as_deref(), Some("English bio")); assert_eq!(payload.bio_for_lang("de").as_deref(), Some("English bio")); } + + #[test] + fn fanart_url_prefers_the_widest_image() { + let payload = ArtistPayload { + fanart: Some(" ".into()), // blank → skipped + fanart2: Some("https://cdn/fanart2.jpg".into()), + wide_thumb: Some("https://cdn/wide.jpg".into()), + banner: Some("https://cdn/banner.jpg".into()), + ..Default::default() + }; + assert_eq!( + payload.fanart_url().as_deref(), + Some("https://cdn/fanart2.jpg") + ); + } + + #[test] + fn fanart_url_falls_back_to_banner() { + let payload = ArtistPayload { + banner: Some("https://cdn/banner.jpg".into()), + ..Default::default() + }; + assert_eq!( + payload.fanart_url().as_deref(), + Some("https://cdn/banner.jpg") + ); + assert_eq!(ArtistPayload::default().fanart_url(), None); + } } diff --git a/src-tauri/migrations/app/20260802120000_metadata_artist_background.sql b/src-tauri/migrations/app/20260802120000_metadata_artist_background.sql new file mode 100644 index 00000000..ad24dbf3 --- /dev/null +++ b/src-tauri/migrations/app/20260802120000_metadata_artist_background.sql @@ -0,0 +1,15 @@ +-- Wide artist backdrop (TheAudioDB fanart) for the Spotify-style artist +-- hero — issue #482. Mirrors the existing `picture_url` / `picture_hash` +-- pair: the URL is kept as a remote fallback, the blake3 hash addresses +-- the downloaded file in the shared `metadata_artwork/` cache. +-- +-- `background_fetched_at` is the "we already looked" marker and is what +-- makes the lookup cheap: an artist with no fanart on TheAudioDB stores +-- NULL in both other columns, which is indistinguishable from "never +-- queried" without it — and TheAudioDB's free key is rate-limited, so +-- re-querying every artist page visit is not an option. Rows written +-- before this migration keep NULL here and are treated as a background +-- cache miss on their next refresh, which backfills them once. +ALTER TABLE metadata_artist ADD COLUMN background_url TEXT; +ALTER TABLE metadata_artist ADD COLUMN background_hash TEXT; +ALTER TABLE metadata_artist ADD COLUMN background_fetched_at INTEGER; diff --git a/src/app.css b/src/app.css index c3f382ee..83d91db1 100644 --- a/src/app.css +++ b/src/app.css @@ -198,6 +198,18 @@ } } +/* Artist hero backdrop cross-fade (issue #482) — opacity only, so a + fanart swapping in over the blurred-photo fallback dissolves instead + of popping. Skipped entirely under `prefers-reduced-motion`. */ +@keyframes artistHeroFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + @utility animate-breathing { animation: breathing 4.5s ease-in-out infinite; } diff --git a/src/components/common/ArtistHeroBackdrop.tsx b/src/components/common/ArtistHeroBackdrop.tsx new file mode 100644 index 00000000..fdf53f55 --- /dev/null +++ b/src/components/common/ArtistHeroBackdrop.tsx @@ -0,0 +1,89 @@ +/** + * Full-bleed backdrop painted behind the artist detail header — issue + * #482, the Spotify-style artist banner. + * + * Two tiers, decided by the caller and reported through `isFanart`: + * + * 1. **Real wide fanart** (TheAudioDB, cached in `metadata_artwork/`) — + * shown nearly crisp. Only a whisper of blur, enough to keep JPEG + * artefacts from crawling under the white header copy. + * 2. **The square artist photo** (Deezer or a local `artist.jpg`) — + * heavily blurred + upscaled, the same colour-field treatment + * [`SkinAmbientBackdrop`](../layout/SkinAmbientBackdrop.tsx) uses. A + * 1:1 image stretched across a 3:1 banner would be unreadable + * otherwise, and this tier is the universal fallback: it works + * offline and needs no network metadata at all. + * + * Legibility is not left to the theme: the image always carries a dark + * scrim so the white header text reads in light *and* dark themes — + * Spotify's header is dark-on-image in every mode. The bottom edge + * fades out through a mask instead of a hard-coded colour stop, so the + * hero dissolves into whatever the current theme/skin paints behind it. + */ +/** + * Escape a URL for interpolation inside a double-quoted CSS `url("…")`. + * Unlike the local artwork paths other backdrops paint, a hero source can + * be a **remote URL straight out of TheAudioDB** — third-party data that + * must not be able to close the string and inject CSS. Inside a quoted + * string only the backslash, the closing quote and raw newlines matter. + */ +function cssUrl(src: string): string { + return src.replace(/[\\"]/g, "\\$&").replace(/[\n\r\f]/g, ""); +} + +interface ArtistHeroBackdropProps { + /** Resolved image URL (asset:// or remote). `null` renders nothing. */ + src: string | null; + /** `true` for real wide fanart, `false` for the square-photo fallback. */ + isFanart: boolean; +} + +export function ArtistHeroBackdrop({ src, isFanart }: ArtistHeroBackdropProps) { + if (!src) return null; + + // Respect the OS-level reduced-motion preference (WCAG 2.3.3) for the + // cross-fade only — the image itself is static, so there's nothing to + // hide from a motion-sensitive user. Sampled synchronously per render, + // same trade-off as SkinAmbientBackdrop; `matchMedia` is missing in + // non-browser hosts, hence the `typeof` guard. + const reduceMotion = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Fade the bottom edge out instead of cutting it — the hero then + // blends into the page background whatever the theme paints there. + const fadeMask = "linear-gradient(to bottom, black 68%, transparent 100%)"; + + return ( +