From f7bf279feee89e6ef074e67449444bf24bc39cc9 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 1 Nov 2026 18:13:05 +0100 Subject: [PATCH 1/5] feat(preferences): remember the version whose what's new was seen --- .../crates/app/src/commands/preferences.rs | 43 +++++++++++++++++++ .../app/src/commands/preferences/tests.rs | 36 ++++++++++++++++ src-tauri/crates/app/src/lib.rs | 2 + src/lib/tauri/preferences.ts | 10 +++++ 4 files changed, 91 insertions(+) diff --git a/src-tauri/crates/app/src/commands/preferences.rs b/src-tauri/crates/app/src/commands/preferences.rs index ad274ea17..6f63337c0 100644 --- a/src-tauri/crates/app/src/commands/preferences.rs +++ b/src-tauri/crates/app/src/commands/preferences.rs @@ -222,6 +222,49 @@ pub async fn set_ui_zoom(state: tauri::State<'_, AppState>, zoom: f64) -> AppRes Ok(()) } +/// The version whose "What's new" the installation is past. App-wide: an +/// update arrives once per installation, and a second profile does not see +/// the panel again. The comparison lives with the pages, in +/// `src/lib/whatsNew.ts`; this only keeps the string. +const KEY_WHATS_NEW_MARKER: &str = "whats_new.seen_version"; + +async fn read_whats_new_marker(pool: &sqlx::SqlitePool) -> AppResult> { + Ok( + sqlx::query_scalar("SELECT value FROM app_setting WHERE key = ?") + .bind(KEY_WHATS_NEW_MARKER) + .fetch_optional(pool) + .await?, + ) +} + +async fn write_whats_new_marker(pool: &sqlx::SqlitePool, version: &str) -> AppResult<()> { + sqlx::query( + "INSERT INTO app_setting (key, value, value_type, updated_at) + VALUES (?, ?, 'string', ?) + ON CONFLICT(key) DO UPDATE + SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(KEY_WHATS_NEW_MARKER) + .bind(version) + .bind(Utc::now().timestamp_millis()) + .execute(pool) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn get_whats_new_marker(state: tauri::State<'_, AppState>) -> AppResult> { + read_whats_new_marker(&state.app_db).await +} + +#[tauri::command] +pub async fn set_whats_new_marker( + state: tauri::State<'_, AppState>, + version: String, +) -> AppResult<()> { + write_whats_new_marker(&state.app_db, &version).await +} + /// Mini-player window bounds in logical pixels. Persisted as a JSON blob /// under `app_setting['mini_player.bounds']` so the four fields move as /// one row — restoring half a position is worse than restoring none of diff --git a/src-tauri/crates/app/src/commands/preferences/tests.rs b/src-tauri/crates/app/src/commands/preferences/tests.rs index 76af4bd1f..a075e075f 100644 --- a/src-tauri/crates/app/src/commands/preferences/tests.rs +++ b/src-tauri/crates/app/src/commands/preferences/tests.rs @@ -88,3 +88,39 @@ fn every_option_round_trips_through_its_stored_value() { } assert_eq!(WindowChrome::parse("gnome"), None); } + +/// The real `app.db` schema: `value_type` and `updated_at` are NOT NULL and +/// checked, which a hand-made table would let a wrong write get past. +async fn migrated_app_db() -> sqlx::SqlitePool { + // One connection: each one to `:memory:` is a database of its own. + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("../../migrations/app") + .run(&pool) + .await + .unwrap(); + pool +} + +/// No row is the answer the frontend tells an update from a new install +/// with, and a later version replaces the row rather than adding one. +#[tokio::test] +async fn the_whats_new_marker_starts_absent_and_follows_the_version() { + let pool = migrated_app_db().await; + assert_eq!(read_whats_new_marker(&pool).await.unwrap(), None); + + write_whats_new_marker(&pool, "2.0.0-beta.1").await.unwrap(); + assert_eq!( + read_whats_new_marker(&pool).await.unwrap().as_deref(), + Some("2.0.0-beta.1") + ); + + write_whats_new_marker(&pool, "2.0.0").await.unwrap(); + assert_eq!( + read_whats_new_marker(&pool).await.unwrap().as_deref(), + Some("2.0.0") + ); +} diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 6c317b58d..7063fbd0d 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -593,6 +593,8 @@ pub fn run() { commands::preferences::set_auto_start, commands::preferences::get_ui_zoom, commands::preferences::set_ui_zoom, + commands::preferences::get_whats_new_marker, + commands::preferences::set_whats_new_marker, commands::preferences::get_window_chrome, commands::preferences::set_window_chrome, commands::preferences::set_window_frame_colors, diff --git a/src/lib/tauri/preferences.ts b/src/lib/tauri/preferences.ts index 27fa85f11..cee8dea55 100644 --- a/src/lib/tauri/preferences.ts +++ b/src/lib/tauri/preferences.ts @@ -36,6 +36,16 @@ export function setUiZoom(zoom: number): Promise { return announceSettingWrite(invoke("set_ui_zoom", { zoom })); } +/** The version whose "What's new" this installation is past, `null` when + * it never recorded one (`app_setting['whats_new.seen_version']`). */ +export function getWhatsNewMarker(): Promise { + return invoke("get_whats_new_marker"); +} + +export function setWhatsNewMarker(version: string): Promise { + return invoke("set_whats_new_marker", { version }); +} + /** * Who draws the frame around the window (issue #696). * From 6dcbf2552e3013a000adcaf1f02bf135cab16e08 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 1 Nov 2026 18:13:08 +0100 Subject: [PATCH 2/5] feat(discovery): show what changed after an update in a carousel panel --- src/components/discovery/FeatureArt.tsx | 278 +++++++++++++- src/components/discovery/FeatureCard.tsx | 5 +- src/components/discovery/FeatureHero.tsx | 66 ++++ src/components/discovery/WhatsNewPanel.tsx | 349 ++++++++++++++++++ src/components/discovery/discovery.css | 196 +++++++++- src/components/layout/AppLayout.tsx | 76 +++- src/components/layout/LyricsPanel.tsx | 27 +- src/components/layout/Sidebar.tsx | 11 +- src/components/layout/TopBar.tsx | 33 ++ src/components/player/ImmersiveView.tsx | 7 +- src/components/player/PlayerBar.tsx | 2 + src/components/views/AboutView.tsx | 54 ++- src/components/views/SettingsView.tsx | 19 + .../settings/sections/GeneralSection.tsx | 83 ++++- .../views/settings/settingsCatalog.ts | 6 + .../views/settings/settingsReset.ts | 12 + src/contexts/PlayerContext.tsx | 14 + src/hooks/useModalA11y.ts | 28 +- src/hooks/usePlayer.ts | 2 + src/hooks/useWhatsNew.ts | 240 ++++++++++++ src/lib/discovery.ts | 45 ++- src/lib/settingsRegistry.ts | 14 + src/lib/whatsNew.ts | 228 ++++++++++++ src/styles/skins/fluid.css | 21 ++ src/styles/skins/liquid.css | 11 + src/styles/skins/lounge.css | 6 + 26 files changed, 1806 insertions(+), 27 deletions(-) create mode 100644 src/components/discovery/FeatureHero.tsx create mode 100644 src/components/discovery/WhatsNewPanel.tsx create mode 100644 src/hooks/useWhatsNew.ts create mode 100644 src/lib/whatsNew.ts diff --git a/src/components/discovery/FeatureArt.tsx b/src/components/discovery/FeatureArt.tsx index e58c1a972..cc8d8d552 100644 --- a/src/components/discovery/FeatureArt.tsx +++ b/src/components/discovery/FeatureArt.tsx @@ -8,8 +8,9 @@ import type { CSSProperties, ReactNode } from "react"; * The motion stops under `prefers-reduced-motion` and under the reduced * visual effects level, and each scene still reads standing still. * - * Shared by the onboarding, the Home's first steps and, later, the - * "What's new" panel: a kind names an idea, not a screen. + * Shared by the onboarding, the Home's first steps and the "What's new" + * panel, whose large illustration (`FeatureHero`) draws one at the centre: + * a kind names an idea, not a screen. */ export type FeatureArtKind = | "crossfade" @@ -30,7 +31,12 @@ export type FeatureArtKind = | "smartPlaylist" | "enrichment" | "tagWorkshop" - | "wrapped"; + | "wrapped" + | "fluidInterface" + | "lyricsEditor" + | "theater" + | "releaseRadar" + | "playlistImport"; /** A row that rises in on a loop, to its own resting opacity. */ function rowStyle(delay: number, opacity: number): CSSProperties { @@ -617,6 +623,272 @@ const SCENES: Record = { ))} ), + // A window: the rail, a large cover, and tiles settling beside it. + fluidInterface: ( + <> + + + + {[0, 1, 2].map((i) => ( + + ))} + + ), + // Stamps down the margin, a line's words filling one after the other. + lyricsEditor: ( + <> + {[0, 1, 2].map((i) => ( + + ))} + + {[0, 1, 2].map((i) => ( + + + + + ))} + + + + ), + // A screen across the room: the cover stays, the controls fade away. + theater: ( + <> + + + + + + + ), + // A sweep going round, new releases lighting up as it passes. + releaseRadar: ( + <> + {[34, 22, 10].map((r) => ( + + ))} + + + + + {[ + [100, 30, 0], + [62, 58, 0.5], + [96, 62, 0.9], + ].map(([cx, cy, delay]) => ( + + ))} + + ), + // A file's entries travelling into a list, a missing one held in place. + playlistImport: ( + <> + + {[0, 1, 2].map((i) => ( + + ))} + {[0, 1, 2].map((i) => ( + + ))} + + + + + ), }; export function FeatureArt({ kind }: { kind: FeatureArtKind }) { diff --git a/src/components/discovery/FeatureCard.tsx b/src/components/discovery/FeatureCard.tsx index 6d1f1d43a..bd4bbc8e3 100644 --- a/src/components/discovery/FeatureCard.tsx +++ b/src/components/discovery/FeatureCard.tsx @@ -31,8 +31,9 @@ export interface FeatureCardProps { /** * One feature told in a card: a small animated scene, a title, the * benefit in a sentence and its state. Activated in a click when it is a - * switch. Shared by the onboarding and the Home's first steps, and meant - * for the "What's new" panel: nothing in it knows which screen it is on. + * switch. Shared by the onboarding and the Home's first steps: nothing in + * it knows which screen it is on. The "What's new" panel tells a feature at + * another scale, through `FeatureHero`, with the same scenes. */ export function FeatureCard({ art, diff --git a/src/components/discovery/FeatureHero.tsx b/src/components/discovery/FeatureHero.tsx new file mode 100644 index 000000000..25b6cd636 --- /dev/null +++ b/src/components/discovery/FeatureHero.tsx @@ -0,0 +1,66 @@ +import type { CSSProperties } from "react"; +import { FeatureArt, type FeatureArtKind } from "./FeatureArt"; +import "./discovery.css"; + +const ROWS = [ + { offset: 0, seconds: 64, reverse: false }, + { offset: 2, seconds: 82, reverse: true }, + { offset: 4, seconds: 72, reverse: false }, +]; + +/** Times the words repeat in one half of a row: one half has to outrun the + * illustration's width, even with a few short words. */ +const REPEATS = 4; + +/** + * A feature told large: its scene at the centre, over a gradient of the + * feature's colour with rows of its keywords sliding behind. Purely + * decorative, since the page says all of it in words. + * + * The gradient is painted once and never moves; each row is a layer of its + * own translated by the compositor, so the motion repaints nothing. Under + * `prefers-reduced-motion` and the reduced effects level the rows stand + * still and the scene holds, which reads just as well. + */ +export function FeatureHero({ + art, + color, + keywords, +}: { + art: FeatureArtKind; + color: string; + keywords: string[]; +}) { + return ( + + ); +} diff --git a/src/components/discovery/WhatsNewPanel.tsx b/src/components/discovery/WhatsNewPanel.tsx new file mode 100644 index 000000000..52c6eec2e --- /dev/null +++ b/src/components/discovery/WhatsNewPanel.tsx @@ -0,0 +1,349 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowRight, ChevronLeft, ChevronRight, X } from "lucide-react"; + +import { + AnimatedModalContent, + AnimatedModalShell, +} from "../common/AnimatedModalShell"; +import { useLibrary } from "../../hooks/useLibrary"; +import { useModalA11y } from "../../hooks/useModalA11y"; +import { usePlayer } from "../../hooks/usePlayer"; +import { useWhatsNewAutoOpen, useWindowFocused } from "../../hooks/useWhatsNew"; +import { + openPlaylistImport, + openView, + requestLyricsEditor, +} from "../../lib/discovery"; +import { + isFileTrack, + isRadioTrack, + isRemoteTrack, +} from "../../lib/playerSources"; +import { listAlbums } from "../../lib/tauri/browse"; +import { openTagWorkshop } from "../../lib/tagWorkshop"; +import type { WhatsNewPage, WhatsNewRelease } from "../../lib/whatsNew"; +import { FeatureHero } from "./FeatureHero"; +import { shuffledLibrary } from "./shuffledLibrary"; + +interface WhatsNewPanelProps { + isOpen: boolean; + release: WhatsNewRelease; + onClose: () => void; +} + +/** + * What changed in this version, page by page: a large illustration, a few + * lines, and a button that goes to the feature. Opened once after an update + * by [`useWhatsNew`], and on demand from the top bar, Settings and About. + * + * The pages come from [`whatsNew.ts`], written by hand per version. No + * `
` or `