fix(shell): #618 window identity/geometry + single-instance present; Plex refresh seed; wire discover/pinned state

Persist the Wayland app_id (set_xdg_app_id) and window geometry/maximized across
tray-hide and miniplayer round-trips so the pinned taskbar icon keeps grouping
and the size/position survive (#618); route the single-instance raise and MPRIS
Raise through a miniplayer-aware present() plus a dedicated D-Bus Present method.
Seed the Local Library Plex refresh button after login (not before, when the
per-user store is unbound). Also the shared wiring these features need in
main.rs / state.slint / app.slint / home.rs / artwork.rs (pinned globals + store
seed + mixed-artwork target, playlist-browse nav, sub-genre reload) and the
bundled i18n strings.

cc
This commit is contained in:
vicrodh committed 2026-07-16 16:31:51 -06:00
1 parent 288e982b44
commit 8e57864bee
23 files changed
+1089 -140

No files matched your search

@@ -521,6 +521,20 @@ msgstr "Deine Top-Künstler"
msgid "Artists to Follow"
msgstr "Künstler zum Folgen"
msgid "Pinned"
msgstr "Angeheftet"
msgid "Pin"
msgstr "Anheften"
msgid "Unpin"
msgstr "Lösen"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Zeige {} von {} Titel."
msgstr[1] "Zeige {} von {} Titeln."
msgid "Spotlight"
msgstr "Spotlight"
@@ -326,3 +326,20 @@ msgstr "Bundle saved. On the daemon box:"
msgid "Could not export settings"
msgstr "Could not export settings"
# Pinned section (Home / For You mixed carousel)
msgid "Pinned"
msgstr "Pinned"
# Pin/Unpin toggle on the detail views (playlist header, album/artist menus)
msgid "Pin"
msgstr "Pin"
msgid "Unpin"
msgstr "Unpin"
# Queue footer count (filtered track list)
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Showing {} of {} track."
msgstr[1] "Showing {} of {} tracks."
@@ -521,6 +521,20 @@ msgstr "Tus Artistas Favoritos"
msgid "Artists to Follow"
msgstr "Artistas para Seguir"
msgid "Pinned"
msgstr "Fijados"
msgid "Pin"
msgstr "Fijar"
msgid "Unpin"
msgstr "Dejar de fijar"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Mostrando {} de {} pista."
msgstr[1] "Mostrando {} de {} pistas."
msgid "Spotlight"
msgstr "Spotlight"
@@ -521,6 +521,20 @@ msgstr "Vos artistes favoris"
msgid "Artists to Follow"
msgstr "Artistes à suivre"
msgid "Pinned"
msgstr "Épinglés"
msgid "Pin"
msgstr "Épingler"
msgid "Unpin"
msgstr "Désépingler"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Affichage de {} sur {} piste."
msgstr[1] "Affichage de {} sur {} pistes."
msgid "Spotlight"
msgstr "Sous les projecteurs"
@@ -520,6 +520,19 @@ msgstr "よく聴くアーティスト"
msgid "Artists to Follow"
msgstr "フォローするアーティスト"
msgid "Pinned"
msgstr "ピン留め"
msgid "Pin"
msgstr "ピン留め"
msgid "Unpin"
msgstr "ピン留めを解除"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "{} / {} トラックを表示中"
msgid "Spotlight"
msgstr "スポットライト"
@@ -521,6 +521,20 @@ msgstr "Jouw topartiesten"
msgid "Artists to Follow"
msgstr "Artiesten om te volgen"
msgid "Pinned"
msgstr "Vastgezet"
msgid "Pin"
msgstr "Vastzetten"
msgid "Unpin"
msgstr "Losmaken"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Toont {} van {} nummer."
msgstr[1] "Toont {} van {} nummers."
msgid "Spotlight"
msgstr "In de schijnwerpers"
@@ -521,6 +521,20 @@ msgstr "Seus Artistas Mais Ouvidos"
msgid "Artists to Follow"
msgstr "Artistas para Seguir"
msgid "Pinned"
msgstr "Fixados"
msgid "Pin"
msgstr "Fixar"
msgid "Unpin"
msgstr "Desafixar"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Mostrando {} de {} faixa."
msgstr[1] "Mostrando {} de {} faixas."
msgid "Spotlight"
msgstr "Em Destaque"
@@ -520,6 +520,21 @@ msgstr "Ваши топ-исполнители"
msgid "Artists to Follow"
msgstr "Исполнители для подписки"
msgid "Pinned"
msgstr "Закреплённые"
msgid "Pin"
msgstr "Закрепить"
msgid "Unpin"
msgstr "Открепить"
msgid "Showing {} of {} track."
msgid_plural "Showing {} of {} tracks."
msgstr[0] "Показано {} из {} трека."
msgstr[1] "Показано {} из {} треков."
msgstr[2] "Показано {} из {} треков."
msgid "Spotlight"
msgstr "В центре внимания"
+1 -1
View File
@@ -31,7 +31,7 @@ export { ThemeState } from "foundation/theme-state.slint";
export { Typography } from "foundation/typography.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint";
export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
+22 -1
View File
@@ -17,13 +17,14 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { ForYouState, RadioStationItem, DiscoverState } from "../state.slint";
import { ForYouState, RadioStationItem, DiscoverState, PinnedState } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { SlimCarousel } from "SlimCarousel.slint";
import { ArtistCarousel } from "ArtistCarousel.slint";
import { Spotlight } from "Spotlight.slint";
import { QobuzMixesRow } from "QobuzMixesRow.slint";
import { RadioCarousel } from "RadioCarousel.slint";
import { PinnedCarousel } from "PinnedCarousel.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
export component ForYouView inherits VerticalLayout {
@@ -66,6 +67,23 @@ export component ForYouView inherits VerticalLayout {
}
}
// pinned — the user's mixed pinned albums/artists/playlists carousel;
// data lives in the shared PinnedState global (the Home arm reads the
// same list). Self-hides while nothing is pinned.
if desc.id == "pinned" && PinnedState.items.length > 0: PinnedCarousel {
title: @tr("Pinned");
items: PinnedState.items;
album-clicked(id) => {
root.open-album(id);
}
artist-clicked(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// releaseWatch — album carousel.
if desc.id == "releaseWatch" && ForYouState.release-watch.albums.length > 0: Carousel {
section: ForYouState.release-watch;
@@ -155,6 +173,9 @@ export component ForYouView inherits VerticalLayout {
if desc.id == "artistsToFollow" && ForYouState.artists-to-follow.length > 0: ArtistCarousel {
title: @tr("Artists to Follow");
artists: ForYouState.artists-to-follow;
// New 200x246 grid card with follow/play/more overlay (preview).
grid-cards: true;
card-follow-mode: "toggle";
artist-clicked(id) => {
root.open-artist(id);
}
+37 -2
View File
@@ -8,12 +8,13 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState, TooltipState } from "../state.slint";
import { HomeState, HomeActions, DiscoverState, DiscoverActions, PinnedState, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState, TooltipState } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { SlimCarousel } from "SlimCarousel.slint";
import { ArtistCarousel } from "ArtistCarousel.slint";
import { QobuzMixesRow } from "QobuzMixesRow.slint";
import { PlaylistCarousel } from "PlaylistCarousel.slint";
import { PinnedCarousel } from "PinnedCarousel.slint";
import { PlaylistTagFilter } from "PlaylistTagFilter.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
import { ForYouView } from "ForYouView.slint";
@@ -377,7 +378,13 @@ export component HomeView inherits Rectangle {
title: "";
playlists: HomeState.playlists;
body-opens: true;
show-view-all: false;
// "View all" -> the full PlaylistBrowseView page
// (server-side tag + genre filtering; a fresh
// open resets its category tab to All).
show-view-all: true;
view-all-clicked => {
HomeActions.open-playlist-browse();
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
@@ -449,6 +456,15 @@ export component HomeView inherits Rectangle {
&& HomeState.popular.length > 0: SlimCarousel {
title: @tr("Popular albums");
items: HomeState.popular;
// "View all" -> the mostStreamed DiscoverBrowse page
// (Tauri parity: the Home slim grid's see-all landed
// on the Most Streamed full listing). The slimGrid
// descriptor carries no section payload, so the
// endpoint + localized title are supplied here.
show-view-all: true;
view-all-clicked => {
root.discover-view-all("/discover/mostStreamed", @tr("Most Streamed"));
}
item-clicked(id) => {
root.album-clicked(id);
}
@@ -520,6 +536,25 @@ export component HomeView inherits Rectangle {
root.media-action(kind, id, action);
}
}
// Pinned — the user's mixed pinned albums/artists/
// playlists carousel; data lives in the shared
// PinnedState global (the For You arm reads the same
// list). Self-hides while nothing is pinned.
if desc.id == "pinned"
&& PinnedState.items.length > 0: PinnedCarousel {
title: @tr("Pinned");
items: PinnedState.items;
album-clicked(id) => {
root.album-clicked(id);
}
artist-clicked(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// For You renders its own ordered, heterogeneous
+7
View File
@@ -55,6 +55,7 @@ import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
import { HomeView } from "../discover/HomeView.slint";
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
import { PlaylistBrowseView } from "../discover/PlaylistBrowseView.slint";
import { RecentAlbumsView } from "../discover/RecentAlbumsView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
@@ -111,6 +112,7 @@ export component AppShell inherits Rectangle {
property <bool> qobuz-view-blocked: OfflineState.offline && (
NavState.view == ContentView.home
|| NavState.view == ContentView.discover-browse
|| NavState.view == ContentView.playlist-browse
|| NavState.view == ContentView.search
|| NavState.view == ContentView.favorites
|| NavState.view == ContentView.album
@@ -314,6 +316,11 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.playlist-browse && !OfflineState.offline: PlaylistBrowseView {
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Recently Played Albums full page — LOCAL play-history data,
// so it mounts offline too (like local-album below); opening a
// Qobuz card from it offline hits the album view's own gate.
+96 -10
View File
@@ -26,6 +26,7 @@ export struct AlbumCardItem {
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) ----
@@ -85,6 +86,9 @@ export struct SlimItem {
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
@@ -104,6 +108,7 @@ 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,
@@ -122,6 +127,34 @@ export struct SearchPlaylistItem {
dominant-color: color,
}
// 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 {
@@ -208,6 +241,9 @@ export global HomeActions {
// "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();
// 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
@@ -324,6 +360,46 @@ export global DiscoverBrowseActions {
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
@@ -618,6 +694,9 @@ export global AlbumState {
// 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;
@@ -994,7 +1073,7 @@ export global DragActions {
}
// 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, recent-albums, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder, purchases, purchase-album, now-playing }
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, 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
@@ -1998,6 +2077,9 @@ export global PlaylistState {
// 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
@@ -3355,6 +3437,9 @@ export global ArtistState {
// (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
@@ -3855,19 +3940,20 @@ export global ShellState {
// 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) minus its VL 16px*2 padding => 208px.
// Equal L/R gutters fall out: 16 + 208 + 16 = 240. (16px == Spacing.md; kept a
// literal here so state.slint stays free of a foundation import — if Spacing.md
// or the 240 open width ever change, update this and AppShell's `x: 16px`.)
out property <length> large-dock-width: root.sidebar-rendered-width - 2 * 16px;
// 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 46 + 10 gap + square cover +
// 9 top pad; OFF = cover + 9 (spectrum band reclaimed by the sidebar list).
// 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
? (46px + 10px + root.large-dock-width + 9px)
: (root.large-dock-width + 9px);
? (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`)
+64
View File
@@ -83,6 +83,11 @@ pub enum ArtworkTarget {
/// A single playlist cover of `HomeState.playlists[idx]` (single cover →
/// slot 0, unlike the 4-slot SearchPlaylistCover/FavPlaylistCover).
HomePlaylistCover { idx: usize },
/// A single playlist cover of `PlaylistBrowseState.playlists[idx]` — the
/// Qobuz Playlists "View all" page. `visible` shares the same model
/// while no search is active, so the rendered grid updates too (same
/// contract as DiscoverBrowseAlbum).
PlaylistBrowseCover { idx: usize },
/// A row in `SearchState.albums[idx]`.
SearchAlbum { idx: usize },
/// A row in `SearchState.tracks[idx]`.
@@ -268,6 +273,13 @@ pub enum ArtworkTarget {
/// The 224×224 header cover of the PurchaseDetailView (single image written
/// to `PurchaseDetailState.artwork`).
PurchaseDetailCover,
/// A card in `PinnedState.items[idx]` — the mixed Pinned carousel (Home
/// and For You share the ONE model). Kinds are mixed; the apply arm reads
/// the row's `kind` to pick the field to write (album / artist `artwork`
/// vs playlist `cover1` + dominant colour). Index-keyed, so jobs are only
/// ever dispatched by `pinned_section::rebuild_pinned` right after it
/// replaced the model — never from a stale row set.
PinnedCard { idx: usize },
}
impl ArtworkTarget {
@@ -306,6 +318,29 @@ pub struct ArtworkJob {
pub url: String,
}
/// Artwork jobs for the mixed Pinned carousel (`PinnedState.items`). One job
/// per row with art, reading the URL from the sub-struct the row's `kind`
/// selects (album / artist `artwork-url`; playlist single-cover `url1` —
/// SearchPlaylistItem has no artwork-url field). Rows without art are
/// skipped. Build ONLY from the freshly-pushed row set (see `PinnedCard`).
pub fn pinned_artwork_jobs(rows: &[crate::PinnedItem]) -> Vec<ArtworkJob> {
rows.iter()
.enumerate()
.filter_map(|(idx, row)| {
let url = match row.kind.as_str() {
"album" => row.album.artwork_url.as_str(),
"artist" => row.artist.artwork_url.as_str(),
"playlist" => row.playlist.url1.as_str(),
_ => "",
};
(!url.is_empty()).then(|| ArtworkJob {
target: ArtworkTarget::PinnedCard { idx },
url: url.to_string(),
})
})
.collect()
}
/// Open the shared QBZ image cache.
pub fn open_cache() -> ImageCache {
match ImageCacheService::new() {
@@ -977,6 +1012,17 @@ fn apply_artwork(
model.set_row_data(idx, item);
}
}
ArtworkTarget::PlaylistBrowseCover { idx } => {
let model = window.global::<crate::PlaylistBrowseState>().get_playlists();
if let Some(mut item) = model.row_data(idx) {
item.cover1 = image; // single cover → slot 0
// Same dominant-colour letterbox as HomePlaylistCover — the
// browse grid renders the same single-cover Discover card.
item.dominant_color =
crate::immersive::dominant_cover_color(pixels, width, height);
model.set_row_data(idx, item);
}
}
ArtworkTarget::SearchAlbum { idx } => {
let model = window.global::<SearchState>().get_albums();
if let Some(mut item) = model.row_data(idx) {
@@ -1646,5 +1692,23 @@ fn apply_artwork(
model.set_row_data(idx, item);
}
}
ArtworkTarget::PinnedCard { idx } => {
let model = window.global::<crate::PinnedState>().get_items();
if let Some(mut item) = model.row_data(idx) {
match item.kind.as_str() {
"album" => item.album.artwork = image,
"artist" => item.artist.artwork = image,
"playlist" => {
item.playlist.cover1 = image; // single cover → slot 0
// Same dominant-colour letterbox as HomePlaylistCover —
// the pinned card renders the single-cover Discover card.
item.playlist.dominant_color =
crate::immersive::dominant_cover_color(pixels, width, height);
}
_ => return,
}
model.set_row_data(idx, item);
}
}
}
}
+3
View File
@@ -149,6 +149,7 @@ where
}
crate::discover_prefs::init_for_user(&dir);
crate::artist_blacklist::init_for_user(&dir);
crate::pinned::init_for_user(&dir);
// Intelligent Search (cache + ranking), seeded from the persisted pref.
crate::search_service::init(&dir, crate::ui_prefs::load().intelligent_search);
// Session persistence (queue + playback): open the per-user session.db
@@ -282,6 +283,7 @@ where
}
crate::discover_prefs::init_for_user(&dir);
crate::artist_blacklist::init_for_user(&dir);
crate::pinned::init_for_user(&dir);
// Intelligent Search (cache + ranking), seeded from the pref.
crate::search_service::init(&dir, crate::ui_prefs::load().intelligent_search);
// Session persistence (queue + playback): open the per-user
@@ -337,6 +339,7 @@ where
runtime.core().clear_artist_vectors().await;
crate::discover_prefs::teardown();
crate::artist_blacklist::teardown();
crate::pinned::teardown();
crate::search_service::teardown();
crate::lyrics::teardown();
runtime.deactivate().await?;
+2
View File
@@ -151,6 +151,7 @@ pub fn render_kind(id: DiscoverySectionId) -> &'static str {
RadioStations => "radio",
TopArtists | ArtistsToFollow => "artistCarousel",
ArtistSpotlight => "spotlight",
Pinned => "pinnedCarousel",
ReleaseWatch | FavoriteAlbums | SimilarAlbums | RediscoverLibrary | EssentialsByGenre => {
"albumCarousel"
}
@@ -189,6 +190,7 @@ pub fn label_for(id: DiscoverySectionId) -> &'static str {
EssentialsByGenre => qbz_i18n::mark("Essentials by Genre"), // discovery.essentialsByGenre
ArtistsToFollow => qbz_i18n::mark("Artists to Follow"), // discovery.artistsToFollow
ArtistSpotlight => qbz_i18n::mark("Artist Spotlight"), // discovery.artistSpotlight
Pinned => qbz_i18n::mark("Pinned"), // Slint-era section, no Tauri key
}
}
+17 -1
View File
@@ -208,6 +208,12 @@ where
}
}
// Genre filtering is server-side: the selected genre ids (parent OR
// sub-genre, raw) were passed to get_discover_index, which honors sub-genre
// ids in `genre_ids`. No client-side narrowing — 1:1 with Tauri
// discovery-v2, which rendered the faceted response as-is (narrowing here
// wrongly dropped albums tagged only at top level).
// Editorial-only set for the Editor's Picks tab — built first
// (by cloning the containers) so the same data can also feed the
// Home set and the most-streamed slim grid below. Order mirrors
@@ -488,7 +494,7 @@ pub(crate) fn map_album(album: DiscoverAlbum) -> CardData {
/// landscape `rectangle`, falling back to the first square `cover`. Owner,
/// duration and tracks_count are intentionally dropped (1:1 with Tauri's
/// PlaylistCardLite, which shows the name only).
fn map_playlist(p: DiscoverPlaylist) -> PlaylistCardData {
pub(crate) fn map_playlist(p: DiscoverPlaylist) -> PlaylistCardData {
let artwork_url = p
.image
.rectangle
@@ -640,6 +646,9 @@ pub(crate) fn slim_to_item(slim: SlimData) -> SlimItem {
artwork_url: slim.artwork_url.into(),
artwork: slint::Image::default(),
following: false,
// Slim rails (popular / recently played) render pin-less slim rows,
// not the grid card — nothing here is pinnable.
is_pinned: false,
}
}
@@ -662,6 +671,9 @@ pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
// Favorite heart state from the login-seeded cache (kept live by
// main::set_album_row_favorite when a favorite toggles anywhere).
is_favorite: crate::fav_cache::is_album_favorite(&card.id),
// Pin badge state from the per-user pinned store (kept live by
// main::set_album_row_pinned when a pin toggles anywhere).
is_pinned: crate::pinned::is_pinned("album", &card.id),
id: card.id.into(),
title: card.title.into(),
artist: card.artist.into(),
@@ -693,6 +705,9 @@ pub(crate) fn playlist_to_item(p: &PlaylistCardData) -> SearchPlaylistItem {
id: p.id.clone().into(),
title: p.title.clone().into(),
subtitle: "".into(),
// Pin badge state from the per-user pinned store (kept live by
// main::set_playlist_row_pinned when a pin toggles anywhere).
is_pinned: crate::pinned::is_pinned("playlist", &p.id),
cover_count: if p.artwork_url.is_empty() { 0 } else { 1 },
url1: p.artwork_url.clone().into(),
url2: "".into(),
@@ -778,6 +793,7 @@ const HOME_RENDERABLE: &[DiscoverySectionId] = &[
DiscoverySectionId::QobuzMixes,
DiscoverySectionId::ReleaseWatch,
DiscoverySectionId::TopArtists,
DiscoverySectionId::Pinned,
];
/// Build one tab's ordered ENABLED descriptor list from `prefs` + the cached
+591 -107
View File
@@ -71,6 +71,8 @@ mod myqbz_play;
mod myqbz_prefs;
mod myqbz_view_prefs;
mod nav;
mod pinned;
mod pinned_section;
mod play_history;
mod playback;
mod qconnect_engine;
@@ -101,6 +103,7 @@ mod visualizer;
mod offline_manager;
mod offline_mode;
mod playlist;
mod playlist_browse;
mod playlist_import;
mod playlist_manager;
mod playlist_snapshot;
@@ -343,12 +346,20 @@ async fn enter_shell(
login_state.set_phase(0);
login_state.set_error("".into());
seed_tray_appearance(&w, &tray);
// Seed the Local Library header Sync button (#573): must run AFTER
// init_shell_for_user bound the per-user plex_settings store —
// pre-login is_configured() always reads the empty defaults (false).
w.global::<LocalLibraryState>()
.set_plex_available(plex_auth::is_configured());
// Seed the My QBZ branding (label + icon) from the per-user store so
// the sidebar row + Settings row paint the custom values immediately.
myqbz_prefs::seed(&w);
// Seed the Discover configurator descriptor lists so the prefs-driven
// render loop has order/visibility data before the first apply_home.
discover_prefs::seed(&w);
// Seed the Pinned section (Home / For You) from the per-user pinned
// store — bound by perform_login / restore before this closure runs.
pinned_section::rebuild_pinned(&w);
w.global::<HomeState>().set_loading(true);
w.set_screen(resolved_shell_screen());
});
@@ -611,6 +622,8 @@ async fn enter_shell_offline(
// D-FIX-a: bind the blacklist offline too — Tauri never initialized it
// in offline mode, so blacklisted artists leaked into offline surfaces.
crate::artist_blacklist::init_for_user(&dir);
// Pinned items are local-only and must render offline too.
crate::pinned::init_for_user(&dir);
// Intelligent Search (cache + ranking), seeded from the persisted pref.
// Cached results stay searchable offline; live revalidation no-ops.
crate::search_service::init(&dir, crate::ui_prefs::load().intelligent_search);
@@ -627,10 +640,18 @@ async fn enter_shell_offline(
let weak = weak.clone();
let _ = weak.clone().upgrade_in_event_loop(move |w| {
seed_tray_appearance(&w, &tray);
// Seed the Local Library header Sync button (#573) — the store is
// bound (init_shell_for_user above), and Plex is a LAN server, so
// it stays usable in a Qobuz-offline session.
w.global::<LocalLibraryState>()
.set_plex_available(plex_auth::is_configured());
myqbz_prefs::seed(&w);
// Seed the Discover configurator descriptor lists (works offline —
// the prefs store is per-user and bound at session activation).
discover_prefs::seed(&w);
// Seed the Pinned section — pinned items are local-only and must
// render offline; disk-cached covers still resolve.
pinned_section::rebuild_pinned(&w);
// No HomeState loading spinner: the discover load is skipped offline
// (the gating slice adds the placeholder views).
w.set_screen(resolved_shell_screen());
@@ -674,11 +695,16 @@ async fn enter_shell_offline(
Ok(())
}
/// The shared genre-filter selection expanded to descendant ids, as the
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
/// The shared genre-filter selection as the `Option<Vec<u64>>` the discover
/// endpoints take (None = no filter). Shared by the home re-fetch and the
/// DiscoverBrowse "View all" page.
fn current_genre_filter() -> Option<Vec<u64>> {
let ids = genre_filter::filter_ids("discover");
// The RAW selection (parent or sub-genre id, exactly as toggled). Qobuz's
// /discover/index honors sub-genre ids server-side — 1:1 with Tauri
// discovery-v2, which sent getSelectedGenreIds() straight through and did
// NOT narrow client-side. Sending a top-level ancestor instead silently
// widened sub-genre selections back to their parent (#618-batch regression).
let ids = genre_filter::selected_ids_for("discover");
(!ids.is_empty()).then_some(ids)
}
@@ -692,10 +718,9 @@ async fn reload_home(
image_cache: &artwork::ImageCache,
active_tab: String,
) {
// Expand the selection to descendants so a parent selection
// covers its child genres (the child-genre filtering recovery).
let genre_ids = genre_filter::filter_ids("discover");
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
// The raw genre selection (parent or sub-genre ids) goes straight to
// /discover/index — Qobuz facets sub-genre ids server-side (Tauri parity).
let genre_ids = current_genre_filter();
match home::load_home(runtime, genre_ids).await {
Ok(data) => {
@@ -827,6 +852,13 @@ fn current_browse_target(window: &AppWindow) -> Option<(String, String)> {
Some((endpoint, state.get_title().to_string()))
}
/// Whether the Qobuz Playlists "View all" page is the active view, so a
/// genre-filter change re-navigates it (preserving its selected tag)
/// instead of reloading the Discover home index. UI thread only.
fn current_playlist_browse_showing(window: &AppWindow) -> bool {
window.global::<NavState>().get_view() == ContentView::PlaylistBrowse
}
/// Push the navigation history flags onto `NavState`. UI thread only.
fn update_nav_flags(window: &AppWindow) {
let state = window.global::<NavState>();
@@ -1237,31 +1269,53 @@ fn install_browser_mouse_nav(window: &AppWindow) {
// through our toggle, so sync the flag from the window state on
// every resize (cheap — a property set with a change guard
// inside Slint).
if let Some(w) = weak.upgrade() {
w.global::<WindowControlActions>()
.set_is_maximized(w.window().is_maximized());
}
let (maximized, fullscreen) = if let Some(w) = weak.upgrade() {
let maximized = w.window().is_maximized();
w.global::<WindowControlActions>().set_is_maximized(maximized);
(maximized, w.window().is_fullscreen())
} else {
(false, false)
};
let scale = slint_window.scale_factor().max(0.01) as f64;
let lw = (size.width as f64 / scale) as f32;
let lh = (size.height as f64 / scale) as f32;
if lw >= 940.0 / active_ui_scale() && lh >= 600.0 / active_ui_scale() {
let mut prefs = crate::ui_prefs::load();
if (prefs.window_width - lw).abs() > 0.5
|| (prefs.window_height - lh).abs() > 0.5
{
prefs.window_width = lw;
prefs.window_height = lh;
crate::ui_prefs::save(&prefs);
}
let mut prefs = crate::ui_prefs::load();
let mut dirty = prefs.window_maximized != maximized;
prefs.window_maximized = maximized;
// window_width/height hold the FLOATING size only: a
// maximized/fullscreen frame must never overwrite it, or the
// restore paths (startup + tray/mini re-show, #618) would
// reproduce the maximized footprint as a floating window.
if !maximized
&& !fullscreen
&& lw >= 940.0 / active_ui_scale()
&& lh >= 600.0 / active_ui_scale()
&& ((prefs.window_width - lw).abs() > 0.5
|| (prefs.window_height - lh).abs() > 0.5)
{
prefs.window_width = lw;
prefs.window_height = lh;
dirty = true;
}
if dirty {
crate::ui_prefs::save(&prefs);
}
EventResult::Propagate
}
WindowEvent::Moved(pos) => {
let mut prefs = crate::ui_prefs::load();
if prefs.window_x != pos.x || prefs.window_y != pos.y {
prefs.window_x = pos.x;
prefs.window_y = pos.y;
crate::ui_prefs::save(&prefs);
// Same floating-only rule as the size: the maximized origin
// (often 0,0) must not overwrite the floating position.
let floating = weak
.upgrade()
.map(|w| !w.window().is_maximized() && !w.window().is_fullscreen())
.unwrap_or(false);
if floating {
let mut prefs = crate::ui_prefs::load();
if prefs.window_x != pos.x || prefs.window_y != pos.y {
prefs.window_x = pos.x;
prefs.window_y = pos.y;
crate::ui_prefs::save(&prefs);
}
}
EventResult::Propagate
}
@@ -1366,6 +1420,7 @@ fn is_offline_blocked_view(window: &AppWindow) -> bool {
match window.global::<NavState>().get_view() {
ContentView::Home
| ContentView::DiscoverBrowse
| ContentView::PlaylistBrowse
| ContentView::Search
| ContentView::Favorites
| ContentView::Album
@@ -1558,6 +1613,172 @@ fn set_album_row_favorite(window: &AppWindow, album_id: &str, favorite: bool) {
}
}
/// Pin twin of [`set_album_row_favorite`]: flip the `is-pinned` badge on every
/// visible album CARD matching `album_id`, across all card surfaces. Cards
/// read `crate::pinned` when they are (re)built; this keeps the ones already
/// on screen in sync the instant a pin toggles anywhere. The Pinned section's
/// own model is NOT walked — `pinned_section::rebuild_pinned` replaces it
/// wholesale right after. Unlike the favorite twin this also walks the Local
/// Library models: LL cards hide the heart but do show the pin glyph.
fn set_album_row_pinned(window: &AppWindow, album_id: &str, pinned: bool) {
let flip = |model: &slint::ModelRc<AlbumCardItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == album_id && item.is_pinned != pinned {
item.is_pinned = pinned;
model.set_row_data(i, item);
}
}
}
};
let flip_section = |section: &DiscoverSection| flip(&section.albums);
let flip_sections = |model: &slint::ModelRc<DiscoverSection>| {
for s in 0..model.row_count() {
if let Some(section) = model.row_data(s) {
flip(&section.albums);
}
}
};
// Artist page — release sections + last-release + the in-page-search
// FULL cache (owned by artist.rs).
artist::set_release_card_pinned(window, album_id, pinned);
// Dedicated discography page (View all).
flip(&window.global::<ArtistReleasesState>().get_albums());
// Album detail carousels — From the same artist / Listening suggestions.
let album = window.global::<AlbumState>();
flip_section(&album.get_more_from_artist());
flip_section(&album.get_suggestions_section());
flip_section(&album.get_lastfm_suggestions_section());
// Search results + the most-popular album hero.
let search = window.global::<SearchState>();
flip(&search.get_albums());
let mut hero = search.get_most_popular_album();
if hero.id == album_id && hero.is_pinned != pinned {
hero.is_pinned = pinned;
search.set_most_popular_album(hero);
}
// Home / Editor's Picks — the descriptor-driven carousels render the
// page; HomeState.sections + recent-albums back the fixed-data arms.
let home = window.global::<HomeState>();
flip_sections(&home.get_sections());
flip(&home.get_recent_albums());
let discover = window.global::<DiscoverState>();
for model in [
discover.get_home_sections(),
discover.get_editor_sections(),
discover.get_foryou_sections(),
] {
for s in 0..model.row_count() {
if let Some(desc) = model.row_data(s) {
flip(&desc.section.albums);
}
}
}
// Discover "View all" page.
let browse = window.global::<DiscoverBrowseState>();
flip(&browse.get_albums());
flip(&browse.get_visible());
// For You.
let foryou = window.global::<ForYouState>();
flip_section(&foryou.get_release_watch());
flip_section(&foryou.get_recent_albums());
flip_section(&foryou.get_favorite_albums());
flip_section(&foryou.get_more_from_library());
flip_section(&foryou.get_rediscover());
flip(&foryou.get_spotlight_albums());
// Recommendations (external reco).
let reco = window.global::<ExternalRecoState>();
flip_section(&reco.get_rec_albums());
flip_section(&reco.get_fresh_releases());
flip_section(&reco.get_deep_cut_albums());
flip_section(&reco.get_top_albums());
// Label pages (landing carousels + releases grid).
let label = window.global::<LabelState>();
flip(&label.get_albums());
flip(&label.get_visible());
flip_sections(&label.get_grouped());
flip_section(&label.get_releases_section());
flip_section(&label.get_critics_section());
// Awards listing.
let award = window.global::<AwardState>();
flip(&award.get_albums());
flip(&award.get_visible());
// Favorites — albums tab (flat + grouped) and the artists sidepanel.
let favs = window.global::<FavoritesState>();
flip(&favs.get_albums());
flip(&favs.get_albums_visible());
flip_sections(&favs.get_albums_grouped());
flip_sections(&favs.get_selected_artist_sections());
// Local Library — albums + folders tabs (flat, visible and grouped) and
// the artists tab's selected-artist grid. The pin glyph is live here even
// though the favorite heart is hidden.
let ll = window.global::<LocalLibraryState>();
flip(&ll.get_albums());
flip(&ll.get_albums_visible());
flip_sections(&ll.get_albums_grouped());
flip(&ll.get_folders());
flip(&ll.get_folders_visible());
flip_sections(&ll.get_folders_grouped());
flip(&ll.get_artists_selected_albums());
}
/// Playlist counterpart of [`set_album_row_pinned`]: flip the `is-pinned`
/// badge on every visible playlist card matching `playlist_id` (Home rail,
/// Qobuz Playlists browse, Search, Favorites, label landings).
fn set_playlist_row_pinned(window: &AppWindow, playlist_id: &str, pinned: bool) {
let flip = |model: &slint::ModelRc<SearchPlaylistItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == playlist_id && item.is_pinned != pinned {
item.is_pinned = pinned;
model.set_row_data(i, item);
}
}
}
};
flip(&window.global::<HomeState>().get_playlists());
flip(&window.global::<SearchState>().get_playlists());
let browse = window.global::<PlaylistBrowseState>();
flip(&browse.get_playlists());
flip(&browse.get_visible());
let favs = window.global::<FavoritesState>();
flip(&favs.get_playlists_favorites());
flip(&favs.get_playlists_following());
flip(&favs.get_playlists_visible());
flip(&window.global::<LabelState>().get_playlists());
}
/// Artist counterpart of [`set_album_row_pinned`]: flip the `is-pinned` badge
/// on every visible ARTIST slim-card row matching `artist_id`. Only true
/// artist models are walked — track/label/award slims share `SlimItem` but
/// are not pinnable, and their ids live in other id spaces (flipping them on
/// a numeric collision would lie).
fn set_artist_row_pinned(window: &AppWindow, artist_id: &str, pinned: bool) {
let flip = |model: &slint::ModelRc<SlimItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == artist_id && item.is_pinned != pinned {
item.is_pinned = pinned;
model.set_row_data(i, item);
}
}
}
};
let search = window.global::<SearchState>();
flip(&search.get_artists());
flip(&search.get_artists_carousel());
flip(&window.global::<HomeState>().get_top_artists());
let foryou = window.global::<ForYouState>();
flip(&foryou.get_top_artists());
flip(&foryou.get_artists_to_follow());
let reco = window.global::<ExternalRecoState>();
flip(&reco.get_rec_artists_common());
flip(&reco.get_rec_artists_recent());
flip(&reco.get_top_artists());
flip(&window.global::<LabelState>().get_artists());
}
/// Feed Capa B (intelligent-search ranking) from a RESULTS-PAGE click, but only
/// when the results page is the active view. `on_open_album` / `on_open_artist`
/// / `on_media_action` are global handlers shared by every view (album detail,
@@ -1951,6 +2172,9 @@ fn navigate_artist(
// false when the feature is disabled, which is acceptable here).
let is_bl = crate::artist_blacklist::is_blacklisted_id_str(&id_for_apply);
let st = w.global::<ArtistState>();
// Seed the pin state from the pinned store (Home "Pinned"
// section) — before set_id, which moves id_for_apply.
st.set_pinned(crate::pinned::is_pinned("artist", &id_for_apply));
st.set_id(id_for_apply.into());
st.set_is_blacklisted(is_bl);
w.global::<NavState>().set_view(ContentView::Artist);
@@ -2338,6 +2562,7 @@ fn scope_for(entry: &nav::NavEntry) -> String {
nav::NavEntry::Favorites { tab } => format!("fav:{tab}"),
nav::NavEntry::LocalLibrary { tab } => format!("ll:{tab}"),
nav::NavEntry::DiscoverBrowse { .. } => "discover-browse".into(),
nav::NavEntry::PlaylistBrowse => "playlist-browse".into(),
nav::NavEntry::RecentAlbums => "recent-albums".into(),
nav::NavEntry::Mix { .. } => "mix".into(),
nav::NavEntry::Playlist(_) => "playlist".into(),
@@ -2520,6 +2745,18 @@ fn apply_entry(
current_genre_filter(),
);
}
nav::NavEntry::PlaylistBrowse => {
// History re-entry keeps the session tag (reset_tag = false);
// only a fresh open from the rail resets it to All.
playlist_browse::navigate(
runtime.clone(),
weak.clone(),
handle,
image_cache.clone(),
current_genre_filter(),
false,
);
}
nav::NavEntry::RecentAlbums => {
navigate_recent_albums(weak.clone(), handle, image_cache.clone());
}
@@ -6673,6 +6910,83 @@ fn active_ui_scale() -> f32 {
ACTIVE_UI_SCALE.get().copied().unwrap_or(1.0)
}
/// Re-apply the persisted main-window geometry: LOGICAL size + (best-effort)
/// position, each clamped to the current monitor so a smaller / disconnected
/// display never opens an oversized or stranded window, plus the maximized
/// state. Shared by the startup path and every re-show (tray restore,
/// miniplayer exit): on Wayland a hide DESTROYS the toplevel and the
/// recreated one would otherwise come back at the .slint preferred size
/// (#618). 0 size = never saved → keep the `.slint` preferred size, EXCEPT
/// under a >1 interface-size preset, where even the preferred size may exceed
/// a small monitor once the preset multiplies it — clamp it exactly like a
/// restored one. Monitor sizes are divided by the SLINT scale factor (the
/// effective, preset-baked one); winit's own factor is the raw compositor DPR
/// and under-clamps when a preset is active. The minimum is physically
/// constant across presets (mirrors the `/ UiScale.factor` bindings in
/// app.slint). The monitor query is best-effort (`with_winit_window` returns
/// None before the surface exists — the WM's own clamping is the fallback,
/// and the Resized handler re-saves the result). Position only takes effect
/// on X11/macOS: winit's Wayland `set_outer_position` is a no-op, the
/// compositor places the surface.
pub(crate) fn restore_main_window_geometry(window: &AppWindow) {
let prefs = crate::ui_prefs::load();
let ui_scale_factor = active_ui_scale();
let min_logical_w = 940.0 / ui_scale_factor;
let min_logical_h = 600.0 / ui_scale_factor;
// Plausibility gate for the persisted size: at least the scaled minimum,
// but never stricter than the historical 940x600 (so sizes saved under a
// previous, less-scaled preset still restore — the .slint mins clamp them
// up if the current preset needs more).
let has_saved_size = prefs.window_width >= min_logical_w.min(940.0)
&& prefs.window_height >= min_logical_h.min(600.0);
if has_saved_size || ui_scale_factor > 1.0 {
let mut w = if has_saved_size { prefs.window_width } else { 1180.0 };
let mut h = if has_saved_size { prefs.window_height } else { 760.0 };
let slint_scale = (window.window().scale_factor() as f64).max(0.01);
window.window().with_winit_window(|win| {
if let Some(mon) = win.current_monitor() {
let avail_w = (mon.size().width as f64 / slint_scale) as f32;
let avail_h = (mon.size().height as f64 / slint_scale) as f32;
if avail_w >= min_logical_w {
w = w.min(avail_w);
}
if avail_h >= min_logical_h {
h = h.min(avail_h);
}
}
});
window.window().set_size(slint::LogicalSize::new(w, h));
}
if prefs.window_x != i32::MIN && prefs.window_y != i32::MIN {
let mut px = prefs.window_x;
let mut py = prefs.window_y;
window.window().with_winit_window(|win| {
if let Some(mon) = win.current_monitor() {
let m = mon.size();
let mp = mon.position();
// Keep the top-left inside the monitor rect, leaving ~100px so a
// sliver stays grabbable even if the saved spot is near an edge.
let max_x = (mp.x + m.width as i32 - 100).max(mp.x);
let max_y = (mp.y + m.height as i32 - 100).max(mp.y);
px = px.clamp(mp.x, max_x);
py = py.clamp(mp.y, max_y);
}
});
window
.window()
.set_position(slint::PhysicalPosition::new(px, py));
}
// Maximized wins over the floating size just applied (which stays the
// restore target for the eventual un-maximize). Only ever set — never
// force false, so a freshly created window keeps its natural state.
if prefs.window_maximized {
window.window().set_maximized(true);
window
.global::<WindowControlActions>()
.set_is_maximized(true);
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// UI SCALE PRESET — must run FIRST: SLINT_SCALE_FACTOR has to be in the
// environment before the backend/window exist (winit reads it at window
@@ -6760,6 +7074,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// forces wgpu; unset / "auto" auto-detects.
let use_gpu_renderer = select_slint_backend()?;
// Wayland/X11 app identity that SURVIVES surface recreation: the winit
// attributes hook (select_slint_backend) only runs once per window
// adapter, and Slint's Wayland hide() destroys the toplevel and rebuilds
// it from DEFAULT attributes (suspend(), i-slint-backend-winit).
// ensure_window() re-applies the context xdg_app_id on EVERY creation, so
// this is the only way a re-shown window keeps grouping under
// com.blitzfc.qbz.desktop (#618). Must run after the backend is selected
// (needs the global context) and before any window is created.
if let Err(e) = slint::set_xdg_app_id("com.blitzfc.qbz") {
log::warn!("[qbz-slint] set_xdg_app_id failed: {e}");
}
// UI language: resolve the persisted language BEFORE the first window is
// created, and set the Rust-side language now so `t()`/date helpers are
// correct from the first call. The persisted key may be "auto" (follow the
@@ -6785,6 +7111,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
let window = AppWindow::new()?;
// Publish the window to the single-instance Present() handler (exported
// since acquire_or_raise above) — a second launch can now raise even the
// login screen, no MPRIS needed. Also drains a Present that raced in
// between the guard and this point.
#[cfg(target_os = "linux")]
single_instance::bind_window(window.as_weak());
// Renderer auto-revert sentinel: disarmed on the FIRST real user input
// (or window close request) via the winit event filter — proof the app
// reached a usable state — with a 30s fallback for no-touch sessions.
@@ -6977,64 +7309,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
&restored_prefs.large_spectrum_mode,
));
}
// Main window geometry: restore the persisted LOGICAL size + (best-effort)
// position, each clamped to the current monitor so a smaller / disconnected
// display never opens an oversized or stranded window. 0 size = never saved
// → keep the `.slint` preferred size, EXCEPT under a >1 interface-size
// preset, where even the preferred size may exceed a small monitor once the
// preset multiplies it — clamp it exactly like a restored one. Monitor
// sizes are divided by the SLINT scale factor (the effective, preset-baked
// one); winit's own factor is the raw compositor DPR and under-clamps when
// a preset is active. The minimum is physically constant across presets
// (mirrors the `/ UiScale.factor` bindings in app.slint). The monitor query
// is best-effort (`with_winit_window` returns None before the surface
// exists → the WM's own clamping is the fallback, and the Resized handler
// re-saves the result).
let min_logical_w = 940.0 / ui_scale_factor;
let min_logical_h = 600.0 / ui_scale_factor;
// Plausibility gate for the persisted size: at least the scaled minimum,
// but never stricter than the historical 940x600 (so sizes saved under a
// previous, less-scaled preset still restore — the .slint mins clamp them
// up if the current preset needs more).
let has_saved_size = restored_prefs.window_width >= min_logical_w.min(940.0)
&& restored_prefs.window_height >= min_logical_h.min(600.0);
if has_saved_size || ui_scale_factor > 1.0 {
let mut w = if has_saved_size { restored_prefs.window_width } else { 1180.0 };
let mut h = if has_saved_size { restored_prefs.window_height } else { 760.0 };
let slint_scale = (window.window().scale_factor() as f64).max(0.01);
window.window().with_winit_window(|win| {
if let Some(mon) = win.current_monitor() {
let avail_w = (mon.size().width as f64 / slint_scale) as f32;
let avail_h = (mon.size().height as f64 / slint_scale) as f32;
if avail_w >= min_logical_w {
w = w.min(avail_w);
}
if avail_h >= min_logical_h {
h = h.min(avail_h);
}
}
});
window.window().set_size(slint::LogicalSize::new(w, h));
}
if restored_prefs.window_x != i32::MIN && restored_prefs.window_y != i32::MIN {
let mut px = restored_prefs.window_x;
let mut py = restored_prefs.window_y;
window.window().with_winit_window(|win| {
if let Some(mon) = win.current_monitor() {
let m = mon.size();
let mp = mon.position();
// Keep the top-left inside the monitor rect, leaving ~100px so a
// sliver stays grabbable even if the saved spot is near an edge.
let max_x = (mp.x + m.width as i32 - 100).max(mp.x);
let max_y = (mp.y + m.height as i32 - 100).max(mp.y);
px = px.clamp(mp.x, max_x);
py = py.clamp(mp.y, max_y);
}
});
window
.window()
.set_position(slint::PhysicalPosition::new(px, py));
}
// Main window geometry: restore the persisted size/position/maximized
// state. Shared with the tray/miniplayer re-show paths (#618) — the
// clamping and plausibility rules live in the helper's doc.
restore_main_window_geometry(&window);
// NOTE: the FFT tap is primed AFTER visualizer::install() (further below) — not
// here — because install() registers the `on_set_enabled` handler this call
// depends on.
@@ -7140,16 +7418,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// run before the shell renders so the first paint is the right palette.
{
let appearance = window.global::<AppearanceState>();
let labels: Vec<slint::SharedString> = crate::theme::dropdown_labels()
let prefs = crate::ui_prefs::load();
// Seed the dropdown honoring the persisted list filter (All/Dark/Light)
// so the list and the filter icon agree on the first paint.
let filter = prefs.theme_filter;
let labels: Vec<slint::SharedString> = crate::theme::filtered_dropdown_labels(filter)
.into_iter()
.map(slint::SharedString::from)
.collect();
appearance.set_themes(slint::ModelRc::new(slint::VecModel::from(labels)));
let slug = crate::ui_prefs::load().theme;
let slug = prefs.theme;
let is_auto = slug == crate::theme::AUTO_SLUG;
let is_custom = slug == crate::theme::CUSTOM_SLUG;
let selected_index = crate::theme::selected_index_for_slug(&slug);
// Highlight the active theme within the (possibly narrowed) list; -1 when
// it is filtered out — the palette is still applied below via the slug.
let selected_index = crate::theme::filtered_selected_index_for_slug(&slug, filter);
appearance.set_theme_index(selected_index);
appearance.set_theme_is_auto(is_auto);
appearance.set_theme_is_custom(is_custom);
@@ -7172,9 +7456,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
appearance.set_theme_is_system(id == qbz_theme::ThemeId::System);
crate::theme::apply_theme(&window, id);
}
// Keep the legacy ThemeState.mode in sync with the dropdown index for
// any residual reads; the palette itself is driven above.
window.global::<ThemeState>().set_mode(selected_index);
// Keep the legacy ThemeState.mode in sync with the UNFILTERED dropdown
// index for any residual reads (the filtered index can be -1); the
// palette itself is driven above.
window
.global::<ThemeState>()
.set_mode(crate::theme::selected_index_for_slug(&slug));
}
// Tell the tray settings UI which platform it's on so it can show the
@@ -9595,7 +9882,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// generates the palette off-thread; every other index maps to a
// stable registry id and hot-switches the static palette.
let mut prefs = crate::ui_prefs::load();
if crate::theme::is_auto_index(index) {
// The dropdown index is a position in the CURRENTLY filtered list
// (All/Dark/Light), so map it through the same filter. Auto/Custom
// only exist in the All view (filtered_*_index is -1 otherwise).
let filter = prefs.theme_filter;
if index == crate::theme::filtered_auto_index(filter) {
prefs.theme = crate::theme::AUTO_SLUG.to_string();
crate::ui_prefs::save(&prefs);
if let Some(w) = theme_weak.upgrade() {
@@ -9605,7 +9896,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
st.set_theme_is_system(false);
}
crate::auto_theme::regenerate(theme_weak.clone(), theme_handle.clone());
} else if crate::theme::is_custom_index(index) {
} else if index == crate::theme::filtered_custom_index(filter) {
// "Custom": persist the slug, derive from the persisted (or
// freshly seeded) custom base, and apply live. The editor
// swatches are seeded from the same base.
@@ -9628,7 +9919,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
} else {
let id = crate::theme::id_for_index(index);
let id = crate::theme::filtered_id_for_index(index, filter);
prefs.theme = id.slug().to_string();
crate::ui_prefs::save(&prefs);
if let Some(w) = theme_weak.upgrade() {
@@ -9646,11 +9937,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
crate::auto_theme::set_source(index, theme_weak.clone(), theme_handle.clone());
}
"theme-filter" => {
// Theme-list filter cycle (0 = All, 1 = Dark, 2 = Light). Cosmetic,
// but persist it so the list doesn't reset to All every launch.
// Theme-list filter cycle (0 = All, 1 = Dark, 2 = Light): persist
// the choice, then rebuild the dropdown to show only matching
// themes and re-derive which row (if any) is the active theme. The
// applied palette is untouched — this only narrows the list.
let mut prefs = crate::ui_prefs::load();
prefs.theme_filter = index;
crate::ui_prefs::save(&prefs);
if let Some(w) = theme_weak.upgrade() {
let st = w.global::<AppearanceState>();
let labels: Vec<slint::SharedString> =
crate::theme::filtered_dropdown_labels(index)
.into_iter()
.map(slint::SharedString::from)
.collect();
st.set_themes(slint::ModelRc::new(slint::VecModel::from(labels)));
st.set_theme_index(crate::theme::filtered_selected_index_for_slug(
&prefs.theme,
index,
));
}
}
other => log::debug!("[qbz-slint] unhandled appearance-select '{other}'"),
});
@@ -9731,6 +10037,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Pin / unpin from the card pin glyphs. The callback carries the full
// display snapshot (kind, id, title, subtitle, artwork url) so the store
// persists a denormalized row without re-fetching (the
// BlacklistActions.block-album pattern). The mutation is local SQLite
// (synchronous): mutate first, then — only on success — flip the
// `is-pinned` badge across every visible card model and rebuild the
// Pinned section from the store (the ONE rebuild path: model first,
// then a fresh index-keyed artwork job batch).
{
let weak = window.as_weak();
window
.global::<PinnedActions>()
.on_toggle_pin(move |kind, id, title, subtitle, artwork| {
if let Some(w) = weak.upgrade() {
let kind = kind.to_string();
let id = id.to_string();
// The cards hardcode these kinds and the store's CHECK
// constraint admits nothing else — anything different is
// a wiring bug.
if !matches!(kind.as_str(), "album" | "artist" | "playlist") {
log::warn!("[qbz-slint] toggle-pin: unsupported kind {kind}");
return;
}
let was_pinned = crate::pinned::is_pinned(&kind, &id);
let res = if was_pinned {
crate::pinned::unpin(&kind, &id)
} else {
crate::pinned::pin(&crate::pinned::PinnedItem {
kind: kind.clone(),
id: id.clone(),
title: title.to_string(),
subtitle: subtitle.to_string(),
artwork_url: artwork.to_string(),
pinned_at: 0, // ignored on write; the service stamps now
})
};
match res {
Ok(()) => {
let pinned = !was_pinned;
// Flip the card badges AND the open detail view's
// header pin (when it is showing this same id).
match kind.as_str() {
"album" => {
set_album_row_pinned(&w, &id, pinned);
let st = w.global::<AlbumState>();
if st.get_id().as_str() == id {
st.set_pinned(pinned);
}
}
"artist" => {
set_artist_row_pinned(&w, &id, pinned);
let st = w.global::<ArtistState>();
if st.get_id().as_str() == id {
st.set_pinned(pinned);
}
}
"playlist" => {
set_playlist_row_pinned(&w, &id, pinned);
let st = w.global::<PlaylistState>();
if st.get_id().as_str() == id {
st.set_pinned(pinned);
}
}
_ => {}
}
crate::pinned_section::rebuild_pinned(&w);
}
Err(e) => {
// Local store mutation failed (no session / DB
// error): nothing was flipped, so there is nothing
// to revert — surface the sibling stores' message.
log::error!("[qbz-slint] toggle-pin {kind} {id} failed: {e}");
crate::toast::error(&w, e);
}
}
}
});
}
// Context-menu / overlay media actions — route play / queue actions
// into the playback controller; favorite / download stay logged.
{
@@ -15259,6 +15644,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Qobuz Playlists rail "View all" -> the full-page playlist browse
// (server-side tag + genre filtering). A fresh open resets the
// category tab to All (Tauri parity); genre-filter and history
// re-navigations preserve it.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<HomeActions>()
.on_open_playlist_browse(move || {
nav::record(nav::NavEntry::PlaylistBrowse);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
playlist_browse::navigate(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
current_genre_filter(),
true,
);
});
}
// Recently-played rails refresh. `home-mounted` fires on every HomeView
// (re)mount: re-read the LOCAL store into the rails IF a play was recorded
// while Home was off-screen (dirty flag — a no-op otherwise, so mounting
@@ -15468,11 +15880,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
return;
}
// When the DiscoverBrowse "View all" page is showing, the
// genre change re-fetches THAT page; otherwise it reloads
// the Discover home index.
// When a "View all" browse page is showing (albums OR the
// Qobuz Playlists page), the genre change re-fetches THAT
// page; otherwise it reloads the Discover home index.
let browse_target = current_browse_target(&w);
if browse_target.is_none() {
let playlist_browse_showing = current_playlist_browse_showing(&w);
if browse_target.is_none() && !playlist_browse_showing {
w.global::<HomeState>().set_loading(true);
}
let active = w.global::<HomeState>().get_active_tab().to_string();
@@ -15483,7 +15896,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle2 = handle.clone();
handle.spawn(async move {
// On a newly-selected genre, eager-load its descendants
// so filter_ids covers the child genres.
// so selected_names covers the child genres (favorites)
// and the tree shows counts.
if !was_selected {
if let Ok(gid) = id.parse::<u64>() {
genre_filter::load_descendants(&runtime, gid).await;
@@ -15502,6 +15916,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
title,
current_genre_filter(),
);
} else if playlist_browse_showing {
// Re-navigation preserves the page's selected tag
// (reset_tag = false).
playlist_browse::navigate(
runtime.clone(),
weak.clone(),
&handle2,
image_cache.clone(),
current_genre_filter(),
false,
);
} else {
reload_home(&runtime, &weak, &image_cache, active).await;
}
@@ -15570,7 +15995,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
}
let browse_target = current_browse_target(&w);
if browse_target.is_none() {
let playlist_browse_showing = current_playlist_browse_showing(&w);
if browse_target.is_none() && !playlist_browse_showing {
w.global::<HomeState>().set_loading(true);
}
let active = w.global::<HomeState>().get_active_tab().to_string();
@@ -15589,6 +16015,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
title,
current_genre_filter(),
);
} else if playlist_browse_showing {
// Re-navigation preserves the page's selected tag
// (reset_tag = false).
playlist_browse::navigate(
runtime.clone(),
weak.clone(),
&handle2,
image_cache.clone(),
current_genre_filter(),
false,
);
} else {
reload_home(&runtime, &weak, &image_cache, active).await;
}
@@ -15827,12 +16264,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
});
}
// Seed the Sync button's visibility from the persisted Plex store (the
// Settings panel may never be opened this session; plex_auth's
// refresh_gates keeps the flag fresh once it is).
window
.global::<LocalLibraryState>()
.set_plex_available(plex_auth::is_configured());
// The Sync button's visibility (plex-available) is seeded at SHELL ENTRY
// (enter_shell + the offline entry), after plex_settings::init_for_user
// has bound the per-user store — seeding here (pre-login callback wiring)
// always reads the session-less defaults and computes false. plex_auth's
// refresh_gates keeps the flag fresh once the Settings panel is opened.
// Settings > Local Library — folder management + maintenance + danger.
// (Scan callbacks scan-all/scan-folder/stop-scan are wired with Slice B.)
@@ -17493,6 +17929,54 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Qobuz Playlists "View all" — pagination, client-side search and the
// single-select category tag bar (server-side re-fetch from offset 0).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<PlaylistBrowseActions>()
.on_load_more(move || {
playlist_browse::load_more(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
current_genre_filter(),
);
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistBrowseActions>()
.on_search_changed(move || {
if let Some(w) = weak.upgrade() {
playlist_browse::apply_filter(&w);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<PlaylistBrowseActions>()
.on_select_tag(move |slug| {
playlist_browse::select_tag(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
slug.to_string(),
current_genre_filter(),
);
});
}
// Favorites view actions — tab switch (lazy-load), open album /
// artist, and per-row track actions routed to the media-action
// "Add to playlist" picker — pick adds the pending track to the
+3 -1
View File
@@ -59,7 +59,9 @@ fn dispatch(ev: MediaEvent, rt: Runtime, weak: slint::Weak<AppWindow>, h: tokio:
}
});
}
MediaEvent::Raise => crate::tray::show_window(&weak),
// Present, not show_window: with the miniplayer open a forced main-
// window show reads as a duplicate instance (#559) — raise the mini.
MediaEvent::Raise => crate::tray::present(&weak),
MediaEvent::Quit => crate::tray::quit(),
MediaEvent::SetVolume(v) => crate::playback::set_volume(rt, weak, h, v as f32),
MediaEvent::SetPosition(micros) => seek_to_micros(rt, h, micros),
+24
View File
@@ -205,10 +205,31 @@ pub fn exit() {
}
});
let _ = m.show();
// enter() hid the main window — on Wayland that destroyed the
// toplevel, and the recreated one comes back at the .slint
// preferred size unless the persisted geometry is re-applied
// before the surface maps (#618).
crate::restore_main_window_geometry(&m);
}
}
}
/// Raise/focus the mini window in place — activation path (MPRIS `Raise` /
/// single-instance `Present()`) while the miniplayer is open. Event-loop
/// thread only: MINI_STRONG is thread_local. Must NOT set CREATING_MINI —
/// show() on the live window is a no-op/re-realization, and the borderless
/// attributes hook must only fire for the original construction.
pub fn present_mini() {
MINI_STRONG.with(|s| {
if let Some(mini) = s.borrow().as_ref() {
let _ = mini.show();
mini.window().with_winit_window(|win| {
win.focus_window();
});
}
});
}
/// Close the whole app from the mini — hide mini, show main, route to the
/// main window's close-app handler (tray-or-quit choreography lives there).
fn close_app() {
@@ -221,6 +242,9 @@ fn close_app() {
if let Some(ctx) = CTX.get() {
if let Some(m) = ctx.main.upgrade() {
let _ = m.show();
// The window becomes user-visible when close-to-tray keeps the
// app alive — same recreated-surface geometry rules as exit().
crate::restore_main_window_geometry(&m);
m.invoke_close_app();
}
}
+78 -17
View File
@@ -5,11 +5,15 @@
//!
//! The first instance takes ownership of the well-known session-bus name
//! `com.blitzfc.qbz` (Flatpak auto-grants owning the app-id name — no
//! finish-args change needed). A second launch sees the name taken, asks
//! the owner to bring its window up through the ALREADY-EXPORTED MPRIS
//! `Raise` method (routes to `tray::show_window` in the running instance),
//! and exits. Any D-Bus problem — no session bus, weird sandbox — falls
//! through as "we are primary": the guard must never block startup.
//! finish-args change needed) and exports a `com.blitzfc.qbz.SingleInstance`
//! interface with a `Present()` method. A second launch sees the name taken,
//! calls `Present()` — which raises whichever window is current (the mini
//! when the miniplayer is open, else the main window) and works from process
//! start, login screen included — and exits. If the primary predates the
//! interface (≤2.0.x) the call errors and the second launch falls back to
//! the MPRIS `Raise` method, which only exists after session entry. Any
//! D-Bus problem — no session bus, weird sandbox — falls through as "we are
//! primary": the guard must never block startup.
//!
//! Blocking zbus API on purpose: this runs once on the main thread before
//! the UI exists, and the async-io executor self-drives the connection
@@ -17,17 +21,63 @@
//! see the rfd/ksni comments in Cargo.toml).
#![cfg(target_os = "linux")]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use zbus::blocking::fdo::DBusProxy;
use zbus::blocking::Connection;
use zbus::fdo::{RequestNameFlags, RequestNameReply};
use zbus::names::WellKnownName;
use crate::AppWindow;
const BUS_NAME: &str = "com.blitzfc.qbz";
const OBJECT_PATH: &str = "/com/blitzfc/qbz";
const IFACE_NAME: &str = "com.blitzfc.qbz.SingleInstance";
/// Keeps the acquired name owned for the process lifetime (releasing it
/// would let a second launch believe it is primary).
static CONN: std::sync::OnceLock<Connection> = std::sync::OnceLock::new();
/// The main window, published by `bind_window` right after `AppWindow::new()`
/// so `Present()` can raise it. `slint::Weak` is Send+Sync; upgrades happen
/// on the event loop (`tray::present` hops there itself).
static MAIN_WEAK: OnceLock<slint::Weak<AppWindow>> = OnceLock::new();
/// A `Present()` arrived before the window existed (simultaneous cold starts:
/// the DoNotQueue loser can call in while the winner is still initializing).
/// Drained once by `bind_window`.
static PENDING_PRESENT: AtomicBool = AtomicBool::new(false);
/// D-Bus surface the primary exports for activation, independent of MPRIS
/// (which only registers after session entry — a second launch while the
/// primary sits at the login window must still raise it).
struct SingleInstanceIface;
#[zbus::interface(name = "com.blitzfc.qbz.SingleInstance")]
impl SingleInstanceIface {
/// Raise whichever window is current (mini when the miniplayer is open,
/// else the main window). Runs on a zbus executor thread — never touch
/// Slint state here; `tray::present` routes through the event loop.
fn present(&self) {
match MAIN_WEAK.get() {
Some(weak) => crate::tray::present(weak),
None => PENDING_PRESENT.store(true, Ordering::SeqCst),
}
}
}
/// Publish the main window to the `Present()` handler. Call right after
/// `AppWindow::new()`; drains a Present that landed before the window existed.
pub fn bind_window(weak: slint::Weak<AppWindow>) {
let _ = MAIN_WEAK.set(weak);
if PENDING_PRESENT.swap(false, Ordering::SeqCst) {
if let Some(weak) = MAIN_WEAK.get() {
crate::tray::present(weak);
}
}
}
/// True = we are the primary instance (name acquired, or D-Bus unusable).
/// False = another instance owns the name; it has been asked to raise its
/// window and the caller should exit.
@@ -45,6 +95,10 @@ pub fn acquire_or_raise() -> bool {
fn probe() -> zbus::Result<bool> {
let conn = Connection::session()?;
// Export the Present interface BEFORE requesting the name: the moment a
// second launch sees Exists, the object must already be callable (no
// window where the name is owned but Present() isn't served yet).
conn.object_server().at(OBJECT_PATH, SingleInstanceIface)?;
let proxy = DBusProxy::new(&conn)?;
let name: WellKnownName<'_> = BUS_NAME.try_into().map_err(zbus::Error::from)?;
match proxy.request_name(name, RequestNameFlags::DoNotQueue.into())? {
@@ -53,19 +107,26 @@ fn probe() -> zbus::Result<bool> {
Ok(true)
}
// Exists (or the DO_NOT_QUEUE-unreachable InQueue): another instance
// runs. Best-effort raise — MPRIS may not be registered yet (login
// screen) or may be disabled; the duplicate still must not start.
// runs. Ask it to present itself; both calls are best-effort — the
// duplicate still must not start.
RequestNameReply::Exists | RequestNameReply::InQueue => {
// Full MPRIS name = "org.mpris.MediaPlayer2." + BUS_SUFFIX, and
// qbz-media-controls registers with BUS_SUFFIX = the app id
// (linux.rs), NOT "qbz".
let _ = conn.call_method(
Some("org.mpris.MediaPlayer2.com.blitzfc.qbz"),
"/org/mpris/MediaPlayer2",
Some("org.mpris.MediaPlayer2"),
"Raise",
&(),
);
let presented = conn
.call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "Present", &())
.is_ok();
if !presented {
// Older primary (≤2.0.x) without the SingleInstance interface:
// fall back to MPRIS Raise. Full MPRIS name =
// "org.mpris.MediaPlayer2." + BUS_SUFFIX, and
// qbz-media-controls registers with BUS_SUFFIX = the app id
// (linux.rs), NOT "qbz".
let _ = conn.call_method(
Some("org.mpris.MediaPlayer2.com.blitzfc.qbz"),
"/org/mpris/MediaPlayer2",
Some("org.mpris.MediaPlayer2"),
"Raise",
&(),
);
}
Ok(false)
}
}
+23
View File
@@ -215,6 +215,12 @@ pub(crate) fn show_window(weak: &slint::Weak<AppWindow>) {
if let Err(e) = w.show() {
log::error!("[tray] window show failed: {e}");
}
// On Wayland the hide destroyed the toplevel and this show recreates
// it from default attributes — re-apply the persisted size/position/
// maximized state before the surface maps, or it comes back at the
// .slint preferred size and the Resized handler persists the loss
// (#618). Same helper as the startup restore.
crate::restore_main_window_geometry(&w);
// Best-effort raise/focus the re-created window (the compositor has
// the final say on Wayland).
use i_slint_backend_winit::WinitWindowAccessor;
@@ -227,6 +233,23 @@ pub(crate) fn show_window(weak: &slint::Weak<AppWindow>) {
});
}
/// Raise whichever window is CURRENT: the miniplayer when it is open, else
/// the main window. Activation entry point for MPRIS `Raise` and the
/// single-instance `Present()` — `show_window` would force the MAIN window
/// up next to a live mini (visually a "second instance", #559/#618). The
/// mini/main decision reads thread_local miniplayer state, so it must run on
/// the event loop; callers may be on any thread.
pub(crate) fn present(weak: &slint::Weak<AppWindow>) {
let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || {
if crate::miniplayer::is_open() {
crate::miniplayer::present_mini();
} else {
show_window(&weak);
}
});
}
/// Hide the main window to the tray (surface destroyed; the process keeps
/// running, the ksni service stays alive on its own thread).
pub(crate) fn hide_window(weak: &slint::Weak<AppWindow>) {
+6
View File
@@ -515,6 +515,11 @@ pub struct UiPrefs {
pub window_x: i32,
#[serde(default = "default_window_pos")]
pub window_y: i32,
/// Last main-window maximized state (restored at startup and on every
/// tray / miniplayer re-show — #618). While true, `window_width/height`
/// keep the FLOATING size (the Resized handler skips persistence).
#[serde(default)]
pub window_maximized: bool,
/// Renderer tier override: `"auto"` (heuristic picks the best tier) |
/// `"wgpu"` | `"gl"` | `"software"`. Linux-only surface; read BEFORE the
/// window exists (`select_slint_backend`), so changes apply on restart.
@@ -734,6 +739,7 @@ impl Default for UiPrefs {
window_height: 0.0,
window_x: default_window_pos(),
window_y: default_window_pos(),
window_maximized: false,
renderer: default_renderer(),
renderer_auto_degraded: String::new(),
renderer_wgpu_alt: String::new(),