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:
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 "В центре внимания"
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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(§ion.albums);
|
||||
let flip_sections = |model: &slint::ModelRc<DiscoverSection>| {
|
||||
for s in 0..model.row_count() {
|
||||
if let Some(section) = model.row_data(s) {
|
||||
flip(§ion.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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user