Files
qbz/crates/qbz-ui/ui/state.slint
T
vicrodh b1b64f2dfb feat(ui): translation language setting in the lyrics flyout
New QbzSelect row (Auto (account language) + en es fr de it pt nl ja ru,
the owner-approved list) after the Size row; language names are native
literals, the same convention as the Appearance dropdown. The options
array lives on LyricsState (reseeded from Rust on language change) and
the choice persists per-user in lyrics_prefs.json as translation_language
("auto" or an ISO 639-1 code), sanitized on load (unknown -> auto) and
round-tripped through apply_to_ui/persist_from_ui like the other prefs.
Flyout popup height grows by the new row (and the comment now counts the
lite-fill row it had missed).

km
2026-07-22 19:59:27 -06:00

6041 lines
297 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared UI state — structs and globals the Rust controllers populate.
//
// The Rust side builds these models on a worker thread, then sets them
// on the global from the Slint event loop (see src/home.rs, src/main.rs).
import { JumpNavTab } from "primitives/JumpNavBar.slint";
export { JumpNavTab }
// Re-export the hotkey text-input guard from its leaf file so existing
// `import { UiFocusState } from "state.slint"` sites keep working, while
// low-level primitives import it directly from "focus-state.slint" (avoids a
// recursive import — state.slint imports JumpNavBar's JumpNavTab).
export { UiFocusState } from "focus-state.slint";
export struct AlbumCardItem {
id: string,
title: string,
artist: string,
artist-id: string, // for the clickable artist name -> artist page
genre: string, // shown in the hover overlay
year: string, // localized "MMM D, YYYY" — shown in the hover overlay
quality-tier: string, // "hires" | "cd" | "" — drives the badge icon
quality-label: string, // "Hi-Res: 24-bit / 96 kHz" — badge hover tooltip
ribbon: string,
ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | ""
artwork-url: string,
artwork: image, // empty until the artwork pipeline resolves it
is-favorite: bool, // filled accent heart on the card when favorited
is-pinned: bool, // filled accent pin badge (Home/For You Pinned section)
removing: bool, // fading out (un-favorited in the favorites view)
selected: bool, // checkbox state in a multi-select grid (LocalLibrary)
// --- List-row extras (AlbumListRow; ignored by the grid AlbumCard) ----
release-type: string, // "Album" | "EP" | "Single" | "Live" | "Compilation"
source: string, // "qobuz" | "local" | "plex" | "" — hideable column
quality-detail: string,// "24-bit / 96 kHz" — bare detail for QualityBadgeFull
track-count: string, // "12" — TRACKS column
plain-year: string, // "1973" — bare year for the list-row YEAR column
plays: int, // play count — shown ONLY in the Most Played Albums
// View-all (0 elsewhere, so no count is drawn).
}
// One entry in an A-Z jump strip (favorites tracks name-group / albums
// A-Z group). `index` is the first row index (tracks); albums jump
// proportionally by the entry's ordinal so it needs no exact offset.
export struct AlphaJump {
letter: string,
index: int,
}
// One button in the shared multi-select bulk bar (MultiSelectBar).
export struct BulkAction {
id: string, // emitted verbatim via MultiSelectBar.action(id)
label: string, // already-translated text — now the hover tooltip
icon: image, // the button glyph (the bar is icon-only)
danger: bool, // red-bordered destructive button (e.g. Remove)
needs-selection: bool, // dimmed + disabled while nothing is selected
}
// One titled discography bucket on the artist page (Albums, EPs & Singles,
// Live, …). Distinct from DiscoverSection so it can carry the raw server
// release_type key, the per-section sort, and the load-more flag without
// bleeding artist-only fields into Discover.
export struct ArtistReleaseSection {
release-type: string, // raw server key (album/epSingle/live/…)
title: string,
albums: [AlbumCardItem],
has-more: bool, // gates the per-section "Load more" affordance
sort-by: string, // default/newest/oldest/title-asc/title-desc
}
export struct DiscoverSection {
title: string,
// Discover endpoint path this section maps to (e.g. "/discover/newReleases").
// Empty for sections with no full-list page; non-empty shows the "View all"
// link and drives the DiscoverBrowse page.
endpoint: string,
albums: [AlbumCardItem],
}
// Compact "slim" card item — a small thumbnail with a title/subtitle,
// optionally rank-numbered (Popular albums, Recently Played).
export struct SlimItem {
id: string,
title: string,
subtitle: string,
rank: string, // "" = no rank
artwork-url: string,
artwork: image,
// Artist follow state — drives the ArtistCard Follow button.
following: bool,
// Pinned state — drives the ArtistGridCard pin badge (artist rows only;
// track/label/award slims are not pinnable and stay false).
is-pinned: bool,
}
// One editorial award on an album — the sidebar AWARDS list. Carries the
// award id (may be "" — some /album/get entries omit it; the controller
// then resolves it by name) so the laurel card can navigate to the award
// page. Mirrors Tauri's `AlbumAward { id?, name }`.
export struct AwardEntry {
id: string,
name: string,
}
// A playlist result in search (and the label landing's Playlists row).
// Qobuz returns up to four cover URLs per playlist; the card renders them
// as a count-based collage. Defined here (before LabelState) so the label
// landing can reuse it — Slint structs must be declared before first use.
export struct SearchPlaylistItem {
id: string,
title: string,
subtitle: string, // owner name + track count
is-pinned: bool, // filled accent pin badge (Home/For You Pinned section)
cover-count: int, // number of cover images, 0-4
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
// First tag name (uppercased accent subtag under the title; "" = none).
// Only the single-cover Discover playlist cards populate it.
category: string,
// Dominant colour of the single cover, filling the contain-fit letterbox
// on Discover playlist cards (default neutral dark; replaced once the
// cover decodes). Mosaic cards ignore it.
dominant-color: color,
// Ownership signals driving the card's overlay/menu affordances:
// is-owned — the user is the Qobuz owner → favorite (heart) only.
// is-following — the user follows this NON-owned Qobuz playlist → the
// overlay shows "unfollow" (check) instead of "follow".
// is-copied — already copied into the user's own playlists → the
// context-menu "Copy to your library" item is hidden.
// Editorial/foreign playlists default is-owned=false (follow + copy).
is-owned: bool,
is-following: bool,
is-copied: bool,
}
// One entry in the mixed Pinned carousel (Home / For You). `kind` selects
// which sub-struct is populated; the other two stay at their defaults
// (Slint has no unions). Declared after AlbumCardItem / SlimItem /
// SearchPlaylistItem — Slint structs must be declared before first use.
export struct PinnedItem {
kind: string, // "album" | "artist" | "playlist"
album: AlbumCardItem, // kind == "album"
artist: SlimItem, // kind == "artist"
playlist: SearchPlaylistItem, // kind == "playlist"
}
// The user's pinned items, newest pin first. ONE shared global feeds both
// the Home and For You "pinned" section arms (a single per-user list,
// mutated only by pin/unpin — no per-tab fetch lifecycle). Rust rebuilds
// it from the pinned store at session seed and after every toggle.
export global PinnedState {
in property <[PinnedItem]> items: [];
}
// Pin/unpin from the card pin glyph. The callback carries the full display
// snapshot (mirroring BlacklistActions.block-album) so Rust can persist a
// denormalized row without re-fetching: kind, id, title, subtitle,
// artwork-url. Wired in main.rs to the pinned store.
export global PinnedActions {
callback toggle-pin(/* kind */ string, /* id */ string, /* title */ string,
/* subtitle */ string, /* artwork-url */ string);
}
// One selectable category tag for the Qobuz Playlists multi-select filter.
// `name` is pre-localized by the discover-index response.
export struct PlaylistTagItem {
slug: string,
name: string,
selected: bool,
}
// One row in the search cortinilla (the live as-you-type dropdown under the
// header search box). Frontend-agnostic: a flat row carrying everything the
// SlimCard-style render needs. `source` is "qobuz" or "local" — it selects
// which controller seam fires on click (Qobuz media-action vs local play).
// `kind` is the navigable category ("album" | "artist" | "track" |
// "playlist" | "localTrack"). Declared before HomeState so the cortinilla
// overlay (a separate component file) can import it from state.slint.
export struct CortinillaRow {
kind: string, // "album" | "artist" | "track" | "playlist" | "localTrack"
id: string,
source: string, // "qobuz" | "local"
title: string,
subtitle: string,
artwork-url: string, // raw URL/fs path — drives the controller artwork job
artwork: image, // empty until the artwork pipeline resolves it
// Stable flat selection index across ALL cortinilla rows: 0 = top-result,
// then 1.. enumerated section by section (declaration order). Set by the
// controller so the overlay needs no cross-loop accumulator (Slint has
// none). Used for click dispatch AND keyboard/hover highlight.
flat-index: int,
}
// One labelled section in the cortinilla (e.g. "Albums", "Tracks",
// "From your library"). `kind` is the category these rows belong to (drives
// `cortinilla-view-more`). `has-more` toggles the trailing "View more" row.
export struct CortinillaSection {
title: string,
kind: string,
rows: [CortinillaRow],
has-more: bool,
}
// Discover / Home screen state.
export global HomeState {
in property <[DiscoverSection]> sections: [];
in property <[SlimItem]> popular: [];
in property <[SlimItem]> recent: [];
in property <[AlbumCardItem]> recent-albums: [];
// The #566 ported rails — same data pipelines as their ForYouState twins
// but separate lifecycles (Home loads them eagerly with the discover
// index; For You lazily on first open). The HomeView arms self-hide
// while empty. Titles: favorite-albums / release-watch are set by Rust;
// top-artists' title is @tr in the view arm (bare list, like For You).
// (qobuzMixes, the fourth ported section, is static tiles — no state.)
in property <DiscoverSection> favorite-albums;
// Library Albums (favoriteAlbums) sort order: 0 = Recently added
// (default), 1 = First added (reverse), 2 = Random (shuffled). Rust
// reorders HomeState.favorite-albums in place when this changes.
in-out property <int> library-albums-sort: 0;
in property <DiscoverSection> release-watch;
// Most Played Albums rail — top albums by local play count (title set by
// Rust from `album_play_history::top_albums`). Self-hides while empty.
in property <DiscoverSection> most-played-albums;
in property <[SlimItem]> top-artists: [];
// Qobuz Playlists row (Home + Editor's Picks). Single-cover cards; the
// active tab's set is swapped in by crate::home::select_tab.
in property <[SearchPlaylistItem]> playlists: [];
// Category tags for the Qobuz Playlists multi-select filter (from the
// discover-index `playlists_tags`). Empty when the response carries none.
in property <[PlaylistTagItem]> playlist-tags: [];
// Number of currently-selected category tags (0 = show all).
in property <int> playlist-tag-count: 0;
in property <bool> loading: false;
// Active Discover tab: "home" | "editorPicks" | "forYou". The
// three tabs draw from the same fetch; switching swaps which
// cached section set is shown (see crate::home::select_tab).
in-out property <string> active-tab: "home";
}
// Discover tab switching + the Qobuz Playlists category filter. Wired in
// main.rs to crate::home.
export global HomeActions {
callback select-tab(string);
// Toggle one category tag (by slug) in/out of the playlists filter —
// client-side, re-filters the cached row. Empty selection = show all.
callback toggle-playlist-tag(string);
// Clear all selected category tags (back to showing every playlist).
callback clear-playlist-tags();
// "View all" on the Recently Played Albums rail -> the full-page listing
// (RecentAlbumsView), fed from the local play-history store.
callback open-recent-albums();
// "View all" on the Qobuz Playlists rail -> the full-page playlist
// browse (PlaylistBrowseView); a fresh open resets its tag to All.
callback open-playlist-browse();
// Opens the full "Most Played Albums" page (MostPlayedAlbumsView), fed
// from the local play-count store.
callback open-most-played-albums();
// Fired by HomeView's init on every (re)mount. Rust re-reads the LOCAL
// recently-played store into the two rails IF a play was recorded while
// Home was off-screen (dirty-flag gated in Rust — a no-op otherwise, so
// mounting Home stays free).
callback home-mounted();
// Manual refresh (the row-header button next to the Recently Played
// Albums nav chevrons): unconditionally re-read the local rails.
callback refresh-recent();
// Library Albums sort changed (see HomeState.library-albums-sort). Rust
// reorders the cached base list and re-pushes favorite-albums + artwork.
callback set-library-albums-sort(int);
}
// RecentAlbumsView — the full "Recently Played Albums" page opened from the
// Home rail's "View all". Fed from the LOCAL play-history store (capped at
// 24, see crate::recently), so it needs no paging/search state; the loader
// repopulates `albums` on every navigation.
export global RecentAlbumsState {
in property <[AlbumCardItem]> albums: [];
in property <bool> loading: false;
}
// MostPlayedAlbumsView — the full "Most Played Albums" page opened from the
// rail's "View all". Ranked by local play count; each item carries its
// `plays`. `search` filters title/artist client-side (visibility only).
export global MostPlayedAlbumsState {
in property <[AlbumCardItem]> albums: [];
in property <bool> loading: false;
in-out property <string> search: "";
}
export global MostPlayedAlbumsActions {
// Filter the loaded page by title/artist. Rust re-sets `albums` to the
// matches and re-fires their artwork (mirrors the Qobuz-playlists filter).
callback filter(string);
}
// ---------------------------------------------------------------------------
// Discover section configurator (Slice 5) — prefs-driven render loop + modal.
// ---------------------------------------------------------------------------
// One Discover section as a render-loop descriptor. Rust emits the ordered
// ENABLED list per tab (DiscoverState.home-sections / editor-sections /
// foryou-sections); each surface iterates and dispatches on `id`
// (DiscoverySectionId::as_str). `kind` is a coarse render family used by the
// one tab-dependent arm (mostStreamed renders as a slim grid on Home, an album
// carousel on Editor's Picks). `section` carries the album-carousel payload for
// Home arms that share the Carousel component but have no dedicated HomeState
// field (newReleases / pressAwards / idealDiscography / editorPicks /
// qobuzissimes / mostStreamed-album / recentlyPlayedAlbums); For You arms read
// the typed ForYouState fields by id and ignore `section`.
export struct SectionDescriptor {
id: string, // "newReleases" | "qobuzPlaylists" | "qobuzMixes" | ...
kind: string, // "albumCarousel" | "slimGrid" | "playlistCarousel" | ...
section: DiscoverSection,
}
// One row in the configurator modal: a section in the active tab's FULL ordered
// list (enabled AND disabled — reorder applies to disabled rows too). `label`
// is resolved in Rust from the id->key map (Slint @tr cannot take a runtime
// key), returning the English string until the Slint gettext pipeline lands.
export struct ConfigRow {
id: string,
label: string,
enabled: bool,
}
// Single owner of the configurator render-driver lists AND the modal payload.
// `active-tab` lives here once (render loop + modal target read it); the Slice-3
// tab pill keeps writing HomeState.active-tab and crate::home::select_tab mirrors
// it into here (one extra set, no second tab-switch path).
export global DiscoverState {
// "home" | "editorPicks" | "forYou". home::select_tab writes this.
in-out property <string> active-tab: "home";
// Ordered ENABLED descriptors per tab (Rust recomputes on every mutation +
// on tab switch). Per-tab so a tab flip is a pure binding read. When
// active-tab is "forYou" the home/editor lists are pushed empty so the Home
// repeater renders nothing for that tab (unconditional-repeater form).
in property <[SectionDescriptor]> home-sections: [];
in property <[SectionDescriptor]> editor-sections: [];
in property <[SectionDescriptor]> foryou-sections: [];
// Configurator modal payload (for the modal's current target = active-tab).
in-out property <bool> configurator-open: false;
in property <[ConfigRow]> config-rows: []; // FULL list (enabled+disabled).
in property <int> enabled-count: 0;
in property <int> total-count: 0;
}
export global DiscoverActions {
callback open-configurator(); // gear -> opens for active-tab
callback close-configurator();
callback toggle-section(/* tab */ string, /* id */ string);
callback move-section(/* tab */ string, /* id */ string, /* dir -1|+1 */ int);
callback reset-tab(/* tab */ string);
}
// Discover "View all" full-list page — one album module (new releases,
// qobuzissimes, ...) opened from a Carousel's See-All link. Paginated.
export global DiscoverBrowseState {
in-out property <string> title;
// Rust-managed: the /discover/<x> endpoint + the next page offset, so
// DiscoverBrowseActions.load-more knows what to fetch next.
in-out property <string> endpoint;
in-out property <int> next-offset;
// Full (unfiltered) loaded page set. The view binds to `visible`
// below; the Rust side rebuilds `visible` from `albums` whenever the
// search query changes (client-side title/artist filter).
in-out property <[AlbumCardItem]> albums;
// The albums actually rendered — equals `albums` with no search, or a
// filtered subset while the search box is non-empty.
in-out property <[AlbumCardItem]> visible;
in-out property <bool> loading;
in-out property <bool> loading-more;
in-out property <bool> has-more;
// Header tools (1:1 with Tauri DiscoverBrowseView).
// Client-side search over the loaded albums; while non-empty,
// infinite-scroll load-more is disabled (Tauri does the same).
in-out property <string> search-query;
// "grid" | "list" — the view toggle.
in-out property <string> view-mode: "grid";
}
export global DiscoverBrowseActions {
// Fetch the next page (offset = DiscoverBrowseState.next-offset).
callback load-more();
// Re-filter `visible` after `search-query` changed (UI thread).
callback search-changed();
}
// Qobuz Playlists "View all" full-list page — the playlist twin of
// DiscoverBrowseState (opened from the Home / Editor's Picks rail).
// Paginated server-side by category tag + the shared genre selection;
// searched client-side over the loaded set.
export global PlaylistBrowseState {
// Localized page heading, pushed from Rust on every navigate (a @tr
// property default would not react to a language switch).
in-out property <string> title;
// Single-select category tag bar: the /playlist/getTags set, names
// pre-localized by the API. `selected` flags mirror selected-tag.
in-out property <[PlaylistTagItem]> tags;
// Active tag slug; "" = All. Changing it re-fetches from offset 0.
in-out property <string> selected-tag;
// Full loaded page set. The view renders `visible` below — equal to
// `playlists` with no search, or a filtered subset while the search
// box is non-empty (Rust rebuilds it on search-changed).
in-out property <[SearchPlaylistItem]> playlists;
in-out property <[SearchPlaylistItem]> visible;
in-out property <bool> loading;
in-out property <bool> loading-more;
in-out property <bool> has-more;
// Rust-managed: the next page offset for load-more.
in-out property <int> next-offset;
// Client-side search over the loaded playlists; while non-empty,
// infinite-scroll load-more is disabled (Tauri does the same).
in-out property <string> search-query;
// "grid" | "list" — persists across navigations like DiscoverBrowse.
in-out property <string> view-mode: "grid";
}
export global PlaylistBrowseActions {
// Fetch the next page (offset = PlaylistBrowseState.next-offset).
callback load-more();
// Re-filter `visible` after `search-query` changed (UI thread).
callback search-changed();
// Select a category tag (slug; "" = All) — server-side re-fetch from
// offset 0 keeping the shared genre selection.
callback select-tag(string);
}
// Discover > For You state. The For You tab has heterogeneous,
// ordered sections (album carousels, a track carousel, artist
// carousels), so it renders from its own state rather than the
// uniform HomeState.sections used by Home / Editor's Picks.
// Populated lazily by crate::foryou the first time the tab opens.
// One album-seeded radio tile in the For You "Radio Stations" row.
export struct RadioStationItem {
album-id: string,
title: string,
artist: string,
artwork-url: string,
artwork: image,
}
export global ForYouState {
in property <DiscoverSection> release-watch;
in property <[RadioStationItem]> radio-stations: [];
in property <DiscoverSection> recent-albums;
// Favorite Albums — the user's favorited albums (For You #6).
in property <DiscoverSection> favorite-albums;
// Most Played Albums — top albums by local play count (same data as
// HomeState.most-played-albums; local, not tab-specific).
in property <DiscoverSection> most-played-albums;
in property <[SlimItem]> recent-tracks: [];
in property <[SlimItem]> top-artists: [];
in property <[SlimItem]> artists-to-follow: [];
in property <DiscoverSection> more-from-library;
in property <DiscoverSection> rediscover;
// Spotlight — one favorite artist highlighted with a hero + a row
// of their content (top tracks card, radio card, albums).
in property <bool> spotlight-visible: false;
in property <string> spotlight-artist-id;
in property <string> spotlight-name;
in property <string> spotlight-category;
in property <string> spotlight-image-url;
in property <image> spotlight-image;
in property <bool> spotlight-has-top-tracks: false;
in property <[AlbumCardItem]> spotlight-albums: [];
in property <bool> loading: false;
// True once a load has completed, so the tab does not refetch on
// every visit.
in-out property <bool> loaded: false;
}
// Discover > Recommendations (the 4th tab). External-recommendations engine
// (Last.fm + ListenBrainz -> Qobuz) carousels, plus the Qobuz-editorial
// cold-start fallback. Mirrors ForYouState's lazy-load latch + self-hiding
// rows (each row's `length > 0` gate). Populated by crate::external_reco.
export global ExternalRecoState {
in property <bool> loading: false;
in-out property <bool> loaded: false;
// Per-row "still loading" flags. The controller sets the rows it is about
// to build true at spawn and clears each as its builder resolves, so a SLOW
// row (the Weekly ListenBrainz playlists resolve a few seconds after the
// fast rows) shows its own skeleton instead of silently missing — the UI now
// signals "more is still loading" during the progressive paint.
in property <bool> pending-rec-artists-common: false;
in property <bool> pending-rec-artists-recent: false;
in property <bool> pending-rec-albums: false;
in property <bool> pending-fresh-releases: false;
in property <bool> pending-weekly-exploration: false;
in property <bool> pending-weekly-jams: false;
in property <bool> pending-deep-cut-albums: false;
in property <bool> pending-top-albums: false;
in property <bool> pending-top-artists: false;
// True when no external source is connected: the editorial fallback
// (top-albums + top-artists) shows instead of the personalized rows.
in property <bool> editorial-fallback: false;
// Recommended artists from your COMMON taste (overall top -> similar).
in property <[SlimItem]> rec-artists-common: [];
// Recommended artists from your RECENT taste (1-month top -> similar).
in property <[SlimItem]> rec-artists-recent: [];
// Recommended albums (Last.fm artist top-albums, not scrobbled).
in property <DiscoverSection> rec-albums;
// Fresh releases (ListenBrainz, from artists you follow).
in property <DiscoverSection> fresh-releases;
// Weekly Exploration (ListenBrainz discovery) tracks.
in property <[SlimItem]> weekly-exploration: [];
// Weekly Jams (ListenBrainz familiar) tracks.
in property <[SlimItem]> weekly-jams: [];
// Deep-cut albums from artists you know.
in property <DiscoverSection> deep-cut-albums;
// Cold-start fallback: Qobuz editorial top albums.
in property <DiscoverSection> top-albums;
// Cold-start fallback: Qobuz editorial top artists.
in property <[SlimItem]> top-artists: [];
// Results-cache freshness window selector index for the Recommendations tab:
// 0=24h 1=36h 2=48h 3=72h. Seeded from discover_prefs at startup; default 48h.
in-out property <int> cache-ttl-index: 2;
}
// Recommendations-tab-only actions: cache-window select + force refresh. Wired to
// discover_prefs (persist) + external_reco (refetch). Other Discover tabs unaffected.
export global ExternalRecoActions {
callback set-cache-ttl(int); // index 0..3 -> persist hours + reseed
callback refresh-now(); // force-invalidate the results cache + refetch
}
// One genre in the Filter-by-genre popup's simple grid.
export struct GenreChip {
id: string,
name: string,
selected: bool,
}
// One row of the advanced-view genre tree, flattened (level 0/1/2)
// with only currently-visible rows emitted by the Rust side.
export struct GenreTreeRow {
id: string,
name: string,
level: int, // 0 parent, 1 child, 2 grandchild
selected: bool,
expanded: bool,
has-children: bool,
count: int, // loaded child count (0 = none / unknown)
}
// Filter-by-genre state. The selection is shared across the three
// Discover tabs (matching Tauri's single "home" genre context) — it
// re-fetches the discover index with the chosen genre ids. Populated
// by crate::genre_filter.
export global GenreFilterState {
in property <[GenreChip]> genres: [];
in property <[GenreTreeRow]> tree: [];
in property <int> selected-count: 0;
in-out property <bool> open: false;
in-out property <bool> remember: true;
in-out property <bool> advanced: false;
in-out property <string> search-query;
in property <bool> loading: false;
}
export global GenreFilterActions {
// Set the context the popup edits ("discover" | "favorites"), called
// when a surface opens the shared popup.
callback set-context(string);
callback toggle(string /* genre id */);
callback toggle-expand(string /* genre id */);
callback search(string /* query */);
callback clear();
callback set-remember(bool);
callback set-advanced(bool);
}
// Small string helpers backed by Rust. Slint 1.16 has no `contains`
// builtin, so the searchable QbzSelect filter (long device lists) needs a
// native case-insensitive substring test. Pure so it can be called from
// property bindings; reusable by any searchable list.
export global TextUtil {
pure callback contains-ci(string /* haystack */, string /* needle */) -> bool;
}
// The single, shared track-row model. Superset of every track-list
// surface (album, artist popular, search, favorites, mix, playlist),
// rendered by the shared primitives/TrackRow component. Fields that a
// given surface doesn't use stay empty/false:
// - `number` explicit track number (album/playlist); rows fall
// back to their list index when empty.
// - `album` shown only by rows with show-album (mix/playlist).
// - `selected` multi-select state (artist Popular, playlist editor).
// - `artwork` the cover thumbnail for show-artwork rows.
export struct TrackItem {
id: string,
number: string,
title: string,
artist: string,
album: string,
duration: string,
quality-tier: string,
// Exact bit-depth / sample-rate line, e.g. "24-bit / 96 kHz" — feeds the
// track-row quality badge (empty for unknown / where only the tier shows).
quality-detail: string,
explicit: bool,
selected: bool,
artwork-url: string,
artwork: image,
// Favorite state — drives the heart fill and the optimistic toggle.
// Populated from the user's favorite-track set when each row is built.
is-favorite: bool,
// Ids for the clickable artist/album links in the row. Empty = the
// text is plain (not a link). artist-id -> artist view, album-id ->
// album view, both via the media-action bubble channel.
artist-id: string,
album-id: string,
// "qobuz" | "local" | "plex" | "" — drives the Local Library source
// indicator (shown instead of the favorite/offline columns).
source: string,
// Artist-blacklist greyout: when true the row renders dimmed + inert
// (no play/drag/favorite/download, nav-only menu) with a blacklist
// glyph in the number/play cell. Stamped per-row by the controllers
// (Task 6) from the user's blacklisted-artist set; never set on local
// / Plex rows. Defaults false (Slint struct fields zero-init).
is-blacklisted: bool,
// Fading out (un-favorited in the favorites view) before removal.
removing: bool,
// Offline-cache status: 0 none, 1 queued, 2 downloading, 3 ready, 4 failed.
// Drives the per-row offline affordance (make-offline / ring / check / warn).
cache-status: int,
// Download progress 0.0..1.0 — meaningful only while cache-status == 2.
cache-progress: float,
// True while this track's offline CMAF bundle is being decrypted for
// playback — drives the padlock glyph (swaps the play/equalizer icon).
unlocking: bool,
// Multi-disc grouping: the disc number whose "Disc N" header should be
// rendered ABOVE this row. Set (> 0) only on the first track of each
// disc, and only when the album spans more than one disc; 0 otherwise
// (single-disc albums and every non-leading track render flat, with no
// header). The "Disc" word is translated in the delegate via @tr so it
// flows through the gettext pipeline, matching the Tauri markup
// `{$t('album.disc')} {discNum}` (groupByDisc + isMultiDisc).
disc-header-number: int,
// Classical "work" grouping: the work-section header (e.g. "Symphony No. 9
// (Beethoven)") rendered ABOVE this row. Set (non-empty) only on the first
// track of each consecutive same-work run, and only on albums that carry
// work metadata; "" otherwise (non-classical albums and every non-leading
// track render flat, with no header). Mirrors the official Qobuz player's
// per-work sections (PR #536); the album view stamps it, all other track
// surfaces leave it "" so the row stays flat.
//
// E3: `work-header` now holds the work TITLE only; the composer is split
// out below so its name can be a clickable link to the artist page.
work-header: string,
// Classical work composer (E3): rendered as a clickable artist link inside
// the work header's parentheses. work-composer-id "" => plain (no link),
// and an empty name => no parenthetical at all. Set only on the work
// header's leading row, by the album view; "" everywhere else.
work-composer-name: string,
work-composer-id: string,
}
// Album detail screen state.
// One credited album artist for the header credit line (E1: full
// "Artist • Pianist • Composer (compositor)" list, not just the interpreter).
export struct ArtistCredit {
// Qobuz artist id as a string (clickable → artist page). "" = not clickable.
id: string,
name: string,
// Localized role suffix shown in parens (e.g. "compositor"); "" for the
// main artist(s).
role: string,
}
export global AlbumState {
in property <string> id;
in property <string> title;
// Primary interpreter (kept for back-compat: now-playing, fallbacks).
in property <string> artist;
in property <string> artist-id;
// Full credited-artist list with roles for the header credit line.
in property <[ArtistCredit]> artists: [];
// Network URL of the cover — used by the artwork right-click
// menu for Open-in-browser and Save-as actions.
in property <string> artwork-url;
in property <bool> has-custom-cover: false;
in property <string> info-line;
// Meta-line split around the label so the header can render the label
// as a clickable link: meta-pre (year) + [label] + meta-post
// (genre • N tracks • duration). info-line stays the fallback.
in property <string> meta-pre;
in property <string> meta-post;
in property <string> quality-tier;
// "24-bit / 96 kHz" — quality-badge detail line.
in property <string> quality-detail;
// Editorial description / review (HTML stripped). May be empty.
in property <string> description;
// Truncated description for the header; full text lives in the modal.
in property <string> description-short;
// Half-length truncation used when the content area is space-constrained
// (ShellState.content-constrained) so the header stays compact and the
// tracks keep priority. Falls back to the full short text when there's room.
in property <string> description-shorter;
in property <image> artwork;
// Static Immersive-style blurred artwork field for the header backdrop.
in property <image> header-atmosphere;
// Artwork-derived tint for the header fallback gradient.
in property <color> header-color: #181820;
in property <string> label;
// Label id for the sidebar label card → label page navigation ("" = none).
in property <string> label-id;
in property <[AwardEntry]> awards: [];
// True when the album ships a downloadable booklet/liner-notes PDF
// (Qobuz goodies). Gates the booklet button in the header — not every
// album has one.
in property <bool> has-booklet: false;
// True when EVERY track of the album is cached offline (cache-status 3).
// Swaps the header menu's "Make available offline" for "Refresh offline
// copy" (Tauri's isAlbumFullyDownloaded). Kept live as downloads land.
in property <bool> album-fully-cached: false;
// Whether the album is in the user's favorites (library). Drives the
// header heart (filled accent when on). Seeded from fav_cache on open,
// flipped optimistically on toggle.
in property <bool> is-favorite: false;
// True while a favorite toggle is in flight (spinner on the heart).
in property <bool> favorite-loading: false;
// Whether THIS album is blocked (album blacklist). Drives the header menu
// toggle ("Block this album" / "Unblock album"). Seeded on open.
in property <bool> is-album-blocked: false;
// Whether THIS album is pinned to Home; seeded on open, flipped by
// toggle-pin when ids match.
in property <bool> pinned: false;
// Multi-select (track selection) mode for the track list. in-out: the
// toolbar toggle flips it from the UI; Rust resets it on album change.
in-out property <bool> multi-select: false;
// Number of selected rows (drives the bulk bar count + select-all label).
in property <int> selected-count: 0;
in property <[TrackItem]> tracks: [];
in property <bool> loading: false;
// True when this is a Local Library album (local files / offline copies).
// Routes the play actions to local playback + hides Qobuz-only chrome.
in property <bool> is-local: false;
// Local source ("user" | "qobuz_download" | "plex" | ""). Gates the
// tag-editor pencil (hidden for Plex; tag write isn't supported there).
in property <string> source;
// ---- Bottom carousels (webplayer parity) + external links -----------
// "More from this artist" carousel ("Del mismo artista..." on the
// webplayer / "By the same artist" in Tauri). Populated after the album
// renders from /artist/getReleasesGrid. `endpoint` non-empty on the
// section shows the "View all" link (-> artist discography).
in property <DiscoverSection> more-from-artist;
in property <bool> show-more-from-artist: false;
// "Listening suggestions" carousel ("Sugerencias de escucha" on the
// webplayer) from /album/suggest. Album cards -> open album.
in property <DiscoverSection> suggestions-section;
in property <bool> show-suggestions: false;
// Second suggestions row, sourced from Last.fm similar artists (one album
// each), deduped against the Qobuz row. Sits under the same heading, gated
// on Last.fm being connected. Empty -> hidden.
in property <DiscoverSection> lastfm-suggestions-section;
in property <bool> show-lastfm-suggestions: false;
// External links sidebar block (Last.fm / Discogs / MusicBrainz),
// Feishin-style. URLs are prebuilt by the controller from artist + title;
// each row opens its url via AlbumActions.open-external-link. Hidden for
// local-only albums (no Qobuz artist/title to search).
in property <bool> show-external-links: false;
in property <string> lastfm-url;
in property <string> discogs-url;
in property <string> musicbrainz-url;
}
// ---------------------------------------------------------------------------
// Track Info + Album Info (Credits/Review) modals — 1:1 port of Tauri's
// TrackInfoModal.svelte + AlbumCreditsModal.svelte. Data is fetched fresh by
// the Rust controller (crate::info_modals) via core.get_track/get_album and
// mapped to these plain structs. Roles are parsed + grouped + localized in
// qbz_qobuz::performers (frontend-agnostic).
// ---------------------------------------------------------------------------
// One grouped-by-role credit cell in the Track Info grid. `role` is the
// already-localized, UPPER-CASED label; each name is clickable -> musician.
export struct InfoCreditRow {
role: string,
// Original (non-localized) role string, passed to the musician-nav hint.
role-raw: string,
names: [string],
// Names pre-joined with ", " — rendered as a single wrapping Text so a long
// credit list (e.g. 5 producers) wraps instead of clipping at the column edge.
names-line: string,
}
// A row in the Track Info 2-column credits grid. The Rust side pairs the
// ordered cells (left + optional right) so the modal needs no dynamic grid
// placement. `has-right` is false on a trailing odd cell.
export struct InfoCreditPair {
left: InfoCreditRow,
right: InfoCreditRow,
has-right: bool,
}
// One performer line inside an expanded Album-Credits track. `roles` is the
// raw ", Role1, Role2" suffix (empty when the performer has no roles).
export struct AlbumCreditPerformer {
name: string,
roles: string,
// First role (clean) — the musician-nav role hint (Tauri roles[0]).
primary-role: string,
}
// One track row in the Album-Credits "Credits" tab.
export struct AlbumCreditTrack {
id: string,
number: string,
title: string,
artist: string,
// True when the row has performers or a copyright line (expandable).
has-credits: bool,
performers: [AlbumCreditPerformer],
copyright: string,
}
// Track Info modal — opened from the NPB (i) button / song-card title /
// TrackRow context menu via media-action("track", id, "track-info").
export global TrackInfoState {
in-out property <bool> open: false;
in property <bool> loading: false;
in property <string> error: "";
in property <string> title;
in property <string> album;
in property <string> artist;
// "" -> artist rendered as plain text (not a link).
in property <string> artist-id;
in property <string> duration;
in property <string> quality;
// "" -> the row is hidden.
in property <string> isrc;
in property <string> label;
in property <string> label-id;
// Paired (2-column) ordered credit cells.
in property <[InfoCreditPair]> credits: [];
// Credits split into two independent columns (even-index -> left,
// odd-index -> right) so the modal renders two natural vertical stacks
// instead of height-coupled paired rows (which left vertical gaps).
in property <[InfoCreditRow]> credits-left: [];
in property <[InfoCreditRow]> credits-right: [];
in property <string> copyright;
}
export global TrackInfoActions {
callback close();
callback open-artist(string /* artist-id */);
callback open-label(string /* label-id */);
callback open-musician(string /* name */, string /* role */);
// Populate TrackInfoState for the given track WITHOUT opening the floating
// modal — used by the immersive split Track Info panel, which renders the
// same data inline. Fetch+map+apply are identical to open-track-info; the
// ONLY difference is `open` stays false so the overlay modal never appears.
callback load-inline(string /* track-id */);
}
// One card in the immersive Suggestions panel (data-panels.md §6): either a
// curated artist PLAYLIST (`kind == "playlist"`, book-collage of up to 3 album
// covers) or the seed-track SONG RADIO (`kind == "radio"`, diamond-collage of
// up to 4 covers). `cover-urls` is the artwork pipeline's input (slot-indexed);
// `covers` holds the decoded images, applied per-slot by the controller. Slots
// 0-3 are addressed via the parallel cover0..cover3 fields (Slint structs can't
// have per-element image arrays painted in place, so flat slots are used — same
// pattern as the search/favorites playlist collages).
export struct SuggestionCard {
// "playlist" | "radio".
kind: string,
title: string,
subtitle: string,
// Remote URLs for the (up to 4) collage slots — the artwork-job input.
cover-urls: [string],
// Decoded collage covers, painted per-slot by the artwork pipeline.
cover0: image,
cover1: image,
cover2: image,
cover3: image,
// Playlist-card target id ("" for the radio card).
playlist-id: string,
// Radio-card seed (empty for playlist cards).
seed-track-id: string,
seed-track-name: string,
seed-artist-id: string,
// "qobuz" (playlist) | "qbz" (radio) — drives the corner badge glyph.
badge: string,
// Radio card: true while the Song Radio session is building (spinner).
loading: bool,
}
// Immersive Suggestions panel state (split-only, split-panel == 2). Populated
// by crate::suggestions::apply_suggestions; the recommended-track list reuses
// the shared TrackItem struct.
export global SuggestionsState {
in property <bool> loading: false;
in property <string> error: "";
// The artist whose suggestions are loaded (dedup guard mirror).
in property <string> artist-id: "";
// The seed track these suggestions were built for.
in property <string> seed-track-id: "";
in property <[SuggestionCard]> cards: [];
in property <[TrackItem]> tracks: [];
}
export global SuggestionsActions {
// Play / queue / play-next a curated artist playlist by id.
callback play-playlist(string /* playlist-id */);
callback queue-playlist(string /* playlist-id */);
callback play-next-playlist(string /* playlist-id */);
// Start the seed-track Song Radio: (seed-track-id, seed-track-name, seed-artist-id).
callback start-radio(string, string, string);
// Play a single recommended track by id.
callback play-track(string /* track-id */);
// Load suggestions for the given track id (the Rust side reads the
// artist-id + name off NowPlayingState). Fired on panel entry and on
// now-playing track change while the panel is open. A "" id resets to the
// empty (no-track) state.
callback load(string /* track-id */);
}
// Playlist "Suggested Songs" section (T8). 1:1 port of the Svelte
// `PlaylistSuggestions` component: a fixed page of suggested tracks (6
// visible) with per-row add / play / info / dismiss + play-on-hover,
// driven by a Rust-held pool + pagination (crate::playlist_suggestions).
// DISTINCT from the immersive SuggestionsState/Actions above (different
// surface, different controller) — the name split is deliberate.
export struct PlaylistSuggestionRow {
// Qobuz ids carried as STRINGS (parsed to u64 in Rust) — matches the
// shared TrackItem id convention; Qobuz catalog ids can exceed Slint's
// 32-bit `int`, and album ids are alphanumeric.
track-id: string,
title: string,
artist-name: string,
artist-id: string,
album-title: string,
album-id: string,
// Remote cover URL (artwork-job input) + the decoded thumbnail.
artwork-url: string,
artwork: image,
duration-label: string,
// Dev-mode "why suggested" string (empty in production — include_reasons=false).
reason: string,
// True while this row's add call is in flight; flips to `added` (a brief
// check state) on success, just before the row leaves the pool.
adding: bool,
added: bool,
}
// Playlist suggestions section state. Populated by
// crate::playlist_suggestions::*; the row list reuses PlaylistSuggestionRow.
export global PlaylistSuggestionsState {
// The user has launched suggestions for the open playlist (manual
// activation gate — mirrors the Svelte `suggestionsActivated`). Until
// then the section shows only the "Suggest Songs" CTA.
in-out property <bool> activated: false;
in property <bool> loading: false;
// A background pool expansion (load-more / auto-variety) is in flight.
in property <bool> loading-more: false;
// More pages are available beyond the visible 6 (drives the refresh hint).
in property <bool> has-more: false;
// The filtered pool is exhausted (nothing left to suggest) — the loaded
// section hides itself, matching the Svelte `isEmpty` branch.
in property <bool> is-empty: false;
in property <string> error: "";
in property <[PlaylistSuggestionRow]> rows: [];
}
export global PlaylistSuggestionsActions {
// Launch suggestions for the open playlist: gather seed artists +
// exclude ids off the loaded Qobuz tracks, then fetch the first pool page.
callback activate();
// Advance the visible page (wraps + expands the pool on cycle — Svelte parity).
callback refresh();
// Add a suggested track to the open playlist, then drop it from the pool.
callback add-track(string /* track-id */);
// Preview / play a single suggested track now.
callback play-track(string /* track-id */);
// Dismiss a suggestion (sticky per-playlist) + drop it from the pool.
callback dismiss-track(string /* track-id */);
// Open the Track Info modal for a suggestion.
callback show-info(string /* track-id */);
// Navigate to a suggestion's album / artist.
callback go-album(string /* album-id */);
callback go-artist(string /* artist-id */);
}
// Album Info modal — opened from the AlbumPageView header (i) button via
// media-action("album", id, "info").
export global AlbumInfoState {
in-out property <bool> open: false;
in property <bool> loading: false;
in property <string> error: "";
// "credits" | "review". The Review tab only exists when has-review.
in-out property <string> active-tab: "credits";
in property <bool> has-review: false;
in property <image> artwork;
in property <string> title;
in property <string> artist;
in property <string> label;
in property <string> label-id;
// Localized "September 2, 2021" ("" hides the date span).
in property <string> release-date;
// "Hard Rock · 10 tracks · 1h 21m".
in property <string> meta-line;
// "24-Bit / 96 kHz" ("" hides).
in property <string> quality;
in property <[AlbumCreditTrack]> tracks: [];
// HTML-stripped editorial review ("" -> no Review tab).
in property <string> review;
}
export global AlbumInfoActions {
callback close();
callback set-tab(string /* "credits" | "review" */);
callback play-track(string /* track-id */);
callback open-label(string /* label-id */);
callback open-musician(string /* name */, string /* role */);
}
// Album-view actions wired to Rust callbacks.
export global AlbumActions {
// Filter the visible track list by a query (client-side).
callback search(string);
// Toggle multi-select (track selection) mode on the album track list.
callback toggle-multi-select();
// Bulk-bar action over the selected rows (id = "select-all" | "play-next"
// | "queue" | "add-to-playlist" | "add-to-favorites" | "make-offline" |
// "clear").
callback bulk-action(string);
// Per-disc context-menu action on a multi-disc album's "Disc N" header —
// scoped to that disc's tracks only (action = "play" | "shuffle" | "queue"
// | "play-next"). The whole-album header buttons are unaffected.
callback disc-action(int /* disc number */, string /* action */);
// Open an external link (Last.fm / Discogs / MusicBrainz) in the system
// browser. The url is prebuilt by the controller (AlbumState.*-url).
callback open-external-link(string /* url */);
}
// ---------------------------------------------------------------------------
// Booklet (digital liner-notes PDF) reader — 1:1 port of Tauri's
// BookletViewer.svelte. The Rust controller (crate::booklet) downloads the
// album goody PDF, rasterizes pages via the qbz-booklet crate (MuPDF), and
// pushes the decoded page image here. Mounted as a top overlay in AppShell
// (ADR-009 z-index >= 3000).
// ---------------------------------------------------------------------------
export global BookletState {
// Modal open / lifecycle.
in-out property <bool> open: false;
// True while the PDF downloads or a page renders (spinner).
in property <bool> loading: false;
in property <bool> error: false;
in property <string> error-text;
// Album title for the modal header.
in property <string> title;
// The current rendered page image + its pixel size (for sizing/scroll).
in property <image> page-image;
in property <int> page-pixel-width: 0;
in property <int> page-pixel-height: 0;
// 1-based current page + total page count.
in property <int> current-page: 1;
in property <int> num-pages: 0;
// Zoom factor (1.0 = fit-width baseline) + cumulative rotation in degrees.
in property <float> zoom: 1.0;
in property <int> rotation: 0;
}
export global BookletActions {
callback close();
callback next-page();
callback prev-page();
callback zoom-in();
callback zoom-out();
callback fit-width();
callback rotate();
// Save the open booklet PDF to a user-chosen location.
callback download();
}
// Track drag-and-drop onto sidebar playlists (a star QBZ feature).
// Slint has no HTML5 DnD, so this is pointer-based: the source row
// grabs the pointer and feeds global coords here; the floating ghost
// + each sidebar playlist row read this to self-detect the drop.
export global DragState {
in-out property <bool> active: false;
// Dragged-track count + ghost text (count == 1 shows title +
// "artist · album"; count > 1 shows "N tracks").
in-out property <int> count: 0;
in-out property <string> ghost-title;
in-out property <string> ghost-subtitle;
// Global pointer position (window coords) while dragging.
in-out property <length> pointer-x;
in-out property <length> pointer-y;
// The sidebar playlist id currently under the pointer (drop target).
in-out property <string> over-playlist-id;
}
export global DragActions {
// Begin a track drag from a row. `track-id` is the row; the Rust
// side expands it to the multi-selection when applicable.
callback start(string /* track id */, string /* title */, string /* subtitle */, length, length);
callback move(length /* gx */, length /* gy */);
callback end();
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, artist-releases, settings, search, musician, label, label-releases, award, award-albums, location, favorites, mix, playlist, discover-browse, playlist-browse, recent-albums, most-played-albums, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder, purchases, purchase-album, now-playing }
// In-app toast (transient notification). Mirrors Tauri's toastStore:
// a single toast at a time (a new one replaces the current), auto-hidden
// by the Rust layer per kind. `buffering` is persistent (no auto-hide).
export enum ToastKind { success, error, info, warning, buffering }
// Local Library (on-disk + Plex music). Greenfield Slint port — the view
// shows header stats + the browse tabs (Albums / Artists / Folders /
// Tracks). All management/maintenance/danger actions live under
// Settings > Local Library; the gear button routes there.
// One card in the Local Library Artists grid (name + "N albums · M tracks").
// Images + click-to-detail are a later slice.
export struct LocalArtistItem {
name: string, // raw/canonical key (used for selection + matching)
display-name: string, // canonical display name (after name-merge)
album-count: int,
track-count: int,
image-path: string, // local file path OR cached Qobuz URL ("" = none)
image: image, // decoded portrait (default-empty = show placeholder)
}
// One A-Z section of the local Artists left rail (master list grouped by the
// display name's first letter, "#" bucket for non-alphabetic). 1:1 with the
// Tauri Artists tab, which groups the compact rows under sticky letters.
export struct LocalArtistSection {
letter: string,
artists: [LocalArtistItem],
}
// One visible row in the Folders tree (flattened-model lazy tree — Slint has
// no native TreeView). Folders expand to fetch children lazily.
export struct FolderNode {
path: string, // absolute filesystem path (the node id)
segment: string, // last path component (display)
depth: int, // indent level
is-folder: bool, // folder vs. track row
expanded: bool, // folder open?
can-expand: bool, // has children (track_count_under > 0)
track-count: int, // recursive track count under a folder
// Select-mode: track rows carry `selected`; folder rows carry a tri-state
// `select-state` (0 none / 1 partial / 2 all) over their descendant tracks.
selected: bool,
select-state: int,
}
// One subfolder cover card in the tree-mode folder-detail pane (1:1 with
// Tauri's `.subfolder-card`). `artwork` is loaded async from the folder cover.
export struct FolderSubcardItem {
path: string, // absolute path (the node id)
name: string, // last path component (display)
track-count: int, // recursive track count under the folder
artwork: image,
artwork-url: string, // cover file path (artwork-pipeline source)
}
// One album block in the ephemeral-folder pane (an ad-hoc folder can hold
// several albums). `artwork` is loaded from the cached cover file on the UI
// thread; `tracks` are the album's tracks in scan order.
export struct EphemeralAlbum {
group-key: string,
title: string,
artist: string,
// "year · N tracks" (already formatted).
meta: string,
quality-tier: string,
// This album is a single-file CUE rip (one audio file, virtual tracks).
is-cue: bool,
artwork: image,
tracks: [TrackItem],
}
export global LocalLibraryState {
// "albums" | "artists" | "folders" | "tracks"
in-out property <string> active-tab: "albums";
in property <bool> loading: false;
in property <string> load-error: "";
// Plex manual sync (#573): `plex-available` gates the header Sync button
// (Plex enabled + authenticated — seeded at startup from the store, kept
// fresh by plex_auth's gate refresh); `plex-syncing` drives its spinner.
in property <bool> plex-available: false;
in property <bool> plex-syncing: false;
// Header stats / per-tab badge counts.
in property <int> album-count: 0;
in property <int> artist-count: 0;
in property <int> track-count: 0;
in property <int> folder-count: 0;
// --- Albums tab (server-paginated, metadata-grouped local albums) ----
// `albums` is the accumulated paged set (also the artwork target). Sort
// + search are pushed to SQL (the performant path); paging fetches the
// next chunk on scroll. List / group / A-Z / filters land with the next
// slice — this slice ships the chunked grid.
in property <[AlbumCardItem]> albums: [];
in property <int> albums-total: 0;
in property <bool> albums-loading: false; // initial page fetch
in property <bool> albums-loading-more: false; // subsequent pages
in property <bool> albums-has-more: false;
in property <bool> albums-load-failed: false;
in-out property <int> albums-next-offset: 0;
in-out property <string> albums-search: "";
// "artist-asc" | "artist-desc" | "title-asc" | "title-desc"
// | "year-desc" | "year-asc"
in-out property <string> albums-sort: "artist-asc";
// "grid" | "list" view toggle.
in-out property <string> albums-view-mode: "grid";
// `albums` is the full mapped set (artwork-index target). `albums-visible`
// / `albums-grouped` are the search+filter+sort+group-derived render sets.
in property <[AlbumCardItem]> albums-visible: [];
in property <[DiscoverSection]> albums-grouped: [];
in-out property <string> albums-group: "off"; // off | alpha | artist
// Album IDENTITY mode — what one "album" IS on the Albums tab:
// "folder" (one card per album-root folder; default — compilations/box
// sets stay whole) | "metadata" (split by album+artist tags, #411).
// Persisted in locallibrary_ui.json; switching re-queries the DB (unlike
// sort/group, which re-derive client-side).
in-out property <string> albums-id-mode: "folder";
in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha group)
in property <int> albums-shown: 0; // count after filter
in-out property <bool> albums-multi-select: false; // album checkboxes on
in property <int> albums-selected-count: 0; // bar count + select-all
// --- Tracks tab (server-paginated flat list — the 16K-freeze fix) ---
// Group-by / multi-select / per-row play land with the playback slice.
in property <[TrackItem]> tracks: [];
in property <int> tracks-total: 0;
in property <bool> tracks-loading: false;
in property <bool> tracks-loading-more: false;
in property <bool> tracks-has-more: false;
in property <bool> tracks-load-failed: false;
in-out property <int> tracks-next-offset: 0;
in-out property <string> tracks-search: "";
// SQL-side sort (defines the pagination order): "default" | "artist-asc"
// | "artist-desc" | "title-asc" | "title-desc" | "year-desc"
// | "year-asc" | "added-desc"
in-out property <string> tracks-sort: "default";
// Group-by + multi-select (this slice). `tracks-visible` is the
// group-ordered + search-filtered render model (the list renders THIS).
in property <[TrackItem]> tracks-visible: [];
in-out property <string> tracks-group-mode: "off"; // off | album | artist | name
in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping)
in-out property <bool> tracks-multi-select: false; // checkbox column on
in property <int> tracks-selected-count: 0; // bar count + select-all
// --- Folders tab ---------------------------------------------------
// flat = album grid grouped by directory; tree = two-pane lazy filesystem.
// Tree is the Slint default (user 2026-06-06).
in-out property <string> folders-view-mode: "tree"; // "flat" | "tree"
// Flat mode: `folders` is the full set (artwork target); `folders-visible`
// / `folders-grouped` are the search/sort/group-derived render sets.
in property <[AlbumCardItem]> folders: [];
in property <bool> folders-loading: false;
in property <bool> folders-load-failed: false;
in property <[AlbumCardItem]> folders-visible: [];
in property <[DiscoverSection]> folders-grouped: [];
in-out property <string> folders-search: "";
in-out property <string> folders-sort: "title-asc"; // title-asc|title-desc|artist-asc|year-desc
in-out property <string> folders-group: "off"; // off | alpha | artist
in-out property <string> folders-grid-view: "grid"; // grid | list
// Tree mode: a flattened visible-node list + the selected folder's detail
// (its direct tracks; subfolders shown as a grid). `folder-tree` is the full
// flattened set; `folder-tree-visible` is the search-filtered render set.
in property <[FolderNode]> folder-tree: [];
in property <[FolderNode]> folder-tree-visible: [];
in property <bool> folder-tree-loading: false;
// Tree rail width (resizable). Default ~20% narrower than the old 340px;
// clamped in the UI to [50% of default, 50% of the content area].
in-out property <length> tree-sidebar-width: 272px;
// Tree rail header: search box + multi-select mode (1:1 with Tauri).
in-out property <string> folders-tree-search: "";
in-out property <bool> tree-select-mode: false;
in property <int> tree-selected-count: 0;
in-out property <string> folders-selected-path: "";
in property <string> folders-selected-name: "";
// Subfolder cover cards (1:1 with Tauri): `folder-detail-subfolders` is the
// full set (artwork target); `-visible` is the search-filtered render set.
in property <[FolderSubcardItem]> folder-detail-subfolders: [];
in property <[FolderSubcardItem]> folder-detail-subfolders-visible: [];
in property <[TrackItem]> folder-detail-tracks: [];
in property <bool> folder-detail-loading: false;
// Recursive track count under the selected folder (the header "N tracks").
in property <int> folder-detail-track-count: 0;
// Per-pane grid/list toggle + subfolder name filter.
in-out property <string> folder-detail-view: "grid"; // "grid" | "list"
in-out property <string> folder-detail-search: "";
// --- Ephemeral folder (ad-hoc play, NOT indexed into library.db) ----
// When `ephemeral-active`, the Folders tab shows the ephemeral pane
// (album-grouped) instead of the normal flat/tree browse. The session is
// in-memory; the folder path is persisted so it rehydrates on restart.
in property <bool> ephemeral-active: false;
in property <bool> ephemeral-loading: false;
in property <string> ephemeral-path: "";
in property <string> ephemeral-name: "";
in property <int> ephemeral-track-count: 0;
// True when the session spans more than one album → show per-album play.
in property <bool> ephemeral-multi-album: false;
in property <[EphemeralAlbum]> ephemeral-albums: [];
// --- Artists tab — two-column master/detail (1:1 with Tauri) --------
// Left rail: the merged/deduped artist master list. `artists` is the full
// merged set (post name-merge); `artists-grouped` / `artists-alpha` are
// the search-and-group-derived render sets; `artists-shown` is the count
// after the search filter (drives the rail header).
in property <[LocalArtistItem]> artists: [];
in property <bool> artists-loading: false;
in property <bool> artists-load-failed: false;
in-out property <string> artists-search: "";
in property <[LocalArtistSection]> artists-grouped: [];
in property <[AlphaJump]> artists-alpha: [];
in property <int> artists-shown: 0;
// Right pane: the selected artist's albums (filtered in place from the
// loaded album set — no new backend call, mirroring Tauri).
in-out property <string> artists-selected-name: ""; // "" = none selected
in property <string> artists-selected-display: "";
in property <[AlbumCardItem]> artists-selected-albums: [];
in property <bool> artists-selected-loading: false;
// Artist-image background fetch (capped Qobuz portrait fill).
in-out property <bool> artists-fetch-images: true; // default ON (mirrors Tauri)
in property <bool> artists-images-fetching: false; // a fetch session is running
in property <int> artists-images-fetched: 0; // painted this session
}
export global LocalLibraryActions {
// Switch browse tab — records a per-tab history entry (mirrors Favorites
// / Discover) so back/forward moves between Local Library tabs.
callback select-tab(string /* "albums"|"artists"|"folders"|"tracks" */);
// Open Settings > Local Library (the gear button — management lives there).
callback open-settings();
// Manual Plex re-sync (#573) — the header Sync button, visible only when
// Plex is configured. Re-fetches sections + tracks, then the Rust side
// reloads the browse models in place.
callback sync-plex();
// --- Albums tab ---
// Re-query page 1 with the current search text (server-side LIKE).
callback albums-search(string /* query */);
// Set the sort key + re-derive (in-memory, full-load).
callback albums-set-sort(string /* sort key */);
// Set group mode (off|alpha|artist) + re-derive.
callback albums-set-group(string /* mode */);
// Set the album identity mode (folder|metadata) + persist + reload the
// albums set (the group key changes — a derive is not enough).
callback albums-set-id-mode(string /* mode */);
// Set grid|list view.
callback albums-set-view(string /* mode */);
// A quality/format/source filter toggle changed -> re-derive.
callback albums-filter-changed();
// Clear all quality/format/source filters.
callback albums-clear-filter();
// Retry after a load error.
callback albums-retry();
// Open a local album's detail view (lands with the album-detail slice).
callback open-album(string /* album id */);
// Open an artist (lands with the Artists slice).
callback open-artist(string /* artist id */);
// Per-card action (play / queue / play-next / toggle-select). The Albums
// grid honors the shared AlbumCollectionView contract.
callback album-action(string /* album id */, string /* action */);
// Toggle the Albums-grid multi-select mode.
callback albums-toggle-multi-select();
// Albums bulk bar action: select-all | clear | queue | play-next |
// add-to-playlist | add-to-mixtape.
callback albums-bulk-action(string /* action */);
// Windowed grid: the visible row band changed (item indices into
// albums-visible, prefetch margin included) — dispatch artwork for it.
callback albums-window-changed(int /* first */, int /* last */);
// --- Tracks tab ---
callback tracks-search(string /* query */);
callback tracks-load-more();
callback tracks-retry();
// Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select).
callback track-action(string /* track id */, string /* action */);
// Group-by mode (off|album|artist|name), persisted.
callback tracks-set-group(string /* mode */);
// SQL sort key (default|artist-asc|...|added-desc), persisted; page-1 re-query.
callback tracks-set-sort(string /* key */);
// Toggle the multi-select checkbox column.
callback tracks-toggle-multi-select();
// Bulk bar action: select-all | clear | queue | play-next | add-to-playlist.
callback tracks-bulk-action(string /* action */);
// --- Folders tab ---
callback folders-search(string /* query */);
callback folders-set-sort(string /* sort key */);
callback folders-set-group(string /* off|alpha|artist */);
callback folders-set-mode(string /* flat|tree */);
// Tree: expand/collapse a folder node (lazy child fetch on expand).
callback folders-toggle-node(string /* path */, bool /* now-expanded */);
// Tree: select a folder -> load its detail (direct tracks + subfolders).
callback folders-select(string /* path */, string /* segment */);
// Tree: play everything under a folder (recursive).
callback folders-play-node(string /* path */);
// Tree: play one track from the selected folder's direct tracks (the
// folder's direct list becomes the queue, starting at this track).
callback folders-play-track(string /* track id */);
// Tree detail pane: filter the subfolder cards by name.
callback folder-detail-search(string /* query */);
// Tree rail header: filter the tree by name; collapse every folder.
callback folders-tree-search(string /* query */);
callback folders-collapse-all();
// Multi-select mode (tree rail): toggle the mode; toggle a folder's
// descendant tracks / a single track; run a bulk action on the selection.
callback folders-toggle-select-mode();
callback folders-toggle-folder-select(string /* folder path */);
callback folders-toggle-track-select(string /* track path */);
callback folders-bulk-action(string /* select-all|clear|queue|play-next|add-to-playlist */);
// --- Ephemeral folder (ad-hoc play) ---
// Open the native folder picker → scan → show the ephemeral pane.
callback ephemeral-open();
// Play the whole ephemeral folder (all albums, scan order).
callback ephemeral-play-all();
// Play one ephemeral track — its album group becomes the queue.
callback ephemeral-play-track(string /* track id */);
// Play one ephemeral album (its tracks become the queue).
callback ephemeral-play-album(string /* album group key */);
// Clear the ephemeral session (drop the pane + forget the path).
callback ephemeral-clear();
// --- Artists tab ---
// Filter the master list (two-way bound to artists-search; re-derive).
callback artists-search(string /* query */);
// Select an artist -> filter their albums into the right pane.
callback artists-select(string /* artist name */);
}
// Albums tab quality/format/source filter (1:1 with Tauri matchesQualityFilters:
// OR within each group, AND between groups; an empty group matches all).
export global LibAlbumFilterState {
in-out property <bool> open: false;
// Quality.
in-out property <bool> hires: false;
in-out property <bool> cd: false;
in-out property <bool> lossy: false;
// Format.
in-out property <bool> flac: false;
in-out property <bool> alac: false;
in-out property <bool> ape: false;
in-out property <bool> wav: false;
in-out property <bool> mp3: false;
in-out property <bool> aac: false;
in-out property <bool> other: false;
// Source.
in-out property <bool> local: false;
in-out property <bool> offline: false;
in-out property <bool> plex: false;
// Active-filter count (Rust-computed) for the toolbar badge.
in property <int> count: 0;
}
// Dedicated Local Library album detail (separate from the Qobuz AlbumView).
// Reuses TrackRow / NavButtons / QualityBadge internals, but ONLY local
// actions (play / shuffle / edit tags / add-to-playlist) — no Qobuz chrome.
// When an album has multiple physical versions (distinct source folders),
// `versions` drives a picker and the track list shows ONE version (no dups).
// One physical version of a local album (a distinct source folder). The
// picker shows the source icon + a quality/format label.
export struct LocalAlbumVersion {
label: string, // "24-bit / 96 kHz · FLAC"
source: string, // "user" | "qobuz_download" | "plex" | ""
}
export global LocalAlbumState {
in property <string> id; // metadata group key
in property <string> title;
in property <string> artist;
in property <string> info-line; // "12 tracks · 48 min"
in property <string> quality-tier; // hires | cd | ""
in property <string> quality-detail; // "24-bit / 96 kHz"
in property <string> cover-url;
in property <image> cover;
// Distinct track artists in first-appearance order (compilations); the
// header shows the single artist line + a "+N more artists" chevron that
// expands into the clickable full list (spec §B2). Empty/1 = no chevron.
in property <[string]> all-artists: [];
in property <bool> loading: false;
in property <[TrackItem]> tracks: [];
// Versions (distinct copies). >1 -> show the picker. Sorted best-quality
// first, so version 0 is the highest-quality copy.
in property <[LocalAlbumVersion]> versions: [];
in-out property <int> version-index: 0;
}
export global LocalAlbumActions {
callback play-all();
callback shuffle();
callback edit-tags();
callback add-to-playlist();
callback add-to-mixtape();
callback play-track(string /* track id */);
// Per-row context-menu actions beyond "play" (play-next / queue /
// add-to-playlist) — resolved against the open version's track cache.
callback track-menu-action(string /* track id */, string /* action */);
callback select-version(int);
// Client-side track filter (mirrors AlbumActions.search), applied over the
// current version's tracks.
callback search(string /* query */);
// Per-disc context-menu action on a multi-disc local album's "Disc N"
// header — scoped to that disc's tracks only (action = "play" | "shuffle" |
// "queue" | "play-next"). The whole-album header buttons are unaffected.
callback disc-action(int /* disc number */, string /* action */);
}
// Unified track context menu — ONE shell-level instance fed by this state
// instead of a `track-menu := TrackContextMenu` popup per row/view (popup
// windows are always inlined by the Slint compiler, so every per-site
// instance generated a full copy: 18 structs / ~22k lines for one logical
// menu). Openers (TrackRow right-click / "..." gutter, SearchTrackHero) set
// EVERY field below and then bump `open-token`; the shell instance watches
// the token and shows itself at (x, y). Dispatch: the shell routes
// media-action to LocalAlbumActions on the local-album view and to the
// window's central media-action everywhere else (spec:
// qbz-nix-docs/context-menu-unification/2026-07-17-single-context-menu-spec.md).
export global TrackMenuState {
// Window-relative open position, pre-computed by the opener with the
// scroll-safe `absolute-position - layout-offset + local-pointer` math.
in-out property <length> x;
in-out property <length> y;
// Payload.
in-out property <string> track-id;
in-out property <string> source; // TrackItem.source ("qobuz" | "local" | "plex" | "ephemeral" | ...)
in-out property <int> cache-status;
// Row-context inputs, copied 1:1 from the opener's row properties.
in-out property <bool> row-inert; // blacklisted row → nav-only menu
in-out property <bool> force-local-menu; // library surface gates
in-out property <bool> remove-from-playlist-action;
in-out property <bool> track-info-action: true;
in-out property <bool> local-goto-actions;
// Increment to open (a counter, not a bool, so open-while-open re-fires).
in-out property <int> open-token;
}
// ============================ Local Library management ====================
// The folder list + scan + maintenance + danger zone live under
// Settings > Local Library (NOT the browse view). One row per registered
// library folder; `Option<T>` maps to a "" / 0 sentinel + a Rust-computed
// label where the raw value is ambiguous.
// One registered library folder. Mirrors `qbz_library::LibraryFolder`
// (8 DB columns) + a UI-owned `selected` checkbox + Rust-computed labels.
export struct LibraryFolderItem {
id: int, // i64 folder id (small)
path: string, // absolute path
alias: string, // "" when none
display-name: string, // alias if non-empty else path (Rust-computed)
enabled: bool,
is-network: bool,
network-fs-type: string, // "" when none (e.g. "nfs","cifs")
user-override-network: bool,
last-scan: int, // unix seconds; 0 = never
last-scan-label: string, // "Never" / localized date (Rust-computed)
accessible: bool, // from the accessibility check; true until resolved
selected: bool, // UI multi-select state
}
// The folder list + maintenance + danger-zone state (Settings > Local Library).
export global LibraryFoldersState {
in property <[LibraryFolderItem]> folders: [];
in property <bool> loading: false;
in-out property <string> filter: ""; // folder search (alias??path substring)
in property <int> selected-count: 0; // drives Edit(==1) / Remove(>0) enablement
in property <bool> cleaning-missing: false;
in property <string> cleanup-status: ""; // "Removed N of M" / "" (auto-clears, Rust-side)
in property <bool> clearing-library: false;
}
// Folder-settings modal state (separate from the playlist FolderEditState).
export global LibFolderEditState {
in-out property <bool> open: false;
in property <int> folder-id: 0;
in-out property <string> alias: "";
in property <string> path: ""; // current path (updated after change-path)
in-out property <bool> enabled: true;
in-out property <bool> is-network: false;
in-out property <bool> user-override-network: false;
// QbzSelect index: 0 auto,1 cifs,2 nfs,3 sshfs,4 rclone,5 webdav,6 glusterfs,7 ceph,8 other.
in-out property <int> fs-type-index: 0;
in property <bool> accessible: true;
in property <bool> checking-accessible: false;
in property <string> last-scan-label: ""; // "Never" / date
}
// Library scan progress — a single global poll target (scan is not per-row,
// unlike the offline cache). `progress` is computed Rust-side (total==0 guard).
export global LibraryScanState {
in property <bool> scanning: false; // status == Scanning
in property <int> scan-status: 0; // 0 Idle,1 Scanning,2 Complete,3 Cancelled,4 Error
in property <int> total-files: 0;
in property <int> processed-files: 0;
in property <float> progress: 0.0; // 0..1
in property <string> current-file: ""; // basename, or the cleanup label
in property <int> error-count: 0;
}
export global LibraryManageActions {
callback load(); // (re)load the folder list (panel init)
callback add-folder(); // native dir picker (Rust)
callback remove-folders(); // bulk-remove selected (confirm)
callback remove-folder(int /* id */); // remove ONE folder directly (confirm)
callback toggle-folder-select(int /* id */);
callback edit-folder(int /* id */); // open the settings modal
callback save-folder-settings(int /* id */, string /* alias */, bool /* enabled */,
bool /* is-network */, string /* fs-type */, bool /* user-override */);
callback change-folder-path(int /* id */); // picker -> update path
callback scan-all();
callback scan-folder(int /* id */);
callback stop-scan();
callback cleanup-missing();
callback clear-library(); // two-step confirm
callback set-filter(string /* query */);
}
// ============================ Plex connection =============================
// Settings > Local Library > Plex. A LAN-only Plex Media Server connection:
// PIN-based auth (or manual token), library-section multi-select, and the
// per-section track caching that downstream browse views read. State is
// Rust-backed (qbz_app::settings::plex store + qbz-plex core); the controller
// lives in `plex_auth`. ADR-008: no pills. Status is a (key, args) pair so the
// Rust side can localize without 5-file JSON sync — `status-text` is the
// already-resolved label, `status-kind` drives the highlight style.
// One Plex music-library section row + its UI selection + cached track count.
export struct PlexSectionItem {
key: string, // Plex Directory@key
title: string, // section title
count: int, // cached track count (plex_cache_get_tracks(key).len())
selected: bool, // membership in selected_section_keys
}
export global PlexSettingsState {
// --- Persisted toggles -------------------------------------------------
in-out property <bool> enabled: false; // master toggle (default OFF)
in-out property <bool> ui-collapsed: false; // collapse chevron
in-out property <bool> metadata-write-enabled: false;
// --- Connection --------------------------------------------------------
// server-url is the RAW host-form text the user types; base-url is the
// Rust-resolved proto://host:port (set after resolve).
in-out property <string> server-url: "";
in property <string> base-url: "";
in property <string> token: ""; // "" = not authenticated
in-out property <bool> manual-token-mode: false;
in-out property <string> token-input: ""; // manual-token field buffer
// Derived gates (computed Rust-side, pushed in).
in property <bool> is-local-address: true; // isLocalPlexAddress(server-url)
in property <bool> can-use: false; // enabled && local && base!="" && token!=""
// --- PIN flow ----------------------------------------------------------
in property <bool> pin-busy: false; // PIN start / poll in flight
in property <string> pin-code: ""; // "" = no active code
in property <string> pin-auth-url: "";
// --- General busy + status --------------------------------------------
in property <bool> busy: false; // ping / sync / auto-setup
// Status as a resolved label + kind (0 none, 1 info, 2 connected/ok, 3 error).
in property <string> status-text: "";
in property <int> status-kind: 0;
// --- Library sections --------------------------------------------------
in property <[PlexSectionItem]> sections: [];
}
export global PlexAuthActions {
callback enable-toggle(bool);
callback collapse-toggle(bool);
callback set-server-url(string /* host-form */);
callback generate-code(); // PIN start + begin poll
callback open-auth-url();
callback copy-code();
callback manual-token-toggle(bool);
callback set-token(string /* token */); // save manual token
callback ping(); // check connection
callback load-sections(); // get libraries
callback toggle-section(string /* key */);
callback metadata-write-toggle(bool);
callback disconnect(); // confirm + reset
callback clear-cache(); // confirm + plex_cache_clear
callback load(); // panel init: seed from store + warm cache
}
// ============================ Scrobblers ==================================
// Settings > Integrations — source-agnostic Last.fm + ListenBrainz scrobbling.
// State mirrors `scrobbler_settings.db`; actions delegate to the Rust
// `scrobble` controller (auth flows + the now-playing/scrobble fire live
// there). ADR-008: no pills.
// Discord Rich Presence (Settings > Integrations). Opt-in, default OFF; the
// `enabled` flag is seeded from ui_prefs and `set-enabled` persists + applies it.
export global DiscordState {
in-out property <bool> enabled: false;
callback set-enabled(bool);
}
export global ScrobbleState {
// --- Master section toggles -------------------------------------------
in-out property <bool> enabled: false; // master toggle (default OFF)
in-out property <bool> ui-collapsed: false; // collapse chevron
// --- Last.fm ----------------------------------------------------------
in-out property <bool> lastfm-enabled: false; // per-service scrobble flag
in property <bool> lastfm-authed: false; // session key present
in property <string> lastfm-username: ""; // "Signed in as ..."
in property <string> lastfm-auth-url: ""; // non-empty after connect
in property <bool> lastfm-busy: false; // get_token / get_session in flight
// --- ListenBrainz -----------------------------------------------------
in-out property <bool> listenbrainz-enabled: false;
in property <bool> listenbrainz-authed: false;
in property <string> listenbrainz-username: "";
in-out property <string> listenbrainz-token-input: ""; // token field buffer
in property <bool> listenbrainz-busy: false; // set_token in flight
// --- Shared status line (0 none, 1 info, 2 ok, 3 error) ---------------
in property <string> status-text: "";
in property <int> status-kind: 0;
}
export global ScrobbleActions {
callback load(); // panel init: seed from store
callback enable-toggle(bool);
callback collapse-toggle(bool);
// Last.fm.
callback lastfm-enable-toggle(bool);
callback lastfm-connect(); // get_token + open browser
callback lastfm-open-auth-url(); // re-open authorize URL
callback lastfm-confirm(); // get_session after approve
callback lastfm-disconnect();
// ListenBrainz.
callback listenbrainz-enable-toggle(bool);
callback listenbrainz-set-token(string);
callback listenbrainz-disconnect();
}
// ============================ Tag editor ==================================
// Local-album metadata editor (Settings-independent modal launched from the
// local album detail). Sidecar (default) + opt-in direct file-write. Remote
// MusicBrainz/Discogs lookup lands as a follow-up sub-slice (needs the
// remote-metadata crate move).
// One editable track row. `cue_start_secs` is f64 (-1.0 = none); `has_cue`
// is precomputed (cue_file_path != "" || cue_start_secs >= 0) and disables
// direct file-write. disc/track 0 = unset sentinel (Option<u32>).
export struct TagTrackEdit {
id: int,
file_path: string,
cue_file_path: string,
cue_start_secs: float,
has_cue: bool,
title: string,
disc_number: string, // raw text (LineEdit); controller parses on save ("" = unset)
track_number: string, // raw text (LineEdit); controller parses on save ("" = unset)
}
// One remote (MusicBrainz/Discogs) search-result card. Option fields map to
// a 0/"" sentinel + a has_* companion where the zero value is valid.
export struct RemoteResultItem {
provider: string, // "musicbrainz" | "discogs"
provider_id: string, // selection key + open-in-browser
title: string,
artist: string,
year: int,
has_year: bool,
track_count: int,
has_track_count: bool,
country: string,
format: string,
label: string,
catalog_number: string,
}
export global TagEditorState {
in-out property <bool> open: false;
in property <string> album_group_key; // == LocalAlbum.id == directory_path
in-out property <string> album_title;
in-out property <string> album_artist;
in-out property <string> year_input; // raw text; controller parses
in-out property <string> genre;
in-out property <string> catalog_number;
in-out property <int> album_total_discs; // collected, NOT persisted (1:1)
in property <string> directory_path; // reference + is_dir gate
in property <bool> can_direct_write; // false if any track has_cue
in-out property <int> persistence_index: 0; // 0 = sidecar, 1 = direct
in-out property <[TagTrackEdit]> tracks: [];
in-out property <bool> saving: false;
in-out property <int> write_progress_current: 0;
in-out property <int> write_progress_total: 0;
// Remote lookup (MusicBrainz / Discogs).
in-out property <int> remote_provider_index: 0; // 0 musicbrainz, 1 discogs
in-out property <bool> remote_searching: false;
in-out property <bool> remote_loading: false; // apply (get full) in flight
in-out property <[RemoteResultItem]> remote_results: [];
in-out property <string> selected_result_id: ""; // "" = none
in-out property <bool> show_remote_panel: false;
in-out property <bool> has_searched: false;
}
export global TagEditorActions {
callback close();
callback save();
callback set-persistence(int); // QbzSelect index -> mode (controller maps)
callback set-provider(int); // provider QbzSelect index
callback search-remote();
callback select-result(string /* provider_id */);
callback apply-remote();
callback open-in-browser();
}
// Global toast state, driven by crate::toast. The Toast overlay (mounted
// at the app root) renders whatever is here. The Rust layer owns timing
// (show + auto-hide); the UI only sets `visible = false` on the close
// button. `enabled` gates non-error toasts (errors always show), to match
// Tauri's "in-app notifications" preference.
export global ToastState {
in-out property <bool> visible: false;
in-out property <string> message;
in-out property <ToastKind> kind: ToastKind.info;
in-out property <bool> persistent: false;
in-out property <bool> enabled: true;
}
// One row in the sidebar playlists list (also used for the folder
// list in the move-to-folder menu).
export struct SidebarPlaylistItem {
id: string,
name: string,
}
// One flattened entry in the sidebar nav list — a folder header or a
// playlist row (indented when inside a folder).
//
// Playlist rows carry up to four cover-art URLs + decoded images so the
// row can render a micro-collage instead of the generic list-music glyph
// (mirrors Tauri's `sidebarPlaylistCollage`). The covers come from the
// same `get_user_playlists()` payload (images300 / images150 / images),
// so no extra per-playlist fetch is needed.
export struct SidebarEntry {
kind: string, // "folder" | "playlist"
id: string,
name: string,
expanded: bool, // folders
count: int, // folder playlist count
indent: bool, // playlist nested in a folder
folder-id: string, // playlist's current folder id ("" = root; folders "")
// "" (Qobuz) | "local" | "offline" — local playlists (library.db,
// ids "local:<uuid>") show a hard-drive glyph instead of list-music.
local-kind: string,
cover-count: int, // number of cover images, 0-4 (playlists)
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
}
// Flyout shown when a folder is clicked while the sidebar is collapsed
// (mini): a popup to the right listing that folder's playlists. The list is
// populated from Rust because collapsed folders' children are NOT present in
// the flat `SidebarState.entries`.
export global SidebarFolderPopupState {
in-out property <bool> open: false;
in-out property <string> folder-id;
in-out property <string> folder-name;
in-out property <int> count: 0; // folder's playlist count (caps the height)
in-out property <length> anchor-x; // window x (sidebar right edge)
in-out property <length> anchor-y; // window y (folder row top)
in property <[SidebarEntry]> playlists: [];
}
// Collapsed (mini) sidebar hover tooltip — the row shows only an icon, so a
// hover bubble surfaces the name (folders also show their playlist count).
// Mirrors Tauri's native `title` on the collapsed folder/playlist buttons.
// Rendered at the AppShell level because the sidebar clips its overflow; the
// anchor is the row's true window position (the collapsed rows live inside a
// Flickable, so SidebarRow corrects the absolute-position double-count).
export global SidebarTooltipState {
in-out property <bool> open: false;
in-out property <string> id; // owning row id (race-safe leave)
in-out property <string> text; // "Folder (N)" or "Playlist name"
in-out property <length> anchor-x; // window x (sidebar right edge)
in-out property <length> anchor-y; // window y (row top)
}
// Closed-sidebar playlists flyout. When the sidebar is fully closed there is no
// playlist list on screen; a header button opens this popup over SidebarState's
// entries (folders + playlists), capped at six visible rows with the rest
// scrolling, plus an always-visible footer link to the Playlist Manager.
export global SidebarPlaylistsPopupState {
in-out property <bool> open: false;
in-out property <length> anchor-x; // window x (header button left)
in-out property <length> anchor-y; // window y (below the button)
}
// The sidebar's playlists + folders. Populated by crate::sidebar on
// shell entry and after create/delete/move.
export global SidebarState {
in property <[SidebarEntry]> entries: [];
// Flat folder list, for the move-to-folder menu.
in property <[SidebarPlaylistItem]> folders: [];
// Folder list filtered by the move-to-folder search box. Equals
// `folders` when the query is empty; a case-insensitive substring
// subset otherwise. Filled by the Rust side (Slint strings have no
// `contains`, so the substring filter can't live in markup). The
// context menu iterates this; the current-folder exclusion stays in
// markup via the per-row `folder-id`.
in property <[SidebarPlaylistItem]> menu-folders: [];
in property <bool> loading: false;
// The currently-open playlist id, for the active-row highlight.
in property <string> active-id;
// PLAYLISTS section collapse (the folder+playlist list hides). Mirrors
// Tauri's `playlistsCollapsed`.
in-out property <bool> playlists-collapsed: false;
// Inline playlist-name search in the header. When open the title is
// hidden and the input takes the full header width.
in-out property <bool> search-open: false;
in-out property <string> search-query;
// Active sort option: "name" | "recent" | "tracks" | "playcount"
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
in-out property <string> sort-option: "name";
// Micro-collage of track covers on playlist rows. Opt-out (ON by
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
// Appearance toggle wires into this in a later step.
in-out property <bool> playlist-collage: true;
}
export global SidebarActions {
callback open-playlist(string /* id */);
// Populate SidebarFolderPopupState.playlists for the collapsed-sidebar
// folder flyout (the folder's children aren't in the flat entries).
callback load-folder-popup(string /* folder id */);
callback create-playlist();
callback create-folder();
callback toggle-folder(string /* folder id */);
callback delete-folder(string /* folder id */);
// Move a playlist into a folder ("" folder id = root).
callback move-playlist(string /* playlist id */, string /* folder id */);
// Right-click context menu actions (folder + playlist rows).
callback edit-playlist(string /* id */);
callback edit-folder(string /* id */);
callback hide-playlist(string /* id */);
callback hide-folder(string /* id */);
// Add a playlist to a Mixtape/Collection (opens the AddToMixtapeModal with
// the playlist as a 1-item payload; restricts to mixtapes since a playlist
// can't go in a Collection).
callback add-to-mixtape(string /* playlist id */);
// Filter `menu-folders` by a case-insensitive substring of the
// move-to-folder search box (Rust does the substring match).
callback search-folders(string /* query */);
// Pick a sort option (re-sorts the list). Same five values as Tauri.
callback set-sort(string /* option */);
// Re-run the playlist-name filter (called as the search input edits).
callback search-changed(string /* query */);
// Re-fetch the playlist list from the network.
callback refresh-playlists();
// Open the full Playlist Manager view.
callback manage-playlists();
// Open the playlist importer modal (sidebar "..." menu).
callback import-playlist();
}
// "Create playlist" modal state. Mirrors Tauri's PlaylistModal (create
// mode): name, optional description, target folder, public flag.
export global CreatePlaylistState {
in-out property <bool> open: false;
in-out property <string> name;
in-out property <string> description;
in-out property <bool> is-public: false;
in property <bool> creating: false;
// Folder dropdown options — index 0 is always "No folder", the rest
// are the user's folder names (parallel to `folder-ids`). Built by
// the create-playlist handler from the sidebar folder list.
in property <[string]> folder-options: ["No folder"];
// Folder ids parallel to `folder-options` ("" for "No folder").
in property <[string]> folder-ids: [""];
// Selected index into `folder-options` (0 = No folder).
in-out property <int> folder-index: 0;
// D8 "Offline-only playlist" opt-in: ON -> created as a LOCAL entity
// (library.db, id "local:<uuid>") that never reaches Qobuz.
in-out property <bool> offline-only: false;
// True while the app is offline: creation always produces a local
// playlist, so the toggle shows ON and locked with a hint.
in property <bool> offline-locked: false;
}
export global CreatePlaylistActions {
callback submit();
callback close();
}
// "Create folder" modal state.
export global CreateFolderState {
in-out property <bool> open: false;
in-out property <string> name;
in property <bool> creating: false;
}
export global CreateFolderActions {
callback submit();
callback close();
}
// "Export settings" modal state (Settings > Developer, 04 §4.2). Opened from
// the Export settings… row, which resets `include-auth` to false first so the
// single --include-auth gate is always default-OFF. confirm() is wired in
// Rust (crate::settings::export_settings): build the bundle via the shared
// engine, open a native save dialog, write it 0600, and toast the import
// command. Cancel / backdrop / close-X set `open = false` directly.
export global SettingsExportState {
in-out property <bool> open: false;
in-out property <bool> include-auth: false;
}
export global SettingsExportActions {
callback confirm();
}
// "Create Mixtape / Collection" modal state (My QBZ). Opened from either
// grid's "+ New" CTA / empty-state button. `kind` is preset by the opening
// grid ("mixtape" | "collection") and can be flipped via the radio toggle
// (artist_collection is created via the Discography Builder, not here).
// `name` is capped at 80 chars (mirrors Tauri's maxlength="80").
export global MyQbzCreateState {
in-out property <bool> open: false;
in-out property <string> kind: "mixtape";
in-out property <string> name;
in property <bool> creating: false;
}
export global MyQbzCreateActions {
callback submit();
callback close();
}
// === "Add to Mixtape/Collection" picker modal (global singleton) =========
// Spec 21/50. A global overlay (mounted ONCE at the window root) that adds one
// or more items (album / track / playlist, from qobuz or local) into an
// existing Mixtape/Collection — or creates a brand-new one inline and adds
// into it. Every app-wide "Add to Mixtape/Collection" trigger calls
// MyQbzAddActions.open(...) with a payload it built; the Rust controller
// (crate::myqbz_add) holds the pending items and drives the picker.
//
// One picker row = one collection (mixtape | collection | artist_collection),
// with its kind label, "N albums" meta, and an `already-has` flag computed
// from `item_exists` so the row can show an "already added" hint.
export struct MyQbzAddRow {
id: string,
name: string,
// "mixtape" | "collection" | "artist_collection".
kind: string,
// Row icon by kind ("mixtape" -> cassette, "artist_collection" -> user,
// else library-big). Resolved in Rust to a key the row maps to a glyph.
icon: string,
// Right-aligned kind tag, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
kind-label: string,
// "N albums" / "1 album" — ICU plural rendered in Rust (always "album(s)").
meta: string,
// True when this collection already contains ALL of the pending items
// (per item_exists). Drives the per-row "already added" hint.
already-has: bool,
}
export global MyQbzAddState {
in-out property <bool> open: false;
// Whether the picker is loading the collection list.
in property <bool> loading: false;
// The collections offered as targets (already kind-restricted +
// recency-sorted + item_exists-resolved by the Rust controller).
in property <[MyQbzAddRow]> rows: [];
// Header title + subtitle (precomputed: single-item title or bulk
// "{count} items" / "{first} + N more").
in property <string> header-title;
in property <string> header-subtitle;
// True when >1 pending item (bulk add).
in property <bool> bulk-mode: false;
// True when ANY pending item is a track/playlist -> targets restricted to
// Mixtapes; the "Create Collection" button is hidden.
in property <bool> restrict-to-mixtape: false;
// Free-text filter over collection names.
in-out property <string> search;
// Id of the collection currently being written to (disables rows/buttons).
in property <string> busy-id;
// --- Create-new sub-panel ---
in-out property <bool> creating: false;
in-out property <string> create-name;
// "mixtape" | "collection".
in-out property <string> create-kind: "mixtape";
in property <bool> create-busy: false;
}
export global MyQbzAddActions {
// Click a collection row -> add the pending items to it.
callback pick(string /* collection id */);
// Filter the list by name.
callback search-changed(string /* query */);
// Open the create-new sub-panel preset to a kind ("mixtape"|"collection").
callback show-create(string /* kind */);
// Back out of the create sub-panel to the picker.
callback create-back();
// Create a new collection (create-kind/create-name) then add the items.
callback create-and-add();
callback close();
}
// Ephemeral "play with active queue" choice — shown when the user triggers an
// ephemeral play action while a queue is already playing. Two outcomes:
// add to the queue, or clear the queue and start playing.
export global EphemeralPlayChoiceState {
in-out property <bool> open: false;
// "all" | "album" | "track" — what the user pressed play on.
in-out property <string> intent-kind: "";
// group key (album) or track id (track); empty for "all".
in-out property <string> intent-arg: "";
// Whether "Add to queue" is offered — only when the current queue is itself
// entirely ephemeral (queue ops may not mix ephemeral with persistent tracks).
in-out property <bool> enqueue-allowed: false;
}
export global EphemeralPlayChoiceActions {
callback enqueue(); // add the selection to the current queue
callback replace(); // clear the queue and start playing the selection
callback close();
}
// Playlist detail screen state. Rows reuse the shared TrackRow
// (show-artwork + show-album); populated by crate::playlist.
export global PlaylistState {
in property <string> id;
in property <string> name;
in property <string> owner;
in property <string> description;
// Truncated description for the header (~2 lines); full text in
// the Read-more modal. Equal to description when it fits.
in property <string> description-short;
in property <int> track-count: 0;
in property <string> total-duration;
in property <string> cover-url;
in property <image> cover;
// True when a user-set custom artwork is shown (overrides the
// track-cover collage).
in property <bool> has-custom: false;
in property <bool> is-favorite: false;
// Whether THIS playlist is pinned to Home; seeded on open, flipped by
// toggle-pin when ids match.
in property <bool> pinned: false;
// True when the playlist is one of the user's own (editable).
in property <bool> is-owner: false;
// Followed (subscribed) on Qobuz — an account-level follow that syncs
// across Qobuz clients, distinct from the internal favorite heart.
in property <bool> is-following: false;
// Already copied into the user's own playlists this session (hides the
// Copy action so it isn't duplicated).
in property <bool> is-copied: false;
// LOCAL playlist (id "local:<uuid>", library.db entity — never on
// Qobuz). Qobuz-bound header actions (favorite / cache) hide; the
// "Upload to Qobuz" action appears when not offline-only.
in property <bool> is-local: false;
// D8: never offered for upload, never reaches Qobuz/QConnect.
in property <bool> offline-only: false;
// D11.a: a MIXED (Qobuz-id) playlist rendered OFFLINE from its local
// sidecar rows only (the Qobuz membership is not enumerable offline).
// Lets the AppShell mount the detail instead of the OfflinePlaceholder.
in property <bool> offline-subset: false;
// Track edit mode — rows show a selection checkbox and a bulk bar
// appears (remove selected). Only available to the owner.
in-out property <bool> multi-select-mode: false;
in property <int> selected-count: 0;
in property <[TrackItem]> tracks: [];
in property <bool> loading: false;
// Sort: "" / "default" = playlist order; else title|artist|album|
// duration. `sort-asc` flips on re-selecting the active field.
in property <string> sort-field: "default";
in property <bool> sort-asc: true;
}
export global PlaylistActions {
// Client-side filter of the visible track list by query.
callback search(string);
// Sort by a field (re-selecting the active field toggles asc/desc).
callback set-sort(string);
// Drag-reorder commit (custom-order list, issue #589): move the row at
// visible index `from` to insertion slot `to` (0..N; slots `from` and
// `from+1` are the no-op gaps and never fire). Complements the
// single-step ("track","move-up"/"move-down") chevron arms.
callback reorder-track(int /* from */, int /* to */);
}
// "Edit playlist" modal state (rename + description + delete).
export global EditPlaylistState {
in-out property <bool> open: false;
in property <string> id;
in-out property <string> name;
in-out property <string> description;
in property <bool> busy: false;
// LOCAL playlist (id "local:<uuid>") — edits route to library.db and
// the offline-only toggle below shows.
in property <bool> is-local: false;
// D8 flag, editable so an offline-created playlist can be unmarked
// later and gain the "Upload to Qobuz" action.
in-out property <bool> offline-only: false;
}
export global EditPlaylistActions {
callback save();
callback delete();
callback close();
}
// === Playlist Manager ===================================================
// A full playlist + folder organization surface (Tauri's
// PlaylistManagerView). Rows are precomputed in Rust (crate::playlist_manager)
// and pushed as ready-to-render models — no per-row map lookups in .slint.
// One folder card / chip / tree-folder. `icon-color` is a solid hex (or
// "" → use the accent). Gradient folder colors are not supported here
// (Slint brush limitation); a gradient string falls back to the accent.
export struct PmFolderItem {
id: string,
name: string,
count: int, // playlists in this folder
// "preset" or "custom".
icon-type: string,
// Lucide preset name ("heart" | "star" | "music" | "folder" | "disc"
// | "library" | "headphones"); drives which glyph the card shows.
icon-preset: string,
// Resolved solid color for the icon square (parsed from the stored
// hex by the Rust controller). When `has-color` is false the card
// uses the accent instead (gradient / empty / unparseable colors
// fall back to accent — Slint has no gradient-brush-in-struct).
icon-color: color,
has-color: bool,
is-hidden: bool,
// Decoded custom image (icon-type == "custom"); empty otherwise.
custom-image: image,
has-custom-image: bool,
}
// One playlist card / list-row / tree-row. All display strings are
// precomputed (track line, local line) so the view never does lookups.
export struct PmPlaylistItem {
id: string, // playlist id (may be negative for offline pending)
name: string,
// "12 tracks" — total (remote + local).
tracks-line: string,
// "1h 43m" — total playtime, "" when zero (list-row meta only).
duration-line: string,
// "(3 local)" — local-only suffix, "" when none.
local-line: string,
local-count: int,
total-count: int,
play-count: int,
// "" | "all_local" | "some_local" | "no" — availability badge.
local-status: string,
is-favorite: bool,
is-hidden: bool,
// LOCAL playlist entity (id "local:<uuid>", library.db) — the card /
// list-row shows a hard-drive marker; offline-only flavors label it.
is-local-playlist: bool,
offline-only: bool,
folder-id: string, // "" = root
// Micro-collage covers (same 0-4 scheme as the sidebar).
cover-count: int,
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
}
// One flattened row of the tree view — a folder header or a nested /
// root playlist. The Rust side emits only currently-visible rows
// (collapsed folders hide their children).
export struct PmTreeRow {
kind: string, // "folder" | "playlist"
expanded: bool, // folders
folder: PmFolderItem, // populated when kind == "folder"
playlist: PmPlaylistItem, // populated when kind == "playlist"
indent: bool, // playlist nested under a folder
}
export global PlaylistManagerState {
in property <[PmFolderItem]> folders: [];
// Filtered folder list for the list-row move-to-folder menu. Starts as
// the full folder set; the menu's search box narrows it via
// `search-folders` (Slint strings have no `contains`, so Rust filters).
in property <[PmFolderItem]> menu-folders: [];
in property <[PmPlaylistItem]> playlists: []; // the filtered+sorted grid/list set
in property <[PmTreeRow]> tree: [];
// Header counters: total folders / total playlists (post-filter for
// the visible playlist count).
in property <int> folder-count: 0;
in property <int> playlist-count: 0;
in property <bool> loading: false;
// Toolbar state (session-scoped; persisted per-user where cheap).
in-out property <string> search-query;
// "all" | "visible" | "hidden".
in-out property <string> filter: "all";
// "name" | "recent" | "playcount" | "tracks" | "custom".
in-out property <string> sort: "name";
// "grid" | "list" | "tree".
in-out property <string> view-mode: "grid";
// Folder mode on (folders shown + folder filtering) vs flat.
in-out property <bool> folder-mode: true;
// Folders section collapse.
in-out property <bool> folders-collapsed: false;
// Reorder is only allowed when sort == "custom".
in property <bool> can-reorder: false;
}
export global PlaylistManagerActions {
// Open a playlist (-> PlaylistView).
callback open-playlist(string /* id */);
// Toolbar.
callback search-changed(string /* query */);
callback set-filter(string);
callback set-sort(string);
callback set-view-mode(string);
// Filter the list-row move-to-folder menu's folder list (substring,
// case-insensitive). Empty query restores the full list.
callback search-folders(string /* query */);
callback toggle-folder-mode();
callback toggle-folders-collapsed();
// Tree-view folder expand/collapse.
callback toggle-tree-folder(string /* folder id */);
// Folder CRUD — "new folder" opens the editor in create mode; edit
// opens it for an existing folder.
callback new-folder();
callback edit-folder(string /* folder id */);
// Per-card playlist actions.
callback toggle-favorite(string /* playlist id */);
callback toggle-hidden(string /* playlist id */);
callback edit-playlist(string /* playlist id */);
callback add-to-mixtape(string /* playlist id */);
// Arrow reorder (custom sort only): move a playlist up / down within
// the current visible order, persisting the new order.
callback move-up(string /* playlist id */);
callback move-down(string /* playlist id */);
// Move a playlist into a folder ("" = root). Used by the tree's
// folder rows and the future DnD path.
callback move-to-folder(string /* playlist id */, string /* folder id */);
}
// === Offline Cache Manager =============================================
// The standalone manage-downloads surface (Tauri's OfflineCacheManagerView).
// Rows are precomputed in Rust (crate::offline_manager): an album-header row
// followed by its track rows, flattened (mirrors PmTreeRow).
export struct OfflineRow {
kind: string, // "album" | "track"
album-id: string, // album-header: the album id; track: its album id
track-id: string, // track rows
title: string, // album title (album) | track title (track)
subtitle: string, // artist
meta: string, // "8 tracks · 320 MB" (album) | "3:45" (track)
// 0 none | 2 downloading | 3 ready | 4 failed. Album = worst across tracks.
status: int,
progress: float, // track downloading 0..1
cover: image, // album-header cover (loaded from the on-disk cover.jpg)
number: string, // track position within its album ("" on album rows)
selected: bool, // multi-select checkbox
}
// One entry in the manager's left A-Z artist rail.
export struct OfflineArtist {
name: string,
meta: string, // "3 albums · 21 tracks"
selected: bool,
}
export global OfflineManagerState {
in property <[OfflineRow]> rows: [];
in property <[OfflineArtist]> artists: [];
in property <bool> loading: false;
// Header stats.
in property <int> tracks-count: 0;
in property <string> size-text: ""; // "1.2 GB"
in property <string> limit-text: ""; // "of 5 GB" | "Unlimited"
in property <float> usage: 0.0; // 0..1 for the bar (0 = unlimited)
in-out property <int> limit-gb: 5; // edit-limit input
// Toolbar — set by the controller from the Rust-side filter state so the
// dropdown selection / toggle / rail highlight reflect the current view.
in property <string> selected-artist: ""; // "" = all artists
in property <int> sort-index: 0; // 0 alpha / 1 recent / 2 largest / 3 smallest
in property <bool> show-only-failed: false;
// Multi-select: how many TRACK rows are checked (drives the bulk bar).
in property <int> selected-count: 0;
}
export global OfflineManagerActions {
// Navigate to the manager (from the Settings "Offline Playback" entry).
callback open();
callback refresh();
// Toolbar.
callback select-artist(string /* name, "" = all */);
callback set-sort(int /* index */);
callback toggle-failed();
// Multi-select.
callback toggle-select(string /* track id */);
callback select-all();
callback clear-selection();
callback bulk-redownload();
callback bulk-remove();
callback remove-track(string /* track id */);
callback remove-album(string /* album id */);
callback redownload-track(string /* track id */);
callback redownload-album(string /* album id */);
callback redownload-failed(string /* album id */);
callback set-limit(int /* gb */);
callback clear-all();
callback open-folder();
callback play-track(string /* track id */);
}
// === Artist Blacklist Manager (Tauri's BlacklistManagerView) ============
// The standalone manage-blacklist surface reached from the Settings content-
// filtering row. One blacklisted-artist row carries everything the view needs;
// the controller (crate::blacklist_manager) precomputes the date string + the
// has-notes flag so the .slint does no formatting. Search filtering is done
// controller-side: `items` already holds only the filtered rows, while `count`
// stays the FULL list count (drives the badge + the empty/no-results split).
export struct BlacklistedArtistItem {
// Numeric Qobuz artist id (fits an int for display + passing back on click
// / remove). Slint has no u64/i64; the controller passes it through as int.
artist-id: int,
artist-name: string,
// Unix seconds (kept for reference; the view renders `added-display`).
added-at: int,
// Controller-formatted "MMM D, YYYY" (English; gettext for Rust is unwired).
added-display: string,
notes: string,
has-notes: bool,
}
export struct BlacklistedAlbumItem {
// Qobuz album id is a STRING (alphanumeric), unlike the artist int id.
album-id: string,
album-title: string,
artist-name: string,
// Network cover URL; resolved to `cover` by the artwork pipeline.
cover-url: string,
cover: image,
added-at: int,
added-display: string,
notes: string,
has-notes: bool,
}
export global BlacklistState {
// The CURRENTLY-VISIBLE rows (already search-filtered by the controller).
in property <[BlacklistedArtistItem]> items: [];
// Global enable flag (default-enabled, mirrors the backend default).
// Shared by both axes (artists + albums).
in property <bool> enabled: true;
in property <bool> loading: true;
// FULL list count (NOT the filtered length) — drives the count badge and
// the empty-vs-no-results branch (count==0 = empty; count>0 && items empty
// = no search results).
in property <int> count: 0;
// Echoed back so the search input reflects the controller's query (and the
// no-results message can interpolate it).
in property <string> search-query: "";
// --- Album axis (parallel String-keyed list, shown under the Albums tab) ---
in property <[BlacklistedAlbumItem]> album-items: [];
// FULL blocked-album count (drives the Albums tab badge + empty state).
in property <int> album-count: 0;
// Active manager tab: 0 = Artists, 1 = Albums.
in property <int> active-tab: 0;
}
export global BlacklistActions {
// Forward-open seam: the Settings content-filtering row (T10) calls this to
// navigate INTO the manager (records the nav entry, swaps the view, loads).
// Mirrors OfflineManagerActions.open — the back chrome is NavButtons.
callback open();
callback back();
callback artist-select(int /* artist id */);
callback toggle-enabled();
callback remove(int /* artist id */);
callback clear-all();
callback search-changed(string /* query */);
// --- Album axis ---
callback set-tab(int /* 0=artists, 1=albums */);
callback album-select(string /* album id */);
callback remove-album(string /* album id */);
callback clear-all-albums();
// Block an album from a context menu (id + display fields for the manager).
callback block-album(string /* id */, string /* title */, string /* artist */, string /* cover-url */);
}
// === My QBZ — Mixtapes & Collections (read-only index grids) ============
// Mirrors PlaylistManagerState/PmPlaylistItem. Both the Mixtapes grid and the
// Collections grid render `MixtapeCardItem`s; the Rust controller (crate::myqbz)
// filters by kind and precomputes every display string, so the views do no
// per-row lookups. Mosaic covers (up to 4, or 9 for a 3x3 Collection) are
// decoded into the item by the artwork pipeline.
//
// READ-ONLY SCOPE (Phase-2 Slice 2): the grids DISPLAY rows + route from the
// sidebar. Card click (open detail) and "+ New" (create) are STUBS that log
// "not yet wired" — wired in later slices.
export struct MixtapeCardItem {
id: string,
name: string,
// "mixtape" | "collection" | "artist_collection".
kind: string,
// Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
label: string,
// "5 albums" / "1 album" — ICU-plural rendered in Rust (always "album(s)"
// regardless of item_type, matching the PSD).
meta: string,
// Item count (drives the 3x3-vs-2x2 mosaic rule + sort).
item-count: int,
play-count: int,
// updated_at epoch ms (sort key).
updated-at: int,
// Custom user-uploaded cover (decoded local file); empty when none. When
// present it full-bleeds the mosaic (precedence over the collage).
custom-cover: image,
has-custom-cover: bool,
// Mosaic cell count actually used: 0 (empty -> kind glyph), or 4 / 9.
cover-count: int,
// Up to 9 collage cover slots (only 0-3 used for 2x2; 0-8 for a 3x3
// Collection with >=9 items). URLs are pre-downscaled in Rust.
url1: string, url2: string, url3: string, url4: string,
url5: string, url6: string, url7: string, url8: string, url9: string,
cover1: image, cover2: image, cover3: image, cover4: image,
cover5: image, cover6: image, cover7: image, cover8: image, cover9: image,
}
export global MyQbzState {
// The Mixtapes grid set (kind == mixtape), filtered + sorted.
in property <[MixtapeCardItem]> mixtapes: [];
// The Collections grid set (kind == collection | artist_collection),
// filtered (by kind-filter) + sorted.
in property <[MixtapeCardItem]> collections: [];
in property <bool> loading: false;
// --- Mixtapes toolbar (session-scoped) ---
in-out property <string> mix-search;
// "position" | "name" | "items" | "updated".
in-out property <string> mix-sort: "position";
// "asc" | "desc".
in-out property <string> mix-sort-dir: "asc";
// "grid" | "list".
in-out property <string> mix-view: "grid";
// --- Collections toolbar (session-scoped) ---
in-out property <string> col-search;
in-out property <string> col-sort: "position";
in-out property <string> col-sort-dir: "asc";
in-out property <string> col-view: "grid";
// "all" | "collection" | "artist_collection".
in-out property <string> col-kind-filter: "all";
}
export global MyQbzActions {
// Card click -> open detail (STUB: logs in this slice).
callback open-card(string /* id */);
// "+ New Mixtape" / "+ New Collection" (STUB: logs in this slice).
callback create-mixtape();
callback create-collection();
// --- Mixtapes toolbar ---
callback mix-search-changed(string /* query */);
callback mix-set-sort(string /* field */);
callback mix-set-view(string /* grid|list */);
callback mix-reset();
// --- Collections toolbar ---
callback col-search-changed(string /* query */);
callback col-set-sort(string /* field */);
callback col-set-view(string /* grid|list */);
callback col-set-kind-filter(string /* all|collection|artist_collection */);
callback col-reset();
}
// === My QBZ — Collection / Mixtape DETAIL view (read-only) ==============
// Spec 12. One row per collection item (album / track / playlist), rendered
// in the 8-col list / the grid. The Rust controller (crate::myqbz_detail)
// precomputes every display string (type label, source kind, quality detail,
// tracks/year columns, downscaled artwork URL) so the view does no lookups.
// READ-ONLY SCOPE (Phase-2 Slice 3): the hero action buttons + per-row
// context-menu items + grid/list view modes render 1:1, but every click is a
// logging STUB. The "expanded" view-mode toggle + shell render; per-item
// inline track expansion is DEFERRED (needs the enqueue resolver — a later
// slice). Edit/rename/delete/cover/DJ-mix modals + the BulkActionBar are also
// deferred.
export struct MixtapeDetailItem {
// 0-based persisted position (the row's stable key; display is +1).
position: int,
// "album" | "track" | "playlist".
item-type: string,
// "qobuz" | "local".
source: string,
source-item-id: string,
title: string,
subtitle: string,
// True for qobuz items whose subtitle is a clickable artist link
// (spec 12 §6.3 col-2 — local subtitles are plain text).
subtitle-is-link: bool,
// Numeric Qobuz artist id resolved by the resolveItems pass ("" until it
// lands / for local-Plex items). Routes the artist link to the QOBUZ
// artist page; without it the link can only fall back to the
// LocalLibrary Artists tab (name-based).
artist-id: string,
// Resolved source kind for the source indicator + source filter
// ("qobuz" | "plex" | "local"). Defaults from `source` until resolveItems
// lands (deferred — for now equals the raw source mapping).
source-kind: string,
// Type-cell label (already localized): "ALBUM" / "TRACK" / "PLAYLIST"
// (or an EP/Single/… release-type override — overrides are a later slice).
type-label: string,
// Quality badge inputs (resolved by the resolveItems pass).
quality-tier: string, // "hires" | "cd" | ""
quality-detail: string, // "24-bit / 96 kHz" | ""
// True while the async resolveItems pass is still pending for this row (no
// cached resolution yet). Drives the quality-cell LoadingDots skeleton —
// shown only during the resolve window, collapsing to the real badge (or an
// empty cell for a no-quality item) once resolution completes.
quality-resolving: bool,
// TRACKS column: "1" for a track, else the count or "—".
tracks-text: string,
// YEAR column: the year or "".
year-text: string,
// Downscaled (_50) row artwork; decoded into `artwork` by the pipeline.
artwork-url: string,
artwork: image,
// UI-owned multi-select state (select-mode — deferred wiring; the column
// renders when select-mode is on so geometry matches the PSD).
selected: bool,
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
// In the "expanded" view-mode every album / playlist item shows its
// tracks rendered directly underneath — always visible, no chevron (1:1
// with Tauri: "like LocalLibrary's Tracks page with Group-by-album").
// Whether this item can host inline tracks (album / playlist; never a
// bare track) — gates the inline section + the auto-fetch.
can-expand: bool,
// True once the per-item track fetch has completed (success OR empty), so
// the view can distinguish "still loading" from "loaded, no results".
tracks-loaded: bool,
// True while the per-item track fetch is in flight (spinner state).
expand-loading: bool,
// The resolved inline tracks (rendered as shared TrackRows under the row).
// Fetched once on entering expanded mode, then cached on the row.
inline-tracks: [TrackItem],
}
export global MyQbzDetailState {
in property <string> id;
// "mixtape" | "collection" | "artist_collection".
in property <string> kind;
// Eyebrow label, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
in property <string> kind-label;
in property <string> name;
in property <string> description;
// "5 albums" / "1 album" meta line (ICU plural, rendered in Rust).
in property <string> meta;
in property <int> item-count: 0;
// play_mode ("in_order" | "album_shuffle") — drives the overflow toggle
// label (deferred wiring; shown for parity).
in property <string> play-mode: "in_order";
// True when the user set a custom cover (full-bleeds the hero mosaic).
in property <bool> has-custom-cover: false;
in property <image> custom-cover;
in property <bool> loading: false;
// False once the load completes with no collection (not-found state).
in property <bool> found: true;
// --- Hero mosaic (CollectionMosaic inputs, same scheme as the card) ----
// cover-count 0 (empty -> kind glyph) / 4 / 9; up-to-9 downscaled URLs +
// decoded covers. Mirrors MixtapeCardItem so CollectionMosaic renders it.
in property <int> cover-count: 0;
in property <string> url1; in property <string> url2; in property <string> url3;
in property <string> url4; in property <string> url5; in property <string> url6;
in property <string> url7; in property <string> url8; in property <string> url9;
in property <image> cover1; in property <image> cover2; in property <image> cover3;
in property <image> cover4; in property <image> cover5; in property <image> cover6;
in property <image> cover7; in property <image> cover8; in property <image> cover9;
// --- Item list (the render model: filter -> search -> sort applied) ----
in property <[MixtapeDetailItem]> items: [];
// --- Toolbar (session-scoped) ------------------------------------------
in-out property <string> search;
// "position" | "name" | "year" | "tracks".
in-out property <string> sort: "position";
in-out property <string> sort-dir: "asc";
// "all" | "album" | "track" | "playlist" (single-select).
in-out property <string> type-filter: "all";
// Multi-select source filter membership (spec 12 §5.5). Three flags
// (Slint has no Set); Rust derives the badge count.
in-out property <bool> src-qobuz: false;
in-out property <bool> src-plex: false;
in-out property <bool> src-local: false;
// "list" | "grid" | "expanded".
in-out property <string> view-mode: "list";
// Multi-select edit mode (renders the per-row checkbox column; bulk-bar
// wiring is DEFERRED).
in-out property <bool> select-mode: false;
in property <int> selected-count: 0;
// Rust-computed filter badge count (sources + (type-filter != all)).
in property <int> filter-count: 0;
// Whether any filter/sort differs from the defaults (drives the reset btn).
in property <bool> has-any-filter: false;
}
export global MyQbzDetailActions {
// Client-side toolbar — re-derive the visible item list (UI thread).
callback search-changed(string /* query */);
callback set-sort(string /* field */);
callback set-type-filter(string /* all|album|track|playlist */);
callback toggle-source-filter(string /* qobuz|plex|local */);
callback set-view-mode(string /* list|grid|expanded */);
callback toggle-select-mode();
callback reset-filters();
// Open an item's detail (album / track-album / playlist). Routed to the
// existing open-album / open-playlist paths in main.rs.
callback open-item(string /* source */, string /* item-type */, string /* source-item-id */);
// Open an item's artist. Qobuz items route by the resolved numeric
// artist-id to the Qobuz artist page; local/Plex items (empty id) route
// by NAME to the LocalLibrary Artists tab.
callback open-artist(string /* source */, string /* artist-name */, string /* artist-id */);
// --- Hero CTAs --------------------------------------------------------
callback play-all();
callback shuffle();
callback dj-mix(); // STUB (the DJ-mix modal is a later slice).
callback sync(); // STUB / no-op (no discography sync impl, spec §8).
// --- Hero overflow (⋯) menu (spec 12 §10) -----------------------------
// Each opens the matching modal or fires the matching mutation + reload.
callback open-rename(); // -> Rename modal (prefilled with name).
callback open-description(); // -> Description modal (prefilled).
callback upload-cover(); // native file picker -> resize -> set + reload.
callback remove-cover(); // clear custom cover -> reload.
callback toggle-play-mode(); // in_order <-> album_shuffle -> reload.
callback convert-kind(); // mixtape <-> collection -> reload + toast.
callback open-delete(); // -> Delete-confirm modal.
// Per-row affordances.
callback play-item(string /* source-item-id */);
callback item-action(string /* source-item-id */, string /* action */);
// Remove ONE item from the collection by its 0-based persisted position
// (the row menu's "Remove from collection"). Routes to the audited bulk
// remover with a single position so compaction + reload + toast are reused.
callback remove-item(int /* position */);
// Toggle one row's selection (select-mode).
callback toggle-item-select(int /* position */);
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
// Fired when the "expanded" view-mode becomes active (and on the initial
// open in expanded mode): the Rust side fetches every expandable item's
// tracks (via the enqueue resolver) that aren't already cached, populating
// each row's inline-tracks model. Idempotent — cached rows are skipped, so
// re-entering expanded mode is instant.
callback ensure-expanded();
// Play / queue / navigate from an inline track row. `action` is one of
// "play" | "play-next" | "play-later" | "go-to-album".
// The parent item's source-item-id scopes the fetch + go-to-album target.
callback inline-track-action(string /* item-source-item-id */, string /* track-id */, string /* action */);
// --- Bulk action bar (select-mode, spec 12 §13) -----------------------
// The bar emits the chosen action id verbatim; the Rust side maps it:
// "add-to-mixtape" -> open the global AddToMixtapeModal with the
// selected items' payloads.
// "remove-selected" -> repo::remove_item each selected position
// (highest-first so compaction doesn't shift),
// then reload the detail + clear selection.
callback bulk-action(string /* id */);
}
// ─────────────────────── Discography Builder (spec 13) ───────────────────
// The full-page flow that assembles an artist's complete discography into a
// saved `kind='artist_collection'` collection. It fetches every release for
// the artist from three sources (Qobuz artist page + local library + Plex
// cache), dedupes them into GROUPS (one logical album = a chosen "primary" +
// "alternates"), lets the user check which to include, reorder (by sort),
// rename, and correct release-type mislabels, then persists the result.
//
// One candidate row (a primary OR an alternate). Every display string is
// precomputed in Rust (crate::myqbz_builder) so the view does no lookups.
export struct DiscoCandidate {
// `${source}|${source_item_id}` — the per-candidate selection key.
key: string,
// "qobuz" | "local" | "plex" — drives the source indicator icon.
source: string,
source-item-id: string,
title: string,
artist: string,
// "2019" or "—" (em-dash placeholder).
year: string,
// "12" or "—".
tracks: string,
artwork-url: string,
// Effective release type AFTER the user's override is applied:
// "album" | "ep" | "single" | "live" | "compilation".
release-type: string,
// True when a user override is active for this candidate (drives the
// dotted-underline marker on the type button).
is-overridden: bool,
// QualityBadgeFull inputs.
quality-tier: string,
quality-detail: string,
// Whether THIS candidate is currently checked (included on save).
checked: bool,
}
// One dedupe GROUP: a primary candidate plus zero or more alternates.
export struct DiscoGroup {
// group_key (`${normalizedTitle}|${year}`) — stable row key.
key: string,
title: string,
// "2019" / "—".
year: string,
primary: DiscoCandidate,
alternates: [DiscoCandidate],
// ALL candidates are compilations → the group is treated as a comp
// (dimmed primary row, ignored by select-all).
is-compilation: bool,
}
export global DiscoBuilderState {
// The artist being built (id + resolved display name + portrait URL).
in property <string> artist-id;
in property <string> artist-name;
in property <string> artist-avatar-url;
in property <image> artist-avatar;
// Render states (spec 13 §4). `loading` true initially + during load;
// `load-error` non-empty shows the raw error message; otherwise the
// groups table renders (empty groups -> "No results found").
in property <bool> loading: true;
in property <string> load-error: "";
// The dedupe groups in CURRENT sort order (Rust re-sorts on order change).
in property <[DiscoGroup]> groups: [];
// The collection-name input (defaulted to "<artist> — Complete Discography").
in-out property <string> collection-name: "";
// Whether the name is empty AFTER trimming whitespace (Slint can't trim, so
// the controller computes this on every name change). Gates the Create
// button so a whitespace-only name disables it (spec 13 §2.13 / §10.19:
// disabled = creating || selectedCount===0 || !collectionName.trim()).
in property <bool> name-trimmed-empty: true;
// Order segmented control: "release_date" | "title" | "manual".
in-out property <string> order-by: "release_date";
// Select-all header checkbox state (tri-state via the two flags).
in property <bool> all-checked: false;
in property <bool> some-checked: false;
// Footer: total checked candidate count + total group count.
in property <int> selected-count: 0;
in property <int> group-count: 0;
// Disables the Create button while a save is in flight.
in property <bool> creating: false;
// The per-row type-override popover open key (`${source}|${source_item_id}`),
// or "" when none is open. One menu open at a time.
in-out property <string> open-type-menu-key: "";
}
export global DiscoBuilderActions {
// Back to the artist page (clears builder state).
callback back();
// Collection-name input edited.
callback name-changed(string);
// Order segmented control: "release_date" | "title" | "manual".
callback set-order(string);
// Toggle one candidate's checked state (group-key + candidate-key).
callback toggle-checked(string /* group-key */, string /* candidate-key */);
// Header tri-state select-all toggle (non-compilation primaries only).
callback toggle-all();
// Open an album (Qobuz album view, or local/Plex local-album view).
callback open-album(string /* source */, string /* source-item-id */);
// Apply a release-type override to a candidate (writes the sidecar).
callback set-type-override(string /* source */, string /* source-item-id */, string /* type */);
// Clear a candidate's override (revert to auto-detected).
callback reset-type-override(string /* source */, string /* source-item-id */);
// Save: create the artist_collection + bulk-add the checked groups.
callback create();
}
// ──────────────────────────── DJ-mix modal ────────────────────────────
// The "Random queue" sampler (TrackMixModal, spec 21 §C / spec 12 §12).
// Opened from the hero DJ-mix CTA. On open the Rust controller resolves the
// collection in-order + computes shuffle::unique_track_count (the slider max);
// the slider snaps to the discrete buildSizeOptions set (50,100,150,…,All).
// On confirm it runs dedup_by_similarity + hybrid_sample (thread RNG, sync
// scope) -> set_queue + play -> stamp queue-source + touch_play, toasting
// "Playing N of M" when the sampled count is below the requested size.
export global MyQbzMixState {
in property <bool> open: false;
// True while resolving + counting unique tracks ("Loading…" state).
in property <bool> loading: false;
// Deduped unique-track count = the slider MAX (and the "All" size).
in property <int> unique-count: 0;
// The discrete size options (buildSizeOptions): the slider index maps into
// this list. e.g. [50,100,150,All(187)]. Empty while loading / for an
// empty collection.
in property <[int]> size-options: [];
// Current slider index into `size-options` (0-based).
in-out property <int> selected-index: 0;
// The size at `selected-index` (the value handed to the sampler).
in property <int> selected-size: 0;
// True when the selected option is the trailing "All (N)" entry.
in property <bool> selected-is-all: false;
// Disables Shuffle while a sample + play is in flight.
in property <bool> busy: false;
}
export global MyQbzMixActions {
callback close();
// Slider moved: the new index into size-options (Rust re-derives the size).
callback set-index(int /* index */);
// Confirm -> run the sample + play.
callback shuffle();
}
// ──────────────────────────── MyQBZ edit modals ───────────────────────
// The Rename / Description / Delete-confirm modals (spec 12 §11), driven
// from the hero overflow (⋯) menu. One global backs all three (only one is
// ever open at a time). `busy` disables submit while the mutation runs.
export global MyQbzEditState {
// "" = closed; "rename" | "description" | "delete".
in-out property <string> mode: "";
in property <bool> open: false;
// The collection name (delete-confirm body interpolation + rename prefill).
in property <string> name;
// Rename: prefilled with the collection name; submit trims.
in-out property <string> draft-name;
// Description: prefilled with the current description ("" clears to null).
in-out property <string> draft-description;
// Disables submit while a mutation + reload is in flight.
in-out property <bool> busy: false;
}
export global MyQbzEditActions {
callback close();
callback submit-rename(); // trim; rename_collection -> reload -> close.
callback submit-description(); // empty -> null; set_description -> reload.
callback confirm-delete(); // delete_collection -> nav back + cleanup.
}
// Folder editor modal — create + edit (icon preset, solid color, custom
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
// color presets are intentionally omitted (Slint brush limitation).
export struct PmIconPreset {
id: string,
icon: image,
}
export struct PmColorSwatch {
// The stored hex value ("" = the "Accent" preset), used as the
// selection key and persisted into the folder record.
value: string,
// Resolved Slint color for the swatch fill (ignored when is-accent).
color: color,
is-accent: bool,
}
export global FolderEditState {
in-out property <bool> open: false;
// Empty id = create mode; non-empty = editing that folder.
in property <string> id: "";
in-out property <string> name;
in-out property <string> icon-preset: "folder";
// Selected solid color hex; "" = accent.
in-out property <string> icon-color: "";
in-out property <bool> is-hidden: false;
// Custom image path (icon-type == "custom" when set) + decoded image.
in-out property <string> custom-image-path: "";
in property <image> custom-image;
in property <bool> busy: false;
// The icon-preset grid + the solid-color swatch grid, built once by
// the Rust controller.
in property <[PmIconPreset]> icon-presets: [];
in property <[PmColorSwatch]> color-swatches: [];
}
export global FolderEditActions {
callback select-preset(string /* preset id */);
callback select-color(string /* color hex, "" = accent */);
callback pick-image();
callback clear-image();
callback toggle-hidden();
callback save();
callback delete();
callback close();
}
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
// changes flow back through the settings-bool / settings-select /
// settings-slider callbacks.
export global SettingsState {
// Sub-section: 0 = Audio, 1 = Playback.
in-out property <int> section: 0;
in property <bool> loading: true;
// --- AUDIO -------------------------------------------------------------
// Audio — dropdowns. Each list is paired with a selected index; the
// Rust controller owns the index -> value mapping.
in-out property <[string]> streaming-qualities: [];
in-out property <int> streaming-quality-index: 0;
in-out property <[string]> backends: [];
in-out property <int> backend-index: 0;
in-out property <[string]> devices: [];
// Parallel to `devices`: true marks a bit-perfect-capable device.
in-out property <[bool]> device-bp: [];
// Parallel to `devices`: a non-empty entry is a section-header label
// shown above that device row (empty = no header).
in-out property <[string]> device-groups: [];
in-out property <int> device-index: 0;
in-out property <[string]> alsa-plugins: [];
in-out property <int> alsa-plugin-index: 0;
// Audio — toggles.
in-out property <bool> limit-quality-to-device: false;
// Detected local device limit (#638 fix 3), shown while the toggle above
// is on. `device-cap-summary` is the Rust-composed value line (e.g.
// "192 kHz · Hi-Res+"; empty = nothing detected yet → row hidden);
// `device-cap-detected` false = the probe fell back to an assumed common
// rate set, which surfaces the plain informative caveat (never an alert).
in-out property <string> device-cap-summary: "";
in-out property <bool> device-cap-detected: true;
in-out property <bool> alsa-hardware-volume: false;
// DSD delivery mode (DSD plan Phases 2-3): Convert to PCM / DoP /
// Native DSD. Only meaningful on the ALSA direct backend.
in-out property <[string]> dsd-modes: [];
in-out property <int> dsd-mode-index: 0;
in-out property <bool> exclusive-mode: false;
in-out property <bool> reserve-dac: false;
in-out property <bool> dac-passthrough: false;
in-out property <bool> pw-force-bitperfect: false;
in-out property <bool> allow-quality-fallback: false;
in-out property <bool> sync-audio-on-startup: false;
in-out property <bool> skip-sink-switch: false;
// Audio — Rust-computed conditional flags driving `if`-gated rows.
in-out property <bool> backend-is-alsa: false;
in-out property <bool> backend-is-pipewire: false;
in-out property <bool> backend-is-jack: false;
in-out property <bool> alsa-plugin-is-hw: false;
// --- PLAYBACK ----------------------------------------------------------
// Playback — toggles. `continue-playback` maps to the autoplay mode
// (on = ContinueWithinSource, off = PlayTrackOnly).
in-out property <bool> continue-playback: true;
in-out property <bool> show-context-icon: true;
in-out property <bool> persist-session: false;
in-out property <bool> resume-position: false;
in-out property <bool> gapless: true;
in-out property <bool> stream-uncached: false;
// Discover — opt-out: show the external "Recommendations" tab. Default on.
// Seeded from persisted discover prefs at session start (crate::discover_prefs).
in-out property <bool> show-recommendations: true;
// MusicBrainz metadata — opt-out (default on). Drives the core client's
// enabled flag, the artist Network/Scene sidebar (mb-available), and the
// playlist Suggested-Songs CTA. Seeded from ui_prefs at session start
// (crate::discover_prefs::seed); persisted via the "musicbrainz" key.
in-out property <bool> musicbrainz-enabled: true;
in-out property <bool> streaming-only: false;
// Volume normalization (loudness leveling). Applied in the shared player
// and bypassed automatically when bit-perfect is active. Surfaced in the
// now-playing bar's normalization group as well as Settings > Playback.
in-out property <bool> normalization: false;
// Audio-output indicators for the now-playing song card (computed in Rust
// from the real backend + bit-perfect constraints). `*-label` is the fixed
// short text; `*-active` colours the led when a deliberate / bit-perfect
// mode is engaged (vs the shared system default).
in-out property <string> output-backend-label: "SYST"; // PIPE|ALSA|JACK|PULS|SYST
in-out property <string> output-mode-label: "DEFAULT"; // DACPASS|BITPERF|EXCL|DIRECT|LOCKED|ROUTED|SHARED|DEFAULT
in-out property <bool> output-backend-active: false;
in-out property <bool> output-mode-active: false;
// Playback — Initial Buffer Size slider (seconds, 1-10).
in-out property <int> buffer-seconds: 3;
// Playback — "When quality retries fail" dropdown.
in-out property <[string]> retry-behaviors: [];
in-out property <int> retry-behavior-index: 0;
// Playback — "Auto-connect Qobuz Connect on startup" dropdown
// (remember-last / on / off). Backed by the QConnect settings DB via the
// Rust controller, which owns the index -> mode mapping.
in-out property <[string]> qconnect-startup-modes: [];
in-out property <int> qconnect-startup-index: 0;
// Playback — Qobuz Connect device name. The persisted custom override
// ("" = unset) plus the effective default name shown as the input
// placeholder ("Qbz - {hostname}"). Backed by the QConnect settings DB
// via the settings-string callback; applies on the next connect.
in-out property <string> qconnect-device-name: "";
in-out property <string> qconnect-device-name-default: "";
// --- OFFLINE MODE --------------------------------------------------------
// Settings > Offline, MODE group (the app operating without Qobuz — NOT
// the offline cache). Backed by the shared engine's per-user store, not
// the audio/playback stores: seeded by OfflineModeActions.load() on panel
// mount; persisted through the settings-bool key "offline-mode-enabled".
in-out property <bool> offline-mode-enabled: false;
// "Check now" re-probe in flight — flips the status row's button label.
// Cleared by Rust on the next status broadcast, or after a short timeout
// when the verdict comes back unchanged (the actor only broadcasts flips).
in-out property <bool> offline-checking: false;
}
// Appearance settings — backs the Settings > Appearance panel. 1:1 with
// Tauri's appearance section. This is a visual/structural replica: every
// row is bound to a property here, but the Rust side does NOT yet persist
// or apply most of them (wiring is a later step). The few that map onto an
// existing, already-functional flag are wired through (see notes inline).
//
// Dropdowns follow the same pattern as SettingsState: an option list paired
// with a selected index; the Rust controller (when wired) owns the
// index -> value mapping. Lists are populated with sensible defaults below
// so the UI renders standalone even while unwired.
export global AppearanceState {
// --- THEME -------------------------------------------------------------
// Seeded at startup by Rust from the qbz-theme registry
// (theme::dropdown_labels), in registry display order. The list below is a
// standalone-preview fallback only; the index is DERIVED from the persisted
// theme slug (theme::index_for_id), never assumed.
in-out property <[string]> themes: [
"Dark", "OLED Black", "Tokyo Night", "System"
];
in-out property <int> theme-index: 0;
// Theme list filter cycle: 0 = All, 1 = Dark, 2 = Light. Mirrors the
// SunMoon/Moon/Sun toggle button next to the theme dropdown.
in-out property <int> theme-filter: 0;
in-out property <bool> album-header-gradient: true;
// True while the "System" theme is selected (native OS-palette follow).
in-out property <bool> theme-is-system: false;
// Auto-Theme (shown when the appended "Auto (dynamic)" theme is selected).
// Source: 0 = System Colors, 1 = Wallpaper Sync, 2 = Custom Image.
in-out property <bool> theme-is-auto: false;
in-out property <[string]> auto-theme-sources: [@tr("System Colors"), @tr("Wallpaper Sync"), @tr("Custom Image")];
in-out property <int> auto-theme-source-index: 0;
in-out property <string> auto-theme-custom-path: "";
in-out property <bool> auto-theme-generating: false;
in-out property <string> auto-theme-detected-de: "";
// --- Custom theme editor (shown when the appended "Custom" theme is
// selected). The editable base is 12 semantic tokens; Rust seeds these
// swatch colors from the persisted custom_theme.json and re-derives the
// whole palette live on every edit. `custom-open-token` holds the key of the
// single token whose inline ColorPicker is expanded ("" = none).
in-out property <bool> theme-is-custom: false;
// Swatch colors for each editable base token, seeded from Rust.
in-out property <color> custom-surface-main: #000000;
in-out property <color> custom-surface-card: #0a0a0a;
in-out property <color> custom-surface-elevated: #1a1a1a;
in-out property <color> custom-text-primary: #ffffff;
in-out property <color> custom-text-secondary: #cccccc;
in-out property <color> custom-accent: #4285f4;
in-out property <color> custom-danger: #ef4444;
in-out property <color> custom-warning: #fbbf24;
in-out property <color> custom-success: #3fae6a;
in-out property <color> custom-border: #2a2a2a;
in-out property <color> custom-favorite: #ef4444;
in-out property <bool> custom-is-dark: true;
// Key of the token whose inline picker is open ("" = all collapsed).
in-out property <string> custom-open-token: "";
// A single token's color changed (drag-live). key = token id, color = value.
callback custom-set-token(string /* key */, color /* value */);
// A single token committed from the HEX field. key = token id, hex = #rrggbb.
callback custom-set-token-hex(string /* key */, string /* hex */);
// Re-seed the base from the currently active theme ("Start from current").
callback custom-seed-from-current();
// Flip the custom theme polarity (dark/light) and re-derive.
callback custom-toggle-dark(bool /* is-dark */);
// --- TYPOGRAPHY / SCALE / LANGUAGE -------------------------------------
// NOTE: the old Tauri-style percentage `ui-scales` placeholder properties
// that lived here were superseded by the working interface-size presets
// (`ui-scale-options` / `ui-scale-index` in the INTERFACE SIZE block
// further down, wired to SLINT_SCALE_FACTOR in main.rs).
in-out property <[string]> languages: [@tr("Auto"), "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語", "Nederlands"];
in-out property <int> language-index: 0;
in-out property <[string]> fonts: ["LINE Seed JP", "Montserrat", "Noto Sans", "Source Sans 3", "System"];
in-out property <int> font-index: 0;
// Immersive search action — what selecting a result in the Immersive search
// cortinilla does (index: 0=Disabled, 1=Replace queue, 2=Play next,
// 3=Add to queue). Persisted via appearance-select("immersive-search-action").
in-out property <[string]> immersive-search-actions: [@tr("Disabled"), @tr("Replace current queue"), @tr("Play next"), @tr("Add to queue")];
in-out property <int> immersive-search-action-index: 1;
// Immersive default view — which immersive view opens by default.
// 0 = Remember last (restore the last view that was open),
// 1 = Album Reactive, 2 = Static, 3 = Coverflow, 4 = Spectrum,
// 5 = Lyrics, 6 = Queue (all 1-6 pin a FOCUS view-mode foreground).
// Persisted via appearance-select("immersive-default-view").
in-out property <[string]> immersive-default-views: [@tr("Remember last"), @tr("Album Reactive"), @tr("Static"), @tr("Coverflow"), @tr("Spectrum"), @tr("Lyrics"), @tr("Queue")];
in-out property <int> immersive-default-view-index: 0;
// --- VISUAL EXTRAS -----------------------------------------------------
// sidebar-playlist-collage is wired to SidebarState.playlist-collage in
// the panel (the only already-functional flag here).
in-out property <bool> sidebar-playlist-collage: true;
in-out property <bool> local-library-track-artwork: false;
// Intelligent search: smart cache, ranking, and the search preview dropdown.
in-out property <bool> intelligent-search: true;
// Player volume +/- stepper buttons — opt-in, hidden by default.
in-out property <bool> show-volume-steppers: false;
// --- NOTIFICATIONS -----------------------------------------------------
in-out property <bool> in-app-toasts: true;
in-out property <bool> system-notifications: true;
// --- WINDOW TITLE ------------------------------------------------------
in-out property <bool> window-title-show: false;
in-out property <string> window-title-template: "";
// --- TITLE BAR / WINDOW CHROME -----------------------------------------
// The header IS the title bar (merged model, owner decision 2026-07-03):
// it is always present; custom chrome (use-system-title-bar off) makes it
// gain the window controls + drag and drops the system decorations.
// Defaults are per-OS, seeded from ui_prefs at startup BEFORE the first
// show (decorations negotiate at surface creation): Linux = system chrome,
// macOS = overlay (native traffic lights over the header). The .slint
// default is the safe Linux one.
in-out property <bool> use-system-title-bar: true;
// APPLIED chrome state (what the window was actually created with, vs the
// persisted pref above which the settings toggle edits). All chrome
// bindings (no-frame, header drag/inset/controls, Purchases placement)
// read THIS one. Rust decides per platform how the pref propagates:
// Linux mirrors the pref here live (hot apply, today's behavior); macOS
// leaves it at the startup value (the overlay attributes are fixed at
// window creation — restart to apply).
in-out property <bool> system-title-bar-active: true;
// Tiling-WM variant: frameless without drawn controls / header drag.
in-out property <bool> hide-title-bar: false;
// Phase 2 (deferred): rounded corners matching the desktop decoration.
in-out property <bool> match-system-chrome: false;
// True when GPU acceleration is on (gates match-system-chrome).
in-out property <bool> hardware-accel-enabled: true;
// Purchases nav entry placement (the only per-item relocation kept —
// the old search-in-title-bar / nav-tb-* toggles were dropped, owner
// decision 2026-07-03).
in-out property <bool> nav-tb-purchases: false;
// Window controls group.
in-out property <[string]> wc-positions: [@tr("Left"), @tr("Right")];
in-out property <int> wc-position-index: 1;
in-out property <[string]> wc-styles: [@tr("Rectangular"), @tr("Full-height rounded"), @tr("Circular"), @tr("Square")];
in-out property <int> wc-style-index: 0;
in-out property <[string]> wc-sizes: [@tr("Small"), @tr("Normal"), @tr("Large")];
in-out property <int> wc-size-index: 1;
in-out property <[string]> wc-color-presets: [@tr("Default"), "macOS", "Adwaita", @tr("Monochrome"), @tr("Custom")];
in-out property <int> wc-color-preset-index: 0;
in-out property <bool> show-window-controls: true;
// --- MINIPLAYER --------------------------------------------------------
in-out property <[string]> miniplayer-views: [@tr("Remember last used"), @tr("Micro"), @tr("Compact"), @tr("Artwork"), @tr("Queue"), @tr("Lyrics")];
in-out property <int> miniplayer-view-index: 0;
// --- STARTUP -----------------------------------------------------------
in-out property <[string]> startup-pages: [@tr("Home"), @tr("Where you left off")];
in-out property <int> startup-page-index: 0;
in-out property <bool> show-purchases: false;
// --- SYSTEM TRAY -------------------------------------------------------
// `is-macos` is set once from Rust (cfg!(target_os = "macos")) so the tray
// section can swap the header to "Menu Bar" and show macOS-only controls.
in-out property <bool> is-macos: false;
in-out property <bool> tray-enable: true;
in-out property <bool> tray-minimize-to-tray: false;
in-out property <bool> tray-close-to-tray: false;
// macOS-only: hide the Dock icon when closed to the menu bar (.accessory
// activation policy). Off = Spotify-style (keep the Dock icon).
in-out property <bool> tray-mac-hide-dock: false;
in-out property <[string]> tray-icon-themes: [@tr("Auto"), @tr("Mono light"), @tr("Mono dark"), @tr("Color")];
in-out property <int> tray-icon-theme-index: 0;
// --- RENDERER ----------------------------------------------------------
// Linux-only: the renderer tier (wgpu / GL / software) is a startup-time
// choice there. Hidden by default; Rust flips this on for Linux. macOS is
// always Skia/Metal, so the row never shows.
in-out property <bool> renderer-setting-visible: false;
in-out property <[string]> renderer-options: [@tr("Auto (recommended)"), @tr("GPU"), @tr("GPU (compatibility)"), @tr("Software")];
in-out property <int> renderer-index: 0;
// Preferred GPU: options built in Rust from the detected adapters
// ("Auto" + each GPU by name); index 0 = Auto.
in-out property <[string]> gpu-power-modes: [@tr("Auto (recommended)")];
in-out property <int> gpu-power-index: 0;
// --- INTERFACE SIZE ----------------------------------------------------
// Window scale-factor preset (XS 0.8 / Small 0.9 / Default 1.0 /
// Large 1.2 / XL 1.5) — startup-time choice, restart to apply.
// Index 2 = Default.
in-out property <[string]> ui-scale-options: [@tr("Extra small"), @tr("Small"), @tr("Default"), @tr("Large"), @tr("Extra large")];
in-out property <int> ui-scale-index: 2;
// --- IMMERSIVE ---------------------------------------------------------
in-out property <[string]> immersive-views: [
@tr("Remember last used"), @tr("Coverflow"), @tr("Static"), @tr("Vinyl"), @tr("Visualizer"),
@tr("Neon Flow"), @tr("Tunnel"), @tr("Comet"), @tr("Lyrics"), @tr("Queue"),
@tr("Split: Lyrics"), @tr("Split: Track Info"), @tr("Split: Suggestions"), @tr("Split: Queue")
];
in-out property <int> immersive-view-index: 0;
in-out property <[string]> immersive-background-modes: [@tr("Full"), @tr("Lite"), @tr("Off")];
in-out property <int> immersive-background-mode-index: 0;
// Collapsible sub-panels (collapsed by default, matching Tauri).
in-out property <bool> immersive-panels-collapsed: true;
in-out property <bool> immersive-fps-collapsed: true;
// --- APP-WIDE DYNAMIC BACKGROUND ("modo Cider") ------------------------
// Album-art background composited behind the WHOLE shell. Opt-in, wgpu-tier
// only. 0 = Off, 1 = Ambient (GPU shader scene 7), 2 = Blurred art (the
// ImmersiveAtmosphere blurred-cover look). Persisted in ui_prefs
// (`app_background`) via appearance-select("app-background", i).
in-out property <[string]> app-background-modes: [@tr("Off"), @tr("Ambient"), @tr("Blurred art")];
in-out property <int> app-background-mode-index: 0;
// False on the femtovg-GL / software tiers (no wgpu underlay, and
// reduce-motion is forced ON there → the effect would freeze). Seeded from
// Rust (= use_gpu_renderer); gates the whole picker row.
in-out property <bool> app-background-available: false;
// Live-tunable look knobs, seeded from Rust env at startup (QBZ_BG_DIM /
// QBZ_BG_SURFACE_ALPHA) so the appearance can be dialed in a single smoke
// session without recompiling, then baked to constants. `dim` = extra dark
// scrim over the background for legibility; `surface-alpha` = translucency
// of the shell chrome when the background is active.
in-out property <float> app-background-dim: 0.35;
in-out property <float> app-background-surface-alpha: 0.5;
// Thin BARS (jump-nav, discover toolbar, network tab row, playlist action
// bar) go MORE transparent than the panels — they read as chrome strips over
// the frosted content panel, not solid bars. Env QBZ_BG_BAR_ALPHA.
in-out property <float> app-background-bar-alpha: 0.3;
// Callbacks. Unwired in this replica — the Rust side may bind them
// later. Kept generic (key + value) so the controller can dispatch.
callback appearance-bool(string /* key */, bool /* value */);
callback appearance-select(string /* key */, int /* index */);
callback appearance-text(string /* key */, string /* value */);
callback appearance-action(string /* key */);
}
// "My QBZ" navigation branding (custom label + icon), surfaced in
// Settings > Appearance and reflected on the sidebar "My QBZ" row.
//
// Mirrors the Tauri `myQbzNavStore` contract (label default "My QBZ",
// icon default the branded /my-qbz.svg). Persistence is per-user JSON
// (see crate::myqbz_prefs); these properties are the live UI mirror and
// the callbacks are the action surface (the Rust side persists + re-seeds).
//
// - `label` : the current label (never empty — blank input coerces
// back to the default "My QBZ" string on the Rust side).
// - `custom-icon` : the user's image, loaded straight from the picked file
// path and rendered as-is (no tint). Only meaningful when
// `has-custom-icon` is true; otherwise the consumer falls
// back to the default branded glyph in markup (mask-tinted
// via QbzIcon on the sidebar). The default glyph stays a
// compile-time `@image-url` — the Rust side never decodes it.
// - `has-custom-icon` : true when a usable user image is set. A stale /
// deleted custom path falls back to the default on the Rust
// side (the seed loads the file; a load failure clears the
// flag without mutating the store), so this flag tracks "a
// usable custom icon is present".
export global MyQbzBrandingState {
in-out property <string> label: "My QBZ";
in-out property <image> custom-icon;
in-out property <bool> has-custom-icon: false;
// Commit a new label (trimmed-empty coerces to the default on the Rust
// side, which also re-seeds `label`). Fired on the text input's edit.
callback set-label(string /* label */);
// Open the native image picker; on pick, persist the path, load it, and
// re-seed `icon` + `has-custom-icon`. No-op on cancel.
callback pick-icon();
// Reset the icon to the default branded glyph (clears the persisted
// custom path). The label is reset separately by clearing the input.
callback reset-icon();
}
// One entry of a nav-tab dropdown.
export struct NavMenuEntry {
label: string,
icon: image,
has-glyph: bool,
// Destination route fired when the row is clicked, e.g. "home",
// "favorites-tracks", "favorites-albums". Empty = no-op (the
// destination isn't implemented yet).
route: string,
}
// Header nav-tab dropdown state.
//
// The four header tabs (Discover / Library / Local Library / My QBZ) no
// longer own a PopupWindow each — a PopupWindow grabs the pointer while
// open, which blocks hover-switching between tabs. Instead a single
// in-window overlay (HeaderMenuOverlay, a late child of the AppShell
// root) draws the open tab's menu. Tabs write their identity into this
// global on hover/click; hovering a different tab overwrites it, giving
// an instant switch. `open-index` of -1 means no menu is open.
export global HeaderMenuState {
// Which tab's menu is open: -1 = none, 0..3 = the tab index.
in-out property <int> open-index: -1;
// Header x-coordinate of the open tab, so the overlay aligns the
// menu panel under it.
in-out property <length> anchor-x: 0px;
// Window y-coordinate of the anchor — used for sidebar flyouts, which
// open to the RIGHT of the row rather than under the header.
in-out property <length> anchor-y: 0px;
// True when the open menu is a sidebar section flyout (opens right of
// the row) rather than a header dropdown (opens under the tab).
in-out property <bool> from-sidebar: false;
// Optional section-name header shown as the first row (with a hairline
// separator below it). Empty = no header row.
in-out property <string> title: "";
// True while the pointer rests on the TRIGGER (the header tab / compact
// nav icon / sidebar nav row). Pauses the idle-close like hovering the
// menu does, so resting on the trigger never lets it close.
in-out property <bool> trigger-hovered: false;
// The open tab's menu rows.
in-out property <[NavMenuEntry]> items: [];
// Index of the row wired to Home (-1 = none); only Discover uses it.
in-out property <int> home-item: -1;
}
export global NavState {
in-out property <ContentView> view: ContentView.home;
// Browser-like history flags, driven by the Rust nav module.
in property <bool> can-back: false;
in property <bool> can-forward: false;
// Navigation requests routed to Rust so history is recorded.
callback request-back();
callback request-forward();
callback request-settings();
// ---- Scroll-position restore (browser-like, per history entry) ----
// The mounted scroll container reports its live viewport-y here on every
// `changed viewport-y`; the Rust nav module stamps it onto the outgoing
// entry so back/forward can put it back.
callback report-scroll(length /* viewport-y */);
// Armed by Rust right before a back/forward view switch: `restore-scope`
// names which scroll container should restore (a stable per-list id, see
// nav::scope_for), and `scroll-restore` is the viewport-y to apply. A
// container restores only when its own scope matches, exactly once per
// mount, and only after its content has laid out (viewport overflows).
in-out property <string> restore-scope: "";
in-out property <length> scroll-restore: 0;
}
// Artist detail screen state.
// One label row in the artist network sidebar's Labels section.
export struct LabelEntry {
id: string,
name: string,
}
// One similar-artist row in the artist network sidebar's Similar
// Artists section.
export struct SimilarEntry {
id: string,
name: string,
}
// One Magazine/Stories teaser (from /artist/story) shown in the sidebar.
export struct StoryItem {
title: string,
author: string,
excerpt: string,
url: string, // https://play.qobuz.com/magazine/story/{id}
image-url: string, // ready-to-use arc-cdn thumbnail URL
image: image, // decoded by the artwork pipeline
}
export global ArtistState {
in property <string> id;
in property <string> name;
// Whether the user follows (Qobuz-favorites) this artist — drives the
// header heart fill; seeded on artist open, toggled by the follow action.
in property <bool> is-following: false;
// Network URL of the portrait — used by the artwork right-click
// menu for Open-in-browser and Save-as actions.
in property <string> artwork-url;
// True when the user has registered a local image to override
// the Qobuz-supplied portrait. Drives the menu's
// "Add image" -> "Change image / Remove image" toggle.
in property <bool> has-custom-image: false;
in property <string> bio;
// Truncated bio (word-boundary cut + ellipsis) for the at-rest view;
// the full bio in `bio` is shown when the Read-more toggle expands.
in property <string> bio-short;
in property <bool> bio-truncated: false;
// Editorial source for the biography ("TiVo" etc); shown as a small
// "Source: <name>" line under the Read more toggle when present.
in property <string> bio-source;
in property <image> artwork;
// Static Immersive-style blurred artwork field for the header backdrop.
in property <image> header-atmosphere;
// Artwork-derived tint for the header fallback gradient.
in property <color> header-color: #181820;
in property <[TrackItem]> top-tracks: [];
// "Novedad más reciente" — the single latest-release highlight (last_release).
// `has-last-release` gates the block; nullable on the API (hidden for artists
// with no recent release, e.g. those with an empty last_release).
in property <bool> has-last-release: false;
in property <AlbumCardItem> last-release;
// "Appears On" (tracks_appears_on) — flat track section of guest appearances.
in property <[TrackItem]> appears-on: [];
// Magazine / Stories teasers (from /artist/story) — sidebar section.
in property <[StoryItem]> stories: [];
in property <bool> stories-loading: false;
// Popular Tracks multi-select (track selection) mode + selected count —
// mirrors AlbumState. in-out: the section toggle flips it from the UI;
// Rust resets it on artist change.
in-out property <bool> top-tracks-multi-select: false;
in property <int> top-tracks-selected-count: 0;
// Releases grouped into titled sections (Albums, EPs & Singles, ...).
in property <[ArtistReleaseSection]> release-sections: [];
// Labels collected from the artist's own album releases (deduped by
// id, alphabetically sorted) — feed the Labels section of the
// network sidebar.
in property <[LabelEntry]> labels: [];
// Similar artists from the /artist/page response — feed the Similar
// Artists section of the network sidebar.
in property <[SimilarEntry]> similar-artists: [];
// Curated playlists featuring this artist (the /artist/page `playlists`
// section). Rendered as a main-column PlaylistCarousel directly above the
// "Other" releases block. Covers filled by ArtworkTarget::ArtistPlaylistCover.
in property <[SearchPlaylistItem]> playlists: [];
// Catalog-vs-library toggle (webplayer "Del catálogo" / "En la biblioteca").
// The toggle only shows when library-count > 0; "library" swaps the main
// column to the user's added items for this artist.
in-out property <string> artist-tab: "catalog"; // "catalog" | "library"
in property <int> library-count: 0;
in property <[TrackItem]> library-tracks: [];
in property <[AlbumCardItem]> library-albums: [];
// Filtered JUMP TO tabs for this artist — only the sections
// that actually have content. Built in artist::apply_artist
// from the page response. anchor-y is the page-local y the
// ArtistPageView will scroll to when the tab is clicked.
in property <[JumpNavTab]> jump-tabs: [];
in property <bool> loading: false;
// Whether the CURRENTLY displayed artist is blacklisted. Drives the
// overflow-menu Blacklist/Show toggle label + the hidden-artist
// banner. Set in navigate_artist from artist_blacklist::is_blacklisted
// (which auto-gates on the enabled flag — reads false when the
// feature is disabled, which is acceptable here).
in-out property <bool> is-blacklisted: false;
// Whether THIS artist is pinned to Home; seeded on open, flipped by
// toggle-pin when ids match.
in property <bool> pinned: false;
}
// Artwork right-click actions — shared by ArtistPageView and
// AlbumPageView for their respective headers. `kind` is "artist" or
// "album"; `key` is the artist name or album id; `url` is the
// current network URL (for open-in-browser and save-as).
export global ArtworkActions {
callback open-in-browser(string /* url */);
callback save-as(string /* url */, string /* default file name */);
// Opens the system file picker and registers the chosen image
// as the custom override for this artist/album.
callback add-custom(string /* kind */, string /* key */);
callback remove-custom(string /* kind */, string /* key */);
}
// Artist-view actions wired to Rust callbacks.
export global ArtistActions {
// Filter the visible Popular Tracks and release-section albums by
// a query string (client-side substring match, mirrors the album
// track search in AlbumActions).
callback search(string);
// Toggle Popular Tracks multi-select mode.
callback toggle-top-tracks-select();
// Bulk-bar action over the selected Popular Tracks (id = "select-all" |
// "play-next" | "queue" | "add-to-playlist" | "add-to-favorites" |
// "clear").
callback top-tracks-bulk-action(string);
// Popular Tracks section "more" menu (id = "next-all" | "queue-all" |
// "shuffle-all" | "playlist-all").
callback top-tracks-menu-action(string);
// Change the persisted sort for one release bucket (release-type, sort).
// sort = default | newest | oldest | title-asc | title-desc.
callback set-section-sort(string, string);
// Load one more page of releases for a bucket (release-type). Capped to
// 4 pages on the index; the full list lives on the dedicated page.
callback load-more-section(string);
// Open the dedicated discography page pre-filtered to a release-type
// ("all" for the whole discography).
callback open-releases(string);
}
// Artist network sidebar — Origin / Labels / Similar / Relationships /
// Discovery. The first three lists come from artist state already; this
// global covers the MusicBrainz-driven sections plus the open/closed
// flag the network button toggles.
// One row of the Origin section. Empty strings mark absent fields so the
// view can hide individual rows without an Option wrapper.
export struct MbOriginData {
// True when the artist is a person (vs group/orchestra/etc). Drives
// the row labels: "Born / Born in / Died" vs "Founded / Founded in /
// Disbanded".
is-person: bool,
// Begin date — "1976" / "May 1976" / "May 14 1976". Empty when MB
// has no life_span.begin.
begin-date: string,
// End date — same shape as begin. Empty when life_span.end is
// missing (or the artist is still active).
end-date: string,
// Location display string ("Berlin, Germany" / "United Kingdom").
// Empty when MB has no location.
location-display: string,
// True when the location is precise enough to drill in (city/state
// precision, or precision==country but a city is present somehow).
// When false, the row renders as plain text.
location-clickable: bool,
}
// One member, group, or collaborator row.
export struct MbRelationship {
mbid: string,
name: string,
// Primary role string for the musician-click callback ("Band Member",
// "Guitar", "Producer", ...). Always populated — defaults to "Band
// Member" / "Band" / "Collaborator" by section when MB has no
// attributes for the relation.
role: string,
// Tooltip — roles joined with ", " plus the period in parens when
// present. Falls back to the name.
tooltip: string,
}
// All MB relationship rows for the sidebar. Each list is independently
// hideable.
export struct MbRelationshipsData {
members: [MbRelationship],
groups: [MbRelationship],
collaborators: [MbRelationship],
// Convenience flag — true when any of the three lists has at least
// one row, so the section header can hide on no-data.
has-data: bool,
}
// One row of the "You may also like" discovery section.
export struct DiscoveryArtist {
// MB id — used by the dismiss callback to remember the rejection.
mbid: string,
name: string,
// Qobuz artist id (string form) when the MB candidate matched a
// Qobuz artist; empty when only available on MB. Drives whether the
// row is clickable.
qobuz-id: string,
}
export global NetworkSidebarState {
// Open/closed. The header network button toggles this; persistence
// is handled by the Rust side (`network_sidebar_prefs::set_open`).
// Defaults to open — Rust overrides on startup from the persisted
// value if any.
in-out property <bool> open: true;
// Active sidebar tab: "network" (Artist Network) | "magazine" (Stories).
// Lives here (not ArtistState) so it survives sidebar close/reopen; reset
// to "network" on each new artist.
in-out property <string> active-tab: "network";
// True until proven otherwise. Set to false when MB is disabled in
// settings, or when the name → mbid resolution fails. Drives whether
// the MB-sourced sections render at all (Origin, Relationships,
// Discovery).
in property <bool> mb-available: true;
// Resolved MB id for the current artist; empty before resolve
// completes or when the artist could not be matched. Used by the
// Rust side as the key for relationship/discovery loads.
in property <string> mb-mbid;
// Origin (MB metadata) section
in property <bool> origin-loading: false;
in property <MbOriginData> origin;
// Relationships (MB) section
in property <bool> relationships-loading: false;
in property <MbRelationshipsData> relationships;
// Discovery (MB tag-based) section — populated in phase 8.
in property <bool> discovery-loading: false;
in property <[DiscoveryArtist]> discovery-artists: [];
// Primary tag the current discovery batch was seeded with —
// delivered as the first argument of the discovery-dismissed
// callback so the Rust side can persist the dismiss under the
// right key.
in property <string> discovery-tag;
}
// Callbacks the sidebar emits. Each delivers the minimum payload the
// Rust controller needs; the controller reconstructs richer payloads
// (location precision, affinity seeds) from cached metadata when it
// has to call out to a target view.
export global NetworkSidebarActions {
// Origin location row clicked — opens ArtistsByLocation view.
callback location-clicked(string /* current artist mbid */);
// Label row clicked — opens LabelReleases view.
callback label-clicked(string /* label id */, string /* label name */);
// Similar / discovery / similar-row clicked — opens the Qobuz
// artist page for `qobuz-id`.
callback artist-clicked(string /* qobuz id */);
// Relationship row clicked — opens MusicianPage with the role.
callback musician-clicked(string /* name */, string /* role */);
// Discovery dismiss — removes the row and remembers the rejection.
// Carries both the MBID (model key for removal) and the name
// (persisted into the per-tag dismiss store after normalization).
callback discovery-dismissed(string /* mbid */, string /* name */);
// Sidebar toggled — Rust persists the new open state.
callback toggle();
}
// One row of MusicianPageView's "Appears On" grid — same shape as
// the album cards on Discover, plus a role label stamped onto each
// album by the Qobuz search wrapping (e.g. "Drums" / "Guitar").
export struct MusicianAppearanceItem {
album-id: string,
album-title: string,
artist-name: string,
year: string,
role-on-album: string,
artwork-url: string,
artwork: image,
}
// MusicianPageView state — opened by the artist network sidebar
// when a relationship row is clicked and the musician has no
// confident Qobuz artist match. Populated by `crate::musician`
// from the resolve + appearances backend calls.
export global MusicianState {
in property <string> name;
in property <string> role;
// "confirmed" | "contextual" | "weak" | "none" — the sidebar
// uses this to skip MusicianPageView entirely on Confirmed and
// navigate to the matched artist instead.
in property <string> confidence;
// Bands / projects from MusicBrainz; not yet populated but
// kept in the data shape so the section renders cleanly when
// wired.
in property <[string]> bands: [];
in property <[MusicianAppearanceItem]> appearances: [];
in property <int> total: 0;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
}
export global MusicianActions {
callback load-more();
}
// LabelReleasesView state — opened by the artist network sidebar's
// Labels section. Header (name + image) from /label/page, the
// album grid from /label/getAlbums. Populated by `crate::label`.
export global LabelState {
in property <string> id;
in property <string> name;
in property <string> image-url;
in property <image> image;
// Releases sub-view (LabelReleasesView) — the paginated album grid.
// `albums` is the full loaded catalog + the artwork-pipeline target
// (stable indices); `visible` is the sorted/filtered/searched subset
// the view actually renders (shares `albums` when no filter is active,
// so artwork stays live — mirrors DiscoverBrowseState).
in property <[AlbumCardItem]> albums: [];
in property <[AlbumCardItem]> visible: [];
in property <int> total: 0;
// Whether the catalog has more pages to fetch (drives Load more). The
// /label/getAlbums endpoint caps each page well below the full catalog,
// so this is the reliable signal — `total` is often unset/underset.
in property <bool> has-more: false;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
// "grid" | "list" — the view toggle (mirrors DiscoverBrowseView).
// UI-owned, like Discover's; no Rust round-trip needed.
in-out property <string> view-mode: "grid";
// --- Landing page (LabelPageView) -----------------------------------
// Header description (HTML-stripped) + truncated form + the flag that
// gates the READ MORE affordance (same pattern as ArtistState.bio).
in property <string> description;
in property <string> description-short;
in property <bool> description-truncated: false;
// Header follow state — true when the label is in the user's favorites.
in property <bool> is-following: false;
// Catalog/library toggle (SegmentedTabBar, mirrors ArtistState.artist-tab
// + library-*): "catalog" renders the /label/page sections; "library"
// renders the user's favorite albums on this label (the session
// library_by_label index). The toggle only shows when count > 0.
in-out property <string> label-tab: "catalog";
in property <int> library-count: 0;
in property <[AlbumCardItem]> library-albums: [];
in property <[TrackItem]> library-tracks: [];
// Popular tracks (album-thumbnail rows, multi-select capable).
in property <[TrackItem]> top-tracks: [];
// Multi-select (mirrors AlbumState): mode flag + count. Per-row toggle
// routes through the shared media-action("track",...) arm.
in-out property <bool> multi-select: false;
in property <int> selected-count: 0;
// Releases + Critics' Picks carousels (reuse Carousel).
in property <DiscoverSection> releases-section;
in property <DiscoverSection> critics-section;
// Playlists / Artists / More-labels carousels. More-labels reuses
// ArtistCarousel (SlimItem already carries `following`).
in property <[SearchPlaylistItem]> playlists: [];
in property <[SlimItem]> artists: [];
in property <[SlimItem]> more-labels: [];
// Sticky JUMP TO tabs — only the sections that have content.
in property <[JumpNavTab]> jump-tabs: [];
// True once the landing page-data load resolves (gates the spinner).
in property <bool> page-loaded: false;
// --- Releases sub-view toolbar (UI-owned, like Discover) ------------
// "newest" | "oldest" | "title-asc" | "title-desc" | "artist-asc".
in-out property <string> sort-by: "newest";
in-out property <bool> filter-hires: false;
in-out property <bool> group-by-artist: false;
// Releases sub-view in-view search query (drives the count line).
in-out property <string> search-query: "";
// Group-by-artist rendering — one section per artist (reuses the
// DiscoverSection + grid pattern). Empty unless group-by-artist is on.
in property <[DiscoverSection]> grouped: [];
// Count of albums actually shown after filter/search (for the count line).
in property <int> shown: 0;
// Count of Hi-Res albums in the loaded catalog (count line).
in property <int> hires-count: 0;
}
export global LabelActions {
// Releases sub-view: fetch the next album page.
callback load-more();
// Landing: toggle follow on the header label or a more-labels card.
callback toggle-follow(string /* label id */);
// Landing: play all popular tracks.
callback play-top();
// Landing: top-tracks header overflow menu (play-next / queue / shuffle).
callback top-tracks-menu();
// Landing: enter/leave multi-select for popular tracks (select-all).
callback select-all-top-tracks();
// Landing: open the full releases sub-view ("See all").
callback open-releases();
// Releases sub-view toolbar.
callback set-sort(string /* option */);
callback set-hires(bool);
callback set-group(bool);
callback search(string /* query */);
}
// Award pages (AwardView landing + AwardAlbumsView listing) — mirrors
// Tauri's AwardView.svelte / AwardAlbumsView.svelte. One global serves
// both, like LabelState: the landing renders the hero + `albums` preview
// (first /award/getAlbums page) + `other-awards`; the listing renders the
// full paginated `albums` with client-side search (`visible`). The grid is
// always sourced from /award/getAlbums — never `/award/page.releases`
// (user-scoped, hero-only).
export global AwardState {
in property <string> id;
in property <string> name;
// Hero image URL (award image, falling back to magazine image) + the
// decoded image. Empty image-url => laurel/gold placeholder.
in property <string> image-url;
in property <image> image;
// Magazine name shown under the award name (may be "").
in property <string> magazine-name;
// Header follow heart — three states: not-following / following /
// toggling (disabled while the API call is in flight).
in property <bool> is-following: false;
in property <bool> following-toggling: false;
// Loaded album catalog (landing preview OR full listing) + the artwork
// pipeline target (stable indices). `visible` is the search-filtered
// subset the listing renders (shares `albums` when the query is empty,
// so artwork stays live — the DiscoverBrowse/Label pattern).
in property <[AlbumCardItem]> albums: [];
in property <[AlbumCardItem]> visible: [];
in property <int> total: 0;
in property <bool> has-more: false;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
// True when the releases load failed (drives the error/retry branch).
in property <bool> load-error: false;
// "Other awards" carousel (from /award/explore, current award excluded).
// Reuses SlimItem: title = award name, subtitle = magazine.
in property <[SlimItem]> other-awards: [];
// True once the landing page-data load resolves (gates the spinner).
in property <bool> page-loaded: false;
// Listing in-view search query + the count actually shown after filter.
in-out property <string> search-query: "";
in property <int> shown: 0;
}
export global AwardActions {
// Header: toggle follow on the open award.
callback toggle-follow(string /* award id */);
// Landing "See all" -> open the full albums listing.
callback open-albums();
// "Other awards" card click -> open that award's landing.
callback open-award(string /* award id */, string /* award name */);
// Listing: fetch the next album page.
callback load-more();
// Listing: re-filter `visible` after `search-query` changed (UI thread).
callback search(string /* query */);
// Releases error branch: retry the failed load.
callback retry();
}
// Dedicated artist discography page (one release bucket, fully paginated)
// reached via "See discography" on the artist page.
export global ArtistReleasesState {
in property <string> id; // artist id
in property <string> name; // artist name (header)
in property <string> release-type; // raw server key (album/epSingle/…)
in property <string> title; // display title (header)
in property <[AlbumCardItem]> albums: [];
in property <bool> has-more: false;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
in property <bool> load-error: false;
in-out property <string> sort-by: "default";
}
export global ArtistReleasesActions {
// Fetch the next page (infinite scroll / Load more).
callback load-more();
// Change the sort (default/newest/oldest/title-asc/title-desc).
callback set-sort(string);
// Retry after a failed load.
callback retry();
}
// ArtistsByLocationView state — opened from the Origin section's
// location link. Populated by `crate::location_view`. Artists ride the
// shared SlimItem model (subtitle = genres line) so the grid uses the
// app-wide ArtistGridCard (follow / play / pin / context menu).
export global LocationViewState {
in property <string> scene-label;
in property <string> genre-summary;
in property <[SlimItem]> artists: [];
in property <int> total: 0;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
}
export global LocationViewActions {
callback open-artist(string /* qobuz id */);
callback load-more();
}
// Interface-size preset (UI scale). Seeded once from Rust before the shell
// renders. The actual scaling happens through the window scale factor
// (SLINT_SCALE_FACTOR set in main.rs) — this global only lets bindings that
// must stay PHYSICALLY constant divide the preset back out (e.g. the window
// minimums, so an XL window still fits a 1366x768 screen).
export global UiScale {
in-out property <float> factor: 1.0;
}
// Shell chrome state — collapsible left sidebar and right-side panels.
// Globals are imported directly by the shell components, so toggles
// need no callback plumbing.
export global ShellState {
// Kiosk profile active (2.0.2 frente #3). Seeded from Rust at startup and
// flipped by the NPB "Kiosk/Desktop mode" toggle; drives that toggle's label.
in-out property <bool> kiosk-profile: false;
// Set by the kiosk Now Playing while its Cover<->Lyrics toggle shows lyrics.
// The Rust lyrics-sync engine (lyrics_sync.rs `lyrics_surface_open`) polls
// this so the karaoke follow runs in the kiosk WITHOUT reusing the desktop
// `lyrics-open` (which also drives the compact drawer). Reset to false when
// showing the cover or leaving Now Playing.
in-out property <bool> kiosk-lyrics-follow: false;
// --- Reduce motion (weak renderer tiers) -------------------------------
// Seeded from Rust: true on the femtovg-GL / software tiers, where every
// animation frame is a full-window repaint on a weak GPU. When set,
// loading indicators / eq bars step at ~8fps from `coarse-tick-ms`
// (advanced by one Timer in AppShell) instead of the display-rate
// animation-tick(). Same phase math, 60fps -> 8fps repaints.
in-out property <bool> reduce-motion: false;
in-out property <int> coarse-tick-ms: 0;
// Three-state sidebar: 0 = open (240px, labels), 1 = mini (64px, icons
// only, Tauri-style), 2 = closed (0px). The collapse toggle cycles
// open -> mini -> closed -> open.
in-out property <int> sidebar-state: 0;
out property <length> sidebar-rendered-width:
root.sidebar-state == 2 ? 0px
: root.sidebar-state == 1 ? 64px
: 240px;
// True while in the icons-only mini state — gates label/header rendering.
out property <bool> sidebar-mini: root.sidebar-state == 1;
// Advance the sidebar through its three states. Large NPB (mode 3) is gated
// on the OPEN sidebar (it docks the cover + spectrum at the sidebar bottom);
// so when the cycle leaves the open state while Large is active, the bar
// reverts LIVE to New (0). The persisted ui_prefs key stays "large", so a
// later relaunch (sidebar defaults open) restores Large — the revert is a
// compatibility fallback, not a preference change.
// Persist the sidebar state / nav placement (wired to ui_prefs in Rust).
callback persist-sidebar-state(int);
callback persist-nav(bool);
callback persist-nav-compact(bool);
// Persist the current top-level view for "Startup page = where you left off"
// (Rust reads NavState.view and stores only SAFE, id-free views).
callback persist-view();
public function cycle-sidebar() {
root.sidebar-state = Math.mod(root.sidebar-state + 1, 3);
if (root.sidebar-state != 0 && root.npb-mode == 3) {
root.npb-mode = 0;
}
root.persist-sidebar-state(root.sidebar-state);
}
// Right-side panels — independent now: Queue and Lyrics can BOTH be open
// at once, stacked vertically in a shared right column (Feishin-style). Each
// is toggled from its bar button and closed from its own X. The column is
// visible when either is open. (Replaces the old exclusive `right-panel` int
// where lyrics closed queue and vice-versa.)
in-out property <bool> queue-open: false;
in-out property <bool> lyrics-open: false;
// Live WINDOW width (set from the AppWindow root). Views read it to make
// content-area responsiveness decisions independent of their own
// (sidebar-shrunk) width — e.g. the album Label/Awards sidebar collapses
// to an icon rail at the now-playing-bar breakpoint (1366px) WHEN a right
// panel is open. Defaults to the preferred window width.
in-out property <length> window-width: 1180px;
// True when the content area is space-constrained: window at/under the
// NPB responsive breakpoint AND a right panel (Queue/Lyrics) is open.
out property <bool> content-constrained:
root.window-width < 1366px && (root.queue-open || root.lyrics-open);
// Global pointer position in window coords, fed from winit. Used by
// hover-only chrome that must not install a TouchArea over interactive
// content (for example auto-hide scrollbars over album cards).
in-out property <bool> pointer-in-window: false;
in-out property <length> pointer-x: -1px;
in-out property <length> pointer-y: -1px;
// Section navigation placement: the sections (Discover/Library/Local
// Library/My QBZ) now live in the sidebar and open right-flyout menus.
in-out property <bool> nav-in-sidebar: true;
// Header-nav flavor while nav-in-sidebar is OFF: when true, the header
// always uses the icon-only compact nav (the sidebar-fully-closed look)
// instead of the full text tabs, regardless of the sidebar state. No
// effect while nav-in-sidebar is ON. Persisted (`nav_header_compact`).
in-out property <bool> nav-header-compact: false;
// Now-playing bar layout mode: 0 = New (default), 1 = Classic, 2 = Small,
// 3 = Large. Persisted in ui_prefs.json (`npb_mode`
// "new"|"classic"|"small"|"large"); set on startup from the store and on the
// player bar's "Now Playing view" menu select.
in-out property <int> npb-mode: 0;
// Small-mode (2) bar extra bottom clearance (owner ask 2026-07-21): the
// compact bar's CJK/descender-heavy meta line clipped at the window's
// bottom edge/bezel on macOS. The bar is bottom-anchored, so it grows
// UPWARD; PlayerBarSmall pins the song card to its old top-anchored
// geometry so the art keeps its position and the extra lands as bottom
// clearance. The AMOUNT is set from Rust at startup (main.rs, macOS-only
// — 0 elsewhere; QBZ_NPB_SMALL_EXTRA env override for tuning) because a
// .slint literal costs a ~2 h qbz-ui rebuild on the 8 GB Mac per
// iteration, a Rust-side constant ~2 min. 2px proved invisible
// (2026-07-22).
in-out property <length> npb-small-extra: 0px;
// (42px == Layout.header-height; literal kept so state.slint needs no
// foundation import — same precedent as large-dock-width below.)
out property <length> npb-small-height: 42px + root.npb-small-extra;
// Large NPB (mode 3) is only valid while the sidebar is OPEN (state 0): the
// cover + spectrum dock lives at the bottom of the open sidebar. Views read
// this to mount the dock and to drive the FFT visualizer tap.
out property <bool> large-active: root.npb-mode == 3 && root.sidebar-state == 0;
// Large dock column: open sidebar (240) − 16px left gutter − the content
// panel's 8px left padding, so the cover's RIGHT edge lands 16px from the
// VISIBLE content bezel — matching the 16px left window gutter (owner: equal
// side margins). 240 + 8 − 2*16 = 216. (16px == Spacing.md; literal kept so
// state.slint needs no foundation import.)
out property <length> large-dock-width: root.sidebar-rendered-width + 8px - 2 * 16px;
// Total bottom band the dock occupies — MUST byte-for-byte equal
// SidebarNowPlayingDock's self-derived `height` in BOTH visualizer states
// (AppShell pins the overlay via `y: root.height - large-dock-height`, so a
// mismatch un-flushes the cover). ON = spectrum 42 + 10 gap + square cover +
// 13 (9 top + 4 bottom) pad; OFF = cover + 13 (spectrum reclaimed by the list).
out property <length> large-dock-height: root.large-visualizer-on
? (42px + 10px + root.large-dock-width + 13px)
: (root.large-dock-width + 13px);
// Large dock spectrum visualization (the click-cycle is now 3-way; the old
// "Off" state is handled by the toggle below): 0 = Bars (mirrored), 1 =
// Waveform, 2 = Energy blocks. PERSISTED in ui_prefs (`large_spectrum_mode`)
// via the Rust ("npb-large", _, "spectrum-cycle") media-action — Slint never
// mutates it directly, so the choice survives restarts.
in-out property <int> large-spectrum-mode: 0;
// Large dock visualizer on/off (the top-right eye toggle on the cover).
// PERSISTED in ui_prefs (`large_visualizer`) via ("npb-large", _,
// "viz-toggle"). When off, the spectrum band collapses and the sidebar
// content reclaims the space; the FFT tap idles (AppShell viz-should-run).
in-out property <bool> large-visualizer-on: true;
// --- APP-WIDE DYNAMIC BACKGROUND (derived) -----------------------------
// Active only when a mode is picked, the wgpu tier allows it, AND a track is
// loaded — so with nothing playing the shell restores its opaque theme
// surfaces (D4). Shell chrome reads this to go translucent; AppShell reads
// it to mount the background layer.
out property <bool> app-background-active:
AppearanceState.app-background-mode-index > 0
&& AppearanceState.app-background-available
&& NowPlayingState.has-track;
}
// Authenticated user, for the header user element.
export global SessionState {
in property <string> user-name: "";
in property <string> subscription: "";
}
// One entry in the playback queue, for the Queue sidebar list.
export struct QueueItem {
id: string,
title: string,
artist: string,
artwork: image,
playing: bool,
duration: string, // "M:SS"
explicit: bool,
// From an ephemeral folder — gates persistence actions in the row menu.
is-ephemeral: bool,
}
// ImmersiveView audio visualizers. Driven at ~30fps by the Rust drain
// (crate::visualizer) from the frontend-agnostic FFT producer in qbz-audio.
// Magnitudes are normalized 0..1. The tap stays disabled (no capture / no FFT
// cost) until the immersive view calls set-enabled(true) on open.
export global VisualizerState {
in property <bool> enabled: false;
in property <[float]> bars; // 16 log-spaced FFT bars (viz:data)
in property <[float]> spectral; // 512 spectral-ribbon bands (viz:spectral)
in property <[float]> energy; // 5 energy bands: sub-bass..air (viz:energy)
in property <[float]> waveform; // 256 L then 256 R samples (viz:waveform)
in property <float> transient: 0; // last transient intensity; UI decays it
callback set-enabled(bool);
}
// Full-window immersive now-playing overlay. The first Slint slice ships the
// Album Reactive focus view only; broader panels can hang off this same state.
export global ImmersiveState {
in-out property <bool> open: false;
in property <image> bg-image;
in property <color> glow-color: #6464ff59;
// View mode — Tauri's focus/split duality (ImmersivePlayer.svelte focus
// vs split layout, recon data-panels.md §"two layout contexts"):
// 0 = FOCUS — full-bleed single panel (the visualizer modes + Lyrics-focus).
// 1 = SPLIT — artwork + metadata on the LEFT, the active data panel on the
// RIGHT. The leading header icon (was inert) toggles this 0<->1.
// Default 0 (focus) so opening lands on a real full-bleed foreground.
in-out property <int> view-mode: 0;
// FOCUS-mode foreground for the conditional-mounted panel (read only while
// view-mode == 0):
// 0 = Album Reactive, 1 = Static, 2 = Coverflow, 3 = Spectrum,
// 4 = Lyrics-focus (one giant centered current line),
// 5 = Queue-focus (full-screen Queue + History panel; reuses QueueState,
// no extra state field — same component as the split Queue panel).
// SESSION default 0 (Album Reactive) so opening shows a real foreground.
// NOTE: this compact enum is a Slint-scaffold contract; it deliberately
// does NOT match Tauri's focus-tab string order (coverflow first). Product
// default + persistence ("remember last view") is a later (header) session.
in-out property <int> mode: 0;
// SPLIT-mode active panel (read only while view-mode == 1):
// 0 = Lyrics, 1 = Track Info, 2 = Suggestions, 3 = Queue.
// Checkpoint A ships only Lyrics (0); the other panels land in their own
// checkpoints. Default 0.
in-out property <int> split-panel: 0;
// Spectrum bar gradient, derived from the album art (contrast colors;
// negative fallback for dark covers so the bars read against the black bg).
// Defaults = the Svelte cyan -> purple until the art resolves.
in property <color> spectrum-primary: #00dcc8;
in property <color> spectrum-secondary: #9632ff;
// Lyrics-focus single-line accent: derived from the album art but rotated
// to the COMPLEMENT of the cover's dominant hue, so it contrasts with the
// atmosphere (which is built from the same cover) instead of blending into
// it. Default high-contrast teal until the art resolves / for B&W covers.
in property <color> lyrics-accent: #3fd9c8;
// GPU fragment-shader underlay: a WGSL shader rendered into a wgpu texture by
// src/shader_underlay.rs and handed back here each drain tick. `shader-mode`
// selects the scene:
// 0 = OFF — use the existing atmosphere + visualizer panels.
// 1 = Plasma — classic sin-field plasma (ui/shaders/plasma.wgsl).
// 2 = Tunnel — neon polar-warped tunnel (ui/shaders/tunnel.wgsl).
// 3 = Aurora — domain-warped aurora curtains (ui/shaders/aurora.wgsl).
// Each scene maps to shader_underlay pipeline index (shader-mode - 1). When
// shader-mode > 0 the atmosphere + the mode panels are hidden so the shader
// is the visible background; the auto-hide chrome (scene picker + compact
// player) stays on top. Pick a scene via the header shader picker, or cycle
// 0->1->2->3->0 with the 'g' key.
in property <image> shader-texture;
in-out property <int> shader-mode: 0;
// App-wide dynamic-background scene selector, kept SEPARATE from
// `shader-mode` so opening the immersive overlay (which force-resets
// shader-mode to 0 in main.rs) does NOT clobber the app-wide background.
// 0 = none, 7 = Ambient (ui/shaders/ambient.wgsl). The drain renders this
// scene into `shader-texture` when the immersive overlay is CLOSED (see
// visualizer.rs); the AppShell root Image binds the same `shader-texture`.
// Derived: Ambient (mode 1) → 7, everything else → 0. No Rust setter needed.
out property <int> app-shader-mode: AppearanceState.app-background-mode-index == 1 ? 7 : 0;
// False on the femtovg-GL / software renderer tiers (no wgpu underlay →
// shader scenes would render black). Seeded from Rust at startup; gates
// the picker rows and the `g` cycle in ImmersiveView.
in-out property <bool> shader-scenes-available: true;
// --- Immersive search cortinilla (parallel to SearchState.cortinilla-*) ---
// Albums/Artists/Playlists only; selecting ACTS ON PLAYBACK (per the Settings
// "search action") instead of navigating, since immersive has no nav. While
// search-open is true the HUD must NOT auto-hide. See the immersive-search
// design spec (qbz-nix-docs/specs/2026-06-20-immersive-search-design.md).
in-out property <bool> search-open: false;
// The TRIMMED query the cortinilla reflects (controller-set, display only).
in property <string> search-query;
// The raw immersive search TextInput text, two-way bound to the field (in-out)
// so the controller can CLEAR it after the user activates a result —
// consistency with the main header cortinilla. Kept SEPARATE from the trimmed
// `search-query` so the controller writing the trimmed value never fights the
// user's in-progress text (e.g. trailing spaces).
in-out property <string> search-input-text: "";
in property <bool> search-loading: false;
in property <[CortinillaSection]> search-sections: [];
in-out property <int> search-selected-index: -1;
in property <length> search-scroll-y: 0px;
}
// Callbacks for the immersive search cortinilla — parallel to SearchActions but
// scoped to the immersive overlay (load on type, keyboard nav, select-acts-on-
// playback, dismiss). Wired in main.rs; active only while ImmersiveState.open.
export global ImmersiveSearchActions {
callback live(string /* query */);
callback row-clicked(int /* flat-index */);
callback move-selection(int /* delta: -1 up / +1 down */);
callback dismiss();
}
// Lifecycle hooks for the immersive overlay, wired in main.rs. The ImmersiveView
// component is conditionally mounted (it exists only while the overlay is OPEN),
// so its `init` == the overlay opening. `opened()` lets Rust apply the configured
// default view (pinned, or restore-last); `view-changed()` lets Rust persist the
// current view when the configured default is "remember last".
export global ImmersiveActions {
callback opened();
callback view-changed();
}
// Native window-control actions for the MAIN AppWindow, exposed to the
// immersive overlay header cluster. The AppWindow uses native OS decorations,
// so the only seam to manipulate it is the winit backend (see
// src/main.rs wire_window_controls): every callback below reaches the MAIN
// window's winit handle via WinitWindowAccessor::with_winit_window. The two
// bool props drive the glyph swap on the maximize/fullscreen buttons; they are
// only updated on OUR OWN toggles, so they can go stale if the user
// maximizes/fullscreens via the OS chrome (acceptable for v1).
export global WindowControlActions {
in property <bool> is-maximized: false;
in property <bool> is-fullscreen: false;
callback minimize();
callback toggle-maximize();
callback toggle-fullscreen();
callback close-app();
callback drag-move();
}
// Now-playing / transport state. The Rust playback poll loop pushes the
// live values here; the PlayerBar and SeekBar bind to it directly.
export global NowPlayingState {
in property <bool> has-track: false;
in property <string> title;
// Secondary alias for the playing track: the LOCAL-LIBRARY ROW ID when the
// track is an offline-cache copy (queue track-id carries the Qobuz catalog
// id there; set by refresh_now_playing_meta in playback.rs). Empty for every
// other source. TrackPlayCell / TrackRow / KioskAlbum match on either id.
in property <string> local-track-id;
in property <string> artist;
in property <string> album;
in property <string> album-id;
// Whether the playing track's ALBUM is a library favorite. Seeded from
// fav_cache in refresh_now_playing_meta (playback.rs) on every track-meta
// push, and flipped live by set_album_row_favorite (main.rs) when a
// favorite toggle from ANY surface matches album-id. Drives the "+"
// flyout's add/remove-album-to-collection entry (TransportControls).
in property <bool> album-favorite: false;
in property <string> artist-id;
in property <image> artwork;
// Higher-res cover (~300px) for the hover preview that floats above the
// now-playing bar's small art, so the ~220px popup is crisp instead of an
// upscale of the 160px bar art. Loaded alongside `artwork`; cleared on
// track change. Falls back to `artwork` in the UI until it resolves.
in property <image> artwork-large;
in property <string> track-id;
// True when the playing track is from an ephemeral folder (synthetic id):
// it has no DB row, so favorite / add-to-playlist / track-info are gated off.
in property <bool> is-ephemeral: false;
// Playing track's source: "qobuz" | "local" | "plex" | "ephemeral". Track
// info is a Qobuz-only feature, so its trigger is gated on `source ==
// "qobuz"` (local/Plex/ephemeral tracks have no Qobuz metadata page).
in property <string> source;
// Parental-warning / explicit flag for the playing track. Drives the
// explicit badge in the miniplayer compact + artwork surfaces (Tauri parity).
in property <bool> explicit: false;
in property <bool> playing: false;
// Fetch/resolve in flight for the track being started — no audio yet.
// Set the instant a play is requested (before the Plex/Qobuz/local
// resolve) and cleared once the poll loop sees the audio advancing.
// Drives the fetch spinner on the bar, the active track row, and the
// album play button.
in property <bool> loading: false;
in property <int> position-secs: 0;
in property <int> duration-secs: 0;
in property <float> progress: 0.0; // 0..1
in property <float> cache: 0.0; // 0..1 buffered
// Furthest seekable fraction. 1.0 for fully-available (local/cached)
// tracks; equal to the streamed buffer while a track is still downloading,
// so the user can't seek into not-yet-downloaded territory.
in property <float> seekable-max: 1.0;
in property <string> elapsed: "0:00";
in property <string> remaining: "0:00";
in property <float> volume: 0.7; // 0..1
in property <bool> muted: false;
in property <bool> shuffle: false;
in property <int> repeat-mode: 0; // 0 off, 1 all, 2 one
// Quality of the playing track, for the song-card badge.
in property <string> quality-tier; // "hires" | "cd" | "mp3" | ""
in property <string> quality-detail; // "24-bit / 96 kHz"
// Numeric stream params for the Spectral Ribbon overlay (header + freq axis
// Nyquist). 0 = unknown.
in property <int> sample-rate-hz: 0;
in property <int> bit-depth: 0;
// EFFECTIVE (delivered) stream quality — what the engine is actually
// decoding after the streaming-quality setting downgrade (#590 follow-up).
// `quality-tier`/`quality-detail` above keep the catalog MAX; while
// `quality-downgraded` is on the AudioStamp's MAIN line reports the
// delivered tier/detail instead (#638 fix 1 — owner decision, Tauri
// parity), the amber arrow turns on, and `quality-true-detail` carries
// the delivered "16-bit / 44.1 kHz" line. 0 / "" = unknown.
in property <int> effective-sample-rate-hz: 0;
in property <int> effective-bit-depth: 0;
in property <bool> quality-downgraded: false;
in property <string> quality-true-detail;
// WHY the delivered stream is below the catalog max (#638 fix 1) — the
// AudioStamp tooltip names the cause so a downgrade the user configured
// never reads as a failure. Discriminants of the Rust `QualityLimit`
// enum: 0 none · 1 streaming-quality preference · 2 local output device
// cap (fix 3) · 3 per-renderer cap (fix 4) · 4 Qobuz catalog
// availability.
in property <int> quality-limit-cause: 0;
// Delivered tier for the badge's main line while downgraded ("hires" |
// "cd" | "mp3"); empty = not downgraded (the badge shows the catalog
// tier). Derived in Rust from the delivered params + the requested tier
// so an MP3-capped stream is not mislabeled CD.
in property <string> quality-effective-tier;
// Output backend + mode LEDs (Mode C "Small" bar reads these). Mirror of
// the same four on SettingsState (:2154-2157) — surfaced here so the
// Small now-playing bar has ONE source for the song card + DAC/EXC
// cluster. Pushed on every audio snapshot-apply (src/settings.rs).
in property <string> output-backend-label: "SYST"; // PIPE|ALSA|JACK|PULS|SYST
in property <string> output-mode-label: "DEFAULT"; // DACPASS|BITPERF|EXCL|DIRECT|LOCKED|ROUTED|SHARED|DEFAULT
in property <bool> output-backend-active: false;
in property <bool> output-mode-active: false;
// Cast / render-aware state (Qobuz Connect). `is-remote` = a peer
// renderer owns playback (controls route to it); `volume-locked` =
// the active renderer disallows remote volume control. Both default
// off and are wired by the QConnect epic; the bar respects them now.
in property <bool> is-remote: false;
in property <bool> volume-locked: false;
// Whether THIS device currently has a Qobuz Connect session running (drives
// the connect flyout's Connect/Disconnect button). Distinct from `is-remote`,
// which means a PEER renderer owns playback. Set by the connect callback.
in property <bool> qconnect-connected: false;
// Name of the active Qobuz Connect renderer (shown under the cast button
// when remote). Empty when local. Populated by the QConnect epic.
in property <string> cast-target;
// Cast (Chromecast / DLNA) — when casting, the two output leds fuse into a
// single purple CAST / DLNA led (mirroring Tauri). Populated by the Cast epic.
in property <bool> cast-active: false;
in property <string> cast-protocol; // "cast" | "dlna"
// Playback CONTEXT — the source the queue was launched from (album /
// playlist / artist / label / mix). Drives the song-card layers button
// (navigate back to that page). Set at play time, persists across track
// changes; falls back to the track's album when no richer source is known.
in property <string> context-kind; // "album" | "playlist" | "artist" | "label" | "mix"
in property <string> context-id;
// Transport callbacks, wired to the Rust playback controller.
callback toggle-play();
callback next();
callback previous();
callback seek(float); // 0..1
callback set-volume(float); // 0..1 — live (fires on every drag tick)
callback persist-volume(float); // 0..1 — drag-end only, persists to ui_prefs
callback toggle-mute();
callback toggle-shuffle();
callback cycle-repeat();
// Qobuz Connect — toggle the session on/off from the bar's connect flyout.
// Wired in main.rs to SlintQconnectService::connect/disconnect.
callback qconnect-toggle();
}
// Miniplayer-specific state. The miniplayer is a SECOND Slint Window with its
// OWN global instances (Slint globals are per-component-tree, NOT shared with
// AppWindow), so the Rust layer mirrors NowPlayingState/LyricsState into the
// mini window's instances and drives THIS global for mini-only concerns:
// the active surface, always-on-top, hover (capsule reveal), the AOT warning
// chip, and a SELF-CONTAINED navigable queue model (current + full upcoming,
// NOT QueueState.upcoming-page which is the 40-row sidebar slice).
export global MiniPlayerState {
// 0 micro · 1 compact · 2 artwork · 3 queue · 4 lyrics
in-out property <int> surface: 2;
// Static artwork-derived background for the mini surfaces.
in-out property <bool> background-blur: false;
// Whole-window hover — reveals the window-controls capsule. Fed from the
// mini window's winit CursorMoved/CursorLeft (or a root TouchArea).
in-out property <bool> window-hovered: false;
// Self-contained navigable queue: current track first, then the FULL
// de-duplicated upcoming list (no 20-cap). Scrollable in the surface.
in property <[QueueItem]> queue-tracks: [];
in property <string> current-track-id;
// Surface navigation + window actions (wired in main.rs to crate::miniplayer).
callback surface-change(int); // 0..4 → apply geometry + persist
callback expand(); // exit mini, show main app
callback close-app(); // close the app from the mini
callback toggle-background-blur(); // toggle static artwork backdrop
callback start-drag(); // begin a borderless window drag
// Per-row play — index into queue-tracks. Routes remote-first (REQ-2):
// play_track_if_remote, bail before mutating local; current row resumes.
callback queue-play(int);
}
// One rendered lyrics line. `time-ms`/`end-ms` are milliseconds from track
// start (-1 = unknown / plain line). `has-words` flags native Qobuz word
// timing held Rust-side (`crate::lyrics::CURRENT_DOC`) — the S4 sync engine
// reads it for the word-anchored karaoke fill; word data itself never
// crosses into the UI model.
export struct LyricsLineItem {
text: string,
time-ms: int,
end-ms: int,
has-words: bool,
// Translation of this line (Qobuz v10), aligned 1:1 with `text` by the
// Rust glue; empty when the doc has no translation (or it wasn't
// requested). Rendered under the original line only while
// `LyricsState.show-translation` is on.
translation-text: string,
}
// One VISUAL line of the ACTIVE logical line, for the per-visual-line karaoke
// highlight. The engine (`lyrics_measure` + `lyrics_sync`) measures the active
// line's word-wrap against the current content width + font + size, and pushes
// one segment per visual row so the highlight fills row 1 to 100% BEFORE row 2
// starts (matching Tauri) instead of lighting every wrapped row at once.
//
// `start-weight` = cumulative width fraction (0..1) of all PRIOR segments;
// `width-weight` = this segment's width / total line width. The row re-maps the
// single global `line-progress` to a per-segment local fraction:
// local = clamp((line-progress - start-weight) / width-weight, 0, 1)
export struct LyricsSegment {
text: string,
start-weight: float,
width-weight: float,
}
// Lyrics panel state. The Rust glue (`crate::lyrics`) pushes fetch results
// here on the de-duped track-change edge; the sidebar binds directly. The
// S4 sync engine drives `active-index` (placeholder -1 until then).
export global LyricsState {
// 0 idle · 1 loading · 2 ready · 3 no lyrics · 4 error · 5 offline-unavailable
in property <int> status: 0;
in property <[LyricsLineItem]> lines: [];
in property <bool> synced: false;
// Provider id ("qobuz" | "lrclib" | "ovh"); `provider-label` is the
// attribution display name — empty for first-party Qobuz (no attribution
// needed, spec §3.5).
in property <string> provider;
in property <string> provider-label;
// Playing-track meta for the header ("{title} - {artist}").
in property <string> track-title;
in property <string> track-artist;
// ---- Translation (Qobuz API v10) ---------------------------------------
// The current doc advertises at least one translation language
// (`translation_langs` non-empty) — gates the sidebar's floating toggle.
// False on every other state, so the sidebar renders exactly as before.
in property <bool> translation-available: false;
// User intent: render the translation under each original line. Driven by
// Rust (`toggle-translation`): ON refetches with the target language and
// flips this only when the translation actually arrived; OFF is pure UI.
in-out property <bool> show-translation: false;
// Sidebar floating-button action (wired in main.rs).
callback toggle-translation();
// Surfaced backend error text (status == 4) — dynamic content, not chrome.
in property <string> error;
// Active line index; -1 = none. The S4 sync engine drives it.
in property <int> active-index: -1;
// Karaoke clip fraction 0..1 of the ACTIVE line's rendered width — the
// S4 engine pushes it at ~30Hz while playing: word-anchored when the
// current doc has native Qobuz wsync words, line-proportional for LRC
// sources (Q2). Drives the clip-rect width in LyricsLinesView.
in property <float> line-progress: 0.0;
// Clip-width animation duration (ms). Slightly above the engine tick
// while progress is continuous so the fill sweeps smoothly between
// pushes; the engine sets 0 across discontinuities (line change /
// backward seek) so the fill first-paints exactly at the reported
// progress (Tauri parity, LyricsLines.svelte:114-138).
in property <int> fill-anim-ms: 0;
// Per-visual-line segmentation of the ACTIVE line, for the karaoke
// highlight on wrapped lines. The engine recomputes it when the active
// index, content-width, font-index or size-index changes (low frequency)
// and pushes one entry per visual row. Empty until the first active line.
in property <[LyricsSegment]> active-segments: [];
// The lyrics view's current text content width (column width minus side
// padding). The view pushes it from its root `changed width`; the engine
// uses it as the wrap budget when segmenting the active line.
in property <length> content-width: 0px;
// Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests
// lyrics for the current track (cache-served when already fetched).
callback panel-opened();
// ---- Display prefs (S5) -------------------------------------------------
// 1:1 to Tauri's lyricsDisplayStore (defaults lyricsDisplayStore.ts:37-44),
// persisted per-user as JSON by `crate::lyrics_prefs` and seeded on every
// session activation. The flyout mutates these in-out props directly and
// fires `prefs-changed()`; Rust reads them back and persists.
in-out property <bool> auto-follow: true;
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
// (Q6 SHIPPED: the four families are bundled TTFs embedded at compile time
// in LyricsLinesView.slint; "System" = empty font-family -> window default).
in-out property <int> font-index: 0;
// 0 S · 1 M · 2 L · 3 XL — Tauri default 'medium'. Px mapping (the
// 280px-adapted scale, Q3) lives in LyricsSidebar.
in-out property <int> size-index: 1;
// 0 off · 1 soft · 2 strong (Tauri default 'strong').
in-out property <int> dimming-mode: 2;
in-out property <bool> uppercase: false;
// Lite fill (perf): when true, the active line is highlighted whole instead
// of the per-word karaoke sweep — no per-frame clip animation, so the lyrics
// surface stops driving continuous full-window repaints (~4% CPU -> idle
// between line changes). Default false (smooth karaoke). Kiosk / weak-GPU
// tiers force it on via ShellState.reduce-motion regardless of this pref.
in-out property <bool> lite-fill: false;
// Translation language (Qobuz v10, persisted like the other prefs):
// 0 Auto (account language) · 1..9 = en es fr de it pt nl ja ru. Option
// labels: the Auto entry is @tr'd, the language names are native
// literals — the same convention as AppearanceState.languages. The @tr
// DEFAULT is not reactive, so `reseed_i18n_labels` re-pushes the array
// on language change.
in-out property <int> translation-language-index: 0;
in-out property <[string]> translation-language-options: [
@tr("Auto (account language)"),
"English",
"Español",
"Français",
"Deutsch",
"Italiano",
"Português",
"Nederlands",
"日本語",
"Русский",
];
// Active (karaoke fill) color. `use-custom-color: false` = follow the theme
// accent (Tauri activeColor: ''). The palette swatches set both (deviation
// D6: swatches instead of a native color input — Slint has none).
in-out property <bool> use-custom-color: false;
in-out property <color> custom-color: #8b5cf6;
// Fired by the flyout after ANY pref mutation -> Rust persists the set.
callback prefs-changed();
// Footer actions: join current lines -> clipboard (+ toast) / restore the
// Tauri defaults (+ persist + re-seed).
callback copy-lyrics();
callback reset-prefs();
// ---- Lyrics cache row (Settings > Offline, S5) --------------------------
// Stats read from the REAL per-user lyrics.db via qbz_lyrics cache_stats
// (fix-forward F1 — Tauri measured a stale global path). Pre-formatted in
// Rust; `cache-loaded` gates the description line.
in property <bool> cache-loaded: false;
in property <string> cache-entries-text;
in property <string> cache-size;
callback cache-refresh();
callback cache-clear();
}
// One row in the QConnect device picker (a session renderer). Populated by the
// event sink from `session.renderers`.
export struct QconnectDevice {
renderer-id: int,
name: string,
is-local: bool, // this is QBZ itself ("Play here")
is-active: bool, // currently the active renderer
// Device-type icon key (mirrors Qobuz Connect): "computer" | "web" | "mobile" | "speaker".
icon: string,
}
// Qobuz Connect DEV diagnostics — live session topology + a rolling event log,
// rendered by QconnectDevModal (opened from the connect flyout). Lets us inspect
// QC behavior at runtime WITHOUT a rebuild (Slint builds are slow). `open` is
// in-out so the flyout button + the modal backdrop toggle it directly.
//
// Also hosts the device-picker model (CONTROLLER mode): the available session
// renderers + the active renderer id, plus the set-active callback the flyout
// rows invoke to switch playback to another renderer (or back to "Play here").
export global QconnectDevState {
in-out property <bool> open: false;
in property <string> status;
// Joined log text (newest first, one entry per line). A single string so the
// modal can render it in a read-only TextEdit (selectable + copyable).
in property <string> log-text;
callback clear();
// Device picker.
in property <[QconnectDevice]> devices: [];
in property <int> active-renderer-id: -1;
callback set-active(int); // renderer-id; the local id = "Play here"
}
// Cast (Chromecast / DLNA) — distinct from Qobuz Connect above. A discovered
// renderer for the cast picker. Modeled as a tagged record (the `protocol`
// field) so Chromecast and DLNA share one list; DLNA carries capability flags.
export struct CastDevice {
id: string,
name: string,
ip: string,
protocol: string, // "chromecast" | "dlna"
model: string,
can-play: bool, // DLNA has-av-transport (Chromecast: true)
can-set-volume: bool, // DLNA has-rendering-control (Chromecast: true)
}
// Cast picker + connection + transport-mirror state. Populated by
// `cast_service.rs`; read by CastPicker and AudioOutputBadges. There is NO
// error/connecting sub-state in the Tauri original beyond a raw error string.
export global CastState {
in-out property <bool> picker-open: false;
in-out property <string> selected-tab: "chromecast"; // "chromecast" | "dlna"
in property <bool> scanning: false; // 15s scan-spinner window
in property <[CastDevice]> devices: [];
in property <int> chromecast-count: 0;
in property <int> dlna-count: 0;
// Connection.
in property <bool> connected: false;
in property <string> protocol; // "chromecast" | "dlna" | ""
in property <string> device-name; // connected device label
// Transport mirror (cast owns transport while connected).
in property <bool> is-playing: false;
in property <float> position-secs: 0;
in property <float> duration-secs: 0;
// Delivered quality — surfaced in the badge tooltip / picker banner.
// MEASURED from the served bytes when probeable (#638 fix 1), catalog
// fallback otherwise.
in property <string> quality-label; // coarse, e.g. "Hi-Res FLAC"
in property <string> quality-detail; // precise, e.g. "24-bit / 192 kHz"
// Fix-1 cast half (#638): measured-truth disclosure for the picker line.
// quality-limit-cause carries the same QualityLimit discriminants as
// NowPlayingState.quality-limit-cause (0 none · 1 preference · 3
// renderer cap · 4 availability; 2 = local device cap NEVER applies
// while casting — precedence rule).
in property <int> quality-limit-cause: 0;
// The served bytes EXCEED the requested tier (a locally-existing
// cache/offline copy under a lower cap) — sent as-is, never resampled
// (owner decision); the picker line discloses it instead of hiding it.
in property <bool> quality-over-cap: false;
in property <string> quality-origin; // "cache" | "offline" | "" (network)
// Per-renderer manual quality cap (#638 fix 4) — the connected device's
// cap row in the picker. Only offered when the renderer has a STABLE
// identity (DLNA UDN always; Chromecast only when the mDNS TXT `id`
// record exists — a fullname-keyed cap would silently detach on
// rename). Options are pushed from Rust and reseeded on language
// change; option 0 embeds the live global streaming-quality label. The
// cap is the user's own quality/bandwidth choice — never a statement
// that the device cannot play the stream (renderers accept ~anything
// and downsample internally).
in property <bool> device-cap-available: false;
in property <string> device-cap-key; // "dlna:<UDN>" | "chromecast:<id>" | ""
in property <[string]> device-cap-options: [];
in property <int> device-cap-index: 0; // 0 follow · 1 hires · 2 cd · 3 mp3
in property <string> global-quality-label; // live Settings tier, e.g. "Hi-Res+"
// Raw last-error for the picker error state (empty = none).
in property <string> error;
}
export global CastActions {
callback open(); // open picker + start discovery
callback close(); // close picker + stop discovery
callback refresh(); // re-arm discovery scan
callback connect(string, string); // device-id, protocol
callback disconnect();
// Persist the manual quality cap for the connected renderer (#638
// fix 4): cap-key, option index (0 = follow the app setting).
callback set-device-quality-cap(string, int);
}
// Single global hover-tooltip channel. Each hoverable control writes its text
// + absolute anchor (window coords: centre-x and top-y of the control) here on
// hover; one TooltipOverlay (mounted last, on top of everything) renders the
// bubble, clamped to the window so it never clips or hides behind a neighbour.
export global TooltipState {
in-out property <string> text;
in-out property <length> anchor-x; // control centre, window coords
in-out property <length> anchor-y; // control top, window coords
in-out property <bool> show: false;
}
// Floating album-art preview (now-playing cover hover). Overlay-rendered like
// the tooltip so it NEVER steals hover from the trigger — a PopupWindow DID,
// which flipped the trigger's has-hover and made the preview flicker. The
// overlay reads the cover straight from NowPlayingState; this only carries the
// anchor + visibility.
export global ArtPreviewState {
in-out property <length> anchor-x; // art centre, window coords
in-out property <length> anchor-y; // art top, window coords
in-out property <bool> show: false;
}
// Playback queue model, for the Queue sidebar.
//
// The sidebar shows two tabs. The Queue tab renders `now-playing` plus a
// paginated slice of `upcoming` (40 per page). The History tab renders
// `history` (most-recent first). The Rust queue controller owns the full
// upcoming/history lists and the pagination maths; it pushes the current
// page's slice into `upcoming-page` and the counters that label it.
export global QueueState {
// --- Queue tab -------------------------------------------------------
// Current track card. `has-current` gates the NOW PLAYING section.
in property <bool> has-current: false;
in property <QueueItem> now-playing;
// Whether the now-playing track is in the user's favorites.
in property <bool> now-playing-favorite: false;
// The slice of upcoming tracks for the current page (<= 40 entries).
in property <[QueueItem]> upcoming-page: [];
// Total upcoming-track count (post-filter when a search is active).
in property <int> upcoming-total: 0;
// Tracks remaining after the current one (total - played - 1).
in property <int> upcoming-remaining: 0;
// Zero-based page index and total page count for the paginator.
in-out property <int> page: 0;
in property <int> page-count: 1;
// Index of the first upcoming entry shown, 1-based, for the counter.
in property <int> page-start: 0;
// Index of the last upcoming entry shown, 1-based, for the counter.
in property <int> page-end: 0;
// --- History tab -----------------------------------------------------
in property <[QueueItem]> history: [];
// --- Immersive coverflow (mode 2) ------------------------------------
// ONE STABLE flat ordered model of the whole queue, oldest-first:
// [history.reversed (oldest..newest), NOW-PLAYING, upcoming...]
// This list and its decoded cover handles do NOT change on a pure track
// advance/jump — only `coverflow-index` (the flat position of NOW) moves,
// and the .slint `scroll` float animates to it. The model is rebuilt by the
// Rust controller ONLY when the queue CONTENTS change (different id-sequence:
// new queue / shuffle / add / remove), gated by `coverflow-seq-hash`. On a
// pure advance the model setter is SKIPPED entirely (no Repeater rebuild, no
// Image churn), so visible covers never re-decode. Windowed (±4) lazy decode
// keeps at most one new cover decoding per advance.
in property <[QueueItem]> coverflow-tracks: [];
// The SAME flat model, REVERSED (last..first). Rebuilt under the same
// id-sequence gate as `coverflow-tracks` (so equally stable — never touched
// on a pure advance). The RIGHT (upcoming) Repeater iterates THIS list so it
// paints far-upcoming -> near-upcoming: a Slint Repeater paints in model
// order and there is no per-element z-index in Slint 1.16, so reversing the
// model is how we get the nearer (upcoming) cover to paint ON TOP of farther
// ones — symmetric with the LEFT side, which already paints far->near in
// ascending index order. A track's element holds the SAME decoded image on
// both lists (the reversed list is built by cloning + reversing the forward
// QueueItems), so no extra decode and offsets computed from the original
// index match the forward side exactly during the ±0.5 slide hand-off.
in property <[QueueItem]> coverflow-tracks-rev: [];
// Flat index of the NOW-PLAYING cover within `coverflow-tracks`
// (= history.len() when a current track exists, else 0). The .slint panel
// binds an animated `scroll` float to this int so a change eases the fan.
in property <int> coverflow-index: 0;
// Order-sensitive rolling fingerprint of the flat id-sequence. The Rust
// controller compares this to the value it last pushed; equal => pure
// advance => skip the model rebuild and only update `coverflow-index`.
in property <int> coverflow-seq-hash: 0;
// --- Shared ----------------------------------------------------------
// Active tab: 0 = Queue, 1 = History.
in-out property <int> tab: 0;
// Live search query; the Rust controller re-filters `upcoming-page`.
in-out property <string> search-query: "";
// --- Callbacks (wired to the Rust queue controller) ------------------
// Play the upcoming entry at `index` within the current page slice.
callback play-upcoming(int);
// Play an upcoming entry by its QUEUE-WIDE (unfiltered) index. Used by the
// immersive coverflow: a side card at flat index `i > coverflow-index` maps
// to up_index = i - coverflow-index - 1 and dispatches here (page/search-
// independent) — NOT the paged play-upcoming.
callback play-coverflow-upcoming(int);
// Play a history entry by its `index` in the `history` list.
callback play-history(int);
// Remove the upcoming entry at `index` within the current page slice.
callback remove-upcoming(int);
// Remove every upcoming entry after the page-local `index` (that row is
// kept). The Rust controller resolves it to a queue-wide upcoming index.
callback remove-all-after(int);
// Open the Add-to-Playlist picker seeded with the upcoming row at the
// page-local `index`.
callback add-to-playlist(int);
// Reorder the upcoming list: move the page-local row `from` to the insertion
// slot `to` (0 = before the first upcoming row, N = after the last). The Rust
// controller resolves the page-local indices to queue-wide upcoming indices
// and routes to the cloud when connected (WS-authoritative).
callback reorder(int /* from */, int /* to */);
// Empty the queue.
callback clear-queue();
// Toggle the favorite state of the now-playing track.
callback toggle-now-playing-favorite();
// Save the current queue as a new playlist.
callback save-as-playlist();
// Toggle infinite-play (auto-refill the queue with similar tracks).
callback toggle-infinite-play();
// Re-filter the upcoming list after `search-query` changed.
callback search-changed();
// Move to the previous / next paginator page.
callback prev-page();
callback next-page();
// Switch the active tab (0 = Queue, 1 = History).
callback set-tab(int);
// Fired when the sidebar becomes visible, so the Rust controller can
// pull a fresh queue snapshot (the queue may have changed while the
// panel was closed, e.g. after a session restore).
callback panel-opened();
// Reflects whether infinite-play is currently enabled.
in property <bool> infinite-play: false;
// "Stop after this song" marker: the decimal-string id of the marked track,
// or "" for none. Each queue row compares its item.id to this to show the
// CircleStop glyph. `toggle-stop-after` flips the marker for a given track id.
callback toggle-stop-after(string /* track-id */);
in property <string> stop-after-id: "";
}
// Sleep timer (queue footer). Frontend-only: a Rust tokio task owns the monotonic
// deadline + 1 Hz countdown and calls core().pause() at expiry. Non-persistent.
export global SleepTimerState {
in property <bool> active: false;
in property <string> remaining: ""; // pre-formatted by Rust (single source of truth)
in-out property <bool> popover-open: false;
in-out property <int> selected-index: 0; // preset index; last entry = Custom
in-out property <int> custom-minutes: 60;
}
export global SleepTimerActions {
callback set(int /* minutes */);
callback cancel();
}
// In-app log viewer (Settings > Developer). One row of the [LogRow] model.
export struct LogRow {
ts: string,
level: string,
target: string,
message: string,
}
export global LogViewerState {
in-out property <bool> open: false;
in property <[LogRow]> rows: []; // already filtered + capped by Rust
in property <int> total: 0; // ring size
in property <int> shown: 0; // after filter + cap
in-out property <string> filter-level: "all"; // all|error|warn|info|debug|trace
in-out property <string> search: "";
in-out property <bool> auto-tail: false;
in property <bool> uploading: false;
in property <string> uploaded-url: "";
in property <bool> copied: false;
callback refresh();
callback clear();
callback set-level(string);
callback set-search(string);
callback copy-all();
callback copy-bundle();
callback upload(); // public paste (already redacted) -> uploaded-url
callback open-log-file();
callback toggle-auto-tail(bool);
callback copy-url(); // copy the uploaded paste URL to the clipboard
}
// Report-an-issue modal (hamburger menu). Mirrors the Tauri ReportIssueModal:
// explains the manual, redacted log-sharing flow, then offers "Go to logs"
// (opens the log viewer) and "Create issue" (opens the GitHub new-issue page).
export global ReportIssueState {
in-out property <bool> open: false;
}
export global ReportIssueActions {
callback create-issue(); // opens the GitHub new-issue URL (Rust: open::that)
}
// Diagnostics panel (Settings > Developer). One saved-vs-runtime table row.
// status: 0 info | 1 match | 2 mismatch.
export struct DiagRow {
label: string,
saved: string,
runtime: string,
status: int,
}
export global DiagnosticsState {
in property <bool> loaded: false;
in property <bool> loading: false;
in property <string> error: "";
in property <string> app-version: "";
in property <bool> copied: false;
in property <bool> cast-scanning: false;
in property <[DiagRow]> system-rows: [];
in property <[DiagRow]> playback-rows: [];
in property <[DiagRow]> qconnect-rows: [];
in property <[DiagRow]> cast-rows: [];
in property <[DiagRow]> audio-rows: [];
in property <[DiagRow]> graphics-rows: [];
in property <[DiagRow]> env-rows: [];
callback refresh();
callback export-clipboard();
callback cast-scan();
}
// One row in the "Add to playlist" picker.
export struct PlaylistPickItem {
id: string,
name: string,
tracks-line: string, // "N tracks"
// LOCAL playlist (library.db entity, id "local:<uuid>") — the row
// shows a hard-drive glyph; adds write the local repo (works offline).
is-local: bool,
// Client-side filter result — recomputed Rust-side on every filter
// keystroke (Slint 1.16 has no string `.contains`, so the substring
// match lives in Rust). Match ordinal among the filtered rows;
// -1 = filtered out. Rows ranked >= 8 hide behind the overflow row.
filter-rank: int,
}
// "Add to playlist" picker — a global overlay opened from the track
// context menu. Holds the pending track id while the user picks a
// destination playlist. Populated by crate::playlist_picker.
export global PlaylistPickerState {
in-out property <bool> open: false;
in property <string> track-id;
in property <[string]> track-ids: []; // bulk add (favorites multi-select)
in property <[PlaylistPickItem]> playlists: [];
in property <bool> loading: false;
// True when track-ids are LocalLibrary row ids (i64) -> route to
// add_local_track_to_playlist instead of the Qobuz endpoint.
in property <bool> local-mode: false;
// Client-side filter: case-insensitive substring over playlist names.
// Pure frontend, no backend call. The list filters live as the user types.
in-out property <string> filter: "";
// Total rows matching the current filter — drives the dropdown's
// no-results/overflow rows and its explicit height.
in property <int> filter-matches: 0;
// Inline "Create new playlist" affordance (mirrors Tauri's
// CREATE_NEW_PLAYLIST inline name field). When `creating-open` is true the
// name input is revealed; `create-name` holds the typed name; `creating`
// gates the confirm while the create-and-add runs.
in-out property <bool> creating-open: false;
in-out property <string> create-name: "";
in-out property <bool> creating: false;
}
export global PlaylistPickerActions {
callback pick(string /* playlist id */);
callback close();
// Inline create-and-add: creates a new playlist (Qobuz online / local
// offline per D8) and adds the carried tracks to it.
callback create-and-add();
// Client-side filter changed — Rust recomputes each row's filter-rank
// plus filter-matches (case-insensitive substring) since Slint 1.16
// lacks string `.contains`.
callback filter-changed(string);
}
// "Duplicate tracks" confirm sub-modal — shown when a Qobuz-tracks → Qobuz-
// playlist add finds tracks already in the target. The track-id Vec<u64> and
// the duplicate id set live Rust-side in a stash cell (Slint never holds
// them); this global only carries the counts + busy flag for rendering.
export global DuplicateConfirmState {
in-out property <bool> open: false;
in property <int> duplicate-count: 0;
in property <int> total-count: 0;
in property <bool> busy: false;
in property <string> playlist-name;
}
export global DuplicateConfirmActions {
callback add-all();
callback add-new-only();
callback cancel();
}
// One artist tile in the Favorites > Artists grid.
export struct FavoriteArtistItem {
id: string,
name: string,
image-url: string,
image: image,
// Pin badge state — kept live by main::set_artist_row_pinned when a pin
// toggles anywhere (mirrors SlimItem.is-pinned).
is-pinned: bool,
}
// An A-Z section of favorite artists (grid grouped mode + sidepanel left
// list). `key` is the bucket letter ("#", "A".."Z"); `title` the header.
export struct FavArtistSection {
key: string,
title: string,
artists: [FavoriteArtistItem],
}
// One label card in the Favorites > Labels grid.
export struct FavoriteLabelItem {
id: string,
name: string,
albums-line: string, // "N albums"
image-url: string, // resolved URL ("" -> gradient placeholder)
image: image, // decoded cover, filled by the artwork pipeline
}
// Library > Favorites state. The active tab decides which model is
// shown; all are populated lazily by `crate::favorites` the first
// time their tab is opened. Albums reuse AlbumCardItem, tracks reuse
// TrackItem (artwork-on), artists use FavoriteArtistItem.
export global FavoritesState {
// "tracks" | "albums" | "artists" | "playlists" | "labels"
in-out property <string> active-tab: "tracks";
// Tracks reuse TrackItem so the shared SearchTrackRow can
// render them (artwork-on, per-row cover thumbnail). `tracks` is the
// full fetched set (the artwork pipeline targets it); `tracks-visible`
// is what the list renders after the search filter (mirrors
// LabelState.albums vs .visible).
in property <[TrackItem]> tracks: [];
in property <[TrackItem]> tracks-visible: [];
in-out property <string> tracks-search: "";
in-out property <string> tracks-group-mode: "off"; // off|album|artist|name
in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping)
in-out property <bool> tracks-multi-select: false;
in property <int> tracks-selected-count: 0;
in property <[AlbumCardItem]> albums: [];
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
in-out property <string> albums-search: "";
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha grouping)
in property <int> albums-shown: 0; // count after filter (grouped or flat)
in property <[FavoriteArtistItem]> artists: [];
// Playlists has two internal sub-tabs (mirror Tauri favoritePlaylists /
// followingPlaylists), both rendered with the rich SearchPlaylistItem
// (4-cover collage). `playlists-visible` is the current sub-tab after the
// search filter (what the grid/list renders).
in property <[SearchPlaylistItem]> playlists-favorites: []; // Library (locally favorited)
in property <[SearchPlaylistItem]> playlists-following: []; // Qobuz-followed, not owned
in property <[SearchPlaylistItem]> playlists-visible: [];
in-out property <string> playlists-sub-tab: "favorites"; // "favorites" | "following" (transient)
in-out property <string> playlists-search: "";
in-out property <string> playlists-view-mode: "grid"; // "grid" | "list" (persisted)
in property <int> playlists-following-count: 0; // Following sub-tab badge
in property <[FavoriteLabelItem]> labels: [];
in property <[FavoriteLabelItem]> labels-visible: []; // after search filter (rendered)
in-out property <string> labels-search: "";
// Artists — toolbar / grid view
in-out property <string> artists-search: "";
in-out property <string> artists-view-mode: "grid"; // grid | sidepanel
in-out property <bool> artists-group-enabled: false; // A-Z grouping (grid)
in property <[FavoriteArtistItem]> artists-visible: []; // after search (flat grid)
in property <[FavArtistSection]> artists-grouped: []; // A-Z sections (grouped grid + sidepanel list)
in property <[AlphaJump]> artists-alpha: []; // A-Z jump strip
in property <int> artists-shown: 0; // count after search
// Artists — sidepanel (the selected artist's albums)
in-out property <string> selected-artist-id: ""; // "" = none selected
in property <string> selected-artist-name: "";
in property <bool> selected-albums-loading: false;
in property <string> selected-albums-error: "";
in property <int> selected-albums-total: 0; // sum across sections
in property <[DiscoverSection]> selected-artist-sections: []; // release-type sections
in property <int> tracks-total: 0;
in property <int> albums-total: 0;
in property <int> artists-total: 0;
in property <int> playlists-total: 0;
in property <int> labels-total: 0;
in property <bool> loading: false;
in property <string> load-error: ""; // non-empty -> show error + retry
}
export global FavoritesActions {
// Switch tab; the Rust side lazy-loads the tab's data if it
// hasn't been fetched yet.
callback select-tab(string);
callback open-album(string);
callback open-artist(string);
callback open-label(string /* label id */, string /* name */);
callback open-playlist(string /* playlist id */);
callback play-track(string /* track id */);
callback track-action(string /* track id */, string /* action */);
callback search-tracks(string /* query */);
callback tracks-set-group(string /* off | album | artist | name */);
callback play-all-tracks();
callback shuffle-tracks();
callback toggle-multi-select();
callback bulk-action(string /* select-all | remove-selected | clear */);
callback album-action(string /* album id */, string /* action */);
callback albums-search(string /* query */);
callback search-labels(string /* query */);
callback playlists-set-sub-tab(string /* favorites | following */);
callback playlists-random();
callback labels-random();
callback search-playlists(string /* query */);
callback playlists-set-view(string /* grid | list */);
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite | follow | unfollow | copy */);
callback search-artists(string /* query */);
callback artists-set-view(string /* grid | sidepanel */);
callback artists-set-group(string /* off | alpha */);
callback artists-shuffle();
callback select-artist(string /* artist id */, string /* name */);
callback albums-set-view(string /* grid | list */);
callback albums-set-sort(string /* sort key */);
callback albums-set-group(string /* off | alpha | artist */);
callback albums-shuffle();
// Windowed grid: the visible row band changed (item indices into
// albums-visible, prefetch margin included) — dispatch artwork for it.
callback albums-window-changed(int /* first */, int /* last */);
callback unfavorite-track(string /* track id */);
callback unfavorite-album(string /* album id */);
callback retry-load();
}
// ── Library "All" — the mixed feed (webplayer /user-library/all) ──
//
// One heterogeneous item that renders as a track / album / artist / playlist /
// label card depending on `kind`. The mixed feed interleaves favorites,
// purchases and followed items sorted by date added. Sort/filter/source-switch
// are applied ENTIRELY in Rust (crate::library_all::derive) — Slint renders the
// pre-computed `items-visible`.
export struct LibraryFeedItem {
kind: string, // "track" | "album" | "artist" | "playlist" | "label"
group: string, // "favorites" | "following" | "purchases"
source: string, // "qobuz" | "local" | "plex"
id: string,
title: string,
subtitle: string, // "Track • Artist" / "year • artist" / owner·count / …
artist: string,
artist-id: string,
album: string,
album-id: string, // for "Go to album" (tracks)
image-url: string,
image: image, // decoded cover (empty until the pipeline resolves it)
quality-tier: string, // "hires" | "cd" | ""
quality-detail: string,// "24-bit / 96 kHz"
is-favorite: bool,
removing: bool,
sort-title: string, // lowercased title for the A-Z sort
sort-artist: string, // lowercased artist for the artist sort
// Lowercased genre name for the client-side genre filter (albums + tracks
// carry it; artists/labels/playlists are ""). "" is excluded when a genre
// is selected.
genre: string,
// Playlist ownership (mirrors SearchPlaylistItem) so the mixed-feed
// PlaylistCard shows favorite (owned) vs follow/copy (foreign).
playlist-owned: bool,
playlist-following: bool,
playlist-copied: bool,
}
export global LibraryAllState {
in property <[LibraryFeedItem]> items: []; // full merged feed
in property <[LibraryFeedItem]> items-visible: []; // after search + source + sort
in-out property <string> search: "";
in-out property <string> sort-by: "date"; // "date" | "title" | "artist"
// Sort direction, PlaylistView-style (re-selecting the active field
// toggles it). For "date": false = newest-first (default), true = oldest;
// for "title"/"artist": true = A→Z (default), false = Z→A.
in-out property <bool> sort-asc: false;
in-out property <string> view-mode: "grid"; // "grid" | "list"
// Source-switch filters — the three webplayer toggles. All on = show all.
in-out property <bool> show-purchases: true;
in-out property <bool> show-favorites: true;
in-out property <bool> show-following: true;
// "show local" — includes local files + Plex (Phase 2). Off until the
// local-favorites layer lands.
in-out property <bool> show-local: false;
in property <int> total: 0; // badge count (deduped union)
in property <bool> loading: false;
in property <string> load-error: "";
}
export global LibraryAllActions {
callback search(string /* query */);
// Re-selecting the active field toggles direction (PlaylistView pattern).
callback set-sort(string /* date | title | artist */);
callback set-view(string /* grid | list */);
callback toggle-source(string /* purchases | favorites | following | local */);
callback open-item(string /* kind */, string /* id */);
callback media-action(string /* kind */, string /* id */, string /* action */);
callback retry();
}
// ── Purchases (My-Purchases library surface, source-of-truth §2.1) ──
//
// A purchased album row/card. `quality-label` is the grid card's kHz form
// ("24/96 kHz"); `quality-list` is the album-list row's formatQuality form
// ("24bit/96kHz" or "CD Quality"). `purchase-date` is the SHORT-month date.
// `downloaded` only drives the hide-downloaded filter (the list view never
// shows a per-album download badge). The Rust side fills `artwork` once the
// pipeline resolves the cover.
export struct PurchaseAlbumItem {
id: string,
title: string,
artist: string,
artist-id: string,
artwork-url: string,
artwork: image,
quality-label: string, // grid card: "{bd}/{sr} kHz" ("" if either missing)
quality-list: string, // list row: "24bit/96kHz" | "CD Quality"
purchase-date: string, // SHORT month "Nov 6, 2025"
downloadable: bool,
downloaded: bool,
}
// A purchased track row. `quality` is the BARE "{bit}/{rate}" string (no kHz,
// §A.6) — "" when either field is missing. `dl-status` is the flattened
// download-store status for the row's 4-state control:
// "" | "downloading" | "complete" | "failed". `downloaded` (server registry)
// OR dl-status=="complete" both render the green redownload check.
export struct PurchaseTrackItem {
id: string,
title: string,
artist: string,
artist-id: string,
album-title: string,
album-id: string,
artwork-url: string,
artwork: image,
quality: string, // BARE "{bit}/{rate}" (§A.6); "" if either missing
duration: string, // "m:ss"
purchase-date: string, // SHORT month
streamable: bool,
downloaded: bool, // server registry downloaded flag
dl-status: string, // "" | downloading | complete | failed
}
// A group-by section for the albums tab (alpha / artist grouping).
export struct PurchaseAlbumGroup {
key: string,
title: string,
albums: [PurchaseAlbumItem],
}
// A group-by section for the tracks tab (name / artist / album grouping).
export struct PurchaseTrackGroup {
key: string,
title: string,
tracks: [PurchaseTrackItem],
}
// One option in the anchored format-picker popup (tracks-tab per-track
// download). `detail` is the "{bd}/{sr}" line shown after the label when both
// are present.
export struct PurchaseFormatItem {
id: int,
label: string,
detail: string,
}
export global PurchasesState {
// "albums" | "tracks"
in-out property <string> active-tab: "albums";
// Region-notice banner (above the nav). Shown until dismissed; the
// persisted "seen" flag seeds the initial value from Rust (Slice 12).
// Default true so a fresh session shows it, matching Tauri's
// `getUserItem('qbz-purchases-region-notice-seen') !== 'true'`.
in property <bool> show-region-notice: true;
// Search (controller-debounced 300ms). `search-query` is the live input;
// the Rust side debounces and refetches/filters.
in-out property <string> search-query: "";
in property <bool> search-active: false; // query.trim().length > 0
// Albums toolbar.
in-out property <string> album-view-mode: "grid"; // "grid" | "list"
in-out property <bool> album-grouping-enabled: false;
in-out property <string> album-group-mode: "alpha"; // "alpha" | "artist"
in-out property <string> album-sort-by: "date"; // date|artist|album|quality
in-out property <string> album-sort-direction: "desc"; // "asc" | "desc"
// Tracks toolbar.
in-out property <bool> track-grouping-enabled: false;
in-out property <string> track-group-mode: "artist"; // artist|album|name
// Filter panel (shared albums + tracks). Persisted per-user.
in-out property <bool> filter-hide-unavailable: false;
in-out property <string> filter-quality: "all"; // all|hires|cd|lossy
in-out property <bool> filter-hide-downloaded: false;
// Mutually-exclusive open menu: "" | albumGroup | albumSort | filter | trackGroup
in-out property <string> open-menu: "";
// Derived flags (computed in Rust, pushed here).
in property <bool> has-active-filters: false;
in property <int> active-filter-count: 0;
// Tab counts (server total unless search/filter active → filtered length).
in property <int> album-tab-count: 0;
in property <int> track-tab-count: 0;
// Rendered collections (already filtered/sorted/grouped in Rust). Flat
// arrays drive the non-grouped render; the *-grouped arrays drive the
// group-by render.
in property <[PurchaseAlbumItem]> albums: [];
in property <[PurchaseAlbumGroup]> albums-grouped: [];
in property <[PurchaseTrackItem]> tracks: [];
in property <[PurchaseTrackGroup]> tracks-grouped: [];
// Stable FULL artwork-target models (every loaded item, in load order).
// The artwork pipeline writes decoded covers here by index, then dual-sets
// into the rendered (filtered/sorted/grouped) models by id (the favorites
// pattern — a sort/filter/group makes the rendered models NOT share these).
// Not bound by the view; targets only.
in-out property <[PurchaseAlbumItem]> albums-full: [];
in-out property <[PurchaseTrackItem]> tracks-full: [];
// Content state.
in property <bool> loading: true;
in property <string> load-error: ""; // i18n key (purchases.*) OR raw string
// Format-picker popup (anchored). `picker-track-id` non-empty => shown.
in property <[PurchaseFormatItem]> picker-formats: [];
in property <string> picker-track-id: "";
in property <length> picker-anchor-x: 0px;
in property <length> picker-anchor-y: 0px;
// Active/playing track (mirror props) for row highlight.
in property <string> active-track-id: "";
in property <bool> playback-active: false;
}
export global PurchasesActions {
// Tabs / search.
callback select-tab(string /* albums | tracks */);
callback search(string /* query */);
callback clear-search();
// Albums toolbar.
callback set-album-group(string /* off | alpha | artist */);
callback select-album-sort(string /* date|artist|album|quality */);
callback set-album-view-mode(string /* grid | list */);
// Tracks toolbar.
callback set-track-group(string /* off | album | artist | name */);
// Filter panel.
callback set-filter-hide-unavailable(bool);
callback set-filter-quality(string /* all|hires|cd|lossy */);
callback set-filter-hide-downloaded(bool);
callback clear-all-filters();
// Menu open/close (mutually exclusive). "" closes.
callback set-open-menu(string);
// Region notice dismiss.
callback dismiss-region-notice();
// Navigation / play.
callback open-album(string /* album id */);
callback open-artist(string /* artist id */);
callback play-track(string /* track id */);
// Per-track download flow. `anchor-x`/`anchor-y` position the format
// picker; the Rust side decides 1-format (no picker) vs >1 (picker).
callback download-track(string /* track id */, length /* anchor-x */, length /* anchor-y */);
// Format-picker: a format chosen → execute the download.
callback pick-format(string /* track id */, int /* format id */);
callback close-format-picker();
// Error branch retry (re-fetch the active tab). INTENTIONALLY UNWIRED: the
// Svelte error state (PurchasesView.svelte:851-855) renders ShoppingBag +
// text with NO retry trigger, so the 1:1 EmptyBlock has no clickable retry.
// The handler in main.rs is kept as scaffolding (it would force-reload the
// active tab) so a future non-1:1 retry affordance has a seam to bind to.
callback retry();
}
// ── Purchase detail / download view (source-of-truth §2.2) ──────────────────
// One row in the detail track table. Mirrors the bespoke Tauri detail row
// (§2.2.5): bare "{bit}/{rate}" quality (§A.6), a 4-state download control, and
// disc grouping. `disc-header-number` > 0 marks the FIRST track of a disc on a
// multi-disc album so a "Disc N" divider renders above it (the AlbumPageView
// disc-header-number mechanism); 0 otherwise (single-disc → flat).
//
// `dl-status` is the format-scoped per-track status ("" | downloading |
// complete | failed) from `track_status_scoped`. `is-downloaded` is the row's
// derived green-check state (server `downloaded_format_ids` for the selected
// format OR a store `complete`). `show-download` gates whether ANY download
// control renders (album downloadable OR already downloaded, §2.2.5).
export struct PurchaseDetailTrack {
id: string,
track-number: int,
title: string,
performer: string, // shown on a 2nd line when ≠ album artist
show-performer: bool,
duration: string, // "m:ss"
quality: string, // BARE "{bit}/{rate}" (§A.6); "" if either missing
streamable: bool,
dl-status: string, // "" | downloading | complete | failed (format-scoped)
is-downloaded: bool, // green redownload check
show-download: bool, // album.downloadable || is-downloaded
disc-header-number: int, // > 0 → "Disc N" header above this row (multi-disc)
}
export global PurchaseDetailState {
// Bound album id. The view watches this and fires `load` on change so a
// navigation between two different purchase albums RE-FETCHES (§A.3); the
// controller seeds it before navigating. Empty → nothing to render.
in-out property <string> album-id: "";
// Content state. `load-error` is the RAW error string (NOT i18n-mapped,
// unlike the list view — §2.2.4 / §8.4).
in property <bool> loading: true;
in property <string> load-error: "";
in property <bool> loaded: false;
// Header.
in property <string> title: "";
in property <string> artist: "";
in property <string> artist-id: ""; // "" → artist is plain text (no link)
in property <image> artwork;
in property <bool> downloadable: true; // !downloadable → header overlay + no actions
in property <string> purchased-on: ""; // LONG-month "November 6, 2025" ("" hides)
in property <string> label-name: ""; // "" hides the "· label" segment
in property <string> genre-name: ""; // "" hides the "· genre" segment
in property <string> quality: ""; // formatQuality "24bit/96kHz" | "CD Quality"
in property <int> total-tracks: 0;
in property <string> total-duration: ""; // "{h}h {m}m" | "{m}m"
// Format dropdown. `format-labels` are the backend fmt.label strings (NOT
// an i18n key); `format-index` is the currently-selected option (default 0).
in property <[string]> format-labels: [];
in property <int> format-index: 0;
in property <bool> has-formats: false;
// Download progress (source-of-truth §2.2.1 / §A.4). `all-complete` IS
// format-scoped; `completed-count` / `progress-percent` / `was-cancelled`
// are NOT (count ALL statuses regardless of selected format).
in property <bool> is-downloading-all: false;
in property <bool> all-complete: false; // format-scoped
in property <bool> was-cancelled: false; // NOT format-scoped
in property <int> completed-count: 0; // NOT format-scoped
in property <float> progress-percent: 0; // completed/total*100, NOT format-scoped
// Whether the progress section renders at all (downloadingAll || allComplete
// || wasCancelled) — precomputed in Rust to keep the .slint declarative.
in property <bool> show-progress: false;
// Add-to-Library block (only when all-complete && a destination is set).
in property <bool> show-add-to-library: false;
in property <bool> adding-to-library: false;
// Download-all circle gating: disabled while downloading-all OR no format.
in property <bool> can-download-all: false;
// Whether the Play-album circle renders (onAlbumPlay present — always true
// in Slint since play routing exists; kept as a flag for parity).
in property <bool> show-play: true;
// Active/playing track for row highlight + playing-bars.
in property <string> active-track-id: "";
in property <bool> playback-active: false;
// The disc-grouped track rows (already flattened with disc-header markers in
// Rust). The view iterates this once.
in property <[PurchaseDetailTrack]> tracks: [];
}
export global PurchaseDetailActions {
// Reactive reload: the view fires this whenever the bound `album-id`
// changes (and on first mount) so a fresh load runs (§A.3).
callback load(string /* album id */);
// Header / navigation.
callback open-artist(string /* artist id */);
callback play-album(); // Play-album circle (§A.1, routing in Slice 10)
callback play-track(string /* track id */); // per-track row play (§A.2)
// Format dropdown — a new option chosen (index into format-labels).
callback select-format(int /* index */);
// Download-all circle → folder picker → startAlbumDownload.
callback download-all();
// Per-track download → folder picker → startTrackDownload.
callback download-track(string /* track id */);
// Progress section cancel button.
callback cancel-download();
// Add-to-Library block button.
callback add-to-library();
}
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened
// from the For You Qobuz Mixes tiles. Populated by crate::mix. Rows
// reuse the shared SearchTrackRow (with show-album), so the track
// context menu works here too.
export global MixState {
in property <string> kind; // daily | weekly | fav | top
in property <string> title;
in property <string> subtitle;
in property <[TrackItem]> tracks: [];
in property <int> track-count: 0;
in property <string> total-duration;
in property <bool> loading: false;
// Keyboard/checkbox multi-select (mirrors AlbumState): mode flag + count.
// Per-row toggle routes through the shared media-action("track",...) arm.
in-out property <bool> multi-select: false;
in property <int> selected-count: 0;
}
// Search results screen state. The Rust search controller populates the
// four category lists, their totals, and the most-popular hero entry.
export global SearchState {
in property <string> query;
// Active tab: 0 = All, 1 = Albums, 2 = Tracks, 3 = Artists, 4 = Playlists.
in property <int> tab: 0;
in property <bool> loading: false;
in property <[AlbumCardItem]> albums: [];
in property <[TrackItem]> tracks: [];
in property <[SlimItem]> artists: [];
// Carousel-only variant of artists — skips the top-result artist when
// it is also the first list entry, so the All-tab carousel does not
// duplicate the Most-popular hero. The Artists tab uses `artists`.
in property <[SlimItem]> artists-carousel: [];
in property <[SearchPlaylistItem]> playlists: [];
in property <int> albums-total: 0;
in property <int> tracks-total: 0;
in property <int> artists-total: 0;
in property <int> playlists-total: 0;
// Most-popular hero: `kind` selects which of the three items is shown.
in property <string> most-popular-kind; // "album" | "artist" | "track" | ""
in property <AlbumCardItem> most-popular-album;
in property <SlimItem> most-popular-artist;
in property <TrackItem> most-popular-track;
// Detailed quality label for the most-popular TRACK hero, e.g.
// "Hi-Res 24-bit / 192 kHz". Shown as text (the hero dropped the icon
// badge). "" when unknown / not a track.
in property <string> most-popular-quality-label;
// Active searchType filter, as a dropdown index (0 = no filter).
// The controller owns the index -> API-value mapping.
in property <int> filter-index: 0;
// The live header search-box text, two-way bound to the TextInput in
// HeaderBar. Owned here (not internal to the TextInput) so the controller
// can CLEAR it after the user activates a cortinilla element (row click /
// "View more" / Enter) — lingering text would otherwise re-invoke the
// dropdown when focus bounces back to the field after any UI interaction.
in-out property <string> header-search-text: "";
// ---- Cortinilla (live as-you-type dropdown under the header search) ----
// Whether the cortinilla overlay is currently shown. The overlay is a
// LAST child of AppShell, visibility-gated on this flag.
in-out property <bool> cortinilla-open: false;
// The query the cortinilla currently reflects (the live header text).
in property <string> cortinilla-query;
// A live cortinilla fetch is in flight (spinner / shimmer affordance).
in property <bool> cortinilla-loading: false;
// The single best match shown at the very top of the cortinilla. Its
// flat index is always 0 (see the flat-index convention on
// SearchActions.cortinilla-row-clicked). `kind == ""` = no top result.
in property <CortinillaRow> top-result;
// The labelled result sections (Albums / Tracks / Artists / Playlists /
// library), each with its own rows + "View more" affordance.
in property <[CortinillaSection]> sections: [];
// Keyboard-nav highlight into the FLAT navigable list (top-result = 0,
// then each section's rows in declaration order). -1 = nothing selected,
// so Enter falls through to a full search-all instead of opening a row.
in-out property <int> selected-index: -1;
// Content-space top-y (px) of the currently keyboard-selected row, set by
// the controller on arrow nav so the overlay can scroll it into view. 0 when
// nothing is selected (scroll to top).
in property <length> cortinilla-scroll-y: 0px;
}
// Search actions wired to the Rust search controller.
export global SearchActions {
// Enter / explicit submit — navigate to the full results page.
callback submit(string);
// Live (debounced) search as the user types.
callback live(string);
// User picked a results tab (0..4).
callback tab-changed(int);
// searchType filter changed — the new dropdown index.
callback filter-changed(int);
// Request the next page for the given tab.
callback load-more(int);
// ---- Cortinilla (live as-you-type dropdown) ----
// A cortinilla row was activated (click or Enter on a highlighted row).
// The argument is a FLAT index into the navigable list, where:
// 0 = top-result (SearchState.top-result)
// 1.. = section rows, in section declaration order, each
// section's rows enumerated in order before the next.
// The controller resolves the flat index back to a CortinillaRow and
// dispatches on its `source`/`kind` (Qobuz media-action vs local play).
callback cortinilla-row-clicked(int /* flat-index */);
// "View more" pressed on a section — open the full results page (or the
// LocalLibrary Tracks tab for the local section) for that category.
callback cortinilla-view-more(string /* kind */);
// Enter with nothing highlighted (selected-index == -1) — run a full
// search-all and navigate to the results page.
callback cortinilla-search-all();
// Click-outside / Escape — close the cortinilla without acting.
callback cortinilla-dismiss();
// Arrow key moved the keyboard highlight: delta -1 = up, +1 = down.
// Wraps/clamps against the flat navigable list in the controller.
callback cortinilla-move-selection(int /* delta: -1 up / +1 down */);
}
// ============================ Offline MODE ================================
// App-wide offline MODE status (the app operating without Qobuz — NOT the
// offline CACHE/downloads). Mirrors `qbz_app::offline_mode::OfflineStatus`;
// pushed by crate::offline_mode::start_ui_forwarder on every engine
// broadcast. Read by the login screen (offline-boot affordances) and the
// shell (D2 recovery banner); the gating slice adds more consumers.
export global OfflineState {
// Any offline flavor active (mode != online).
in property <bool> offline: false;
// 0 = online, 1 = real offline, 2 = induced offline.
in property <int> mode: 0;
// Raw connectivity, independent of the mode: 0 unknown, 1 up, 2 down.
in property <int> connectivity: 0;
// A probe was answered by a redirect — typical captive portal.
in property <bool> captive-portal: false;
// D2: unauthenticated offline session while connectivity is back up —
// drives the header badge's "Sign in" affordance (formerly a banner).
in property <bool> show-recovery-banner: false;
// Session started without Qobuz auth ("Start offline" on the login
// screen). Unlike show-recovery-banner this is true regardless of
// connectivity — the badge's "Logged out" state needs it even while
// the link is down.
in property <bool> offline-session: false;
// A previous login exists (last_user_id on disk). Gates the login
// screen's "Start offline" affordance: offline mode needs a prior
// user's data, never the empty user-0 profile.
in property <bool> has-previous-session: false;
// Startup session-restore failure detail, shown on the login screen
// ("" = none). Set by the restore task's error arm; cleared on any
// successful shell entry.
in property <string> login-error: "";
// Header status-badge state, derived: 0 = hidden (online + authenticated),
// 1 = hard offline (no connectivity), 2 = manual offline (induced),
// 3 = logged out (offline session — the actionable fact, so it takes
// precedence over the other two).
out property <int> badge-state: self.offline-session ? 3
: self.mode == 2 ? 2
: self.mode == 1 ? 1
: 0;
}
// ============================ Login flow ==================================
// Browser sign-in progress, driven by the Rust auth flow (crate::auth via
// main.rs). The app has no embedded webview, so the login screen must
// narrate the system-browser OAuth — the browser may open in the background
// without stealing focus, and the code exchange takes a few seconds.
export global LoginState {
// 0 = idle · 1 = waiting for the user to finish in the browser ·
// 2 = authorization code received, exchanging it for a session.
in property <int> phase: 0;
// Last sign-in failure ("" = none). Cleared when a new attempt starts
// and on successful shell entry.
in property <string> error: "";
}
// Settings > Offline, MODE group — Rust-wired actions (crate::offline_mode).
export global OfflineModeActions {
// (Re)seed the MODE-group toggle states from the engine store. Fired by
// the panel's init, so every mount of Settings > Offline re-reads them.
callback load();
// Force an immediate connectivity re-probe (the status row's "Check
// now"). The status text updates by itself via the engine forwarder.
callback check-now();
// Flip induced offline from anywhere outside the Settings panel (the
// header badge flyout's quick toggle). Routes through the same
// persistence + #279 snapshot path as the Settings toggle.
callback set-offline(bool);
}
// B9 — offline Favorites "playable favorites" rail. While OFFLINE the
// Favorites view is replaced by the OfflinePlaceholder; this rail (mounted
// as the placeholder's children on the Favorites mount only) lists the
// favorite tracks that are still playable — offline-cache READY copies and
// library qobuz_download rows — so the view isn't a dead end.
export global OfflineFavoritesState {
// The playable favorite tracks, display order = play order (the Rust
// side keeps a matching queue; a row click plays the rail from there).
in property <[SlimItem]> tracks: [];
}
export global OfflineFavoritesActions {
// (Re)build the rail from the local stores — fired by the rail's init
// on every mount of the Favorites offline placeholder.
callback load();
// Play the rail starting at the clicked track id (offline-capable
// queue path; the cached copies play through the offline tier).
callback play(string);
}
// ================= Playlist Importer (public playlists) ==================
// "Import Playlist" modal state — 1:1 port of Tauri's
// PlaylistImportModal.svelte. All interpolated strings (log lines, status
// line, summary block) arrive pre-formatted from the Rust controller
// (crate::playlist_import); the UI renders them verbatim. Provider
// detection lives Rust-side too (Slint 1.16 has no string `.contains`) —
// every URL keystroke round-trips through `url-edited`.
// One append-only line in the conversion log.
export struct ImportLogEntry {
// Final display string (pre-formatted Rust-side).
message: string,
// "info" | "success" | "error" — drives the row color.
status: string,
}
export global PlaylistImportState {
in-out property <bool> open: false;
in-out property <string> url;
// Rename field — prefilled with the fetched playlist name.
in-out property <string> custom-name;
// True during preview fetch AND import execute.
in property <bool> loading: false;
// "" = none; shown as the red banner at the top of the body.
in property <string> error: "";
// "" | "spotify" | "apple" | "tidal" | "deezer" — the locked-or-
// detected provider; its source logo renders full-opacity.
in property <string> active-provider: "";
// Rust-computed: provider detected && !offline.
in property <bool> can-fetch: false;
// Rust-computed: a preview exists AND the URL hasn't been edited
// since the fetch — flips the modal into step B (rename + Import).
in property <bool> show-preview: false;
in property <bool> import-completed: false;
// The "Conversion progress" panel (any log lines, or loading).
in property <bool> progress-visible: false;
// Bar + status/current-track lines visible.
in property <bool> has-progress: false;
in property <float> progress: 0.0; // 0..1
in property <string> status-line: "";
in property <string> current-track: "";
in property <[ImportLogEntry]> log: [];
// Folder dropdown options — exact CreatePlaylistState pattern: index
// 0 is always "No folder" (id ""), the rest are the user's folder
// names parallel to `folder-ids`.
in property <[string]> folder-options: ["No folder"];
in property <[string]> folder-ids: [""];
in-out property <int> folder-index: 0;
// Summary block, pre-formatted lines ("" = hidden).
in property <string> summary-playlist: "";
in property <string> summary-matched: "";
in property <string> summary-skipped: "";
in property <string> summary-parts: "";
}
export global PlaylistImportActions {
callback close();
// Fired on every URL keystroke — Rust recomputes active-provider /
// can-fetch / show-preview (and the post-completion reset path).
callback url-edited(string);
// Keeps Rust's custom_name mirror fresh.
callback name-edited(string);
// Step A: preview_public_playlist(url).
callback fetch();
// Step B: import_public_playlist(...) with rename + folder choice.
callback execute();
}
// ── HiFi Wizard (DAC setup) ─────────────────────────────────────────────
// Native-Slint port + redesign of the Tauri DACSetupWizard. 6-step adaptive
// flow: welcome → check → select-dacs → review-and-apply → test → done.
// READ-ONLY by contract: the wizard never writes a system audio config — it
// auto-detects DACs (pw-dump, Slice 0), generates copy-paste config, and runs
// an in-app playback test reading the real negotiated rate (N6, Slice 8b).
// A "you're missing X" fix for the check step: a caption + a copy-paste command.
export struct RemediationRow {
caption: string,
command: string,
}
// One enumerated sink the user can pick to configure.
export struct DacCandidateRow {
id: string, // PipeWire node.name (the value the user used to paste)
description: string, // pretty name, e.g. "DacMagic Plus Analog Stereo"
bus: string, // "usb" | "pci" | "bluetooth" | ""
is-default: bool, // current system default sink
looks-like-dac: bool, // hardware && bus ∈ {usb,pci} → pre-selected
checked: bool, // user selection
rates-label: string, // pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz"
}
// One selected DAC's generated copy-paste config (Slice 10, read-only).
export struct DacConfigRow {
name: string,
node-name: string,
pipewire-conf: string,
pulse-conf: string,
wireplumber-conf: string,
expanded: bool, // accordion state
}
export global DacWizardState {
in-out property <bool> open: false;
// 0 welcome · 1 check · 2 select-dacs · 3 review · 4 test · 5 done
in-out property <int> step: 0;
// welcome
in-out property <bool> welcome-confirmed: false;
// check (Slice 6): distro + init both auto-detected, both overridable
// (distro → package manager, init → service commands; they're orthogonal).
in-out property <[string]> distro-options: [];
in-out property <int> distro-index: 0;
in-out property <[string]> init-options: [];
in-out property <int> init-index: 0;
in-out property <bool> health-ok: false;
in-out property <string> health-summary: "";
in-out property <[RemediationRow]> remediations: [];
// In a Flatpak/Snap sandbox QBZ can't inspect the host stack, so it shows
// reference setup commands + an info notice instead of a health verdict.
in-out property <bool> sandboxed: false;
in-out property <string> sandbox-name: "";
// select-dacs (Slice 7)
in-out property <[DacCandidateRow]> candidates: [];
in-out property <bool> detecting: false; // enumeration in flight
in-out property <bool> has-enumeration: true; // false → manual escape hatch
in-out property <bool> any-dac-selected: false; // gates Next on the DACs step
in-out property <string> manual-node-name: "";
in-out property <bool> manual-valid: false;
in-out property <string> manual-dac-type: ""; // usb|pci|bluetooth|virtual|unknown
// test (Slice 9): live read-back lines, set by the controller
in-out property <string> test-requested-label: ""; // "QBZ requesting 96000 Hz · 24-bit"
in-out property <string> test-negotiated-label: ""; // "DAC running at 96000 Hz" (N6)
in-out property <bool> test-rate-matched: false;
in-out property <bool> test-playing: false;
// review-and-apply (Slice 10): generated copy-paste config per selected DAC.
in-out property <[DacConfigRow]> dac-configs: [];
in-out property <string> backup-cmd: "";
in-out property <string> restart-cmd: "";
in-out property <[string]> created-paths: []; // done step
// Progress checkboxes (Tauri parity) — help the user track what they ran.
in-out property <bool> review-backup-done: false;
in-out property <bool> review-config-done: false;
in-out property <bool> review-restart-done: false;
}
export global DacWizardActions {
// Wired in Rust (crate::dac_wizard); plain step nav (back/close) stays
// inline in the modal via state mutation.
callback open(); // open + detect distro/init + run health probe
callback set-distro(int); // recompute remediations for the chosen distro
callback set-init(int); // recompute service commands for the chosen init
callback copy-command(string); // → crate::share::copy_to_clipboard
callback run-detect(); // enumerate sinks → candidates (Slice 7)
callback toggle-dac(int); // flip one candidate's checked state
callback validate-manual(string);// validate a pasted node.name (escape hatch)
callback start-test(); // queue + play the 4 test tracks (Slice 9)
callback verify-own(); // start the read-back using the user's own music
callback poll-test(); // refresh the requested-vs-negotiated read-back
callback test-play-index(int); // jump straight to one of the 4 test tracks
callback stop-test();
callback gen-configs(); // build the per-DAC copy-paste config (Slice 10)
callback toggle-config(int); // collapse/expand one DAC's config accordion
}
// ============================================================================
// Sandbox (Flatpak/Snap) — Settings section holding the one-time permission
// commands for sandboxed installs (Tauri parity: Settings > Flatpak / Snap).
// The section is only shown when install-method is "flatpak" or "snap".
// ============================================================================
export global SandboxState {
// "flatpak" | "snap" | anything else (section hidden). Seeded once at
// startup from qbz_app::diagnostics::system_info().install_method.
in property <string> install-method: "";
callback copy-command(string); // → crate::share::copy_to_clipboard
}
// ============================================================================
// Keyboard shortcuts (hotkeys) — port of the Tauri keybindings system.
// ============================================================================
// One action row, prebuilt in Rust (crate::keybindings) with its display
// shortcut already formatted ("Ctrl + →"). Shared by the read-only cheatsheet
// and the editable customize editor.
export struct KeybindingRow {
id: string, // stable action id, e.g. "playback.toggle"
label: string, // localized action label
shortcut: string, // formatted display, e.g. "Ctrl + →" / "Space"
modified: bool, // differs from the default binding
contextual: bool, // only fires in a context (immersive / mini)
}
// A category group (Playback / Navigation / Interface / Immersive / Mini Player)
// with its localized header label and its ordered rows.
export struct KeybindingCategoryGroup {
label: string, // localized category header
rows: [KeybindingRow],
}
export global KeybindingsState {
// Ordered category groups, rebuilt in Rust whenever a binding changes.
in property <[KeybindingCategoryGroup]> groups: [];
// Same groups split round-robin into three columns, for the read-only
// cheatsheet's 3-column layout (the editor still uses the full `groups`).
in property <[KeybindingCategoryGroup]> groups-col1: [];
in property <[KeybindingCategoryGroup]> groups-col2: [];
in property <[KeybindingCategoryGroup]> groups-col3: [];
// Count of actions whose binding differs from default ("N modified").
in property <int> modified-count: 0;
// --- Customize editor capture state -------------------------------------
// Action id currently being recorded ("" = not recording). The winit key
// handler reads this; while non-empty it captures the next combo instead
// of dispatching a hotkey.
in-out property <string> recording-id: "";
// Live formatted display of the combo being pressed while recording.
in property <string> pending-display: "";
// Localized label of the action that already owns the pressed combo
// ("" = no conflict). When set, the capture is NOT applied.
in property <string> conflict-label: "";
}
export global KeybindingsActions {
callback start-record(string); // begin capturing for an action id
callback cancel-record(); // stop capturing (Escape / click-away)
callback reset-one(string); // reset one action to its default
callback reset-all(); // reset every action to defaults
}
// Open flags for the two keybindings modals. The cheatsheet (read-only, `?`)
// and the editable customize editor. Self-gated mounts in AppShell (ADR-010).
export global KeyboardShortcutsState {
in-out property <bool> open: false; // read-only cheatsheet
in-out property <bool> customize-open: false; // editable customize editor
}
// ============================================================================
// Open Qobuz Link (Ctrl+L) — cross-platform link resolver modal.
// ============================================================================
export global LinkResolverState {
in-out property <bool> open: false;
// Two-way bound to the input field.
in-out property <string> url: "";
// Detected platform id for the leading icon: "qobuz" | "spotify" | "apple"
// | "tidal" | "deezer" | "songlink" | "" (none/unknown). Set by Rust on
// url-changed; reset to "" from the hamburger menu on open (hence in-out).
in-out property <string> platform: "";
in-out property <bool> resolving: false;
// Localized error message ("" = none).
in-out property <string> error: "";
// Playlist banner: the link is a playlist → offer the Playlist Importer.
in-out property <bool> playlist-detected: false;
in property <string> playlist-provider: "";
}
export global LinkResolverActions {
callback submit(string); // resolve + navigate
callback close();
callback url-changed(string); // live platform detection for the icon
callback open-importer(); // playlist banner → open Playlist Importer
}
// UiFocusState (the hotkey text-input guard) lives in the leaf file
// `focus-state.slint` and is re-exported at the top of this file.
// ===================================================================
// About modal (hamburger menu → "About QBZ").
// ===================================================================
// One author/contributor row: GitHub handle + the profile URL opened on click,
// plus the GitHub avatar (fetched async by crate::about — default/blank until it
// arrives).
export struct AboutContributorRow {
name: string,
url: string,
avatar: image,
}
// One wrap row of contributor chips. Slint has no flex-wrap, so crate::about
// pre-groups the flat contributor list into fixed-width rows (4 per row) and the
// modal renders an outer VerticalLayout over the groups + an inner
// HorizontalLayout over each group's items.
export struct AboutContributorGroup {
items: [AboutContributorRow],
}
export global AboutState {
in-out property <bool> open: false;
// Seeded by crate::about at install time (and on language change for the
// attributions descriptions). Static for the lifetime of the app.
in property <string> version: "";
in property <string> platform-label: ""; // e.g. "Linux (Slint)"
in property <string> build-date: ""; // YYYY-MM-DD ("" hides it)
in property <string> build-commit: ""; // short hash ("" hides it)
in property <string> release-url: "https://github.com/vicrodh/qbz/releases";
// Contributors pre-grouped into wrap rows (see AboutContributorGroup).
in property <[AboutContributorGroup]> contributor-rows: [];
// The single author row (vicrodh) — kept as discrete fields since it
// renders differently (slightly larger) from the contributor list.
in property <string> author-name: "vicrodh";
in property <string> author-url: "https://github.com/vicrodh";
in property <image> author-avatar;
}
export global AboutActions {
// Open an external URL in the system browser (Rust: open::that).
callback open-url(string);
}
// ===================================================================
// What's New modal (hamburger menu → "What's New").
// ===================================================================
// One rendered block of the release-notes body. `kind`: 0 = section heading,
// 1 = bullet, 2 = paragraph. `level` is the bullet nesting depth (1, 2, …).
// `id` is the slug for section blocks (TOC anchor; empty for bullets/paragraphs).
export struct WhatsNewBlock {
kind: int,
level: int,
text: string,
id: string,
url: string,
}
// One table-of-contents entry (level-0 section headings only).
export struct WhatsNewTocEntry {
id: string,
label: string,
}
export global WhatsNewState {
in-out property <bool> open: false;
in property <bool> loading: false;
in property <string> version: "";
in property <string> date: ""; // formatted "Mon D, YYYY"
in property <bool> has-body: false; // false → show the empty state
in property <[WhatsNewBlock]> blocks: [];
in property <[WhatsNewTocEntry]> toc: [];
}
export global WhatsNewActions {
// Open the modal AND kick off the GitHub release fetch (Rust).
callback open();
callback close();
// Open an external URL from a standalone release-notes link (Rust: open::that).
callback open-url(string);
}