From a16cf56c86f128f2d53c6f15f2ed14fd3d4aa0c1 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:10:19 -0600 Subject: [PATCH] feat(qt): playlist manager, folders, importer and the Library parity round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same /qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc --- crates/Cargo.lock | 1 + crates/qbz-qt/Cargo.toml | 6 + crates/qbz-qt/build.rs | 37 +- crates/qbz-qt/qml/Main.qml | 19 + crates/qbz-qt/qml/cards/PlaylistCollage.qml | 39 +- .../qbz-qt/qml/controls/FolderEditPanel.qml | 391 +++++ crates/qbz-qt/qml/controls/FolderModals.qml | 368 +++++ .../qbz-qt/qml/controls/PlaylistEditModal.qml | 353 +++++ .../qml/controls/PlaylistImportModal.qml | 688 +++++++++ .../qml/controls/PlaylistPickerModal.qml | 685 +++++++++ crates/qbz-qt/qml/controls/PmFolderIcon.qml | 117 ++ .../QbzAlphaStrip.qml} | 20 +- crates/qbz-qt/qml/controls/QbzContextMenu.qml | 9 + crates/qbz-qt/qml/controls/QbzLineEdit.qml | 13 + .../qbz-qt/qml/controls/QbzPrimaryButton.qml | 7 +- .../qbz-qt/qml/controls/QbzSectionHeader.qml | 95 +- crates/qbz-qt/qml/controls/QbzToolButton.qml | 39 +- crates/qbz-qt/qml/rows/TrackCols.qml | 26 +- crates/qbz-qt/qml/rows/TrackListHeader.qml | 14 +- crates/qbz-qt/qml/rows/TrackRow.qml | 305 +++- crates/qbz-qt/qml/shell/AppShell.qml | 49 +- crates/qbz-qt/qml/shell/HeaderBar.qml | 129 +- .../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 21 +- crates/qbz-qt/qml/shell/PlayerBar.qml | 23 +- crates/qbz-qt/qml/shell/QueuePanel.qml | 413 ++++- crates/qbz-qt/qml/shell/Sidebar.qml | 426 +++--- .../qbz-qt/qml/shell/SidebarFolderFlyout.qml | 230 +++ crates/qbz-qt/qml/shell/SidebarRowMenu.qml | 367 +++++ crates/qbz-qt/qml/shell/ViewModeMenu.qml | 12 + crates/qbz-qt/qml/theme/QbzIcon.qml | 18 +- crates/qbz-qt/qml/views/AlbumListHeader.qml | 7 + crates/qbz-qt/qml/views/AlbumListRow.qml | 45 +- crates/qbz-qt/qml/views/ArtistView.qml | 104 +- crates/qbz-qt/qml/views/LabelView.qml | 16 +- crates/qbz-qt/qml/views/LibraryView.qml | 1353 +++++++---------- crates/qbz-qt/qml/views/LocalAlbumView.qml | 212 ++- crates/qbz-qt/qml/views/LocalLibraryView.qml | 79 +- crates/qbz-qt/qml/views/MixView.qml | 17 +- crates/qbz-qt/qml/views/PlaylistListRow.qml | 24 +- .../qbz-qt/qml/views/PlaylistManagerView.qml | 358 +++++ crates/qbz-qt/qml/views/PlaylistView.qml | 230 ++- .../qbz-qt/qml/views/library/FeedGridCell.qml | 146 ++ .../qbz-qt/qml/views/library/FeedListRow.qml | 330 ++++ .../qml/views/library/LibraryAlbumsList.qml | 134 ++ .../qml/views/library/LibraryArtistsPanel.qml | 402 +++++ .../qml/views/library/LibraryToolbar.qml | 666 ++++++++ .../qbz-qt/qml/views/local/LocalAlbumsTab.qml | 2 +- .../qml/views/local/LocalArtistsTab.qml | 2 +- .../qbz-qt/qml/views/local/LocalToolbar.qml | 7 +- .../qbz-qt/qml/views/local/LocalTrackRow.qml | 68 +- .../qbz-qt/qml/views/local/LocalTracksTab.qml | 32 +- .../views/playlistmanager/PmActionButton.qml | 68 + .../views/playlistmanager/PmFolderCard.qml | 120 ++ .../views/playlistmanager/PmFolderChip.qml | 127 ++ .../views/playlistmanager/PmFolderMenu.qml | 168 ++ .../qml/views/playlistmanager/PmGridCard.qml | 203 +++ .../qml/views/playlistmanager/PmListRow.qml | 252 +++ .../views/playlistmanager/PmLocalBadge.qml | 58 + .../qml/views/playlistmanager/PmMenuRow.qml | 86 ++ .../qml/views/playlistmanager/PmPageHead.qml | 218 +++ .../qml/views/playlistmanager/PmToolbar.qml | 228 +++ .../views/playlistmanager/PmTreeFolderRow.qml | 102 ++ .../playlistmanager/PmTreePlaylistRow.qml | 128 ++ crates/qbz-qt/src/artwork_qt.rs | 46 +- crates/qbz-qt/src/bridge.rs | 20 +- crates/qbz-qt/src/folder_edit_bridge.rs | 245 +++ crates/qbz-qt/src/folder_edit_qt.rs | 644 ++++++++ crates/qbz-qt/src/folders_qt.rs | 206 +++ crates/qbz-qt/src/foryou_qt.rs | 2 +- crates/qbz-qt/src/icon_tint_qt.rs | 86 +- crates/qbz-qt/src/library_bridge.rs | 84 +- crates/qbz-qt/src/library_bulk.rs | 252 +++ crates/qbz-qt/src/library_db_qt.rs | 24 +- crates/qbz-qt/src/library_prefs.rs | 217 +++ crates/qbz-qt/src/library_qt.rs | 34 +- crates/qbz-qt/src/library_sidepanel.rs | 125 ++ crates/qbz-qt/src/local_album_actions.rs | 35 +- crates/qbz-qt/src/local_albums.rs | 180 ++- crates/qbz-qt/src/local_artist_match.rs | 585 +++++++ crates/qbz-qt/src/local_bridge.rs | 76 +- crates/qbz-qt/src/local_bridge_ops.rs | 15 + crates/qbz-qt/src/local_bulk.rs | 34 +- crates/qbz-qt/src/local_library_qt.rs | 6 +- crates/qbz-qt/src/local_playback.rs | 111 +- crates/qbz-qt/src/local_playlist_qt.rs | 978 ++++++++++++ crates/qbz-qt/src/local_state.rs | 31 +- crates/qbz-qt/src/main.rs | 391 ++++- crates/qbz-qt/src/myqbz_detail_qt.rs | 32 +- crates/qbz-qt/src/nav_qt.rs | 16 +- crates/qbz-qt/src/playback_qt.rs | 138 +- crates/qbz-qt/src/playlist_edit_bridge.rs | 192 +++ crates/qbz-qt/src/playlist_edit_qt.rs | 539 +++++++ crates/qbz-qt/src/playlist_import_bridge.rs | 153 ++ crates/qbz-qt/src/playlist_import_qt.rs | 960 ++++++++++++ crates/qbz-qt/src/playlist_manager_bridge.rs | 246 +++ crates/qbz-qt/src/playlist_manager_ops.rs | 285 ++++ crates/qbz-qt/src/playlist_manager_qt.rs | 909 +++++++++++ crates/qbz-qt/src/playlist_manager_rows.rs | 1042 +++++++++++++ crates/qbz-qt/src/playlist_picker_bridge.rs | 222 +++ crates/qbz-qt/src/playlist_picker_qt.rs | 720 +++++++++ crates/qbz-qt/src/playlist_qt.rs | 352 ++++- crates/qbz-qt/src/queue_bridge.rs | 65 + crates/qbz-qt/src/queue_qt.rs | 200 ++- crates/qbz-qt/src/settings_qt.rs | 25 + crates/qbz-qt/src/shell_bridge.rs | 39 +- crates/qbz-qt/src/sidebar_qt.rs | 443 +++++- crates/qbz-qt/src/sleep_timer_qt.rs | 90 ++ .../translations/de/LC_MESSAGES/qbz-ui.po | 51 + .../translations/en/LC_MESSAGES/qbz-ui.po | 51 + .../translations/es/LC_MESSAGES/qbz-ui.po | 51 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 51 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 49 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 51 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 51 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 53 + 115 files changed, 20778 insertions(+), 1637 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/FolderEditPanel.qml create mode 100644 crates/qbz-qt/qml/controls/FolderModals.qml create mode 100644 crates/qbz-qt/qml/controls/PlaylistEditModal.qml create mode 100644 crates/qbz-qt/qml/controls/PlaylistImportModal.qml create mode 100644 crates/qbz-qt/qml/controls/PlaylistPickerModal.qml create mode 100644 crates/qbz-qt/qml/controls/PmFolderIcon.qml rename crates/qbz-qt/qml/{views/local/AlphaStrip.qml => controls/QbzAlphaStrip.qml} (57%) create mode 100644 crates/qbz-qt/qml/shell/SidebarFolderFlyout.qml create mode 100644 crates/qbz-qt/qml/shell/SidebarRowMenu.qml create mode 100644 crates/qbz-qt/qml/views/PlaylistManagerView.qml create mode 100644 crates/qbz-qt/qml/views/library/FeedGridCell.qml create mode 100644 crates/qbz-qt/qml/views/library/FeedListRow.qml create mode 100644 crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml create mode 100644 crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml create mode 100644 crates/qbz-qt/qml/views/library/LibraryToolbar.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmActionButton.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmListRow.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmLocalBadge.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmMenuRow.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmPageHead.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmToolbar.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmTreeFolderRow.qml create mode 100644 crates/qbz-qt/qml/views/playlistmanager/PmTreePlaylistRow.qml create mode 100644 crates/qbz-qt/src/folder_edit_bridge.rs create mode 100644 crates/qbz-qt/src/folder_edit_qt.rs create mode 100644 crates/qbz-qt/src/folders_qt.rs create mode 100644 crates/qbz-qt/src/library_bulk.rs create mode 100644 crates/qbz-qt/src/library_prefs.rs create mode 100644 crates/qbz-qt/src/library_sidepanel.rs create mode 100644 crates/qbz-qt/src/local_artist_match.rs create mode 100644 crates/qbz-qt/src/local_playlist_qt.rs create mode 100644 crates/qbz-qt/src/playlist_edit_bridge.rs create mode 100644 crates/qbz-qt/src/playlist_edit_qt.rs create mode 100644 crates/qbz-qt/src/playlist_import_bridge.rs create mode 100644 crates/qbz-qt/src/playlist_import_qt.rs create mode 100644 crates/qbz-qt/src/playlist_manager_bridge.rs create mode 100644 crates/qbz-qt/src/playlist_manager_ops.rs create mode 100644 crates/qbz-qt/src/playlist_manager_qt.rs create mode 100644 crates/qbz-qt/src/playlist_manager_rows.rs create mode 100644 crates/qbz-qt/src/playlist_picker_bridge.rs create mode 100644 crates/qbz-qt/src/playlist_picker_qt.rs create mode 100644 crates/qbz-qt/src/sleep_timer_qt.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index efa99e34d..68c9b6144 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6636,6 +6636,7 @@ dependencies = [ "qbz-mixtape", "qbz-models", "qbz-offline-cache", + "qbz-playlist-import", "qbz-plex", "qbz-qobuz", "qbz-source", diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index d2105788b..db398192f 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -43,6 +43,12 @@ qbz-source = { path = "../qbz-source" } qbz-cast = { path = "../qbz-cast" } qbz-plex = { path = "../qbz-plex" } qbz-lyrics = { path = "../qbz-lyrics" } +# Playlist Importer: the provider scrapers (Spotify / Apple Music / Tidal / +# Deezer), the Qobuz matcher and the playlist creation. Headless and +# frontend-agnostic (ADR-006) — the same crate the Slint app uses +# (crates/qbz/Cargo.toml), so the two frontends share one importer instead of +# each growing its own. src/playlist_import_qt.rs drives it. +qbz-playlist-import = { path = "../qbz-playlist-import" } qbz-text-utils = { path = "../qbz-text-utils" } qbz-credentials = { path = "../qbz-credentials" } qbz-i18n = { path = "../qbz-i18n" } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 99fe3bc85..6009c14a1 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -51,7 +51,7 @@ fn main() { // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain // modules and must NOT be listed — only files that declare a // #[cxx_qt::bridge] mod belong in this array. - rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs"], + rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs"], qml_files: &[ "qml/LoginScreen.qml", "qml/Main.qml", @@ -69,8 +69,14 @@ fn main() { "qml/controls/CardMenu.qml", "qml/controls/CardOverlayButton.qml", "qml/controls/CardOverlayRow.qml", + "qml/controls/FolderEditPanel.qml", + "qml/controls/FolderModals.qml", "qml/controls/GroupHeader.qml", "qml/controls/MyQbzModals.qml", + "qml/controls/PlaylistEditModal.qml", + "qml/controls/PlaylistImportModal.qml", + "qml/controls/PlaylistPickerModal.qml", + "qml/controls/PmFolderIcon.qml", "qml/controls/QbzCircleAction.qml", "qml/controls/QbzConfirmModal.qml", "qml/controls/QbzContextMenu.qml", @@ -117,6 +123,8 @@ fn main() { "qml/shell/PlayerBar.qml", "qml/shell/QueuePanel.qml", "qml/shell/Sidebar.qml", + "qml/shell/SidebarFolderFlyout.qml", + "qml/shell/SidebarRowMenu.qml", "qml/shell/SidebarNowPlayingDock.qml", "qml/shell/AudioSettingsMenu.qml", "qml/shell/ViewModeMenu.qml", @@ -160,6 +168,14 @@ fn main() { "qml/shell/LyricsSyncEngine.qml", "qml/shell/NavGestureLayer.qml", "qml/controls/QbzCheckbox.qml", + // Library lane (B1-B5): the promoted A-Z strip + the + // per-surface bodies LibraryView.qml was split into. + "qml/controls/QbzAlphaStrip.qml", + "qml/views/library/FeedGridCell.qml", + "qml/views/library/FeedListRow.qml", + "qml/views/library/LibraryAlbumsList.qml", + "qml/views/library/LibraryArtistsPanel.qml", + "qml/views/library/LibraryToolbar.qml", "qml/controls/WarningBanner.qml", "qml/settings/AudioSettings.qml", "qml/settings/BlacklistSettings.qml", @@ -186,7 +202,6 @@ fn main() { "qml/shell/SpectrumBand.qml", "qml/shell/VizSettle.qml", "qml/views/LocalAlbumView.qml", - "qml/views/local/AlphaStrip.qml", "qml/views/local/FilterChip.qml", "qml/views/local/FolderSubcard.qml", "qml/views/local/LocalAlbumCollection.qml", @@ -220,6 +235,24 @@ fn main() { "qml/views/myqbz/MyQbzDetailRow.qml", "qml/views/myqbz/MyQbzDetailView.qml", "qml/views/myqbz/MyQbzGridView.qml", + // Playlist Manager (route "playlistmanager"): the router target + // plus the TWELVE files of its own module directory. A .qml + // missing from this array is absent from the qrc and fails its + // PARENT file at load with "… is not a type" — invisible to + // cargo check, and it takes the whole view down, not one row. + "qml/views/PlaylistManagerView.qml", + "qml/views/playlistmanager/PmActionButton.qml", + "qml/views/playlistmanager/PmFolderCard.qml", + "qml/views/playlistmanager/PmFolderChip.qml", + "qml/views/playlistmanager/PmFolderMenu.qml", + "qml/views/playlistmanager/PmGridCard.qml", + "qml/views/playlistmanager/PmListRow.qml", + "qml/views/playlistmanager/PmLocalBadge.qml", + "qml/views/playlistmanager/PmMenuRow.qml", + "qml/views/playlistmanager/PmPageHead.qml", + "qml/views/playlistmanager/PmToolbar.qml", + "qml/views/playlistmanager/PmTreeFolderRow.qml", + "qml/views/playlistmanager/PmTreePlaylistRow.qml", ], qrc_files: &qrc_refs, ..Default::default() diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index 0c52d5223..7f7cc3ea0 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -131,6 +131,25 @@ ApplicationWindow { QbzMyQbzAdd.boot() QbzDisco.boot() QbzBlacklist.boot() + QbzPlaylistPicker.boot() + // The folder modals. Its boot() also publishes both closed documents, + // which is what puts the icon-preset and colour-swatch constants on + // the QML side BEFORE the first open rather than one turn after it. + QbzFolderEdit.boot() + // The SHARED playlist editor (rename · description · offline-only · + // delete), opened from the manager's three delegates, the sidebar row + // menu and the playlist detail header's pencil. Its boot() publishes + // the closed document so the modal's parse sees the full shape. + QbzPlaylistEdit.boot() + // Playlist Manager. Its boot() also seeds the default document and + // kicks the cache-independent folder read, so the SIDEBAR's folder + // consumers have a list before the manager view has ever been opened. + QbzPlaylistManager.boot() + // Playlist Importer. Nothing to seed — the modal is closed until a + // sidebar row opens it — but WITHOUT this line every publish the + // importer makes (the whole progress panel, the log, the summary) is + // dropped on the floor with nothing logged on either side. + QbzPlaylistImport.boot() // Seed the maximized latch ONCE, imperatively — see its declaration for // why it must not be a binding on the bridge property. diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml index 5f9c32175..edca35934 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCollage.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -68,6 +68,29 @@ Item { // Mosaic seam, 1:1 with PlaylistCollage.slint. property real gap: 2 + /// `"mosaic"` (discover/PlaylistCollage.slint — the default everywhere) or + /// `"pm"` (primitives/PmCollage.slint, the Playlist Manager's thumbnail). + /// + /// They are genuinely different layout FUNCTIONS, which is the whole delta: + /// the mosaic splits 2 and 3 covers into cells, while the manager collapses + /// anything under four to ONE full-bleed cover and only splits at four, with + /// a 1 px seam instead of 2. Everything else — the url → path resolution + /// through QbzShell.sidebarArtworkWindow + QbzLibrary.libraryArtworkReady, + /// the 120 ms debounce, the MultiEffect rounding with the measured + /// maskThresholdMin 0.5 / maskSpreadAtMin 1.0, the three-way `_noShaders` + /// test and the recycled-delegate invalidation — is identical and must not + /// be forked into a second file. + property string layout: "mosaic" + + /// The manager pins its seam at 1 px (PmCollage.slint:27). + readonly property real effGap: root.layout === "pm" ? 1 : root.gap + + /// How many of the (up to four) tiles this layout actually draws. The + /// mosaic draws every cover it has; `"pm"` draws ONE below four. + readonly property int drawnTiles: root.layout === "pm" + ? (root.tiles.length >= 4 ? 4 : (root.tiles.length >= 1 ? 1 : 0)) + : root.tiles.length + QbzTheme { id: theme } // De-duplicated + capped at four (Tauri dedupes the same way: repeated @@ -127,9 +150,19 @@ Item { var w = root.width var h = root.height var n = root.tiles.length - if (w <= 0 || h <= 0 || n <= 0 || index >= n) + if (w <= 0 || h <= 0 || n <= 0 || index >= root.drawnTiles) return Qt.rect(0, 0, 0, 0) - var g = root.gap + var g = root.effGap + // Playlist Manager: 1-3 covers -> one full-bleed tile, 4+ -> a 2×2 grid + // (PmCollage.slint:39-80). + if (root.layout === "pm") { + if (n < 4) + return Qt.rect(0, 0, w, h) + var cwPm = (w - g) / 2 + var chPm = (h - g) / 2 + return Qt.rect((index % 2) * (cwPm + g), + Math.floor(index / 2) * (chPm + g), cwPm, chPm) + } if (n === 1) return Qt.rect(0, 0, w, h) if (n === 2) { @@ -276,7 +309,7 @@ Item { delegate: RoundedImage { required property int index readonly property rect cell: root.cellOf(index) - visible: index < root.tiles.length + visible: index < root.drawnTiles x: cell.x y: cell.y width: cell.width diff --git a/crates/qbz-qt/qml/controls/FolderEditPanel.qml b/crates/qbz-qt/qml/controls/FolderEditPanel.qml new file mode 100644 index 000000000..b95868d20 --- /dev/null +++ b/crates/qbz-qt/qml/controls/FolderEditPanel.qml @@ -0,0 +1,391 @@ +// FolderEditPanel — the BODY of the folder editor +// (primitives/FolderEditModal.slint:66-266): name field, the seven icon-preset +// tiles plus the custom-image tile, the eleven colour swatches, the "Hide from +// sidebar" row and the Delete / Save footer. +// +// The chrome around it — scrim, card, title, close X, Escape scope and the +// delete confirm — belongs to FolderModals.qml, which mounts this. The split +// is declared up front (rule 2): 04 §5.2's single ~380-line FolderModals is +// rejected because the two panels plus the confirm plus the drafts land nearer +// 600, and the port's precedents for "one file, several panels" are +// MyQbzModals.qml at 523 lines for three SIMPLER panels and +// PlaylistPickerModal.qml at 685. +// +// ── THE DRAFTS LIVE HERE ─────────────────────────────────────────────────── +// Name, preset, colour and the hidden switch are QML-LOCAL and reach Rust only +// as arguments to `editSave(name, preset, color, hidden)`. That is the port's +// standing rule (MyQbzModals.qml's header: "TEXT STATE IS QML-LOCAL ... every +// submit invokable takes the value as an argument") and it is why the bridge +// has no `selectPreset` / `selectColor` / `toggleHidden` invokables: a colour +// click has no business making a round trip. +// +// Preset selection is the ONE draft write with a Rust side effect — it also +// calls `QbzFolderEdit.clearImage()`, because picking a preset means dropping +// the custom image, and the image is Rust-owned (the file dialog is native). +// That call is also what makes the reference's declared-but-never-called +// `clear-image` callback (main.rs:5611-5619) live. +// +// ── SEEDING ──────────────────────────────────────────────────────────────── +// `doc.name` / `iconPreset` / `iconColor` / `isHidden` are SEEDS, consumed +// once on the open transition. They are NOT bound: Rust republishes editJson +// on every `busy` flip and on every image pick, and a bound field would throw +// away whatever the user had typed each time. `hasCustomImage` / +// `customImagePath` / `busy` ARE read live, because Rust owns them. +// +// ── REUSED, NOT REDRAWN ──────────────────────────────────────────────────── +// QbzLineEdit the name field (its width MUST be set — the plain arm is +// a fixed 240, where the Slint LineEdit stretches). +// QbzToggle the hidden row. The reference uses QbzToggle too +// (FolderEditModal.slint:214). A second switch is exactly +// the fork rule 5 forbids. +// QbzPrimaryButton the Save button. The reference's `#ffffffd6` hover on an +// accent fill and its hardcoded `#ffffff` label are NOT +// copied — that white-wash is the white-button-on-artwork +// treatment and it washes out on an accent fill (illegible +// on the 11 light themes). The sibling +// LibFolderEditModal.slint:376 already uses accent-hover. +// Contract §10 Q5 records the exact literals if parity is +// ever wanted. +// PmFolderIcon NOT reused for the preset tiles: a tile is a +// surface-elevated square with a theme-tinted 17px glyph, +// where PmFolderIcon is a saturated colour tile with a +// fixed-white glyph at half its own size. Only the id -> +// glyph chain is common, and it is seven ternary arms; a +// shared .js would need its own build.rs entry for that. +// Keep the two in step — PmFolderIcon.qml carries the +// same chain. +// Delete button hand-drawn, because it is a GHOST danger button and the +// port's filled danger control (QbzPrimaryButton +// `destructive`) is a different affordance. SettingsButton +// has `danger`, but it is a settings-row control with its +// own leading-glyph geometry. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + /// The parsed QbzFolderEdit.editJson (§4.5). The HOST parses it once; a + /// second `JSON.parse` in here would double the work on every republish. + property var doc: ({}) + /// The panel never raises the confirm itself — FolderModals owns it, so + /// the confirm can stack over this panel with its own z (D21). + signal deleteRequested() + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // --- Drafts ----------------------------------------------------------- + property string draftName: "" + property string draftPreset: "folder" + property string draftColor: "" + property bool draftHidden: false + + readonly property bool busy: root.doc.busy === true + readonly property bool isCreate: (root.doc.id || "") === "" + readonly property bool canSave: root.draftName.trim() !== "" && !root.busy + + /// Consume the seeds. Called by the host on the open transition — never on + /// a plain republish. + function seed() { + root.draftName = root.doc.name || "" + root.draftPreset = root.doc.iconPreset || "folder" + root.draftColor = root.doc.iconColor || "" + root.draftHidden = root.doc.isHidden === true + } + + /// Focus the name field. Split out so the host can call it AFTER the panel + /// is actually visible — an invisible item cannot take active focus. + function focusName() { nameField.focusField() } + + /// The id -> glyph chain, shared with PmFolderIcon.qml. "folder" is not a + /// case in the reference's chain at all and falls through to the default + /// arm, which is also the unknown-id arm; "headphones" draws audio-lines. + /// Both are upstream intent (§4.5) — do not "fix" either. + function presetGlyph(id) { + return id === "heart" ? "heart" + : id === "star" ? "star" + : id === "music" ? "music" + : id === "disc" ? "disc" + : id === "library" ? "library" + : id === "headphones" ? "audio-lines" + : "folder" + } + + implicitHeight: col.implicitHeight + + Column { + id: col + width: parent.width + spacing: 16 + + // ================= Name ========================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Folder name") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + QbzLineEdit { + id: nameField + // MUST be explicit: the plain arm is a fixed `width: 240` + // (QbzLineEdit.qml:62), where the Slint LineEdit stretches to + // the panel. + width: parent.width + text: root.draftName + placeholder: root.t("Enter folder name") + onEdited: function (value) { root.draftName = value } + // `accepted`, NEVER `committed`: committed also fires on blur, + // and closing the modal blurs the field, which would submit a + // second time (MyQbzModals.qml:34-36 documents the incident). + onAccepted: function (value) { + root.draftName = value + if (root.draftName.trim() !== "" && !root.busy) + QbzFolderEdit.editSave(root.draftName, root.draftPreset, + root.draftColor, root.draftHidden) + } + } + } + + // ================= Icon ========================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Icon") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Row { + spacing: 8 + + Repeater { + model: root.doc.presets || [] + delegate: Rectangle { + id: presetTile + required property var modelData + // A preset is only "the" selection while there is no + // custom image — the image wins, exactly as in the + // reference (`custom-image-path == "" && ...`). + readonly property bool active: + root.doc.hasCustomImage !== true + && root.draftPreset === presetTile.modelData + width: 40 + height: 40 + radius: 8 + color: presetTile.active + ? theme.accent + : (presetArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated) + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: root.presetGlyph(presetTile.modelData) + // On an accent fill the port goes through the + // measured on-accent selector, not a hardcoded + // #ffffff (QbzTheme.qml "ON AN ACCENT FILL"). + tintName: presetTile.active ? theme.accentGlyphTint : "secondary" + } + MouseArea { + id: presetArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.draftPreset = presetTile.modelData + // The image is RUST-owned (native dialog), so + // dropping it is the one draft write that has + // to make a round trip. + QbzFolderEdit.clearImage() + } + } + } + } + + // --- The custom-image tile. + Rectangle { + id: imageTile + readonly property bool active: root.doc.hasCustomImage === true + width: 40 + height: 40 + radius: 8 + color: imageTile.active + ? theme.accent + : (imageArea.containsMouse ? theme.surfaceHover + : theme.surfaceElevated) + // No `clip: true` — it is a rectangular scissor and does + // not follow `radius`. RoundedImage masks its own corners. + RoundedImage { + anchors.fill: parent + visible: imageTile.active + source: imageTile.active ? (root.doc.customImagePath || "") : "" + radius: 8 + fit: "crop" + } + QbzIcon { + anchors.centerIn: parent + visible: !imageTile.active + width: 17 + height: 17 + name: "image-plus" + tintName: "secondary" + } + MouseArea { + id: imageArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzFolderEdit.pickImage() + } + } + } + } + + // ================= Colour ======================================= + Column { + width: parent.width + spacing: 6 + Text { + text: root.t("Color") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + // ELEVEN swatches, ONE row — 11 * 28 + 10 * 8 = 388 px. The + // .slint comment at :167 saying "Two rows" is wrong about its own + // grid. The wrap arithmetic is kept anyway, so a longer constant + // list wraps instead of overflowing the card. + Item { + id: swatchGrid + readonly property int columns: 11 + readonly property int sw: 28 + readonly property int gap: 8 + readonly property int count: (root.doc.swatches || []).length + readonly property int rows: Math.ceil(swatchGrid.count / swatchGrid.columns) + width: parent.width + height: swatchGrid.rows * swatchGrid.sw + + Math.max(0, swatchGrid.rows - 1) * swatchGrid.gap + + Repeater { + model: root.doc.swatches || [] + delegate: Rectangle { + id: swatch + required property int index + required property var modelData + readonly property string value: swatch.modelData.value || "" + readonly property bool active: root.draftColor === swatch.value + x: (swatch.index % swatchGrid.columns) * (swatchGrid.sw + swatchGrid.gap) + y: Math.floor(swatch.index / swatchGrid.columns) + * (swatchGrid.sw + swatchGrid.gap) + width: swatchGrid.sw + height: swatchGrid.sw + radius: swatchGrid.sw / 2 + color: swatch.modelData.isAccent === true ? theme.accent : swatch.value + // A fixed white ring: the host is a saturated swatch + // under every theme, so a theme-following border would + // vanish on half of them. + border.width: swatch.active ? 2 : 0 + border.color: "#ffffff" + QbzIcon { + anchors.centerIn: parent + visible: swatch.active + width: 14 + height: 14 + name: "check" + tintName: "white" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // "" IS a value ("use the theme accent") and it + // reaches the DB as "" — see contract D24. + onClicked: root.draftColor = swatch.value + } + } + } + } + } + + // ================= Hidden ======================================= + Item { + width: parent.width + height: 22 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 50) + text: root.t("Hide from sidebar") + color: theme.textSecondary + font.pixelSize: theme.fontBody + elide: Text.ElideRight + } + QbzToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.draftHidden + enabled: !root.busy + // QbzToggle never self-flips; the owner of the state does. + onToggled: function (value) { root.draftHidden = value } + } + } + + // ================= Footer ======================================= + Item { + width: parent.width + height: 36 + + // Delete — edit mode only. A ghost danger button: fill on hover + // only, 1px danger border, danger label. theme.danger / + // theme.dangerHover ARE the reference's #ef4444 / #ef444433 under + // the default theme (the engine defaults danger to rgb(ef,44,44) + // and derives danger_hover as a 0.2 tint, i.e. alpha 0x33) and the + // correct colours under the other 35. + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: !root.isCreate + width: delLabel.implicitWidth + 28 + height: 36 + radius: 8 + color: (delArea.containsMouse && !root.busy) ? theme.dangerHover : "transparent" + border.width: 1 + border.color: theme.danger + opacity: root.busy ? 0.5 : 1.0 + Text { + id: delLabel + anchors.centerIn: parent + text: root.t("Delete") + color: theme.danger + font.pixelSize: 15 + font.weight: theme.weightMedium + } + MouseArea { + id: delArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!root.busy) root.deleteRequested() + } + } + + // Save. The label stays "Save" in create mode too — that is the + // reference (FolderEditModal.slint:246); the small sidebar create + // panel is the one that says "Create". + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.t("Save") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canSave + onClicked: QbzFolderEdit.editSave(root.draftName, root.draftPreset, + root.draftColor, root.draftHidden) + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/FolderModals.qml b/crates/qbz-qt/qml/controls/FolderModals.qml new file mode 100644 index 000000000..213d3319c --- /dev/null +++ b/crates/qbz-qt/qml/controls/FolderModals.qml @@ -0,0 +1,368 @@ +// FolderModals — the TWO folder panels and their delete confirm, in one file, +// as sibling self-gating overlays (the MyQbzModals.qml shape): +// +// CREATE primitives/CreateFolderModal.slint (109 lines) — a name field and +// a Create button, opened from the sidebar's "..." -> New folder. +// Panel width min(host - 80, 400). +// EDITOR primitives/FolderEditModal.slint (277) — the full editor, opened +// from every folder card / chip / tree row (D7) and from the +// sidebar row menu, and by the manager toolbar's own "New folder" +// in CREATE mode (`id == ""`). Panel width min(host - 80, 480). Its +// body is FolderEditPanel.qml; this file owns the chrome. +// CONFIRM QbzConfirmModal, declared LAST so it stacks over the editor, with +// its own z: 3100. +// +// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after +// MyQbzModals and before QbzToast, with no `z` — that file's convention is +// declaration order, and ADR-009's ">= 3000" is satisfied structurally +// (AppShell.qml:533-563). 05 §5.4's "mount the create modal inside Sidebar.qml" +// is rejected: the sidebar animates to width 0 with clip: true and already +// force-closes its own flyout on a state change, so a modal parented into it +// disappears with the sidebar. +// +// ── TWO DRAFTS, NOT ONE ──────────────────────────────────────────────────── +// `draftCreateName` belongs to the CREATE panel and is reset on its OWN open +// transition; the editor's four drafts live inside FolderEditPanel. Sharing +// one draft set between them (04 §5.6) makes "New folder" reopen holding +// whatever was last typed in the editor. +// +// ── `busy` HOLDS THE PANEL OPEN ──────────────────────────────────────────── +// Neither panel closes itself. Rust closes them, and only after the write +// landed (contract D22) — a failed create keeps the name on screen and toasts, +// where the reference closes first and loses both the name and the error. +// +// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ───────────────────── +// QbzConfirmModal gets away with a sibling FocusScope because it has no text +// field. Here the name input takes active focus, and QML propagates an +// unhandled key up the focused item's PARENT chain — a sibling scope is never +// on that chain, so Escape would be dead the moment the field is focused. +// Each panel is therefore wrapped in its scope. QbzLineEdit's TextInput leaves +// Escape unaccepted on the plain arm (QbzLineEdit.qml:177-184), so it reaches +// us. Escape closes the innermost open thing: while the confirm is up it holds +// focus and handles its own. +// +// ── ENTER ────────────────────────────────────────────────────────────────── +// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on +// focus-out, and closing the modal blurs the field, which would submit the +// same value twice (MyQbzModals.qml:34-36). + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // --- Documents. Guarded parses: a raw JSON.parse in a binding throws on + // the pre-publish frame (PlaylistView.qml:55-61). + readonly property var createDoc: { + try { return JSON.parse(QbzFolderEdit.createJson) } catch (e) { return ({}) } + } + readonly property var editDoc: { + try { return JSON.parse(QbzFolderEdit.editJson) } catch (e) { return ({}) } + } + + readonly property bool createOpen: root.createDoc.open === true + readonly property bool creating: root.createDoc.creating === true + readonly property bool editOpen: root.editDoc.open === true + /// Rust REFUSES `createClose()` / `editClose()` while a write is in flight + /// (D22 — `busy` holds the panel open). The close X must therefore SHOW + /// that it is inert for that window, or it is a control that renders and + /// no-ops. Same treatment the panel's own Delete button already carries. + readonly property bool editBusy: root.editDoc.busy === true + + visible: root.createOpen || root.editOpen + enabled: root.visible + + // ================================================================== + // PANEL 1 — Create folder (CreateFolderModal.slint) + // ================================================================== + property string draftCreateName: "" + readonly property bool canCreate: root.draftCreateName.trim() !== "" && !root.creating + + onCreateOpenChanged: { + if (root.createOpen) { + // Its OWN draft, reset on its OWN open transition. + root.draftCreateName = "" + createScope.forceActiveFocus() + createField.focusField() + } + } + + FocusScope { + id: createScope + anchors.fill: parent + visible: root.createOpen + enabled: root.createOpen + + // Taken HERE rather than in the open handler: an invisible item cannot + // hold active focus, and a handler on the document property is not + // ordered against the `visible` binding it shares a frame with. + onVisibleChanged: if (createScope.visible) createScope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + QbzFolderEdit.createClose() + event.accepted = true + } + + // `radius` is load-bearing: this Item fills AppShell's ROUNDED content + // frame and Qt Quick's clip is a rectangular scissor, so an opaque + // full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED — + // Slint is #RRGGBBAA, Qt is #AARRGGBB. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzFolderEdit.createClose() + } + } + + Rectangle { + width: Math.min(root.width - 80, 400) + height: createCol.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // FIRST child: swallows clicks so they never reach the scrim. + MouseArea { anchors.fill: parent } + + Column { + id: createCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // --- Title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: root.t("New folder") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.creating ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: createCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: createCloseArea + anchors.fill: parent + // `createClose()` is refused mid-create, so the + // affordance goes with it rather than lying. + enabled: !root.creating + hoverEnabled: !root.creating + cursorShape: root.creating ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzFolderEdit.createClose() + } + } + } + + // --- Name. No label above it — the reference has none here. + QbzLineEdit { + id: createField + width: parent.width + text: root.draftCreateName + placeholder: root.t("Folder name") + onEdited: function (value) { root.draftCreateName = value } + onAccepted: function (value) { + root.draftCreateName = value + if (root.canCreate) + QbzFolderEdit.createSubmit(root.draftCreateName) + } + } + + // --- Footer. + Item { + width: parent.width + height: 36 + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.creating ? root.t("Creating...") : root.t("Create") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canCreate + onClicked: QbzFolderEdit.createSubmit(root.draftCreateName) + } + } + } + } + } + + // ================================================================== + // PANEL 2 — The folder editor (FolderEditModal.slint) + // ================================================================== + // Seeds are consumed ONCE per open, and again when the editor is pointed + // at a different folder without closing. A plain republish (a busy flip, a + // picked image) must NOT reseed — it would throw away what the user typed. + readonly property string editSeedKey: + (root.editOpen ? "1" : "0") + "|" + (root.editDoc.id || "") + + onEditSeedKeyChanged: { + if (root.editOpen) { + editPanel.seed() + editScope.forceActiveFocus() + editPanel.focusName() + } + } + onEditOpenChanged: { + // A sub-modal must never outlive its host panel. + if (!root.editOpen) + deleteConfirm.close() + } + + FocusScope { + id: editScope + anchors.fill: parent + visible: root.editOpen + enabled: root.editOpen + + onVisibleChanged: if (editScope.visible) editScope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + // The confirm holds focus while it is up and handles its own Esc, + // so reaching here means the panel is the innermost open thing. + QbzFolderEdit.editClose() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzFolderEdit.editClose() + } + } + + Rectangle { + width: Math.min(root.width - 80, 480) + height: editCol.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { anchors.fill: parent } + + Column { + id: editCol + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // --- Title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + // "" is CREATE mode — the manager toolbar's New folder + // opens this same editor with no id. + text: (root.editDoc.id || "") === "" ? root.t("New folder") + : root.t("Edit folder") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.editBusy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: editCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: editCloseArea + anchors.fill: parent + // `editClose()` is refused while a save or delete + // is in flight — see `editBusy`. + enabled: !root.editBusy + hoverEnabled: !root.editBusy + cursorShape: root.editBusy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzFolderEdit.editClose() + } + } + } + + FolderEditPanel { + id: editPanel + width: parent.width + doc: root.editDoc + onDeleteRequested: deleteConfirm.open() + } + } + } + } + + // ================================================================== + // The delete confirm — LAST, so it stacks over the editor (D21). + // ================================================================== + // The reference raises a NATIVE rfd message box with untranslated &str + // (main.rs:5724); this is the in-app surface the port already has, and its + // two strings are new msgids. The body interpolates the LIVE draft name, + // matching the reference's `get_name()` — rename the folder, then delete + // it without saving, and the prompt still names what is on screen. + QbzConfirmModal { + id: deleteConfirm + anchors.fill: parent + danger: true + title: root.t("Delete folder") + body: root.t("Delete the folder “{}”? Its playlists move back to the root.") + .replace("{}", editPanel.draftName) + confirmLabel: root.t("Delete") + onConfirmed: { + QbzFolderEdit.editDeleteConfirmed() + // Hand focus back so Esc keeps working on the panel that is still + // up while the delete is in flight. + if (root.editOpen) + editScope.forceActiveFocus() + } + onCancelled: { + if (root.editOpen) + editScope.forceActiveFocus() + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml new file mode 100644 index 000000000..1ffe0194e --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -0,0 +1,353 @@ +// PlaylistEditModal — THE playlist editor (primitives/EditPlaylistModal.slint): +// rename, description, the offline-only flag and delete, for BOTH kinds of +// playlist (`local:` and a Qobuz u64) and for every surface that offers +// "Edit playlist": +// +// * the manager's PmGridCard / PmListRow / PmTreePlaylistRow pencils, +// * qml/shell/SidebarRowMenu.qml's "Edit playlist" row, +// * qml/views/PlaylistView.qml's header pencil (contract block 7) — whose +// inline rename+delete Popup this file REPLACES. That popup could express +// neither a description nor the offline-only flag, and it carried a delete +// arm, so the replacement has to cover both verbs or deleting it removes +// working functionality (D20). +// +// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after +// FolderModals and before QbzToast, with no `z` — that file's convention is +// DECLARATION ORDER, and ADR-009's ">= 3000" is satisfied structurally. +// +// ── NO INPUTS ────────────────────────────────────────────────────────────── +// It reads QbzPlaylistEdit.editJson (§4.6) and owns its own drafts. QML has no +// lexical scope across files, so everything it reads is declared on its own +// root or comes from a singleton. +// +// ── THE DRAFTS LIVE HERE, THE ID DOES NOT ────────────────────────────────── +// Name, description and the offline-only checkbox are QML-LOCAL and reach Rust +// only as arguments to `save(name, description, offlineOnly)` (MyQbzModals.qml's +// standing rule). The id being edited is RUST state and is never echoed back, +// so a republish can never make this modal save under the wrong playlist. +// +// ── `descLoaded` IS NOT COSMETIC ─────────────────────────────────────────── +// When Rust could not resolve the real description, the field is NOT RENDERED +// and `save` sends `None`, i.e. "leave the stored description alone". A field +// the user never saw can never overwrite stored data. The reference seeds "" +// and always writes it back, which deletes the description on every rename +// (§5.2). Do not "simplify" this into an always-visible empty field. +// +// ── `busy` HOLDS THE MODAL OPEN ──────────────────────────────────────────── +// The modal never closes itself. Rust closes it, and only after the write +// landed (D22) — a failed rename keeps what the user typed on screen and +// toasts. `close()` is REFUSED while busy, so the close X and the scrim go +// inert for that window rather than rendering and no-opping. +// +// ── NO DELETE CONFIRM, ON PURPOSE ────────────────────────────────────────── +// The reference's danger button calls `EditPlaylistActions.delete()` straight +// (EditPlaylistModal.slint:150-155), unlike the FOLDER editor, whose reference +// raises a native rfd message box that this port replaced with +// QbzConfirmModal. Adding one here would be a redesign, not a port. +// +// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ───────────────────── +// The name field takes active focus and QML propagates an unhandled key up the +// focused item's PARENT chain, so a sibling scope would be dead the moment the +// field is focused (the FolderModals.qml note). QbzLineEdit's TextInput leaves +// Escape unaccepted on the plain arm, so it reaches us. +// +// ── ENTER ────────────────────────────────────────────────────────────────── +// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on +// focus-out, and closing the modal blurs the field, which would rename twice +// (MyQbzModals.qml:34-36). +// +// ── REUSED, NOT REDRAWN ──────────────────────────────────────────────────── +// QbzLineEdit the name field. Its width MUST be set — the plain arm is +// a fixed `width: 240` (QbzLineEdit.qml:62) where the Slint +// LineEdit stretches to the panel. +// QbzTextArea the description body, the port's multiline input (its one +// prior call site is MyQBZ's edit modal, the same job). +// QbzCheckbox the offline-only row. Already 18x18 r4, accent when +// checked, 2px muted ring when not, and it never self-flips +// — exactly what a QML-local draft needs (§5.21). +// QbzPrimaryButton Save. The reference's `#ffffffd6` hover on an accent fill +// and its hardcoded `#ffffff` label are NOT copied; both +// wash out on the light palettes. +// Delete button hand-drawn, byte-for-byte the ghost danger button +// FolderEditPanel.qml uses, for the same reason: the port's +// filled danger control is a different affordance. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // Guarded parse: a raw JSON.parse in a binding throws on the pre-publish + // frame (PlaylistView.qml:55-61). + readonly property var doc: { + try { return JSON.parse(QbzPlaylistEdit.editJson) } catch (e) { return ({}) } + } + + readonly property bool editOpen: root.doc.open === true + readonly property bool busy: root.doc.busy === true + readonly property bool isLocal: root.doc.isLocal === true + readonly property bool descLoaded: root.doc.descLoaded === true + + // --- Drafts ----------------------------------------------------------- + property string draftName: "" + property string draftDescription: "" + property bool draftOfflineOnly: false + + readonly property bool canSave: root.draftName.trim() !== "" && !root.busy + + visible: root.editOpen + enabled: root.visible + + // Seeds are consumed ONCE per open, and again when the editor is pointed + // at a different playlist without closing. A plain republish (the `busy` + // flip on save) must NOT reseed — it would throw away what the user typed. + readonly property string seedKey: + (root.editOpen ? "1" : "0") + "|" + (root.doc.id || "") + + onSeedKeyChanged: { + if (root.editOpen) { + root.draftName = root.doc.name || "" + root.draftDescription = root.doc.description || "" + root.draftOfflineOnly = root.doc.offlineOnly === true + scope.forceActiveFocus() + nameField.focusField() + } + } + + function submit() { + if (root.canSave) + QbzPlaylistEdit.save(root.draftName, root.draftDescription, + root.draftOfflineOnly) + } + + FocusScope { + id: scope + anchors.fill: parent + visible: root.editOpen + enabled: root.editOpen + + // Taken HERE rather than only in the open handler: an invisible item + // cannot hold active focus, and a handler on the document property is + // not ordered against the `visible` binding it shares a frame with. + onVisibleChanged: if (scope.visible) scope.forceActiveFocus() + + Keys.onEscapePressed: function (event) { + QbzPlaylistEdit.close() + event.accepted = true + } + + // `radius` is load-bearing: this Item fills AppShell's ROUNDED content + // frame and Qt Quick's clip is a rectangular scissor, so an opaque + // full-bleed child paints into the frame's four bezel corners + // (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED — + // Slint is #RRGGBBAA, Qt is #AARRGGBB. + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistEdit.close() + } + } + + Rectangle { + width: Math.min(root.width - 80, 420) + height: col.implicitHeight + 40 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // FIRST child: swallows clicks so they never reach the scrim. + MouseArea { anchors.fill: parent } + + Column { + id: col + x: 20 + y: 20 + width: parent.width - 40 + spacing: 16 + + // ================= Title + close X ====================== + Item { + width: parent.width + height: 28 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - 28) + text: root.t("Edit playlist") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + opacity: root.busy ? 0.5 : 1.0 + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + // `close()` is refused while a write is in flight, + // so the affordance goes with it rather than lying. + enabled: !root.busy + hoverEnabled: !root.busy + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: QbzPlaylistEdit.close() + } + } + } + + // ================= Name ================================= + // No label above it — the reference has none here, only the + // placeholder (EditPlaylistModal.slint:66). + QbzLineEdit { + id: nameField + width: parent.width + text: root.draftName + placeholder: root.t("Playlist name") + enabled: !root.busy + onEdited: function (value) { root.draftName = value } + onAccepted: function (value) { + root.draftName = value + root.submit() + } + } + + // ================= Description ========================== + // Rendered ONLY when Rust resolved the real one. A Column + // skips invisible children, so this leaves no gap. + Column { + width: parent.width + spacing: 6 + visible: root.descLoaded + Text { + text: root.t("Description") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + QbzTextArea { + width: parent.width + height: 90 + text: root.draftDescription + enabled: !root.busy + onEdited: function (value) { root.draftDescription = value } + } + } + + // ================= Offline-only ========================= + // LOCAL playlists only — the flag is a `local_playlists` + // column with no Qobuz analogue. Unmark it to make "Upload to + // Qobuz" available (D8). + // An Item with a FIXED height, not a Row: a Row's height comes + // from its tallest child, so anchoring a child to + // `parent.verticalCenter` inside one is a binding loop. Same + // shape FolderEditPanel.qml's hidden row uses. + Item { + width: parent.width + height: 20 + visible: root.isLocal + // 20x20 wrapper: the larger hit target the reference's + // TouchArea gives the 18px box (§5.21). + Item { + id: offlineHit + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + QbzCheckbox { + anchors.centerIn: parent + checked: root.draftOfflineOnly + enabled: !root.busy + // QbzCheckbox never self-flips; the owner of the + // state does. + onToggled: root.draftOfflineOnly = !root.draftOfflineOnly + } + } + Text { + anchors.left: offlineHit.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.t("Offline-only playlist") + color: theme.textSecondary + font.pixelSize: 15 + elide: Text.ElideRight + } + } + + // ================= Footer =============================== + Item { + width: parent.width + height: 36 + + // Delete — a ghost danger button: fill on hover only, 1px + // danger border, danger label. theme.danger / + // theme.dangerHover ARE the reference's #ef4444 / + // #ef444433 under the default theme (the engine defaults + // danger to rgb(ef,44,44) and derives danger_hover as a + // 0.2 tint, i.e. alpha 0x33) and the correct colours under + // the other 35. + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: delLabel.implicitWidth + 28 + height: 36 + radius: 8 + color: (delArea.containsMouse && !root.busy) ? theme.dangerHover + : "transparent" + border.width: 1 + border.color: theme.danger + opacity: root.busy ? 0.5 : 1.0 + Text { + id: delLabel + anchors.centerIn: parent + text: root.t("Delete") + color: theme.danger + font.pixelSize: 15 + font.weight: theme.weightMedium + } + MouseArea { + id: delArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.busy ? Qt.ArrowCursor + : Qt.PointingHandCursor + onClicked: if (!root.busy) QbzPlaylistEdit.deletePlaylist() + } + } + + QbzPrimaryButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: root.t("Save") + btnHeight: 36 + labelSize: 15 + btnEnabled: root.canSave + onClicked: root.submit() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml new file mode 100644 index 000000000..c1ca3c44b --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml @@ -0,0 +1,688 @@ +// "Import Playlist" — QML port of +// crates/qbz-ui/ui/primitives/PlaylistImportModal.slint (itself a 1:1 port of +// Tauri's PlaylistImportModal.svelte). +// +// Two-step flow: URL entry -> provider auto-detect (RUST-side, one round trip +// per keystroke, exactly as the reference does) -> fetch preview -> rename + +// optional folder -> import with a live progress bar, a status line and an +// append-only log, then an in-panel Summary block. Every interpolated string +// arrives PRE-FORMATTED in the document (playlist_import_qt.rs); this file +// renders them verbatim and never pluralises or interpolates a count itself. +// +// Mounted ONCE in AppShell next to PlaylistPickerModal, NOT inside the sidebar +// (05 §5.8.5): the two surfaces that open it — the sidebar `...` menu and the +// closed-sidebar flyout — have no lifetime guarantee across a navigation, and +// closing the modal must never cancel an in-flight import. +// +// CLOSE SEMANTICS (reference §1.8): the footer Close disables while loading +// (Tauri-verbatim); the header X and the scrim stay clickable mid-import — +// closing only hides the UI, the tokio task runs to completion and still +// toasts, assigns the folder and refreshes the sidebar. +// +// OFFLINE is gated in TWO layers (05 §5.8.7), both required: the sidebar row is +// inert while `QbzSession.offline`, AND this modal carries its own banner and a +// `canFetch` that Rust computes as `provider detected && !offline`. The URL +// field stays enabled — only the fetch is refused. +// +// PROVIDER MARKS. Spotify / Apple Music / Deezer are self-coloured marks and +// render UNTINTED out of qml/assets/brand/, the rule SourceIcon.qml calls +// load-bearing. Tidal's upstream asset is a black `fill` with no colour to +// lose, so it goes through QbzIcon at `textPrimary` instead: the reference +// hardcodes it to white (PlaylistImportModal.slint:271-279) which is +// illegible on the 11 light themes, and a monochrome glyph is exactly what the +// tint table is for. The header's Qobuz mark is the port's existing +// `qobuz-logo-filled.svg` brand asset for the same reason — the reference tints +// its outline logo white, which disappears on a light `surface-card`. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + // An unopened overlay is an invisible, non-interactive Item and costs + // nothing (the AppShell mount comment states the contract). + visible: doc.open === true + + readonly property var doc: JSON.parse(QbzPlaylistImport.importJson) + readonly property var logRows: doc.log || [] + readonly property bool loading: doc.loading === true + readonly property bool showPreview: doc.showPreview === true + readonly property bool importCompleted: doc.importCompleted === true + readonly property var folderOptions: doc.folderOptions || [] + + // Bumped by every QbzPlaylistImport.open(). The reference remounts the + // whole Svelte component per open and Slint reproduces it by clearing 14 + // properties; here Rust resets its document and this counter tells the two + // QML-local text mirrors to drop what the user last typed (05 §5.8.3). + readonly property int resetSeq: doc.resetSeq || 0 + onResetSeqChanged: { + urlInput.text = "" + nameInput.text = "" + } + + // Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a relative + // URL resolves against the CONSUMER's document depth. + readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/" + + QbzTheme { id: theme } + + // Scrim — click outside dismisses, EVEN MID-IMPORT (§1.8: closing only + // hides the UI, the task keeps running). + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistImport.close() + } + } + + Rectangle { + id: card + anchors.centerIn: parent + // Tauri: 560px max, min-height 420px, max-height 90vh. + width: Math.min(parent.width - 80, 560) + // 140 = header 68 + 2 hairlines + footer 70 (16/34/20); the body + // scrolls inside the Flickable past the 90 % clamp. + height: Math.min(Math.max(420, bodyCol.implicitHeight + 140), parent.height * 0.9) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + // Swallow clicks so they never reach the scrim. + MouseArea { anchors.fill: parent } + + readonly property int contentWidth: card.width - 48 + + // ---------------------------- header ---------------------------- + Item { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 68 + + Image { + id: qobuzMark + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 26 + height: 26 + source: root.brandDir + "qobuz-logo-filled.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 52 + sourceSize.height: 52 + smooth: true + opacity: 0.9 + } + Text { + anchors.left: qobuzMark.right + anchors.leftMargin: 10 + anchors.right: closeX.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Import Playlist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + // X close — allowed mid-import (§1.8). + Item { + id: closeX + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeXArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeXArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistImport.close() + } + } + } + Rectangle { + id: headerDiv + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + // ---------------------------- footer ---------------------------- + // Declared before the body so the body can anchor to the divider; the + // footer is pinned to the card bottom either way. + Item { + id: footerBar + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 70 + + Row { + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.bottom: parent.bottom + anchors.bottomMargin: 20 + spacing: 12 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Close", QbzSession.trRev) + btnHeight: 34 + minWidth: 0 + // Tauri-verbatim: the FOOTER close disables while loading; + // the header X and the scrim do not. + enabled: !root.loading + onClicked: QbzPlaylistImport.close() + } + // The single accent confirm (ADR-008). Step A fetches, step B + // imports; it stays visible-but-disabled after completion. + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.showPreview + ? (root.loading ? QbzSession.tr("Importing...", QbzSession.trRev) + : QbzSession.tr("Import", QbzSession.trRev)) + : (root.loading ? QbzSession.tr("Fetching...", QbzSession.trRev) + : QbzSession.tr("Fetch playlist", QbzSession.trRev)) + btnEnabled: root.showPreview + ? (!root.loading && !root.importCompleted) + : (root.doc.canFetch === true && !root.loading) + onClicked: { + if (root.showPreview) + QbzPlaylistImport.execute() + else + QbzPlaylistImport.fetch() + } + } + } + } + Rectangle { + id: footerDiv + anchors.bottom: footerBar.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + // ----------------------------- body ----------------------------- + // Scrolls when the progress panel outgrows the card. + Flickable { + id: bodyFlick + anchors.top: headerDiv.bottom + anchors.bottom: footerDiv.top + anchors.left: parent.left + anchors.right: parent.right + clip: true + contentWidth: width + contentHeight: bodyCol.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyCol + width: card.width + padding: 24 + spacing: 16 + + // --- Offline banner --------------------------------------- + // The fetch is gated (Rust drives `canFetch` false); the URL + // input stays enabled, exactly as the reference does. + // + // The shared control, with the deltas BlacklistManagerView.qml + // already records for it: the copy goes in `title` because that + // is the TONE-coloured line, the glyph is `info` rather than + // `cloud-off`, and the tone alphas are 10 %/30 % against the + // reference's #eab3081a / #eab3084d (identical arithmetic, the + // theme's warning token instead of a literal). + WarningBanner { + width: card.contentWidth + visible: QbzSession.offline + variant: "warning" + title: QbzSession.tr("This feature requires internet connection", QbzSession.trRev) + } + + // --- Error banner (preview or execute failure) ------------- + WarningBanner { + width: card.contentWidth + visible: (root.doc.error || "") !== "" + variant: "error" + title: root.doc.error || "" + } + + // --- URL input -------------------------------------------- + // Every keystroke routes the provider detection through Rust + // (the reference does the same because Slint 1.16 strings have + // no `.contains`; here it is what keeps ONE detector — the + // crate's `detect_provider_key` — as the source of truth + // instead of a second JS copy that would drift). + Column { + width: card.contentWidth + spacing: 8 + Text { + text: QbzSession.tr("Playlist URL", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + // The standard 43px input box (the PlaylistPickerModal + // recipe — QbzLineEdit is 34px, commits only on Enter/blur + // in its plain arm and has no `enabled` gate, so it cannot + // serve a per-keystroke field that must disable mid-fetch). + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: urlInput.activeFocus ? theme.accent : theme.borderSubtle + opacity: root.loading ? 0.5 : 1.0 + + TextInput { + id: urlInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.loading + onTextEdited: QbzPlaylistImport.urlEdited(text) + onAccepted: { + if (!root.showPreview && root.doc.canFetch === true && !root.loading) + QbzPlaylistImport.fetch() + } + // Re-seed from the document while the field is NOT + // being edited (the PlaylistPickerModal shape): user + // typing breaks a plain `text:` binding for good, so + // a reset would never reach the field. + Binding { + target: urlInput + property: "text" + value: root.doc.url || "" + when: !urlInput.activeFocus + } + } + Text { + // Placeholder. Plain string: not localized in the + // reference either (it is a URL shape, not copy). + visible: urlInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: "https://open.spotify.com/playlist/..." + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + } + } + + // --- Allowed sources -------------------------------------- + // The detected provider's mark at full opacity, the rest dimmed + // (the reference's §1.5 homologation: no glow / translate / + // scale, opacity 0.45 idle / 1.0 active). + Column { + width: card.contentWidth + spacing: 8 + Text { + text: QbzSession.tr("ALLOWED SOURCES", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + } + Row { + spacing: 10 + Image { + width: 70 + height: 24 + source: root.brandDir + "spotify-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "spotify" ? 1.0 : 0.45 + } + Image { + width: 70 + height: 24 + source: root.brandDir + "apple-music-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "apple" ? 1.0 : 0.45 + } + // Monochrome mark -> the tint table, so it reads on the + // dark AND the light themes (see the file header). + QbzIcon { + width: 70 + height: 24 + name: "tidal-tidal" + tintName: "textPrimary" + opacity: root.doc.activeProvider === "tidal" ? 1.0 : 0.45 + } + Image { + width: 70 + height: 24 + source: root.brandDir + "deezer-logo.svg" + fillMode: Image.PreserveAspectFit + sourceSize.width: 140 + sourceSize.height: 48 + smooth: true + opacity: root.doc.activeProvider === "deezer" ? 1.0 : 0.45 + } + } + } + + // --- Customization panel (step B) ------------------------- + // Rename + optional folder. Editing the URL reverts to step A + // (`showPreview` recomputes Rust-side on every keystroke). + Rectangle { + width: card.contentWidth + visible: root.showPreview + height: customCol.implicitHeight + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: customCol + width: parent.width + padding: 16 + spacing: 12 + + Column { + width: parent.width - 32 + spacing: 8 + Text { + text: QbzSession.tr("Playlist name", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle + opacity: nameInput.enabled ? 1.0 : 0.5 + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.loading && !root.importCompleted + onTextEdited: QbzPlaylistImport.nameEdited(text) + onAccepted: { + // Enter here executes (step B). + if (!root.loading && !root.importCompleted) + QbzPlaylistImport.execute() + } + // Rust prefills this with the fetched + // playlist name, so the re-seed is not just + // a reset path here — it is how the name + // arrives at all. + Binding { + target: nameInput + property: "text" + value: root.doc.customName || "" + when: !nameInput.activeFocus + } + } + } + } + + // Folder dropdown — only when the user HAS folders + // (index 0 is always "No folder", so length > 1). + Column { + width: parent.width - 32 + spacing: 8 + visible: root.folderOptions.length > 1 + Text { + text: QbzSession.tr("Folder", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + QbzSelect { + options: root.folderOptions + currentIndex: root.doc.folderIndex || 0 + menuWidth: parent.width + // The reference caps its menu at 380px; here the + // control is full-width, so the popup matches it. + popupWidth: parent.width + searchable: root.folderOptions.length > 8 + onSelected: function (i) { QbzPlaylistImport.setFolderIndex(i) } + } + } + } + } + + // --- Progress panel --------------------------------------- + // Spinner header, determinate bar, status + current-track + // lines, the append-only log, and the Summary block. + Rectangle { + width: card.contentWidth + visible: root.doc.progressVisible === true + height: progressCol.implicitHeight + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: progressCol + width: parent.width + padding: 16 + spacing: 10 + + Item { + width: parent.width - 32 + height: 20 + Text { + anchors.left: parent.left + anchors.right: spinnerSlot.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Conversion progress", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + // Tauri: a 14px accent ring while loading. + Item { + id: spinnerSlot + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + QbzSpinner { + anchors.centerIn: parent + size: 14 + visible: root.loading + spinning: root.loading + } + } + } + + Column { + width: parent.width - 32 + spacing: 6 + visible: root.doc.hasProgress === true + + // Determinate 6px bar (the LocalLibrarySettings + // recipe); the track uses the subtle border tone + // because the panel is already surface-elevated. + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: theme.borderSubtle + clip: true + Rectangle { + width: parent.width * Math.max(0, Math.min(1, root.doc.progress || 0)) + height: parent.height + radius: 3 + color: theme.accent + } + } + // Status line — the 12px Tauri metric, fixed height + // (the CJK floor femtovg needed; harmless here and + // it keeps the panel from reflowing per event). + Text { + width: parent.width + height: 18 + text: root.doc.statusLine || "" + color: theme.textSecondary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // Current-track line — "Artist - Title" while + // matching, "Part i/n" while adding. + Text { + width: parent.width + visible: (root.doc.currentTrack || "") !== "" + height: visible ? 16 : 0 + text: root.doc.currentTrack || "" + color: theme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + // Append-only log. The height is static arithmetic over + // the ROW COUNT, never row geometry (the reference calls + // that out as a recursion footgun). No autoscroll — + // Tauri parity. Render-only rows: no MouseAreas here. + Flickable { + width: parent.width - 32 + visible: root.logRows.length > 0 + height: visible ? Math.min(root.logRows.length * 20, 160) : 0 + contentWidth: width + contentHeight: root.logRows.length * 20 + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + width: parent.width + Repeater { + model: root.logRows + delegate: Text { + required property var modelData + width: parent ? parent.width : 0 + height: 20 + text: modelData.message || "" + // Hex literals kept — the reference + // hardcodes them (Tauri did too), and + // the port has no success/danger glyph + // ramp for a 13px log row. + color: modelData.status === "success" ? "#34d399" + : (modelData.status === "error" ? "#f87171" : theme.textMuted) + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + + // Summary block — pre-formatted lines, hairline + // separated, "" = hidden. + Column { + width: parent.width - 32 + spacing: 6 + visible: (root.doc.summaryPlaylist || "") !== "" + + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Text { + text: QbzSession.tr("Summary", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + width: parent.width + height: 18 + text: root.doc.summaryPlaylist || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + height: 18 + text: root.doc.summaryMatched || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + height: 18 + text: root.doc.summarySkipped || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + visible: (root.doc.summaryParts || "") !== "" + height: visible ? 18 : 0 + text: root.doc.summaryParts || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + } + } + + // The 14px gutter scrollbar, inset 4px from the right — the port's + // shared control, attached as a SIBLING of the Flickable (it is an + // Item with a `target`, not a QtQuick.Controls ScrollBar). + QbzScrollBar { + target: bodyFlick + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: bodyFlick.top + anchors.bottom: bodyFlick.bottom + } + } +} diff --git a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml new file mode 100644 index 000000000..ea5a1fdfb --- /dev/null +++ b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml @@ -0,0 +1,685 @@ +// "Add to playlist" picker — QML port of +// crates/qbz-ui/ui/primitives/PlaylistPickerModal.slint (+ its +// PlaylistDuplicateConfirmModal sibling, folded in here because the two +// share one document and one carried track set). +// +// A focus-opened dropdown lists the user's playlists; clicking a row only +// SELECTS it (the selection becomes the input's placeholder); the footer +// button fires the actual add. "+ Create new playlist" is always the first +// dropdown row and reveals an inline name field confirmed by the same +// footer button ("Create & Add"). +// +// Mounted ONCE in AppShell next to AddToMixtapeModal: every host that opens +// it (the queue footer and row menu today) can be unmounted by the time the +// user picks a target, so the modal must outlive them. +// +// Data: QbzPlaylistPicker.pickerJson (playlist_picker_qt.rs PickerDoc). The +// name filter runs in RUST (the QbzMyQbzAdd.search convention), so the row +// list arrives already filtered; the 8-row window and the overflow line are +// applied here against `total`/`playlists.length`. +// +// LOCAL playlists are listed alongside the Qobuz ones (a hard-drive glyph +// leads the row, `isLocal` in the document) and are the ONLY rows while +// offline — a Qobuz playlist cannot be written to without a session, so +// offering one would be a dead target (D3/D11). The carried payload is +// likewise either Qobuz catalog ids or LocalLibrary refs; `localMode` only +// changes the counter's wording ("Adding {} local track(s)"), never the row +// list — a local selection can legitimately target a Qobuz playlist, which +// Rust routes through the library.db sidecar. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + // An unopened overlay is an invisible, non-interactive Item and costs + // nothing (the AppShell mount comment states the contract). + visible: doc.open === true || dup.open === true + + readonly property var doc: JSON.parse(QbzPlaylistPicker.pickerJson) + readonly property var rows: doc.playlists || [] + readonly property var dup: doc.dup || ({}) + readonly property int trackCount: doc.trackCount || 0 + // The carried payload is LocalLibrary refs (counter wording only). + readonly property bool localMode: doc.localMode === true + readonly property bool creatingOpen: doc.creatingOpen === true + readonly property bool creating: doc.creating === true + + // Rows past the 8-row window collapse into the "{} more playlist(s)..." + // line, exactly as the .slint's filter-rank window does. + readonly property int windowSize: 8 + readonly property int shown: Math.min(rows.length, windowSize) + readonly property int overflow: Math.max(0, rows.length - windowSize) + + // --- Local selection state (the .slint keeps these on the `if open:` + // subtree so a remount resets them; here they are reset when `open` + // flips, which is the same lifetime without a conditional Loader). --- + property bool dropdownOpen: false + property string selectedId: "" + property string selectedLabel: "" + property string filterText: "" + property string createName: "" + + onVisibleChanged: if (!visible) resetLocals() + function resetLocals() { + dropdownOpen = false + selectedId = "" + selectedLabel = "" + filterText = "" + createName = "" + } + + function selectRow(row) { + selectedId = row.id + selectedLabel = (row.tracksLine || "") === "" + ? row.name : row.name + " (" + row.tracksLine + ")" + // The reference clears the query on selection (Tauri §2.4). + filterText = "" + QbzPlaylistPicker.search("") + QbzPlaylistPicker.showCreate(false) + dropdownOpen = false + } + + function confirm() { + if (root.creating) + return + if (root.creatingOpen) { + if (createName.trim() !== "") + QbzPlaylistPicker.createAndAdd(createName) + } else if (selectedId !== "") { + QbzPlaylistPicker.pick(selectedId) + } + } + + QbzTheme { id: theme } + + // ======================= The picker ================================== + Item { + anchors.fill: parent + visible: root.doc.open === true && root.dup.open !== true + + // Scrim — click outside dismisses. + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistPicker.close() + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 490) + // Tauri: min-height 400 / max-height 90vh. The body stretches so + // the footer pins to the bottom. + height: Math.min(Math.max(400, panel.implicitHeight), parent.height * 0.9) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + // NO clip: the dropdown legitimately overflows the card bottom. + + // Swallow clicks so they never reach the scrim; doubles as + // "a click inside the card closes the list" (Tauri parity). + MouseArea { + anchors.fill: parent + onClicked: root.dropdownOpen = false + } + + Column { + id: panel + width: parent.width + + // --- Header ------------------------------------------- + Item { + width: parent.width + height: 68 + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: closeX.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Add to Playlist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: closeX + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: closeArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistPicker.close() + } + } + } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + + // --- Body --------------------------------------------- + Column { + id: body + width: parent.width + padding: 24 + spacing: 16 + + // Loading — the playlists are still on the wire. + Item { + width: parent.width - 48 + height: visible ? 30 : 0 + visible: root.doc.loading === true + QbzSpinner { + anchors.horizontalCenter: parent.horizontalCenter + size: 30 + } + } + + Column { + width: parent.width - 48 + spacing: 8 + + Item { + width: parent.width + height: 20 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Choose playlist", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + // Two msgids, not one with an inserted word: the + // .slint counter switches the whole string on + // `local-mode` so every locale can order it its + // own way. + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: (root.localMode + ? QbzSession.tr("Adding {} local track(s)", QbzSession.trRev) + : QbzSession.tr("Adding {} track(s)", QbzSession.trRev)) + .replace("{}", root.trackCount) + color: theme.textMuted + font.pixelSize: theme.fontLink + } + } + + // The search / selection input (43px, the Tauri metric). + Rectangle { + id: inputBox + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle + + TextInput { + id: searchInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 36 // room for the clear chip + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: root.doc.loading !== true + text: root.filterText + onTextEdited: { + root.filterText = text + root.dropdownOpen = true // typing re-opens + QbzPlaylistPicker.search(text) + } + onAccepted: root.confirm() + // Tauri: the list opens on focus. + onActiveFocusChanged: if (activeFocus) root.dropdownOpen = true + // Re-seed while not being edited (the + // AddToMixtapeModal Binding shape). + Binding { + target: searchInput + property: "text" + value: root.filterText + when: !searchInput.activeFocus + } + } + // Placeholder, doubling as the SELECTION label — + // the filter is cleared on select, so the field is + // empty exactly when the label should show. + Text { + visible: searchInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 36 + verticalAlignment: Text.AlignVCenter + text: root.creatingOpen + ? QbzSession.tr("+ Create new playlist", QbzSession.trRev) + : (root.selectedLabel !== "" + ? root.selectedLabel + : QbzSession.tr("Search playlists...", QbzSession.trRev)) + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + // Clear-selection chip — only with a selection (or + // the inline-create state) and the list closed. + Rectangle { + visible: (root.selectedId !== "" || root.creatingOpen) + && !root.dropdownOpen + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + radius: 10 + color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 12 + height: 12 + tintName: chipArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selectedId = "" + root.selectedLabel = "" + root.createName = "" + QbzPlaylistPicker.showCreate(false) + } + } + } + } + } + + // Inline create — a second form group BELOW the picker + // (Tauri §1.4.6); the footer button confirms it. + Column { + width: parent.width - 48 + spacing: 8 + visible: root.creatingOpen + Text { + text: QbzSession.tr("Playlist Name", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + Rectangle { + width: parent.width + height: 43 + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontLink + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + enabled: !root.creating + text: root.createName + onTextEdited: root.createName = text + onAccepted: root.confirm() + Binding { + target: nameInput + property: "text" + value: root.createName + when: !nameInput.activeFocus + } + } + Text { + visible: nameInput.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: QbzSession.tr("My Playlist", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + } + } + } + } + + // --- Footer, pinned to the card bottom -------------------- + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footerRow.top + height: 1 + color: theme.borderSubtle + } + Row { + id: footerRow + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.bottom: parent.bottom + anchors.bottomMargin: 16 + spacing: 12 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Cancel", QbzSession.trRev) + btnHeight: 34 + minWidth: 0 + onClicked: QbzPlaylistPicker.close() + } + // The single accent confirm (ADR-008). Disabled until valid — + // this is what replaces Tauri's in-body error box. + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.creating + ? QbzSession.tr("Saving...", QbzSession.trRev) + : (root.creatingOpen + ? QbzSession.tr("Create & Add", QbzSession.trRev) + : QbzSession.tr("Add", QbzSession.trRev)) + btnEnabled: !root.creating + && (root.creatingOpen + ? root.createName.trim() !== "" + : root.selectedId !== "") + onClicked: root.confirm() + } + } + + // --- Dropdown overlay ------------------------------------- + // Declared LAST inside the card so it paints above the body and + // the footer (declaration order IS z-order in QML), and it + // legitimately overflows the unclipped card bottom. + Rectangle { + id: dropdown + visible: root.dropdownOpen && root.doc.loading !== true + x: 24 + // Anchored below the input by static arithmetic over fixed + // metrics — never an absolute-position binding: header 68 + + // 1 divider + 24 body pad + 20 label row + 8 spacing + + // 43 input + 4 gap. + y: 68 + 1 + 24 + 20 + 8 + 43 + 4 + width: parent.width - 48 + // create row 41 + 1 divider, 41 per shown row, 41 no-results, + // 1 + 34 overflow. Tauri max-height 320 + border. + height: Math.min(2 + 42 + root.shown * 41 + + ((root.rows.length === 0 && root.filterText !== "") ? 41 : 0) + + (root.overflow > 0 ? 35 : 0), 322) + radius: theme.radiusSm + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // Keep clicks on the list chrome from reaching the card + // swallower, which would close the list under the pointer. + MouseArea { anchors.fill: parent } + + Flickable { + anchors.fill: parent + anchors.margins: 1 + contentWidth: width + contentHeight: dropInner.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: dropInner + width: parent.width + + // "+ Create new playlist" — ALWAYS first, never + // filtered out (Tauri parity). + Rectangle { + width: parent.width + height: 41 + color: createArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: QbzSession.tr("+ Create new playlist", QbzSession.trRev) + color: theme.accent + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + elide: Text.ElideRight + } + MouseArea { + id: createArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selectedId = "" + root.selectedLabel = "" + root.filterText = "" + QbzPlaylistPicker.search("") + QbzPlaylistPicker.showCreate(true) + root.dropdownOpen = false + } + } + } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + + Repeater { + model: root.rows.slice(0, root.windowSize) + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 41 + color: rowArea.containsMouse ? theme.surfaceHover : "transparent" + // LOCAL playlists (library.db) keep a small + // hard-drive glyph LEADING the name, muted — + // PlaylistPickerModal.slint:62-69. It is what + // tells a local target apart from a Qobuz one + // in a list that mixes both. + QbzIcon { + id: localGlyph + visible: modelData.isLocal === true + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + name: "hard-drive" + width: 14 + height: 14 + tintName: "muted" + } + Text { + anchors.left: localGlyph.visible ? localGlyph.right : parent.left + anchors.leftMargin: localGlyph.visible ? 8 : 12 + anchors.right: countText.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: theme.textPrimary + font.pixelSize: theme.fontLink + elide: Text.ElideRight + } + Text { + id: countText + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: modelData.tracksLine || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.selectRow(modelData) + } + } + } + + Text { + visible: root.rows.length === 0 && root.filterText !== "" + width: parent.width + height: visible ? 41 : 0 + text: QbzSession.tr("No playlists found", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + // Overflow line — the rows past the 8-row window. + Rectangle { + visible: root.overflow > 0 + width: parent.width + height: visible ? 1 : 0 + color: theme.borderSubtle + } + Text { + visible: root.overflow > 0 + width: parent.width + height: visible ? 34 : 0 + text: QbzSession.tr("{} more playlist(s)...", QbzSession.trRev) + .replace("{}", root.overflow) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.italic: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + } + } + + // ============= Duplicate-confirm sub-modal =========================== + // Opens INSTEAD of adding when the chosen playlist already holds some of + // the carried tracks. It replaces the picker rather than stacking on it + // (the picker's own state is already parked in Rust). + Item { + anchors.fill: parent + visible: root.dup.open === true + + Rectangle { + anchors.fill: parent + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: QbzPlaylistPicker.cancelDuplicates() + } + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width - 80, 460) + height: dupPanel.implicitHeight + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + MouseArea { anchors.fill: parent } + + Column { + id: dupPanel + width: parent.width + padding: 24 + spacing: 16 + + Item { + width: parent.width - 48 + height: 28 + Text { + anchors.left: parent.left + anchors.right: dupClose.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Some of these tracks were already added", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: dupClose + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: dupCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: dupCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistPicker.cancelDuplicates() + } + } + } + + Text { + width: parent.width - 48 + text: QbzSession.tr("This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates.", QbzSession.trRev) + .replace("{}", root.dup.duplicateCount || 0) + .replace("{}", root.trackCount) + color: theme.textSecondary + font.pixelSize: theme.fontLink + wrapMode: Text.WordWrap + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 24 + spacing: 12 + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: root.dup.busy === true + ? QbzSession.tr("Adding...", QbzSession.trRev) + : QbzSession.tr("Add all tracks", QbzSession.trRev) + btnHeight: 34 + minWidth: 0 + enabled: root.dup.busy !== true + onClicked: QbzPlaylistPicker.confirmDuplicates(true) + } + QbzPrimaryButton { + anchors.verticalCenter: parent.verticalCenter + btnHeight: 34 + label: root.dup.busy === true + ? QbzSession.tr("Adding...", QbzSession.trRev) + : QbzSession.tr("Add only new tracks", QbzSession.trRev) + btnEnabled: root.dup.busy !== true + onClicked: QbzPlaylistPicker.confirmDuplicates(false) + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/PmFolderIcon.qml b/crates/qbz-qt/qml/controls/PmFolderIcon.qml new file mode 100644 index 000000000..55da635cf --- /dev/null +++ b/crates/qbz-qt/qml/controls/PmFolderIcon.qml @@ -0,0 +1,117 @@ +// PmFolderIcon — the folder icon tile (primitives/PmFolderIcon.slint, 48 +// lines): a rounded colour square carrying either one of the seven lucide +// preset glyphs or a user-picked custom image. +// +// It lives in qml/controls/, not in qml/views/playlistmanager/, because it is +// shared chrome rather than a view part: the three manager consumers today are +// the folder card, the folder chip and the tree folder row, and the folder +// EDITOR (controls/FolderEditPanel.qml) is the fourth the moment it grows a +// live preview tile — the reference's FolderEditModal.slint has none, so the +// port draws none either (rule 5: no control the reference does not have). A +// view-part directory would make a control import a view (contract D12). +// +// ── FLAT FIELDS, NOT A FOLDER OBJECT ─────────────────────────────────────── +// The reference takes one `PmFolderItem` struct. Here the publishers carry +// DIFFERENT shapes — `QbzPlaylistManager.foldersJson` entries (§4.1) and +// `QbzFolderEdit.editJson` (§4.5, which additionally carries the swatch and +// preset constants and the editor's busy flag, and whose `iconColor` is NOT +// validity-gated because it is a swatch SEED, not a paint) — so passing "the +// folder object" would mean one of them silently handing over the wrong keys. +// QML does not report that: a property the target type does not declare is an +// ignored line, and a key the object does not carry is `undefined`. The +// interface is therefore the six flat fields of contract §2.5 and nothing +// else. +// +// ── THE GLYPH SITS UNDER THE IMAGE, NOT OPPOSITE IT ──────────────────────── +// The reference pairs `if has-custom-image: Image` with +// `if !has-custom-image: QbzIcon`, so a tile whose image fails to decode — or +// has not decoded yet — is a bare coloured square. Here the glyph is always +// drawn and the image is layered over it: identical once the image loads, +// degrades to the preset glyph when it does not (§5.20). +// +// ── TINT IS "white", DELIBERATELY ────────────────────────────────────────── +// A fixed #ffffff, matching the reference's hardcoded tint — NOT +// "textPrimary". The host is a saturated, user-chosen colour tile under every +// theme, and a theme-following tint would paint a dark glyph on it on the 11 +// light themes. ("primary" is the legacy alias of "white"; QbzIcon.qml:55-65 +// says do not add new call sites for it.) +// +// ── NO `clip: true` ──────────────────────────────────────────────────────── +// `Rectangle { radius; clip: true }` does NOT round in Qt Quick — `clip` is a +// rectangular scissor. The fill Rectangle rounds itself and RoundedImage does +// the image's corners with a mask. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// Edge length. The root binds BOTH dimensions off it — the reference's + /// component does the same, and a sketch that omits it renders 0x0. + property int size: 40 + /// One of the seven ids in §4.5. ANY unknown value renders "folder", which + /// is also where the id "folder" itself lands: it is not a case in the + /// reference's id -> glyph chain at all and falls through to the default + /// arm. Both are upstream intent; both are kept. + property string iconPreset: "folder" + /// "" means "use the theme accent"; only read when `hasColor` is true. + property string iconColor: "" + /// False when the stored value was empty, a gradient or a CSS var — the + /// validity gate lives in RUST (§5.20), so a stored gradient string can + /// never reach `Qt.color()` here. + property bool hasColor: false + /// `icon_type == "custom" && custom_image_path.is_some()` — BOTH, decided + /// in Rust. A stale path is reachable, so this is never derived from the + /// path alone. + property bool hasCustomImage: false + /// A percent-encoded `file://…` URL, "" when there is none. A bare + /// `/home/...` string would be resolved against this component's `qrc:` + /// base and fail silently (D25). + property string customImagePath: "" + + /// Glyph size and corner radius are DERIVED, because §2.5 gives this + /// control `size` as its only geometry input. The three reference call + /// sites pass 64/32/12 (grid card, PlaylistManagerView.slint:314), + /// 36/20/8 (list chip, :397) and 28/15/6 (tree row, :903); the bands below + /// reproduce all three exactly and interpolate sanely in between. Both are + /// plain properties rather than readonly ones, so a call site that wants + /// to pin the reference numbers can just assign them. + property int glyphSize: root.size >= 56 ? Math.round(root.size * 0.5) + : root.size >= 32 ? Math.round(root.size * 0.556) + : Math.round(root.size * 0.536) + property int tileRadius: root.size >= 56 ? 12 : root.size >= 32 ? 8 : 6 + + QbzTheme { id: theme } + + width: root.size + height: root.size + radius: root.tileRadius + color: root.hasColor && root.iconColor !== "" ? root.iconColor : theme.accent + + QbzIcon { + anchors.centerIn: parent + width: root.glyphSize + height: root.glyphSize + tintName: "white" + name: root.iconPreset === "heart" ? "heart" + : root.iconPreset === "star" ? "star" + : root.iconPreset === "music" ? "music" + : root.iconPreset === "disc" ? "disc" + : root.iconPreset === "library" ? "library" + // The id/glyph mismatch is upstream intent, not a typo: the preset + // persisted as "headphones" has always drawn audio-lines. + : root.iconPreset === "headphones" ? "audio-lines" + : "folder" + } + + RoundedImage { + anchors.fill: parent + visible: root.hasCustomImage && root.customImagePath !== "" + source: (root.hasCustomImage && root.customImagePath !== "") + ? root.customImagePath : "" + radius: root.tileRadius + fit: "crop" + } +} diff --git a/crates/qbz-qt/qml/views/local/AlphaStrip.qml b/crates/qbz-qt/qml/controls/QbzAlphaStrip.qml similarity index 57% rename from crates/qbz-qt/qml/views/local/AlphaStrip.qml rename to crates/qbz-qt/qml/controls/QbzAlphaStrip.qml index 019179db3..4cc38080f 100644 --- a/crates/qbz-qt/qml/views/local/AlphaStrip.qml +++ b/crates/qbz-qt/qml/controls/QbzAlphaStrip.qml @@ -1,11 +1,21 @@ -// Vertical A-Z jump strip (LocalLibraryView.slint:577 AlphaStrip, itself a -// 1:1 lift of FavoritesView's). 18px gutter, 15px rows, 9px semibold muted -// letters that go accent on hover. Emits jump(ordinal, index); the host -// scrolls its own view proportionally — exactly like the Slint (:1298). +// Vertical A-Z jump strip (favorites/FavoritesView.slint:398 `AlphaStrip`; +// LocalLibraryView.slint:577 is a 1:1 lift of the same component). 18px +// gutter, 15px rows, 9px semibold muted letters that go accent on hover. +// Emits jump(ordinal, index); the host scrolls its own view — exactly like +// the Slint, which scrolls proportionally on the grouped grids (:1996) and by +// row index on the uniform track list (:1687). +// +// PROMOTED from views/local/AlphaStrip.qml, unchanged except for the import +// depth — same move QbzMultiSelectBar made and for the same reason: the Slint +// component was always shared (Local Library albums/artists/tracks AND the +// Library favorites tabs), so the "local" folder was a naming accident. The +// old views/local/AlphaStrip.qml is GONE; this file is the only copy. +// Call sites: views/local/{LocalAlbumsTab,LocalArtistsTab,LocalTracksTab}.qml, +// views/library/{LibraryAlbumsList}.qml and views/LibraryView.qml. import QtQuick import com.blitzfc.qbz -import "../../theme" +import "../theme" Item { id: root diff --git a/crates/qbz-qt/qml/controls/QbzContextMenu.qml b/crates/qbz-qt/qml/controls/QbzContextMenu.qml index e6c74f1e6..80c91f868 100644 --- a/crates/qbz-qt/qml/controls/QbzContextMenu.qml +++ b/crates/qbz-qt/qml/controls/QbzContextMenu.qml @@ -36,6 +36,15 @@ Popup { property int menuWidth: 196 default property alias menuContent: col.data + /// Row gap inside the panel. Defaults to the Column's own 0 — every + /// existing call site is unaffected — and exists because a call site CANNOT + /// reach `col`: QML gives a component no access to another file's ids, so + /// `col.spacing: 1` inside a `QbzContextMenu { … }` block is a grouped + /// assignment against a property the type does not have and fails the WHOLE + /// consuming file at load. The Playlist Manager's three menus set 1, which + /// is the reference's `spacing: 1px` + /// (playlist/PlaylistManagerView.slint:1153, :1197, :816). + property alias contentSpacing: col.spacing QbzTheme { id: theme } diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index c4a14f399..60cf53caf 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -96,6 +96,19 @@ Rectangle { input.focus = false } + /// Focus the field from OUTSIDE the control — what a modal needs on open. + /// + /// The deferred Timer below already existed, but it was driven only by + /// `onOpenChanged`, and `open` is the EXPANDABLE-SEARCH arm's property; + /// the control exposes no alias to its inner TextInput either, so a modal + /// had no way in. Setting `open: true` on a plain field happens to work + /// (with `expandable: false`, `box.width` is `root.width` regardless of + /// `open`, :139) but it repurposes a search-arm property and reads as a + /// bug. This is the seam instead. It is deferred for the same reason the + /// expandable arm defers: focusing synchronously inside the caller's own + /// open transition races it. + function focusField() { focusDefer.restart() } + // Deferred focus — see the header note. onOpenChanged: if (open) focusDefer.restart() Timer { diff --git a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml index e90a492c8..a0c0173bf 100644 --- a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml +++ b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml @@ -42,6 +42,11 @@ Rectangle { property bool btnEnabled: true property bool destructive: false property int btnHeight: 48 + /// Label size. The control draws Typography.button (17px), which is right + /// for the footer chips it was written for; the folder / playlist modals + /// are 15px (Typography.body) in the reference, and hardcoding 17 there + /// would be a visible parity break on every one of them. + property int labelSize: theme.fontButton signal clicked() QbzTheme { id: theme } @@ -67,7 +72,7 @@ Rectangle { width: Math.min(implicitWidth, root.width - 40) text: root.label color: theme.accentGlyphColor - font.pixelSize: theme.fontButton + font.pixelSize: root.labelSize font.weight: theme.weightSemibold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter diff --git a/crates/qbz-qt/qml/controls/QbzSectionHeader.qml b/crates/qbz-qt/qml/controls/QbzSectionHeader.qml index 1de7a18ff..f3a09f52d 100644 --- a/crates/qbz-qt/qml/controls/QbzSectionHeader.qml +++ b/crates/qbz-qt/qml/controls/QbzSectionHeader.qml @@ -8,16 +8,35 @@ // Arms: `title` (fontSection semibold); `showViewAll` + `viewAllLabel` + // `viewAllAccent` (Search's accent chip vs Home's secondary link) + // viewAllClicked(); `showChevrons` + `leftEnabled`/`rightEnabled` + -// pageLeft()/pageRight() (the host owns the page-step math). +// pageLeft()/pageRight() (the host owns the page-step math); and, since the +// Playlist Manager landed, `collapsible` + `collapsed` + toggled() and +// `titleSecondary`. // Deliberately NOT absorbed: ArtistView.ReleaseSection's header (adds a // sort dropdown + "See discography" — distinct arms, still stubs) and the // uppercase 10-11px eyebrow mini-headers (different type scale). +// +// ── THE `id: root` REFACTOR CAME FIRST, AND IT WAS NOT COSMETIC ─────────── +// This file used to have no root id: its Text read `text: parent.title` and +// three deeper bindings walked `parent.parent…`. Wrapping that Text in the +// Row the collapse chevron needs would have changed its `parent` and silently +// BLANKED the title on all thirteen existing instantiations +// (HomeView.qml:346,537,612,773,1096,1150 · SearchView.qml:526,555,584,690,790 +// · SectionRail.qml:28 · LabelView.qml:139). Every property read now goes +// through `root`, so the tree can be restructured again without that trap. +// +// The collapse chevron sits IMMEDIATELY RIGHT OF THE TITLE (the Playlist +// Manager's `PmSectionHeader`, playlist/PlaylistManagerView.slint:1034-1058), +// not in the right-hand Row. `Row` skips invisible children, so with +// `collapsible: false` — the default — the thirteen existing sites are +// pixel-identical: no width, no spacing, nothing. import QtQuick import com.blitzfc.qbz import "../theme" Item { + id: root + property string title: "" property bool showViewAll: false property string viewAllLabel: "" @@ -25,30 +44,69 @@ Item { property bool showChevrons: true property bool leftEnabled: false property bool rightEnabled: false + /// Paint the title `text-secondary` instead of `text-primary` — the + /// Playlist Manager's two section headers do + /// (playlist/PlaylistManagerView.slint:1029). + property bool titleSecondary: false + /// Render the 22 × 22 collapse chevron right of the title. + property bool collapsible: false + property bool collapsed: false signal viewAllClicked() signal pageLeft() signal pageRight() + signal toggled() QbzTheme { id: theme } width: parent ? parent.width : 0 height: 28 - Text { + Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: parent.title - color: theme.textPrimary - font.pixelSize: theme.fontSection - font.weight: theme.weightSemibold + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: root.titleSecondary ? theme.textSecondary : theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + // The chevron's 22 × 22 PLATE lights on hover; the glyph itself stays + // `text-muted` in every state (`:1040-1049`). + Rectangle { + visible: root.collapsible + anchors.verticalCenter: parent.verticalCenter + width: 22 + height: 22 + radius: 4 + color: chevArea.containsMouse ? theme.surfaceHover : "transparent" + + QbzIcon { + anchors.centerIn: parent + name: root.collapsed ? "chevron-right" : "chevron-down" + width: 14 + height: 14 + tintName: "muted" + } + MouseArea { + id: chevArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled() + } + } } + Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 4 // "View all" link/chip (accent = Search's, secondary = Home's). Rectangle { - visible: parent.parent.showViewAll + visible: root.showViewAll anchors.verticalCenter: parent.verticalCenter width: vaText.implicitWidth + 16 height: 26 @@ -57,10 +115,10 @@ Item { Text { id: vaText anchors.centerIn: parent - text: parent.parent.parent.viewAllLabel !== "" - ? parent.parent.parent.viewAllLabel + text: root.viewAllLabel !== "" + ? root.viewAllLabel : QbzSession.tr("View all →", QbzSession.trRev) - color: parent.parent.parent.viewAllAccent ? theme.accent + color: root.viewAllAccent ? theme.accent : vaArea.containsMouse ? theme.textPrimary : theme.textSecondary font.pixelSize: 14 font.weight: theme.weightMedium @@ -70,7 +128,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: parent.parent.parent.viewAllClicked() + onClicked: root.viewAllClicked() } } // `ambient: true` on BOTH: these two are the carousel page chevrons, @@ -89,19 +147,22 @@ Item { // section header on OPAQUE chrome would need the arm off — there is // no such host today, and adding one means adding the property then, // not carrying a dead knob now. + // + // The Playlist Manager's two headers are the first consumers that are + // NOT carousels, and they say so with `showChevrons: false`. QbzNavButton { - visible: parent.parent.showChevrons + visible: root.showChevrons name: "chevron-left" ambient: true - btnEnabled: parent.parent.leftEnabled - onClicked: parent.parent.pageLeft() + btnEnabled: root.leftEnabled + onClicked: root.pageLeft() } QbzNavButton { - visible: parent.parent.showChevrons + visible: root.showChevrons name: "chevron-right" ambient: true - btnEnabled: parent.parent.rightEnabled - onClicked: parent.parent.pageRight() + btnEnabled: root.rightEnabled + onClicked: root.pageRight() } } } diff --git a/crates/qbz-qt/qml/controls/QbzToolButton.qml b/crates/qbz-qt/qml/controls/QbzToolButton.qml index 7fa49f911..e1ca89f4a 100644 --- a/crates/qbz-qt/qml/controls/QbzToolButton.qml +++ b/crates/qbz-qt/qml/controls/QbzToolButton.qml @@ -37,33 +37,54 @@ Rectangle { property bool active: false /// Active = accent FILL (labelled buttons) vs accent BORDER (icon-only). property bool fillActive: false + /// THE SECOND SCALE — the Playlist Manager toolbar's `PmToolButton` / + /// `PmIconButton` (playlist/PlaylistManagerView.slint:162-230), which is the + /// same control 4 px taller with more air. The port's established idiom for + /// a second size on one control (`QbzLineEdit.sm`, `QbzSelect.sm`, + /// `QbzToggleButton.sm`), rather than a 90 %-identical fork. + /// + /// Under `large`: height 34, padH 11 (the reference's `row { x: 11 }` and + /// `width: row.preferred-width + 22`), row spacing 7, label 13, NO border + /// (except the accent-border arm, which the PM never uses), and an + /// icon-only button is a 34 × 34 square with a 16 px glyph — the 15 → 16 + /// delta between the labelled and icon-only forms IS the reference + /// (`:180-184` vs `:216-222`); do not unify it. + /// + /// The idle GLYPH tint also changes, and it is the largest of the deltas: + /// this control tints idle glyphs `muted` (#888888) where the reference + /// paints `Theme.text-secondary` (#cccccc) on five of its six toolbar + /// controls. Defaults are untouched, so the MyQBZ toolbars cannot regress. + property bool large: false signal clicked() QbzTheme { id: theme } readonly property bool filled: root.active && root.fillActive - readonly property int padH: root.label === "" ? 8 : 10 + readonly property bool iconOnly: root.label === "" + readonly property int padH: root.large ? 11 : (root.iconOnly ? 8 : 10) - height: 30 - implicitWidth: 2 * root.padH + row.implicitWidth + height: root.large ? 34 : 30 + implicitWidth: (root.large && root.iconOnly) + ? 34 + : 2 * root.padH + row.implicitWidth radius: 6 color: root.filled ? theme.accent : (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) - border.width: 1 + border.width: (root.large && !(root.active && !root.fillActive)) ? 0 : 1 border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle Row { id: row anchors.centerIn: parent - spacing: 6 + spacing: root.large ? 7 : 6 QbzIcon { anchors.verticalCenter: parent.verticalCenter name: root.name - width: 15 - height: 15 + width: (root.large && root.iconOnly) ? 16 : 15 + height: (root.large && root.iconOnly) ? 16 : 15 tintName: root.filled ? theme.accentGlyphTint - : (root.active ? "textPrimary" : "muted") + : (root.active ? "textPrimary" : (root.large ? "secondary" : "muted")) } Text { visible: root.label !== "" @@ -71,7 +92,7 @@ Rectangle { text: root.label color: root.filled ? theme.accentGlyphColor : (root.active ? theme.textPrimary : theme.textSecondary) - font.pixelSize: 12 + font.pixelSize: root.large ? 13 : 12 verticalAlignment: Text.AlignVCenter } Rectangle { diff --git a/crates/qbz-qt/qml/rows/TrackCols.qml b/crates/qbz-qt/qml/rows/TrackCols.qml index 30b6fc5c8..baf74a22f 100644 --- a/crates/qbz-qt/qml/rows/TrackCols.qml +++ b/crates/qbz-qt/qml/rows/TrackCols.qml @@ -49,6 +49,14 @@ QtObject { /// Inter-column gap (TrackRow.slint:267). readonly property int gap: 14 + /// Reorder gutter — the up/down chevron stack at the LEADING edge of a + /// custom-order row (TrackRow.slint:272-280, `width: 22px`). Drawn only + /// on a reorderable surface (the playlist detail under custom sort, and + /// a local playlist under its natural order), so it is the one arm the + /// Slint header does NOT reserve. It is reserved here, because a header + /// that ignores a column the row draws slides every label after it by + /// width + gap — the exact drift this file exists to prevent. + readonly property int colReorder: 22 /// Number / play-cell (TrackRow.slint:344 `number-width: 32px`). readonly property int colNumber: 32 /// Artwork thumbnail (TrackRow.slint:336, show-artwork arm). @@ -67,11 +75,15 @@ QtObject { readonly property int colMenu: 32 /// Sum of every FIXED cell that is actually drawn for these arms. - function fixedWidth(artwork, albumCol, favorite, download, menu) { + /// + /// `reorder` is TRAILING and optional on all three functions: it landed + /// after the call sites did, and `undefined` is falsy, so a caller that + /// does not know about the gutter keeps its old answer exactly. + function fixedWidth(artwork, albumCol, favorite, download, menu, reorder) { return colNumber + (artwork ? colArt : 0) + (albumCol ? colAlbum : 0) + colDuration + colQuality + (favorite ? colFavorite : 0) + (download ? colDownload : 0) - + (menu ? colMenu : 0) + + (menu ? colMenu : 0) + (reorder ? colReorder : 0) } /// Sum of the inter-column gaps. The unconditional cells are number, @@ -79,17 +91,17 @@ QtObject { /// (QML's Row skips invisible children entirely — no cell, no gap — and /// so does Slint's `if` in a HorizontalLayout, which is why the count is /// arm-dependent on both sides.) - function gapWidth(artwork, albumCol, favorite, download, menu) { + function gapWidth(artwork, albumCol, favorite, download, menu, reorder) { return (3 + (artwork ? 1 : 0) + (albumCol ? 1 : 0) + (favorite ? 1 : 0) - + (download ? 1 : 0) + (menu ? 1 : 0)) * gap + + (download ? 1 : 0) + (menu ? 1 : 0) + (reorder ? 1 : 0)) * gap } /// The stretch column. `rowWidth` is the OUTER width of the row (the /// padding is subtracted here), so a header and a row that are the same /// width place every column at the same x. - function titleWidth(rowWidth, artwork, albumCol, favorite, download, menu) { + function titleWidth(rowWidth, artwork, albumCol, favorite, download, menu, reorder) { return Math.max(0, rowWidth - 2 * padH - - fixedWidth(artwork, albumCol, favorite, download, menu) - - gapWidth(artwork, albumCol, favorite, download, menu)) + - fixedWidth(artwork, albumCol, favorite, download, menu, reorder) + - gapWidth(artwork, albumCol, favorite, download, menu, reorder)) } } diff --git a/crates/qbz-qt/qml/rows/TrackListHeader.qml b/crates/qbz-qt/qml/rows/TrackListHeader.qml index 79e76049c..44a5faef6 100644 --- a/crates/qbz-qt/qml/rows/TrackListHeader.qml +++ b/crates/qbz-qt/qml/rows/TrackListHeader.qml @@ -41,6 +41,10 @@ Item { property bool showFavorite: true property bool showDownload: false property bool showMenu: true + /// Leading reorder gutter (the row's up/down chevrons). MIRROR the + /// delegate's `showReorder`, or every label slides by 22 + 14 the moment + /// the user switches the list to a custom / reorderable order. + property bool showReorder: false /// Draw the 14px heart / cloud-download in the head of those two /// reserved columns (AlbumPageView.slint). Off = an empty reserved slot, @@ -96,6 +100,13 @@ Item { anchors.rightMargin: cols.padH spacing: cols.gap + // Reorder gutter — unlabelled, reserved (the row draws two chevrons + // there; there is nothing to name). + Item { + visible: root.showReorder + width: cols.colReorder + height: root.bandH + } // "#" — centred, because the cell it labels is TrackRow's play cell // and its number is centred in the same 32px. (Slint's album headers // left-align this one over a centred number; the playlist header @@ -113,7 +124,8 @@ Item { } ColLabel { width: cols.titleWidth(root.innerWidth, root.showArtwork, root.showAlbum, - root.showFavorite, root.showDownload, root.showMenu) + root.showFavorite, root.showDownload, root.showMenu, + root.showReorder) text: QbzSession.tr("Title", QbzSession.trRev) } ColLabel { diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index ad5c61585..37c463569 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -11,7 +11,9 @@ // // item contract: { id, title, artist, artistId, album, albumId, number?, // duration, qualityTier, qualityDetail, explicit, isFavorite, artPath? } -// (plus playlistTrackId for the remove-from-playlist arm). `qualityDetail` +// (plus playlistTrackId for the remove-from-playlist arm, and the OPTIONAL +// `source` / `unavailable` a mixed-source producer carries — the Add to +// playlist gate below is the one arm that reads them). `qualityDetail` // is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer // does not carry it yet the badge degrades to the tier label alone rather // than to a blank cell. `item.isFavorite` SEEDS the row's own `favorite` @@ -27,7 +29,8 @@ // removeRequested(), bodyDragStarted(index) (fired BEFORE the shared // dragStart — the #589 reorder pre-hook), mixtapeRequested() (the host // builds the MyQBZ AddItem payload), goToRequested(kind) (only when -// `routeGoToExternally` is set). +// `routeGoToExternally` is set), playlistAddRequested() (only when +// `routePlaylistAddExternally` is set — the local-mode "Add to playlist"). // Favorite toggling, Go-to-artist/album, Share and Track info are // identical on every site — handled internally. The row BODY is the drag // source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT @@ -85,12 +88,34 @@ Rectangle { property bool menuShowGoTo: true property bool menuShowFavorite: true property bool menuShowRemove: false + /// Reorder arm (primitives/TrackRow.slint:87 `show-reorder`): a leading + /// 22px gutter with an up/down chevron stack. ON = this list is in an + /// order the user OWNS and can rewrite (the playlist detail under custom + /// sort; a LOCAL playlist under its natural order, whose repo `position` + /// IS that order). Default off, so no existing call site grows a gutter. + /// + /// It is also the gate for `preventStealing` on the row-body drag — see + /// the DRAG vs. DRAG-TO-SCROLL block at the bottom of this file. + property bool showReorder: false + /// Ends of the list: the reference dims nothing, but a chevron that + /// renders and no-ops is the defect class this round is fixing, so the + /// first row's ↑ and the last row's ↓ are drawn disabled instead. + property bool canMoveUp: true + property bool canMoveDown: true // Drag source arm (additive; default = the existing behaviour). The // Local Library rows set this false: their `item.id` is a local DB row // id, and the sidebar drop handler forwards whatever it receives to // `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a // playlist would silently add an unrelated catalog track. property bool draggable: true + /// Multi-select arm (primitives/TrackRow.slint:58 `multi-select-mode`): + /// the leading play/number cell becomes a selection checkbox, the WHOLE + /// row body is a selection target, and the row's own gestures (play, + /// double-click, drag, right-click menu) stand down. Default off, so every + /// existing call site is untouched. + property bool selectMode: false + property bool checked: false + signal toggleSelect() // Per-row artwork placeholder (see the 36px cell below). The host view // owns the phase clock so one timer drives every row. property bool artPending: false @@ -112,13 +137,45 @@ Rectangle { // matching `menuAction` branch when the invokable lands; the entry then // appears in its .slint-correct slot. Why each is missing: // radio no radio invokable on any Qt bridge object - // playlistAdd no picker modal, no by-id add (only the sidebar drag) // songlink needs the ISRC -> Deezer -> Odesli round-trip (backend) // offlineCache no per-track cache bridge (the download cell is a stub) readonly property bool hasRadioSeam: false - readonly property bool hasPlaylistAddSeam: false readonly property bool hasSonglinkSeam: false readonly property bool hasOfflineCacheSeam: false + + // --- Add to playlist ------------------------------------------------- + // LIVE since QbzPlaylistPicker landed. It is NOT a flat constant, because + // the entry's correctness depends on WHICH ID SPACE this row's `item.id` + // lives in — the one thing the picker's whole design exists to keep + // unmistakable (playlist_picker_qt.rs's four-case matrix): + // + // catalogRow `item.id` is a Qobuz catalog id -> openForTrack. + // localSourceRow the row carries its own source word and it says + // local/plex -> `item.id` is a library.db row id or + // a Plex synthetic id. Sending it to the Qobuz arm + // adds an UNRELATED track, so the entry is only + // offered when the HOST takes the routing + // (`routePlaylistAddExternally`, below), and is + // ABSENT otherwise rather than wrong. + // item.unavailable a stored ref that cannot resolve at all + // (playlist_qt.rs PlaylistTrackRow.unavailable); + // there is no track to copy anywhere. + // + // Ephemeral rows never reach this decision: their surface sets + // `showMenu: false`, so the whole menu is absent (LocalEphemeralPane). + readonly property bool localSourceRow: + root.item.source === "local" || root.item.source === "plex" + /// Hand "Add to playlist" to the HOST (the `routeGoToExternally` idiom): + /// the host answers `playlistAddRequested()` with the LOCAL-mode call its + /// surface can make. The local playlist detail is the one that needs it — + /// only Rust can turn a detail row's display id into a source-aware ref + /// (`local_playlist_qt::local_picker_ref_for_row`). + property bool routePlaylistAddExternally: false + signal playlistAddRequested() + property bool hasPlaylistAddSeam: root.item.unavailable === true + ? false + : (root.routePlaylistAddExternally + || (root.catalogRow && !root.localSourceRow)) // LIVE since the MyQBZ domain landed: QbzMyQbzAdd.open() takes a JSON // array of AddItem, so the row only has to ASK — it does not know its own // itemType/source, which is why the payload is built by the host (see @@ -130,6 +187,11 @@ Rectangle { signal enqueueRequested(string mode) signal removeRequested() signal bodyDragStarted(int index) + /// Reorder gutter (`showReorder`). The HOST answers — only it knows the + /// row's id and which playlist is open (TrackRow.slint routes the same + /// pair through `media-action("track", id, "move-up"/"move-down")`). + signal moveUpRequested() + signal moveDownRequested() /// "Add to mixtape" — the HOST answers, because only the host knows the /// row's `itemType` / `source` (a MyQBZ AddItem needs both) and building /// the array here would hardcode `("track", "qobuz")` for every surface. @@ -232,8 +294,13 @@ Rectangle { // has-hover does not propagate to ancestor TouchAreas" — Qt's does not // either, so without this the row (and the play circle with it) would go // un-hovered exactly while the pointer sits on the play button. + // The reorder chevrons are in the same set for the same reason: they are + // hover-enabled areas ON the row, and the row must not un-hover under + // them. Their ids resolve whether or not the gutter is `visible` — the + // objects always exist (no Loader / no `if`), only their painting is off. readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse || playArea.containsMouse + || chevUp.hovered || chevDown.hovered readonly property bool isActive: QbzPlayer.npTrackId === (item.id || "") // --- Column geometry: rows/TrackCols.qml, NOT literals --------------- @@ -243,9 +310,58 @@ Rectangle { // arithmetic lives there too (`titleWidth`) — the header asks the same // function the same question and gets the same answer. TrackCols { id: cols } + + /// One chevron of the reorder gutter. Declared at the file's top level + /// (an inline component must be — the same rule TrackListHeader's + /// `ColLabel` follows), not inside the Row. + /// + /// It reads NOTHING off an outer id: `width` is set by the two call + /// sites, which are in the outer scope and can reach `cols` there. An + /// inline component that reaches for an enclosing id is legal only under + /// the default (unbound) ComponentBehavior, and this file does not + /// declare the pragma either way — so it simply does not depend on it. + component ReorderChevron: Item { + id: chev + property string glyph: "chevron-up" + property bool armed: true + /// Read by the row's own `hovered` (see it): a hover-enabled MouseArea + /// on top BLOCKS hover to the areas below it, so without republishing + /// it here the row un-hovers the moment the pointer enters the gutter + /// — the same defect this file already documents for the play cell. + readonly property bool hovered: chevArea.containsMouse + signal activated() + height: 18 + QbzIcon { + name: chev.glyph + width: 14 + height: 14 + anchors.centerIn: parent + opacity: chev.armed ? 1.0 : 0.35 + tintName: (chev.armed && chevArea.containsMouse) ? "textPrimary" : "muted" + } + MouseArea { + id: chevArea + anchors.fill: parent + // ENABLED even when the chevron is not armed, and the click is + // swallowed instead. A DISABLED MouseArea does not accept the + // press at all, so it fell through to the row body underneath + // (`trArea`, z:-1) — and this surface has `clickPlays: true`, so + // clicking the greyed-out ↑ on the first row STARTED PLAYBACK. + // The reference has no disabled state at all (its chevrons are + // always armed and the Rust arm no-ops at the ends), so a dead + // click there is genuinely dead; ours has to make it dead too. + hoverEnabled: true + cursorShape: chev.armed ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (chev.armed) + chev.activated() + } + } + } + readonly property int titleColWidth: cols.titleWidth(root.width, root.showArtwork, root.showAlbum, root.showFavorite, - root.showDownload, root.showMenu) + root.showDownload, root.showMenu, root.showReorder) // Static now-playing mark: 3px accent pill on the left edge. Rectangle { @@ -264,6 +380,45 @@ Rectangle { anchors.rightMargin: cols.padH spacing: cols.gap + // --- Reorder gutter (primitives/TrackRow.slint:270-311) ---------- + // The LEADING cell on a reorderable list: a 22px column with an + // up/down chevron stack, 18px each. It is the affordance a user who + // cannot (or does not want to) drag reorders with — the reference + // ships it and the port had only the drag, which is half of owner + // finding 8. + // + // Reuse note: the Playlist Manager's equivalent is + // views/playlistmanager/PmGridCard.qml's 22px header (chevron-up · + // grip-vertical · chevron-down through PmActionButton). That one is + // a GRID card with 140px of width to spend; this is a 50px table row + // whose gutter is 22px wide, so the same three-across header does not + // fit and PmActionButton's 22px disc would leave no room for the + // second chevron. The geometry here is the reference row's own. + Item { + visible: root.showReorder + width: cols.colReorder + height: parent.height + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + ReorderChevron { + id: chevUp + width: cols.colReorder + glyph: "chevron-up" + armed: root.canMoveUp + onActivated: root.moveUpRequested() + } + ReorderChevron { + id: chevDown + width: cols.colReorder + glyph: "chevron-down" + armed: root.canMoveDown + onActivated: root.moveDownRequested() + } + } + } + // Number cell — the number-variant of primitives/TrackPlayCell.slint // (:172-232), the play affordance shared by every track row. // @@ -304,15 +459,48 @@ Rectangle { readonly property bool showOverlay: root.hovered || root.isActive // `accent-ring` (:108): the playing row, static form. readonly property bool accentRing: root.isActive && QbzPlayer.npPlaying + // Selection checkbox — TrackRow.slint:392-419: a 13px disc, accent + // when selected, muted/text-primary ring when not. It REPLACES the + // number and the play disc (both gate on !selectMode below), so + // the leading column keeps its width and nothing else shifts. + Rectangle { + visible: root.selectMode + anchors.centerIn: parent + width: 14 + height: 14 + radius: 7 + color: root.checked ? theme.accent : "transparent" + border.width: root.checked ? 0 : 1.5 + border.color: selArea.containsMouse ? theme.textPrimary : theme.textMuted + Behavior on color { ColorAnimation { duration: 120 } } + QbzIcon { + visible: root.checked + name: "check" + width: 8 + height: 8 + anchors.centerIn: parent + // The glyph sits ON the accent fill — the same selector + // controls/QbzCheckbox.qml routes through, not a literal + // #ffffff (which is under 2.6:1 on 16 of the 35 palettes). + tintName: theme.accentGlyphTint + } + MouseArea { + id: selArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleSelect() + } + } Text { - visible: !playCell.showOverlay + visible: !playCell.showOverlay && !root.selectMode anchors.centerIn: parent text: root.number color: theme.textMuted font.pixelSize: 13 } Rectangle { - visible: playCell.showOverlay + visible: playCell.showOverlay && !root.selectMode anchors.centerIn: parent width: 24 height: 24 @@ -342,6 +530,12 @@ Rectangle { MouseArea { id: playArea anchors.fill: parent + // Declared BEFORE the checkbox in z-order terms? No — the + // checkbox is declared FIRST and this fills the same cell, so + // it would swallow the checkbox's press. Disabling it in + // select mode is what hands the cell over (the play + // affordance is invisible there anyway). + enabled: !root.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { @@ -511,6 +705,11 @@ Rectangle { MouseArea { id: favArea anchors.fill: parent + // TrackRow.slint:527/558/623/688 — the in-row controls stand + // down in select mode so their click reaches the row body. + // Without it, aiming at a row and hitting the heart + // un-favourites a track the user meant to tick. + enabled: !root.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.toggleFavorite() @@ -623,6 +822,17 @@ Rectangle { } else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() + // Add to playlist. The internal arm is the CATALOG one and nothing + // else can reach it: `hasPlaylistAddSeam` only builds the entry for a + // `catalogRow` that is not a local/plex row, or when the host has + // taken the routing. Belt and braces on the dispatch too, so a host + // that sets `routePlaylistAddExternally` and forgets the handler + // cannot fall through into the Qobuz call with a local id. + else if (a === "add-to-playlist") { + if (root.routePlaylistAddExternally) root.playlistAddRequested() + else if (root.catalogRow && !root.localSourceRow) + QbzPlaylistPicker.openForTrack(root.item.id) + } else if (a === "remove") root.removeRequested() else if (a === "share-qobuz") root.copyToClipboard( "https://open.qobuz.com/track/" + (root.item.id || "")) @@ -676,6 +886,51 @@ Rectangle { // release plays (clickPlays) or ignores (album view: double-click). property bool dragging: false property point downPos: Qt.point(0, 0) + + // --- DRAG vs. DRAG-TO-SCROLL (owner finding 8) ----------------------- + // The reference does not solve this because it does not HAVE it: Slint's + // ListView is not mouse-drag-scrollable, so its row-body drag has no + // competitor. Qt's is — every one of these rows lives in a Flickable, and + // a Flickable filters its children's mouse events and takes the grab once + // the press travels past `QStyleHints.startDragDistance` (~10px). Our + // drag arms at 6px, so BOTH fire: the ghost appears, the list then takes + // the grab, this area gets `canceled` and never `released`, `dragging` + // stays true and the ghost never ends. + // + // The resolution is the one this port ALREADY made for the same collision + // in shell/QueuePanel.qml:479-491 — `preventStealing` scoped to the rows + // that are actually reorderable, described there as "the Qt equivalent of + // QueueSidebar.slint's `interactive: false` on the queue Flickable, but + // without that side effect: Qt's Flickable also drops WHEEL events when + // it is non-interactive, so wheel + scrollbar keep working, and only a + // press that starts ON a reorderable row is denied to the flick." Same + // trade here, same shape, so the two reorderable lists in the app behave + // identically: while a list is reorderable it is not drag-scrollable, and + // the wheel and the scrollbar carry scrolling. + // + // Scoped to `showReorder` on purpose: on every OTHER surface the row-body + // drag is the add-to-a-sidebar-playlist gesture, which shipped with the + // >6px arm and drag-scroll both live, and re-timing it is not this + // round's job. `onCanceled` below is the universal half — it costs no + // gesture change and it is what stops a stolen drag from stranding the + // ghost anywhere. + // + // Rejected: constraining the drag AXIS (a reorder drag is vertical, i.e. + // the same axis as the scroll — it separates nothing), and moving the + // drag source onto a grip handle (see the gutter above: 22 x 50 leaves no + // room beside the two chevrons, and the chevrons already are the non-drag + // affordance a grip would duplicate). + + /// Release / cancel teardown — ONE implementation, because the two used + /// to be able to disagree about whether the shared drag had ended. + function endBodyDrag() { + if (root.dragging) { + QbzShell.dragEnd() + root.dragging = false + return true + } + return false + } MouseArea { id: trArea anchors.fill: parent @@ -689,10 +944,15 @@ Rectangle { propagateComposedEvents: true // Right press opens the SAME menu as ⋯ (rowMenu), at the pointer. acceptedButtons: Qt.LeftButton | Qt.RightButton - cursorShape: root.clickPlays ? Qt.PointingHandCursor : Qt.ArrowCursor + cursorShape: (root.clickPlays || root.selectMode) ? Qt.PointingHandCursor : Qt.ArrowCursor + // Keep the grab on a reorderable row so the enclosing Flickable + // cannot turn a reorder drag into a scroll — see the block above. + preventStealing: root.showReorder && root.draggable && !root.selectMode onPressed: function (mouse) { if (mouse.button === Qt.RightButton) { - if (root.showMenu) + // TrackRow.slint:201 — the row menu is suppressed in + // multi-select mode (a right-click toggles nothing there). + if (root.showMenu && !root.selectMode) rowMenu.openAtCursor(trArea, mouse.x, mouse.y) mouse.accepted = true return @@ -701,7 +961,10 @@ Rectangle { } onPositionChanged: function (mouse) { // Only a LEFT press drags — a right press is the context gesture. - if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable) return + // Dragging a row onto a sidebar playlist is off in select mode + // (TrackRow.slint keeps the whole body as a selection target). + if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable + || root.selectMode) return const g = mapToItem(null, mouse.x, mouse.y) if (!root.dragging && (Math.abs(mouse.x - root.downPos.x) > 6 @@ -716,9 +979,15 @@ Rectangle { onReleased: function (mouse) { if (mouse.button === Qt.RightButton) return - if (root.dragging) { - QbzShell.dragEnd() - root.dragging = false + const wasDragging = root.endBodyDrag() + // In select mode the WHOLE row body is a selection target + // (TrackRow.slint:174) — never a play. + if (root.selectMode) { + root.toggleSelect() + mouse.accepted = true + return + } + if (wasDragging) { mouse.accepted = true } else if (root.clickPlays) { root.playRequested() @@ -726,7 +995,17 @@ Rectangle { mouse.accepted = false } } + // Grab lost (the Flickable stole it, the delegate was recycled, the + // window lost focus). Without this the shared drag stayed "active" + // with no way to end it and the ghost never disappeared. + onCanceled: root.endBodyDrag() onDoubleClicked: function (mouse) { + // ":156 — In select mode a double-click just selects (via + // `clicked`); no play." The single-click release above has already + // toggled twice by then, which lands back where it started, so the + // double-click must not add a third. + if (root.selectMode) + return if (mouse.button === Qt.LeftButton) root.playRequested() } diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index c8e587274..731eb0377 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -193,7 +193,13 @@ Rectangle { : QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml" // Settings > Blacklist > Manage. Not reachable from the // sidebar — blacklist_qt::open_manager records the route. - : QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml" : "" + : QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml" + // Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar + // section — playlist_manager_qt::navigate() records the route. + // The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller + // records the id, this arm mounts it, and the failure mode for a + // missing arm is a BLANK content pane, logged nowhere. + : QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml" : "" } // MyQbzGridView serves BOTH the "mixtapes" and "collections" routes @@ -548,9 +554,50 @@ Rectangle { AddToMixtapeModal { anchors.fill: parent } + // PlaylistPickerModal — the "Add to playlist" picker, opened from the + // queue footer (save the queue as a playlist) and the queue row menu. + // Same reason it lives out here as its Mixtape sibling: the surface that + // opened it is often gone by the time the user picks a target, and the + // queue panel in particular is `visible`-gated. + PlaylistPickerModal { + anchors.fill: parent + } + // PlaylistImportModal — the public-playlist importer (Spotify / Apple + // Music / Tidal / Deezer), opened from the sidebar `...` menu and from the + // closed-sidebar flyout. It mounts HERE and not in the sidebar by explicit + // contract (05 §5.8.5): closing it must not cancel an in-flight import, and + // neither surface that opens it has a lifetime guarantee across a + // navigation — the importer's own completion arm navigates to the imported + // playlist while the modal is still up. + PlaylistImportModal { + anchors.fill: parent + } MyQbzModals { anchors.fill: parent } + // FolderModals — the "New folder" create panel, the full folder editor and + // their delete confirm (contract D21). Out here for the same reason as its + // neighbours: it is opened from the SIDEBAR's "..." menu and row menu as + // well as from the Playlist Manager view, and the sidebar animates to + // width 0 with clip: true, so a modal parented into it would disappear + // with it. Self-gates on QbzFolderEdit.createJson / editJson, so while + // both are closed it is an invisible, non-interactive Item. + FolderModals { + anchors.fill: parent + } + // PlaylistEditModal — the ONE playlist editor (rename · description · + // offline-only · delete), for both `local:` and Qobuz playlists (contract + // D20/D21). Out here for the same reason as its neighbours: it is opened + // from the manager's cards and rows, from the SIDEBAR's row context menu + // and from the playlist detail header — and its delete arm navigates away + // from the page that raised it. It replaces the inline Popup that used to + // live in views/PlaylistView.qml, which could express neither a + // description nor the offline-only flag. Self-gates on + // QbzPlaylistEdit.editJson, so while closed it is an invisible, + // non-interactive Item. + PlaylistEditModal { + anchors.fill: parent + } QbzToast { anchors.fill: parent } diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index eff3cb4fd..08b4b6708 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -26,6 +26,22 @@ // POC-NOTE: the custom window-chrome parts (drag surface, drawn // min/max/close WindowControls) are skipped — the POC keeps NATIVE window // decorations. +// +// FILE LENGTH (>500): this is ONE bar with one placement state machine — +// `navInSidebar` x `navHeaderCompact` x the three sidebar widths decide which +// of the header's forms is mounted, and every block below reads that same +// state. The parts that are self-contained have already been extracted and are +// SHARED with the sidebar rather than duplicated: the section dropdown +// (shell/NavFlyout.qml), its leading glyph (shell/NavSectionGlyph.qml), the +// nav buttons (controls/QbzNavButton.qml) and the hover bubble +// (controls/QbzTooltip.qml, mounted once by AppShell). What remains is the +// bar's own layout — left controls, the absolutely-centred search, the status +// badge, the window controls and the app menu — each a positional slot of this +// single row, so splitting them would hand every half the same placement +// properties to keep in sync while none of them is reusable anywhere else. +// The closed-sidebar playlists flyout is the one candidate left: it is +// self-contained, but it reads `plBtn` for its anchor and the sidebar's shared +// search query, so it moves the day a second surface needs it. import QtQuick import QtQuick.Controls @@ -331,10 +347,16 @@ Rectangle { // 248px wide, a search header over the SAME Rust-side filter // the sidebar uses, then the flat entries (folders toggle in // place, playlists open and close the flyout). Six rows - // visible, the rest scroll. - // GAP: Slint's footer (Import / Manage playlists) is omitted — - // this port has neither seam, and the sidebar "..." menu marks - // the same two as unavailable rather than faking them. + // visible, the rest scroll, then Slint's two footer rows — + // Import and Manage playlists. + // + // Those two call the IDENTICAL invokables the sidebar "..." + // menu's rows do, and both surfaces close themselves and clear + // the SHARED search query before acting (this popup's onClosed + // already does the clearing). Wiring only one of them would + // re-create the gap this comment used to describe: the flyout + // is the only playlists surface a user with a closed sidebar + // has, so an entry point missing here is missing outright. Popup { id: plPopup y: plBtn.height + 6 @@ -480,6 +502,105 @@ Rectangle { } } } + + // ---- Footer (SidebarPlaylistsPopup.slint:189-269) --- + Rectangle { + width: parent.width + height: 1 + color: theme.borderSubtle + } + // Import — offline-gated exactly like the sidebar's + // "..." row. The importer modal carries its own offline + // banner as the second layer; the row alone is not the + // gate. + Rectangle { + id: plImportRow + readonly property bool rowEnabled: !QbzSession.offline + width: parent.width + height: 40 + radius: 5 + color: (plImportRow.rowEnabled && plImportArea.containsMouse) + ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + QbzIcon { + name: "import" + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: plImportRow.rowEnabled ? "secondary" : "muted" + } + Text { + width: parent.width - 23 + height: parent.height + text: QbzSession.tr("Import", QbzSession.trRev) + color: plImportRow.rowEnabled ? theme.textSecondary : theme.textMuted + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: plImportArea + anchors.fill: parent + enabled: plImportRow.rowEnabled + hoverEnabled: plImportRow.rowEnabled + cursorShape: plImportRow.rowEnabled + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + QbzPlaylistImport.open() + // Closing clears the shared search query + // (plPopup.onClosed) so it does not silently + // scope the expanded sidebar's list later. + plPopup.close() + } + } + } + // Manage playlists — always available; the manager is a + // local-store surface and the only place a hidden + // playlist can be un-hidden. + Rectangle { + id: plManageRow + width: parent.width + height: 40 + radius: 5 + color: plManageArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + QbzIcon { + name: "library-big" + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + width: parent.width - 23 + height: parent.height + text: QbzSession.tr("Manage playlists", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: plManageArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + QbzPlaylistManager.navigate() + plPopup.close() + } + } + } } } } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 753e92cad..42004dcd3 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -495,11 +495,23 @@ Rectangle { "icon": root.npFavorite ? "heart-filled" : "heart", "action": "favorite" }, - { "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" }, { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, { "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }, { "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }, ] + // "Add to playlist" is SECOND (TransportControls.slint:143 — the + // full bar carries the rationale). Same `npSource` gate as the + // mixtape entry: the picker's Qobuz arm takes catalog ids and an + // `isLocal` current row is four different id spaces this bar + // cannot tell apart, so unknown provenance drops the entry rather + // than rendering one that cannot work. + if (root.npSource === "qobuz") { + m.splice(1, 0, { + "label": QbzSession.tr("Add to playlist", QbzSession.trRev), + "icon": "list-music", + "action": "playlist" + }) + } // Only when the track's source is KNOWN (see `npSource`). if (root.npSource !== "") { m.push({ @@ -523,6 +535,10 @@ Rectangle { if (id !== "") QbzQueue.queueToggleFavorite("track", id) } else if (a === "queue" || a === "later" || a === "next") { if (id !== "") QbzPlayer.enqueueTrack(id, a) + } else if (a === "playlist") { + // Only built when `npSource === "qobuz"`, so `id` is a catalog + // id here by the gate above. + if (id !== "") QbzPlaylistPicker.openForTrack(id) } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) @@ -540,9 +556,6 @@ Rectangle { "year": null, "trackCount": null }])) } - // TODO(qt-bridge): "playlist" (add-to-playlist modal) has no - // invokable in the Qt port yet — the row is rendered 1:1 with the - // Slint flyout and does nothing for now. } } } diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index d491ef466..ef2ad7be0 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -561,11 +561,25 @@ Rectangle { "icon": root.npFavorite ? "heart-filled" : "heart", "action": "favorite" }, - { "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" }, { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, { "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }, { "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }, ] + // "Add to playlist" sits SECOND in TransportControls.slint:143 — + // spliced in there rather than appended, because the flyout's + // order is part of the parity. It rides the SAME `npSource` gate + // as "Add to mixtape" and for the same reason: the picker's Qobuz + // arm takes catalog ids, and an `isLocal` current row is any of + // four different id spaces this bar cannot tell apart (see + // `npSource`). Unknown provenance -> the entry is ABSENT, never + // rendered-and-inert. + if (root.npSource === "qobuz") { + m.splice(1, 0, { + "label": QbzSession.tr("Add to playlist", QbzSession.trRev), + "icon": "list-music", + "action": "playlist" + }) + } // Only when the track's source is KNOWN (see `npSource`): a row we // cannot address is not offered. if (root.npSource !== "") { @@ -590,6 +604,10 @@ Rectangle { if (id !== "") QbzQueue.queueToggleFavorite("track", id) } else if (a === "queue" || a === "later" || a === "next") { if (id !== "") QbzPlayer.enqueueTrack(id, a) + } else if (a === "playlist") { + // The entry only exists when `npSource === "qobuz"`, so `id` + // is a catalog id here by the gate above. + if (id !== "") QbzPlaylistPicker.openForTrack(id) } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) @@ -608,9 +626,6 @@ Rectangle { "year": null, "trackCount": null }])) } - // TODO(qt-bridge): "playlist" (add-to-playlist modal) has no - // invokable in the Qt port yet — the row is rendered 1:1 with the - // Slint flyout and does nothing for now. } } } diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index fb17f182a..fe4e08df5 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -2,16 +2,19 @@ // crates/qbz-ui/ui/shell/QueueSidebar.slint. // // Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the -// #442 "Next in queue" / "Next up" section markers, 40-row pagination, -// live search filter, row actions (play / remove / remove-all-after / +// #442 "Next in queue" / "Next up" section markers and the "stop after this +// song" marker, 40-row pagination, live search filter, row actions (play / +// remove / remove-all-after / stop-after / add-to-playlist / track info / // heart), drag reorder (6px press-drag, drop indicator + floating ghost), -// footer (count line + Clear / stubs + search field), History tab -// (thumbnail rows, click replays as a fresh single-track queue), exact -// empty states. Data: QbzQueue.queueJson (queue_qt.rs QueueDoc). +// footer (count line + Clear / save-as-playlist / infinite play / sleep +// timer + search field), History tab (thumbnail rows, click replays as a +// fresh single-track queue), exact empty and no-results states. +// Data: QbzQueue.queueJson (queue_qt.rs QueueDoc) + the two scalar sleep +// properties on the same singleton. // -// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert), -// infinite-play + sleep timer engines, stop-after marker, row menu's -// "Add to playlist" / "Track info" entries, ephemeral rows. +// EPHEMERAL rows drop every PERSISTENCE affordance (heart, Add to playlist, +// Track info) exactly as QueueSidebar.slint gates on `item.is-ephemeral`: +// an ephemeral play must leave nothing behind inside QBZ. import QtQuick import QtQuick.Controls @@ -32,6 +35,34 @@ Rectangle { readonly property var upcoming: doc.upcoming || [] readonly property var historyRows: doc.history || [] readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0 + // "Stop after this song": the decimal id of the marked track, "" for none. + readonly property string stopAfterId: doc.stopAfterId || "" + // A search is active but nothing matched — a different state from an empty + // queue, and the reason the reference echoes the query back. + readonly property bool noSearchResults: !queueEmpty + && (doc.searchQuery || "") !== "" && (doc.upcomingTotal || 0) === 0 + // The now-playing track is ephemeral -> the queue cannot be persisted, so + // the footer's save-as-playlist disappears (QueueSidebar.slint:850). + readonly property bool currentEphemeral: currentRow !== null && currentRow.isEphemeral === true + + // The panel is `visible`-gated rather than conditionally mounted, so this + // is the equivalent of the reference's `init => QueueState.panel-opened()`: + // pull a fresh snapshot each time it comes back on screen, because the + // queue can change while it is hidden (a session restore, a play from any + // view). Cheap — an unchanged document never reaches Qt. + onVisibleChanged: { + if (!visible) + return + QbzQueue.queuePanelOpened() + // AND re-dispatch the covers. `onDocChanged` alone is not enough: the + // panel is `visible`-gated rather than mounted on demand, and a queue + // that was published while it was hidden leaves the covers never + // requested — measured live, a local track's now-playing thumbnail + // stayed blank with NO artwork dispatch after the publish. The + // publish dedup means `queuePanelOpened` above often changes nothing, + // so it cannot be relied on to trigger the doc change either. + dispatchCovers() + } // --- Favourite state, id-keyed (the ArtistView.localToggles idiom) --- // The rows here are plain JS objects parsed out of `doc`, so the old @@ -240,15 +271,33 @@ Rectangle { anchors.bottomMargin: 4 spacing: 9 - // Leading: track number (UP NEXT) or thumbnail (History). - Text { + // Leading: track number (UP NEXT) or thumbnail (History). On the + // row carrying the "stop after this song" marker the number is + // REPLACED by the accent CircleStop — same slot, so the list never + // reflows when a marker is placed or cleared. + Item { visible: showNumber width: 22 - anchors.verticalCenter: parent.verticalCenter - text: rowIndex + 1 + (doc.page || 0) * 40 - color: theme.textMuted - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter + height: parent.height + readonly property bool marked: qrRoot.inQueue + && root.stopAfterId !== "" && row.id === root.stopAfterId + Text { + visible: !parent.marked + anchors.centerIn: parent + width: parent.width + text: rowIndex + 1 + (doc.page || 0) * 40 + color: theme.textMuted + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + } + QbzIcon { + visible: parent.marked + anchors.centerIn: parent + name: "circle-stop" + width: 13 + height: 13 + tintName: "accent" + } } Rectangle { visible: !showNumber @@ -346,12 +395,40 @@ Rectangle { QbzContextMenu { id: rowMenu menuWidth: 196 + // QueueSidebar.slint's track menu, in its order. The + // last entry is this port's own addition (the reference + // has no heart in the queue row menu) and is kept + // BELOW the ported set so the 1:1 block reads intact. + // EPHEMERAL rows drop the two persistence entries, the + // same gate the .slint applies (and the reason its menu + // is 105px tall instead of 175px on those rows). Repeater { - model: [ - { "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" }, - { "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" }, - { "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" }, - ] + model: { + var items = [ + { "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" }, + { "label": row.id === root.stopAfterId + ? QbzSession.tr("Cancel stop after this", QbzSession.trRev) + : QbzSession.tr("Stop after this", QbzSession.trRev), "icon": "circle-stop", "action": "stop-after" }, + { "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" }, + ] + if (row.isEphemeral !== true) { + // LOCAL/Plex rows drop it too: the queue's + // `add_to_playlist` refuses them + // (queue_qt.rs — a queue row's id is a + // library.db / Plex synthetic id and this + // panel has no source-aware resolver to + // turn it into a local-mode ref), so the + // entry would render and no-op. Absent + // beats dead; the LOCAL surfaces that DO + // have the resolver offer it instead + // (local/LocalTrackRow.qml). + if (row.isLocal !== true) + items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" }) + items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" }) + items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" }) + } + return items + } delegate: Rectangle { required property var modelData width: parent ? parent.width : 0 @@ -382,7 +459,10 @@ Rectangle { rowMenu.close() var a = modelData.action if (a === "remove") QbzQueue.queueRemoveUpcoming(rowIndex) + else if (a === "stop-after") QbzQueue.queueToggleStopAfter(row.id) else if (a === "remove-after") QbzQueue.queueRemoveAllAfter(rowIndex) + else if (a === "add-to-playlist") QbzQueue.queueAddToPlaylist(rowIndex) + else if (a === "track-info") qrRoot.openTrackInfo() else if (a === "favorite") root.toggleFav(row) } } @@ -501,6 +581,27 @@ Rectangle { radius: 1 color: theme.accent } + + // --- Track info (rows/TrackRow.qml's lazy-Loader idiom) ------------- + // A full Popup tree per delegate would cost 40 of them on a full page, + // so it is activated on demand and torn down on close (Qt.callLater so + // the Popup is not destroyed inside its own signal). + Loader { + id: trackInfoLoader + active: false + sourceComponent: TrackInfoModal { } + } + Connections { + target: trackInfoLoader.item + ignoreUnknownSignals: true + function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) } + } + function openTrackInfo() { + if (!qrRoot.row || !qrRoot.row.id) + return + trackInfoLoader.active = true + trackInfoLoader.item.openFor(qrRoot.row.id) + } } Column { @@ -637,7 +738,11 @@ Rectangle { } } Column { - width: parent.width - 34 - 28 - 2 * 9 + // A Row SKIPS invisible children, so the + // heart's 28px + its 9px gap come back to + // the text when it is hidden. + width: parent.width - 34 - 9 + - (root.currentEphemeral ? 0 : 28 + 9) anchors.verticalCenter: parent.verticalCenter spacing: 2 Text { @@ -656,8 +761,12 @@ Rectangle { elide: Text.ElideRight } } - // Favorite toggle (wired). + // Favorite toggle. HIDDEN on an ephemeral + // track: there is no DB row to favourite, and + // an ephemeral play must leave nothing behind + // (QueueSidebar.slint:563-565). Rectangle { + visible: !root.currentEphemeral width: 28 height: 28 radius: theme.radiusSm @@ -797,6 +906,20 @@ Rectangle { wrapMode: Text.WordWrap } } + + // No-results state — a search is active but nothing + // matched. Distinct from the empty queue above: the queue + // still has tracks, they are just filtered out, so telling + // the user to "play an album to get started" would be a lie. + Text { + visible: root.noSearchResults + width: parent.width - 20 + topPadding: 32 - 10 + text: QbzSession.tr("No tracks match your search", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + } } } @@ -880,24 +1003,248 @@ Rectangle { readonly property int contentWidth: width - leftPadding - rightPadding - // Action 1 — Clear queue (wired). + // Action 1 — Clear queue. QbzIconButton { btnSize: 30 name: "trash-list" onClicked: QbzQueue.queueClear() } - // Action 2 — Save as playlist (INERT: opens a picker modal - // upstream — POC-NOTE). - QbzIconButton { btnSize: 30; name: "add-to-list" } - // Action 3 — infinite play (INERT engine — POC-NOTE). - QbzIconButton { btnSize: 30; name: "infinity" } - // Action 4 — sleep timer (INERT engine — POC-NOTE). - QbzIconButton { btnSize: 30; name: "clock" } + // Action 2 — Save the queue as a playlist: seeds the shared + // Add-to-Playlist picker with the whole queue, whose inline + // "Create new playlist" row is what names it. HIDDEN while an + // ephemeral track plays — an ephemeral queue cannot be + // persisted (QueueSidebar.slint:848-858). + QbzIconButton { btnSize: 30 + name: "add-to-list" + visible: !root.currentEphemeral + onClicked: QbzQueue.queueSaveAsPlaylist() + } + // Action 3 — infinite play (accent while armed). + QbzIconButton { btnSize: 30 + name: "infinity" + active: doc.infinitePlay === true + onClicked: QbzQueue.queueToggleInfinitePlay() + } + // Action 4 — sleep timer. Accent while armed; the popover + // opens ABOVE the footer with the preset picker / countdown. + QbzIconButton { + id: sleepBtn + btnSize: 30 + name: "clock" + active: QbzQueue.sleepActive + onClicked: sleepMenu.opened ? sleepMenu.close() : sleepMenu.open() + + Popup { + id: sleepMenu + // Anchored to the button's top-left and pushed up by + // its own height, so it grows upward out of the footer + // (the .slint hard-codes x:-188 y:-214 for the same + // effect; here the height is not a magic number). + x: -188 + y: -height - 6 + width: 220 + padding: 0 + // A click INSIDE must not dismiss it, or a preset + // could never be picked before Set is pressed — the + // same reason the .slint sets close-on-click-outside. + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + background: Rectangle { + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + } + // Local until Set is pressed: the preset choice is a + // draft, and the reference keeps it in UI state too. + property int selectedIndex: 0 + property string customMinutes: "60" + + contentItem: Column { + padding: 12 + spacing: 10 + + // Armed: countdown + Cancel. + Column { + visible: QbzQueue.sleepActive + width: 196 + spacing: 8 + Text { + text: QbzSession.tr("Stops in", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + } + Text { + text: QbzQueue.sleepRemaining + color: theme.accent + font.pixelSize: 16 + font.weight: theme.weightSemibold + } + Rectangle { + width: parent.width + height: 32 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Text { + anchors.fill: parent + text: QbzSession.tr("Cancel Timer", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + QbzQueue.sleepTimerCancel() + sleepMenu.close() + } + } + } + } + + // Idle: preset picker (+ custom minutes) + Set. + Column { + visible: !QbzQueue.sleepActive + width: 196 + spacing: 10 + Text { + text: QbzSession.tr("Stop playback after:", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: [ + QbzSession.tr("30 min", QbzSession.trRev), + QbzSession.tr("1 hr", QbzSession.trRev), + QbzSession.tr("2 hr", QbzSession.trRev), + QbzSession.tr("3 hr", QbzSession.trRev), + QbzSession.tr("5 hr", QbzSession.trRev), + QbzSession.tr("Custom…", QbzSession.trRev) + ] + delegate: Rectangle { + required property var modelData + required property int index + width: parent ? parent.width : 0 + height: 30 + radius: theme.radiusSm + color: sleepMenu.selectedIndex === index + ? theme.accent + : (presetArea.containsMouse ? theme.surfaceHover : "transparent") + Text { + x: 10 + width: parent.width - 20 + height: parent.height + text: modelData + color: sleepMenu.selectedIndex === index + ? theme.accentText : theme.textPrimary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + MouseArea { + id: presetArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: sleepMenu.selectedIndex = index + } + } + } + } + // Custom minutes — revealed by the last preset. + // A bare TextInput in a styled Rectangle, the + // AddToMixtapeModal convention (and what the + // .slint does): QbzLineEdit has no numeric arm. + Rectangle { + width: parent.width + height: 32 + visible: sleepMenu.selectedIndex === 5 + radius: theme.radiusSm + border.width: 1 + border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle + color: theme.surfaceElevated + TextInput { + id: customInput + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 34 + color: theme.textPrimary + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + clip: true + selectByMouse: true + // Digits only, so "Set" can never be + // handed something `parseInt` reads as + // NaN (the Rust side clamps to + // [1, 1440] on top of this). + validator: IntValidator { bottom: 1; top: 1440 } + inputMethodHints: Qt.ImhDigitsOnly + text: sleepMenu.customMinutes + onTextEdited: sleepMenu.customMinutes = text + Binding { + target: customInput + property: "text" + value: sleepMenu.customMinutes + when: !customInput.activeFocus + } + } + Text { + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("min", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + } + } + Rectangle { + width: parent.width + height: 34 + radius: theme.radiusSm + color: setArea.containsMouse ? theme.accentHover : theme.accent + Text { + anchors.fill: parent + text: QbzSession.tr("Set Timer", QbzSession.trRev) + color: theme.accentText + font.pixelSize: 12 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + MouseArea { + id: setArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var mins = [30, 60, 120, 180, 300][sleepMenu.selectedIndex] + if (mins === undefined) + mins = parseInt(sleepMenu.customMinutes, 10) + if (!isNaN(mins) && mins > 0) + QbzQueue.sleepTimerSet(mins) + sleepMenu.close() + } + } + } + } + } + } + } // Filler so the collapsed magnifier sits at the right edge; - // the field opens LEFT across it (5 gaps of spacingXs). + // the field opens LEFT across it. One gap per visible control + // (save-as-playlist disappears on an ephemeral queue). Item { - width: Math.max(0, footerRow.contentWidth - 4 * 30 - 30 - - 5 * theme.spacingXs) + width: Math.max(0, footerRow.contentWidth + - (root.currentEphemeral ? 3 : 4) * 30 - 30 + - (root.currentEphemeral ? 4 : 5) * theme.spacingXs) height: 1 } // Inline queue search (wired) — the shared expandable control. diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index f72e95e75..dedc5c058 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -15,13 +15,18 @@ // flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot // expand in place, so a toggle there would be a control that does nothing. // -// FILE LENGTH (>500): the mini folder flyout is a shared single instance -// declared at this root (never one Popup per row), which is what keeps it -// cheap; the hover bubble is not here at all any more — it is the shell's -// shared QbzTooltip overlay. The flyout is an extraction candidate into -// qml/shell/SidebarFolderFlyout.qml, but a new .qml also needs a build.rs -// entry and an unregistered type fails the WHOLE file at load, so they stay -// here until that glue lands. +// FILE LENGTH (>500): what remains after the mini folder flyout moved to +// shell/SidebarFolderFlyout.qml and the row context menu to +// shell/SidebarRowMenu.qml is ONE surface with one state machine — the three +// sidebar widths. Every block below is a WIDTH-CONDITIONAL variant of the same +// tree (the nav rows, the PLAYLISTS toolbar, the row delegate and the +// scrollbar each read `root.mini`), so splitting them into files would hand +// each half a `mini` property to keep in sync and put the row delegate's +// drop-target arithmetic — which needs `root`'s own window coordinates — a +// file away from the root it measures. The three things that DID come out are +// the three that are self-contained: both overlays and the hover bubble (the +// shell's shared QbzTooltip). All three are shared single instances declared +// at this root, never one per row: the tree can hold hundreds of rows. // // Top-level section nav rows (Discover / Library / Local Library / My QBZ) // replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500 @@ -45,14 +50,26 @@ // nothing uses it today (a row that clicks into nothing is a defect, not a // stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My // QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`). -// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, -// sort, search, expand/collapse, drag-drop target); folder CREATION, -// the Playlist Manager and the importer have no seam and are disabled in -// the "..." menu. +// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, sort, +// search, expand/collapse, drag-drop target), and so is everything the "..." +// menu offers: New folder opens QbzFolderEdit's create panel (the small +// name-only create modal, controls/FolderModals.qml), Manage playlists routes +// to the Playlist Manager, Import opens the importer and Refresh rebuilds the +// tree. Import is the only row that dims — it is the only Qobuz-only one, so it +// follows the reference's `enabled: !offline`; Refresh stays lit because its +// bridge call is the offline-safe rebuild (see the Repeater's comment). Nothing +// in this file renders a control that no-ops. +// +// Right-pressing a row opens shell/SidebarRowMenu.qml (edit / hide / mixtape / +// move-to-folder). It is suppressed on the mini rail: Slint anchors that menu +// at `row.width - 210px`, which on a 34px rail row is deeply negative and Qt's +// viewport clamp would drop it at x=8 — not the reference's placement, just +// the clamp. import QtQuick import QtQuick.Controls import com.blitzfc.qbz +import "../controls" import "../theme" Rectangle { @@ -184,33 +201,31 @@ Rectangle { // Mini folder click: the flyout, not a toggle (the nested rows are hidden, // so toggling is a control that renders and does nothing — Slint opens the // popup instead, Sidebar.slint:355-377). + // + // It no longer force-expands the folder on the way in. That used to be + // necessary because the flyout filtered the published `entries`, which + // carry a folder's children ONLY while it is expanded — a persistent side + // effect (the folder stayed open once the sidebar was re-opened) the + // reference does not have. The flyout now asks Rust for a dedicated + // cache-built document instead (SidebarFolderFlyout.qml / §4.7). function openFolderFlyout(row, entry) { - folderFlyout.folderId = entry.id - folderFlyout.folderName = entry.name - folderFlyout.folderCount = entry.count - // The children are only present in the published entries while the - // folder is EXPANDED. Expand it if it is not — never toggle blindly: - // a folder left open before the sidebar collapsed would be CLOSED by a - // toggle and the flyout would list nothing (and while a search query is - // active every folder is force-expanded, sidebar_qt.rs:299). - if (!entry.expanded) QbzShell.sidebarToggleFolder(entry.id) - var p = row.mapToItem(root, row.width + 6, 0) - folderFlyout.x = p.x - folderFlyout.y = p.y // The flyout takes the same +6px slot the bubble occupies, and it is a // Popup (window overlay) so it would paint straight over it. Slint drops // the tooltip explicitly here too (Sidebar.slint:353). if (root.tooltip) root.tooltip.hideAll() - folderFlyout.open() + folderFlyout.openFor(entry, row) } - // Cycling the sidebar while either is open would leave it floating over a - // rail that no longer exists (the bubble self-hides through `root.mini`; - // the flyout needs telling). + // Cycling the sidebar while any of the three is open would leave it + // floating over a rail that no longer exists (the bubble self-hides + // through `root.mini`; the two Popups need telling). Connections { target: QbzShell function onSidebarStateChanged() { folderFlyout.close() + // The row menu is row-anchored and the rows are about to change + // width — or, going to state 2, stop existing. + rowMenu.close() // The bubble's anchor is a rail row that is about to move or stop // existing (SidebarTooltip.slint:14 gates on sidebar-mini for the // same reason). @@ -238,157 +253,34 @@ Rectangle { // TooltipState, state.slint:4791), precisely so a clipped surface — and // this sidebar clips — can still show a bubble outside its own bounds. - // Folder flyout — SidebarFolderPopup.slint: 230px, 30px header row + - // hairline + up to four 32px rows, 4px inner padding, surface-main / - // radius sm / 1px border-muted. - Popup { + // Folder flyout (mini rail) and the row context menu — ONE shared instance + // each, declared at this root, never one per delegate: the tree can hold + // hundreds of rows. Both are Popups, so their content is reparented to the + // WINDOW overlay and this root's `clip: true` cannot cut them off. + SidebarFolderFlyout { id: folderFlyout - width: 230 - padding: 0 - topPadding: 4 - bottomPadding: 4 - // Slint's literal panel height (34 + 1 + list + 8) — 4px taller than - // the content, kept 1:1 rather than tightened. - height: 34 + 1 + folderFlyout.visibleRows * 32 + 8 - // Replaces Slint's full-window scrim TouchArea (SidebarFolderPopup - // .slint:56-60); the Popup's own background swallows clicks inside, - // so no hand-built scrim and no click-through. - closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + onPlaylistActivated: function (id) { root.activePlaylistId = id } + } - property string folderId: "" - property string folderName: "" - property int folderCount: 0 + // The canonical folder list, parsed once. `foldersJson` is the Playlist + // Manager's property because the manager owns the rich folder record, but + // it is kept fresh independently of the manager's document cache + // (refresh_folders()), so the sidebar can read it before — and without — + // the manager view ever being opened. + readonly property var pmFolders: { + try { return JSON.parse(QbzPlaylistManager.foldersJson) } catch (e) { return [] } + } + // Hidden folders are dropped EVERYWHERE in the sidebar tree, so the row + // menu must not offer them, must not count them toward its search + // threshold and must not let them suppress its empty state. Derived once, + // here, and read three times over there. + readonly property var visibleFolders: root.pmFolders.filter(function (f) { + return f.isHidden !== true + }) - // The rows are a BINDING over the published entries, never a snapshot - // read inside the click handler: sidebarToggleFolder republishes - // sidebarJson through the cxx-qt UI queue (shell_bridge.rs::ui -> - // CxxQtThread::queue), so the children land a LATER event-loop turn. - // Reading `root.entries` at click time would open an empty panel. - readonly property var rows: root.entries.filter(function (e) { - return e.kind !== "folder" && e.folderId === folderFlyout.folderId - }) - // Height off the folder's own count (known at click time) so the panel - // does not resize when the children land a tick later. - readonly property int visibleRows: - Math.min(Math.max(folderFlyout.folderCount, folderFlyout.rows.length), 4) - - background: Rectangle { - color: theme.surfaceMain - radius: theme.radiusSm - border.width: 1 - border.color: theme.borderMuted - } - contentItem: Column { - spacing: 0 - - // Header — folder name (30px, 12px side padding, 14px accent - // folder-open glyph, 13px/600 text-primary). - Item { - width: parent.width - height: 30 - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - spacing: 8 - QbzIcon { - name: "folder-open" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - tintName: "accent" - } - Text { - width: parent.width - 22 - height: parent.height - text: folderFlyout.folderName - color: theme.textPrimary - font.pixelSize: 13 - font.weight: theme.weightSemibold - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - } - Rectangle { - width: parent.width - height: 1 - color: theme.borderSubtle - } - - // Playlist list — four rows visible, the rest scroll (the shared - // QbzScrollBar mounts past four, like Slint's ListScrollbar). - Item { - width: parent.width - height: folderFlyout.visibleRows * 32 - - Flickable { - id: fpFlick - anchors.fill: parent - clip: true - contentWidth: width - contentHeight: fpColumn.implicitHeight - boundsBehavior: Flickable.StopAtBounds - - Column { - id: fpColumn - width: parent.width - Repeater { - model: folderFlyout.rows - delegate: Rectangle { - id: fpRow - required property var modelData - width: fpColumn.width - height: 32 - radius: 5 - color: fpArea.containsMouse ? theme.surfaceHover : "transparent" - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - spacing: 8 - QbzIcon { - name: "list-music" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - Text { - width: parent.width - 22 - height: parent.height - text: fpRow.modelData.name - color: theme.textSecondary - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - MouseArea { - id: fpArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.activePlaylistId = fpRow.modelData.id - QbzBridge.openPlaylist(fpRow.modelData.id) - folderFlyout.close() - } - } - } - } - } - } - QbzScrollBar { - visible: folderFlyout.folderCount > 4 && fpFlick.contentHeight > fpFlick.height - target: fpFlick - width: 10 - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - } - } - } + SidebarRowMenu { + id: rowMenu + visibleFolders: root.visibleFolders } width: QbzShell.sidebarState === 2 ? 0 @@ -604,9 +496,17 @@ Rectangle { } } } - // New playlist (+) — single core call (create_playlist), then + // New playlist (+) — single bridge call (create_playlist), then // reload the tree. Slint opens a naming modal; the POC creates // with the default name (POC-NOTE). + // + // NOT offline-gated, and that is deliberate: `crate::create_playlist` + // branches on connectivity and creates a LOCAL playlist while + // offline (the reference's D8 — its create modal locks the + // offline-only toggle ON there). Dimming this row would take the + // ONE way an account-less user makes a playlist away from exactly + // the state they live in. Before that branch existed the button was + // lit and its whole offline effect was a log line. Rectangle { width: 22 height: 22 @@ -649,15 +549,22 @@ Rectangle { } Popup { id: sortMenu - // Opens to the RIGHT of the "..." (overlay into the - // content area) — owner request in Sidebar.slint. - x: parent.width + 18 + // Slint puts this panel's left edge at `toolbar.width - + // 40px`. Resolved: the open sidebar's content column is + // 240 - 2*16 = 208 wide and the "..." button occupies + // 160…182 of it, so 208 - 40 = 168 is the button's own + // x + 8. This Popup is parented to the BUTTON, not to the + // row, so the 1:1 value is x: 8 — the old `parent.width + + // 18` (= 40) put it 32px too far right. + x: 8 y: 26 width: 200 padding: 6 closePolicy: Popup.CloseOnPressOutside background: Rectangle { - color: theme.surfaceCard + // Matches this root's own ambient treatment + // (Sidebar.slint:920-922). + color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard radius: 8 border.width: 1 border.color: theme.borderSubtle @@ -727,41 +634,104 @@ Rectangle { } } Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } - // New folder / Manage playlists / Import — DISABLED. - // GAP, not a half-path: this port has no folder - // creation seam (`sidebar_qt.rs` exposes only - // load/sort/search/toggle-folder — no create/delete), - // no Playlist Manager view, and no importer. They used - // to render as live-looking rows (hover highlight + - // pointing-hand cursor) whose click did nothing at - // all; a control that renders and no-ops is a defect, - // so they now read as unavailable: dimmed, no hover - // treatment, no pointer cursor, no MouseArea. Restore - // the interactive form together with the seam. + // New folder / Manage playlists / Import / Refresh — + // all four LIVE (the Refresh row was missing from this + // port entirely and is restored here). + // + // `on` is the enabled flag and it drives everything at + // once: the hover fill, the pointer cursor, the glyph + // tint, the 0.4 dim AND whether the MouseArea exists. + // There is no way to light one row and leave another + // inert without it. + // + // Import is the only row that can be off, and only + // while offline — importing a playlist is Qobuz-only + // work. + // + // REFRESH STAYS LIT OFFLINE (reversed 2026-07-31 on the + // owner's smoke). The earlier decision dimmed it + // because QbzShell.reloadSidebar() called + // crate::reload_sidebar(), which early-returns offline + // — a lit row would have been a no-op, and a control + // that renders and no-ops is a defect. The bridge now + // calls crate::reload_sidebar_including_local() + // instead, which offline re-reads folders, folder + // membership, the hidden set and the LOCAL playlists + // from library.db and republishes (preserving the + // cached Qobuz set). That is a real refresh, and it is + // precisely the tree an account-less user owns — the + // owner hit an empty offline sidebar with no way to + // rebuild it. + // + // Every arm closes the menu FIRST. Slint's PopupWindow + // dismisses on any click, including inside itself, + // which is why its own items never call close(); Qt's + // CloseOnPressOutside does the opposite. This is the + // single most common silent divergence in this seam. Repeater { model: [ - { "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) }, - { "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev) }, - { "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev) }, + { "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev), + "act": "newFolder", "on": true }, + { "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev), + "act": "manage", "on": true }, + // LIVE. The importer landed with this seam + // (src/playlist_import_bridge.rs + + // controls/PlaylistImportModal.qml), so contract + // §10 Q2 resolves to "land the importer" and + // there is no `typeof QbzPlaylistImport` guard + // here: the row is a real row, dimmed only + // while offline — the only row that dims. + { "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev), + "act": "import", "on": !QbzSession.offline }, + { "icon": "refresh-cw", "label": QbzSession.tr("Refresh", QbzSession.trRev), + "act": "refresh", "on": true }, ] delegate: Rectangle { + id: actRow required property var modelData + readonly property bool rowEnabled: modelData.on === true width: parent ? parent.width : 0 height: 30 radius: 5 - color: "transparent" - opacity: 0.4 + color: (actRow.rowEnabled && actArea.containsMouse) + ? theme.surfaceHover : "transparent" + opacity: actRow.rowEnabled ? 1.0 : 0.4 Row { anchors.fill: parent anchors.leftMargin: 8 + anchors.rightMargin: 8 spacing: 8 - QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" } + QbzIcon { + name: actRow.modelData.icon + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: actRow.rowEnabled ? "secondary" : "muted" + } Text { + width: parent.width - 22 height: parent.height - text: modelData.label - color: theme.textMuted + text: actRow.modelData.label + color: actRow.rowEnabled ? theme.textSecondary : theme.textMuted font.pixelSize: 13 verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: actArea + anchors.fill: parent + enabled: actRow.rowEnabled + hoverEnabled: actRow.rowEnabled + cursorShape: actRow.rowEnabled + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + sortMenu.close() + var a = actRow.modelData.act + if (a === "newFolder") QbzFolderEdit.openCreate() + else if (a === "manage") QbzPlaylistManager.navigate() + else if (a === "import") QbzPlaylistImport.open() + else if (a === "refresh") QbzShell.reloadSidebar() } } } @@ -870,12 +840,18 @@ Rectangle { : 32 visible: !(root.mini && modelData.indent) radius: 6 - // success-bg + success-border while hot (Theme - // success family, #3fae6a at 10% / 30%). - color: dropHot ? "#3fae6a1a" + // success-bg + success-border while hot. These used + // to be the Slint literals "#3fae6a1a" / + // "#3fae6a4d" copied verbatim, which is a real + // colour bug: Slint's 8-digit form is #RRGGBBAA and + // Qt's is #AARRGGBB, so the drop target rendered + // olive-brown at 25% alpha instead of success green + // at 15% / 35%. The tokens carry the converted + // values (theme.successBg = #263fae6a). + color: dropHot ? theme.successBg : ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent") border.width: dropHot ? 1 : 0 - border.color: "#3fae6a4d" + border.color: theme.successBorder Row { anchors.fill: parent @@ -967,7 +943,12 @@ Rectangle { - (isFolder ? 22 + (countLabel.visible ? countLabel.implicitWidth + 8 : 0) - : 0)) + : 0) + // The local mark is a sibling AFTER + // this Text, so its width has to come + // out of the name's reserve or the + // glyph lands past the row's clip. + - (modelData.isLocal === true ? 20 : 0)) height: parent.height text: modelData.name color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary @@ -976,6 +957,18 @@ Rectangle { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + // LOCAL playlist mark. A local playlist has no + // Qobuz side at all — this is how the row says + // so, and it is the only visual difference + // between the two kinds in the list. + QbzIcon { + visible: !root.mini && modelData.isLocal === true + name: "hard-drive" + width: 12 + height: 12 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } // Folder count (hover) + chevron. The count is // hover-FADED, not hidden — opacity does not // free layout space, so it must stay in the @@ -1014,16 +1007,39 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + // Right-press anywhere on the row opens its + // context menu (Sidebar.slint:389-397). The old + // hover "..." button was removed upstream + // because the scrollbar overlapped it and ate + // the click — do not reintroduce it. + acceptedButtons: Qt.LeftButton | Qt.RightButton // Mini: the row is an icon, so hovering names // it (Sidebar.slint:342-349). No-op while open. onContainsMouseChanged: { if (containsMouse) root.showMiniTip(plRow, plRow.modelData) else root.hideMiniTip(plRow.modelData.id) } - onClicked: { + onClicked: function (mouse) { // Slint drops the bubble on click so it // does not linger behind the flyout. root.hideMiniTip(plRow.modelData.id) + if (mouse.button === Qt.RightButton) { + // SUPPRESSED ON THE RAIL. Slint anchors + // this menu at `row.width - 210px`, + // which on a 34px rail row is deeply + // negative; Qt's viewport clamp would + // silently drop it at x=8, which is not + // the reference's placement, just the + // clamp. + if (root.mini) + return + var lx = plRow.width - rowMenu.menuWidth + if (isFolder) + rowMenu.openForFolder(plRow.modelData, plRow, lx, 28) + else + rowMenu.openForPlaylist(plRow.modelData, plRow, lx, 28) + return + } if (isFolder) { // Mini: the nested rows are hidden, so // a toggle would change NOTHING on diff --git a/crates/qbz-qt/qml/shell/SidebarFolderFlyout.qml b/crates/qbz-qt/qml/shell/SidebarFolderFlyout.qml new file mode 100644 index 000000000..d29459786 --- /dev/null +++ b/crates/qbz-qt/qml/shell/SidebarFolderFlyout.qml @@ -0,0 +1,230 @@ +// Mini-rail folder flyout — the port of shell/SidebarFolderPopup.slint. +// +// In the 64px rail a folder's nested rows collapse to height 0, so expanding +// one in place would be a control that changes nothing on screen. Clicking a +// folder opens THIS panel to its right instead, listing that folder's +// playlists (four visible, the rest scroll). +// +// Extracted out of Sidebar.qml (block 6 of the playlist-manager contract). Two +// things changed in the move, both fixes rather than transcription: +// +// 1. The rows come from a DEDICATED document, QbzShell.sidebarFolderPopupJson +// (contract §4.7), built in Rust from the sidebar CACHE. The old inline +// version filtered the flattened `entries`, which only carry a folder's +// children while that folder is EXPANDED — so it had to force-expand the +// folder on the way in, and the folder then stayed open once the sidebar +// was re-opened. The reference has no such side effect. +// 2. The name and the count still come SYNCHRONOUSLY from the clicked entry. +// The document arrives a LATER event-loop turn (the cxx-qt UI queue), so +// binding the header to it would show folder A's name while B is opening; +// `rows` is therefore ignored until the document's own folderId matches +// the one this panel was opened for. +// +// Geometry is 1:1 with SidebarFolderPopup.slint: 230px wide, 34 + 1 + rows*32 +// + 8 tall (the 34 is deliberately 4px more than the 30px header it holds — +// kept, not tightened), surface-main / Radius.sm / 1px border-muted, 4px inner +// padding, 30px header with a 14px accent folder-open glyph, a 1px hairline, +// then 32px rows with a 14px muted list-music glyph. +// +// The list-music glyph is drawn for a LOCAL playlist too: SidebarFolderPopup +// .slint has no local-kind branch (unlike SidebarRow), so the hard-drive mark +// is lost inside the flyout. That is the reference — recorded, not an omission. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Popup { + id: root + + // Set SYNCHRONOUSLY by openFor() from the clicked entry — never from the + // document (see the header note). + property string folderId: "" + property string folderName: "" + property int folderCount: 0 + + /// The sidebar owns `activePlaylistId` (the row highlight); a component + /// cannot reach another file's ids, so it is told rather than assumed. + signal playlistActivated(string id) + + width: 230 + padding: 0 + topPadding: 4 + bottomPadding: 4 + // Slint's literal panel height (34 + 1 + list + 8) — 4px taller than the + // content, kept 1:1 rather than tightened. + height: 34 + 1 + root.visibleRows * 32 + 8 + // Replaces Slint's full-window scrim TouchArea (SidebarFolderPopup.slint: + // 56-60); the Popup's own background swallows clicks inside, so no + // hand-built scrim and no click-through. + closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + + // Guarded parse — a raw JSON.parse in a binding throws on the pre-publish + // frame. The fallback carries the FULL shape, so `.rows.length` is safe. + readonly property var doc: { + try { + return JSON.parse(QbzShell.sidebarFolderPopupJson) + } catch (e) { + return ({ "folderId": "", "folderName": "", "count": 0, "rows": [] }) + } + } + // Only this folder's document counts. Without the guard, opening folder B + // while A's document is still the published one lists A's playlists. + readonly property var rows: + (root.doc.folderId === root.folderId && root.doc.rows) ? root.doc.rows : [] + // Height off the folder's own count (known at click time) so the panel + // does not resize when the rows land a tick later; `max` because the + // entry's count is computed post-search and the document's is not. + readonly property int visibleRows: + Math.min(Math.max(root.folderCount, root.rows.length), 4) + + /// Open beside `anchorItem` (the rail row), +6px to its right and aligned + /// to its top — SidebarFolderPopup's anchor-x / anchor-y. + /// + /// `entry` is the SidebarEntry the row carries: {id, name, count, …}. + function openFor(entry, anchorItem) { + root.folderId = entry.id + root.folderName = entry.name + root.folderCount = entry.count + // Fill the rows from the cache. No expand side effect (header note 1). + QbzShell.sidebarOpenFolderPopup(entry.id) + var p = anchorItem.mapToItem(root.parent, anchorItem.width + 6, 0) + root.x = p.x + root.y = p.y + root.open() + } + + background: Rectangle { + color: theme.surfaceMain + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderMuted + } + + contentItem: Column { + spacing: 0 + + // The theme tokens. It lives HERE, inside the content tree, because a + // QML file has no lexical scope across files and this Popup's own + // default property is `contentData` — a positioner ignores a plain + // QtObject in its data, so nothing is laid out for it. + QbzTheme { id: theme } + + // Header — folder name (30px, 12px side padding, 14px accent + // folder-open glyph, 13px/600 text-primary). + Item { + width: parent.width + height: 30 + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + QbzIcon { + name: "folder-open" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "accent" + } + Text { + width: parent.width - 22 + height: parent.height + text: root.folderName + color: theme.textPrimary + font.pixelSize: 13 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + Rectangle { + width: parent.width + height: 1 + color: theme.borderSubtle + } + + // Playlist list — four rows visible, the rest scroll (the shared + // QbzScrollBar mounts past four, like Slint's ListScrollbar). + // + // Deliberately NOT a ListView: the panel shows at most four rows of a + // single folder, the reference is a Flickable over a VerticalLayout, + // and the recycling a ListView buys is worth nothing at that size. + Item { + width: parent.width + height: root.visibleRows * 32 + + Flickable { + id: fpFlick + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: fpColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: fpColumn + width: parent.width + Repeater { + model: root.rows + delegate: Rectangle { + id: fpRow + required property var modelData + width: fpColumn.width + height: 32 + radius: 5 + color: fpArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + QbzIcon { + name: "list-music" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Text { + width: parent.width - 22 + height: parent.height + text: fpRow.modelData.name + color: theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: fpArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + // `local:` ids survive untouched — + // openPlaylist routes them to the local + // loader and works offline. + root.playlistActivated(fpRow.modelData.id) + QbzBridge.openPlaylist(fpRow.modelData.id) + root.close() + } + } + } + } + } + } + QbzScrollBar { + visible: Math.max(root.folderCount, root.rows.length) > 4 + && fpFlick.contentHeight > fpFlick.height + target: fpFlick + width: 10 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/SidebarRowMenu.qml b/crates/qbz-qt/qml/shell/SidebarRowMenu.qml new file mode 100644 index 000000000..fb5163be3 --- /dev/null +++ b/crates/qbz-qt/qml/shell/SidebarRowMenu.qml @@ -0,0 +1,367 @@ +// Sidebar row context menu — the port of the PopupWindow declared inside +// SidebarRow (shell/Sidebar.slint:400-545). Right-press any tree row and it +// opens, row-anchored, with one of two arms: +// +// FOLDER Edit folder · Hide from sidebar (exactly two rows — +// there is NO Delete here. Slint declares a delete-folder callback +// and wires it, but no menu item ever fires it: it belonged to a +// hover "..." button that was deleted. Folder deletion lives only +// in the Playlist Manager, so it is not ported as a menu entry.) +// +// PLAYLIST Edit playlist · Hide from sidebar* · Add to Mixtape/Collection* +// · divider* · "Move to folder" caption · folder search (>= 8) · +// the folder list · Move to root (when filed) · "No folders yet." +// (*) Qobuz only — see below. +// +// Facts that look like bugs and are not: +// +// - LOCALS CAN BE MOVED TO FOLDERS. The move-to-folder section is gated on +// "not a folder row", never on "not a local": one folder holds both kinds, +// and for a user with no Qobuz account that is the whole feature. +// - LOCALS CANNOT BE HIDDEN from here, and cannot be added to a Mixtape. +// `myqbz_add_qt::accepts_item` answers NONE for (Playlist, Local) and the +// core rejects the enqueue outright, so the row is ABSENT rather than +// dimmed — a permanently dead affordance is the defect this port removed +// from the sidebar, not a state to render. +// - The divider is Qobuz-only too, so a local's menu reads "Edit playlist" +// straight into the "Move to folder" caption with no separator. +// - "Move to root" (chevron-left) is this surface's wording; the manager's +// sibling menu says "No folder". Two words for one concept, in two +// surfaces, exactly as the reference has them. Not unified without the +// owner. +// - Folders in this list carry a GENERIC folder glyph. The sidebar never +// receives a folder's icon preset / colour, and the manager's own move menu +// shows no icon at all. +// +// THE CLOSE-POLICY INVERSION, recorded so nobody re-derives it: Slint's +// PopupWindow closes on ANY click, which is why its items barely need +// close(); this one had to opt OUT (close-on-click-outside) so clicking the +// folder-search input would not dismiss it. Qt is the mirror image — +// CloseOnPressOutside never closes on an inside click — so the search input +// works for free, and EVERY row that acts must call close() itself. +// +// Reuse: this inherits controls/QbzContextMenu.qml, which already delivers the +// exact ContextMenu.slint chrome (surface-main, Radius.sm, 1px border-muted, +// 5px vertical padding), the window-overlay parenting a clipping sidebar +// needs, and the 8px viewport clamp. Rejected controls/CardMenu.qml (a fixed +// play/queue/pin/fav set bound to a card document — nothing in common beyond +// "it is a popup") and writing a second popup surface. The rows are a local +// inline component rather than a control, because ContextMenuItem's shape is +// 33/11/16/10/15/13 full-bleed and no shipped control is that. +// +// The one chrome override: QbzContextMenu pads all four sides by 5, while +// ContextMenu.slint pads only top and bottom — its rows are full-bleed. Hence +// leftPadding/rightPadding 0 below. + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../controls" +import "../theme" + +QbzContextMenu { + id: root + + // Sidebar.slint:402 — `width: 210px`, not QbzContextMenu's 196 default. + menuWidth: 210 + leftPadding: 0 + rightPadding: 0 + topPadding: 5 + bottomPadding: 5 + + /// The clicked SidebarEntry: {kind, id, name, count, indent, folderId, + /// covers, isLocal?}. Null before the first open. + property var entry: null + /// foldersJson MINUS the hidden folders, derived ONCE by the sidebar and + /// passed in. Every folder-dependent decision in this file reads THIS, not + /// the raw folder list: the sidebar tree drops hidden folders everywhere + /// else, so offering one here — or counting one toward the >= 8 search + /// threshold, or letting one suppress "No folders yet." — would show a + /// search box over a three-row list and move a playlist somewhere it + /// cannot be seen. + property var visibleFolders: [] + /// Local substring filter over `visibleFolders`. The reference runs this + /// in Rust (`search_menu_folders`) for ONE reason it states in its own + /// source: Slint strings have no `contains`. JavaScript has `indexOf`, so + /// the round trip per keystroke is dropped. Deliberate; do not re-add it + /// "for parity". + property string folderQuery: "" + + readonly property bool isFolder: root.entry !== null && root.entry.kind === "folder" + readonly property bool isLocal: root.entry !== null && root.entry.isLocal === true + readonly property string entryId: root.entry !== null ? String(root.entry.id) : "" + readonly property string entryFolderId: + (root.entry !== null && root.entry.folderId) ? String(root.entry.folderId) : "" + + readonly property var menuFolders: { + var q = root.folderQuery.trim().toLowerCase() + if (q === "") + return root.visibleFolders + return root.visibleFolders.filter(function (f) { + return String(f.name).toLowerCase().indexOf(q) !== -1 + }) + } + // Height source for the list. The CURRENT folder's row is excluded in + // markup (where Slint has it), so sizing off `menuFolders.length` would + // leave one row of dead space — the reference's own bug, not ported. + readonly property int folderRowCount: root.menuFolders.filter(function (f) { + return String(f.id) !== root.entryFolderId + }).length + + /// Open on a PLAYLIST row. `anchorItem` is the row; `localX`/`localY` are + /// row-local coordinates, so the sidebar reproduces Slint's row-anchored + /// `x: root.width - 210px; y: 28px` rather than opening at the pointer + /// (this menu is row-anchored in the reference, unlike the TrackRow menus). + function openForPlaylist(e, anchorItem, localX, localY) { + root._openWith(e, anchorItem, localX, localY) + } + + /// Open on a FOLDER row. Same placement; the content arms switch on + /// `entry.kind`. + function openForFolder(e, anchorItem, localX, localY) { + root._openWith(e, anchorItem, localX, localY) + } + + function _openWith(e, anchorItem, localX, localY) { + root.entry = e + // Slint clears the folder query on EVERY right-press, folder rows + // included, before showing the menu. + root.folderQuery = "" + folderSearch.clearSearch() + root.openAtCursor(anchorItem, localX, localY) + } + + // Declared BEFORE the inline component that reads it, matching + // NavFlyout.qml's shipped shape. A QML file has no lexical scope across + // files, so QbzContextMenu's own `theme` is not reachable from here. + QbzTheme { id: theme } + + /// One 33px row — ContextMenuItem.slint, full-bleed, no radius. + component MenuRow: Rectangle { + id: mr + property string icon: "" + property string label: "" + signal activated() + + width: parent ? parent.width : 0 + height: 33 + color: mrArea.containsMouse ? theme.surfaceHover : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 16 + spacing: 10 + QbzIcon { + name: mr.icon + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: mrArea.containsMouse ? "textPrimary" : "secondary" + } + Text { + width: parent.width - 25 + height: parent.height + text: mr.label + color: mrArea.containsMouse ? theme.textPrimary : theme.textSecondary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: mrArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: mr.activated() + } + } + + // ---- Folder arm ------------------------------------------------------ + MenuRow { + visible: root.isFolder + icon: "pen-line" + label: QbzSession.tr("Edit folder", QbzSession.trRev) + onActivated: { + root.close() + QbzFolderEdit.openEditor(root.entryId) + } + } + MenuRow { + visible: root.isFolder + icon: "eye-off" + label: QbzSession.tr("Hide from sidebar", QbzSession.trRev) + onActivated: { + root.close() + // The folder editor owns folder visibility, and its write + // republishes foldersJson — otherwise a folder hidden from here + // would stay in every other playlist's move-to-folder list. + QbzFolderEdit.setHidden(root.entryId, true) + } + } + + // ---- Playlist arm ---------------------------------------------------- + MenuRow { + visible: !root.isFolder + icon: "pen-line" + label: QbzSession.tr("Edit playlist", QbzSession.trRev) + onActivated: { + root.close() + // A `local:` id survives untouched — the editor seeds itself from + // the local DB for those and works offline. + QbzPlaylistEdit.open(root.entryId) + } + } + MenuRow { + // Locals are hidden from the Playlist Manager, never from here: the + // reference's own hide path parses a u64 and bails on a local id. + visible: !root.isFolder && !root.isLocal + icon: "eye-off" + label: QbzSession.tr("Hide from sidebar", QbzSession.trRev) + onActivated: { + root.close() + // One-way door FROM the sidebar; the manager's All/Visible/Hidden + // filter is the only undo surface in the app, which is why this + // row could not ship before that filter existed. + QbzPlaylistManager.toggleHidden(root.entryId) + } + } + MenuRow { + visible: !root.isFolder && !root.isLocal + icon: "cassette-tape" + label: QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev) + onActivated: { + root.close() + // Built HERE from the SidebarEntry the row already carries — the + // mixtape payload is assembled at the call site everywhere in this + // port, and the sidebar must not grow a Rust helper for another + // domain. `source` is always "qobuz" because the row is absent on + // a local entry. + // + // `trackCount` is deliberately OMITTED, which is the one place + // this file departs from the contract's literal snippet. A + // SidebarEntry's `count` is populated for FOLDER rows only — + // `entry_for` / `local_entry_for` both hardcode `count: 0` + // (sidebar_qt.rs:379, :390), and the field is the folder's member + // count, not a track count. `myqbz_add_qt` PERSISTS this value + // into the collection row (`add_item_with(..., it.track_count, + // ...)`), so sending 0 would write a permanent wrong number; + // absent means NULL, which is the truthful "unknown". It is + // Option on the Rust side and every other optional key here + // is already omitted the same way. + var e = root.entry + QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "playlist", + "source": "qobuz", + "sourceItemId": String(e.id), + "title": String(e.name), + "artworkUrl": (e.covers && e.covers.length > 0) ? String(e.covers[0]) : "" + }])) + } + } + // Divider before the move-to-folder section — Qobuz only. + Item { + visible: !root.isFolder && !root.isLocal + width: parent ? parent.width : 0 + height: 7 + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: theme.borderSubtle + } + } + + // ---- Move to folder -------------------------------------------------- + // Caption block: padding L 11 / R 16 / bottom 3, 18px text row. + Item { + visible: !root.isFolder + width: parent ? parent.width : 0 + height: 21 + Text { + x: 11 + width: parent.width - 27 + height: 18 + text: QbzSession.tr("Move to folder", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + // Search block: padding L 11 / R 16 / bottom 4, 26px field. Appears only + // once the list is long enough to warrant it (the reference's threshold, + // counted over the VISIBLE folders). + Item { + visible: !root.isFolder && root.visibleFolders.length >= 8 + width: parent ? parent.width : 0 + height: 30 + QbzLineEdit { + id: folderSearch + x: 11 + width: parent.width - 27 + height: 26 + searchMode: true + placeholder: QbzSession.tr("Search folders", QbzSession.trRev) + onEdited: function (v) { root.folderQuery = v } + } + } + // The folder list. A ListView at a fixed clip height, not a Repeater in a + // Flickable: only ~5 rows are VISIBLE, but the model is every folder the + // user has. + ListView { + visible: !root.isFolder + width: parent ? parent.width : 0 + height: Math.min(root.folderRowCount * 33, 165) + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.menuFolders + delegate: MenuRow { + required property var modelData + // A ListView does NOT size its delegates: `parent.width` here is + // the contentItem's, which is not the view's. + width: ListView.view ? ListView.view.width : 0 + // The playlist's CURRENT folder is excluded in markup, exactly + // where Slint has it. + visible: String(modelData.id) !== root.entryFolderId + height: visible ? 33 : 0 + icon: "folder" + label: String(modelData.name) + onActivated: { + root.close() + QbzPlaylistManager.moveToFolder(root.entryId, String(modelData.id)) + } + } + } + // Move to root — only when the playlist is currently filed. It comes + // AFTER the scrolling list, and "No folders yet." after that; both sit + // outside the scroll area. Reference ordering, kept. + MenuRow { + visible: !root.isFolder && root.entryFolderId !== "" + icon: "chevron-left" + label: QbzSession.tr("Move to root", QbzSession.trRev) + onActivated: { + root.close() + // "" is ROOT. + QbzPlaylistManager.moveToFolder(root.entryId, "") + } + } + // Empty state: padding L 11 / R 16 / bottom 4, 22px text row. + Item { + visible: !root.isFolder && root.visibleFolders.length === 0 + width: parent ? parent.width : 0 + height: 26 + Text { + x: 11 + width: parent.width - 27 + height: 22 + text: QbzSession.tr("No folders yet.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } +} diff --git a/crates/qbz-qt/qml/shell/ViewModeMenu.qml b/crates/qbz-qt/qml/shell/ViewModeMenu.qml index fe733d57d..3993ba2ae 100644 --- a/crates/qbz-qt/qml/shell/ViewModeMenu.qml +++ b/crates/qbz-qt/qml/shell/ViewModeMenu.qml @@ -14,6 +14,18 @@ QbzContextMenu { id: root menuWidth: 196 + // The theme tokens. Declared HERE even though the BASE type + // (controls/QbzContextMenu.qml) already has a `QbzTheme { id: theme }`: + // a QML id belongs to the document that declares it, and a derived + // document is a DIFFERENT scope — the base's context is created as a + // child of this one, never as its parent, so `theme` was resolving to + // nothing here and every binding below threw a silent ReferenceError + // (leaving the row Rectangles at their default WHITE fill). The four + // sibling menus built on the same base — CardMenu, AudioSettingsMenu, + // PmFolderMenu, SidebarRowMenu — all declare their own; this file was + // the lone outlier. + QbzTheme { id: theme } + Repeater { model: [ { "label": QbzSession.tr("New", QbzSession.trRev), "icon": "panel-left", "mode": 0 }, diff --git a/crates/qbz-qt/qml/theme/QbzIcon.qml b/crates/qbz-qt/qml/theme/QbzIcon.qml index 4e45c2767..b1e2e96ea 100644 --- a/crates/qbz-qt/qml/theme/QbzIcon.qml +++ b/crates/qbz-qt/qml/theme/QbzIcon.qml @@ -65,6 +65,11 @@ // add new ones. // "black" a dark glyph on a host painted light under every theme // "amber" #e0b341, the audio stamp's brand accent (one icon) +// "green" #22c55e, PmLocalBadge's "all local" state (one icon: +// wifi). A literal brand colour, not a theme token — the +// `amber` precedent, one partial dir for one glyph. +// "orange" #f59e0b, PmLocalBadge's "some local" state (one icon: +// cloud-download). Same precedent. // // Getting this pair wrong is how the bug this file fixes happened in reverse: // resolving "primary" to Theme.text-primary would paint a DARK glyph on a @@ -106,9 +111,10 @@ // could not be trusted: `black/` and `warning/` were missing mp3 + translate, // `secondary/` and `accent/` were missing mp3 (and `black/cd.svg` was a // #cccccc glyph — a light-grey disc in the DARK-glyph directory). All six -// neutral/chromatic dirs are now 96 files, the same set as primary/ and as -// MASTERS; only `favorite/` (52) and `amber/` (1) stay deliberately partial, -// and neither is reachable from the polarity mapping. A new icon goes into +// neutral/chromatic dirs are now 111 files, the same set as primary/ and as +// MASTERS; only `favorite/` (57), `amber/` (6) and the two one-glyph brand +// dirs `green/` / `orange/` stay deliberately partial, and none of them is +// reachable from the polarity mapping. A new icon goes into // assets/icons/primary/, into the MASTERS table in src/icon_tint_qt.rs, AND // into black/ + secondary/ + muted/ if any theme-following tint can ask for // it. @@ -208,7 +214,11 @@ Image { return "muted" // "accent", "warning", "favorite" (chromatic, legible on both), and - // the fixed "black" / "amber" — each already names its own dir. + // the fixed "black" / "amber" / "green" / "orange" — each already + // names its own dir. The three fixed ones also fall out of the + // runtime bake by themselves: `token_for()` has no entry for them + // (icon_tint_qt.rs:323), so `dirFor` returns "" and this qrc dir is + // what actually serves them. default: return root.tintName } diff --git a/crates/qbz-qt/qml/views/AlbumListHeader.qml b/crates/qbz-qt/qml/views/AlbumListHeader.qml index 7864f7af5..243aaf49d 100644 --- a/crates/qbz-qt/qml/views/AlbumListHeader.qml +++ b/crates/qbz-qt/qml/views/AlbumListHeader.qml @@ -20,6 +20,11 @@ Rectangle { QbzTheme { id: theme } + /// Mirrors AlbumListRow.selectMode: the rows below grow an 18px leading + /// checkbox cell in Library > Albums multi-select, and the header has to + /// inset by the same amount or every column label drifts off its column. + property bool selectMode: false + // AlbumListCols: art 52, quality 150, year 64, overflow 36, gap 12. readonly property int colArt: 52 readonly property int colQuality: 150 @@ -46,10 +51,12 @@ Rectangle { anchors.rightMargin: 12 spacing: root.colGap + Item { visible: root.selectMode; width: visible ? 18 : 0; height: 1 } Item { width: root.colArt; height: 1 } ColLabel { width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.selectMode ? 18 + root.colGap : 0) text: QbzSession.tr("ITEM", QbzSession.trRev) elide: Text.ElideRight } diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index a4de3b0e6..2c53b1424 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -32,6 +32,17 @@ Rectangle { /// NOT named `index`: a Repeater injects one into the delegate context /// and a same-named property here would shadow it. property int rowIndex: 0 + /// Cover override for hosts whose rows carry no `artPath` of their own. + /// The Library feed is id-keyed (`artKey` -> a decoded file:// path in + /// LibraryView's artMap), so its rows never gain the key AlbumCollection's + /// producers bake in; everything else keeps working unchanged because "" + /// falls straight back to `item.artPath`. + property string artSource: "" + /// Multi-select arm (FavoritesView.slint:514 — Library > Albums in LIST + /// mode only). Default off, so the two catalog call sites are untouched. + property bool selectMode: false + property bool checked: false + signal toggleSelect() QbzTheme { id: theme } @@ -58,10 +69,16 @@ Rectangle { acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: function (mouse) { - if (mouse.button === Qt.RightButton) - rowMenu.openAtCursor(rowArea, mouse.x, mouse.y) - else - QbzAlbum.openAlbum(root.item.id || "") + if (mouse.button === Qt.RightButton) { + // In select mode the row is a selection target, not a menu + // host (Tauri/Slint parity: a right-click toggles nothing + // there either). + if (!root.selectMode) + rowMenu.openAtCursor(rowArea, mouse.x, mouse.y) + return + } + if (root.selectMode) root.toggleSelect() + else QbzAlbum.openAlbum(root.item.id || "") } } @@ -111,6 +128,20 @@ Rectangle { anchors.rightMargin: 12 spacing: root.colGap + // Selection checkbox — takes the leading slot in select mode + // (MultiSelectBar's companion; the art cell keeps its own width so + // the columns below the header never shift). + Item { + visible: root.selectMode + width: visible ? 18 : 0 + height: parent.height + QbzCheckbox { + anchors.centerIn: parent + checked: root.checked + onToggled: root.toggleSelect() + } + } + // Art cell (52px column, 44px thumb centred). Item { width: root.colArt @@ -124,7 +155,7 @@ Rectangle { clip: true RoundedImage { anchors.fill: parent - source: root.item.artPath || "" + source: root.artSource !== "" ? root.artSource : (root.item.artPath || "") radius: 4 } } @@ -132,8 +163,12 @@ Rectangle { // ITEM — title over artist (the artist line links to the artist). Column { + // The select checkbox adds a leading 18px cell AND a gap; without + // subtracting both, the row overflows its width by 30px the moment + // multi-select is switched on. width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.selectMode ? 18 + root.colGap : 0) anchors.verticalCenter: parent.verticalCenter spacing: 2 Text { diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index 9c356d8b7..0ac272190 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -714,7 +714,9 @@ Rectangle { color: "transparent" QbzIcon { anchors.centerIn: parent; name: "cloud-download"; width: 16; height: 16; tintName: "muted" } } - // ⋯ (play-only menu for the POC). + // ⋯ row menu. It used to be a hover-lit button with NO handler at + // all — a control that renders and no-ops, which is the defect + // class this round is closing. Rectangle { width: cols.colMenu height: cols.colMenu @@ -727,11 +729,83 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - // POC-NOTE: the unified track context menu is out of scope. + onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } + // primitives/TrackContextMenu.slint, in ITS order, restricted to the + // entries whose seam is live at this call site. + // + // REUSE, and why it is not `rows/TrackRow.qml`: PopularTrackRow is a + // pre-existing, documented fork (see the component header) and the + // fork is load-bearing here — its heart reads the VIEW-level + // optimistic store (`root.toggleState("track:" + id, …)`, shared with + // the header and the library tab) while TrackRow owns a private + // `favorite` property, and its play route is the artist-context + // `playArtistTrack`. Swapping the component is a view rewrite, not a + // menu fix. What IS reused is the menu surface itself: + // `controls/CardMenu.qml`, the same primitive rows/TrackRow.qml opens. + // + // ABSENT, not dead (the same discipline TrackRow applies): the radio + // pair, Share Qobuz link / Song.link, the offline-cache block and + // Track info. The first two need bridge seams that do not exist; the + // last two need the shared row's lazy Loaders (a TextEdit for the + // clipboard, a TrackInfoModal), and duplicating those per artist row + // is exactly the fork-drift this file already paid for once. + CardMenu { + id: popMenu + menuWidth: 224 + entries: { + var t = QbzSession.tr + var r = QbzSession.trRev + var fav = root.toggleState("track:" + popRow.row.id, popRow.row.isFavorite) + var m = [ + { "label": t("Play now", r), "icon": "play-fill", "action": "play" }, + { "label": t("Play next", r), "icon": "list-start", "action": "next" }, + { "label": t("Play later", r), "icon": "list-plus", "action": "later" }, + { "label": t("Add to queue", r), "icon": "list-end", "action": "queue" }, + { "label": fav ? t("Remove from Library", r) : t("Add to Library", r), + "icon": fav ? "heart-filled" : "heart", "action": "favorite" }, + { "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" }, + { "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" }, + ] + if ((popRow.row.albumId || "") !== "") + m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" }) + if ((popRow.row.artistId || "") !== "") + m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) + return m + } + onPicked: function (a) { + var id = popRow.row.id + if (a === "play") QbzPlayer.playArtistTrack(id) + else if (a === "next") QbzPlayer.enqueueTrack(id, "next") + else if (a === "later") QbzPlayer.enqueueTrack(id, "later") + else if (a === "queue") QbzPlayer.enqueueTrack(id, "queue") + else if (a === "favorite") { + root.setToggleState("track:" + id, + !root.toggleState("track:" + id, popRow.row.isFavorite)) + QbzLibrary.libraryToggleFavorite("track", id) + } + else if (a === "add-to-playlist") QbzPlaylistPicker.openForTrack(id) + else if (a === "mixtape") { + // The HOST builds the AddItem payload. SOURCE: every row + // this component draws is a Qobuz catalog track — the + // `/artist/page` `top_tracks` / `appears_on` lists, and + // the in-library tab's rows, which artist_qt maps from + // Qobuz `Track` values. There is no local artist page. + QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "track", "source": "qobuz", + "sourceItemId": id, "title": popRow.row.title || "", + "subtitle": popRow.row.artist || "", "artworkUrl": "", + "year": null, "trackCount": null + }])) + } + else if (a === "go-album") QbzAlbum.openAlbum(popRow.row.albumId) + else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) + } + } + MouseArea { id: trArea anchors.fill: parent @@ -740,6 +814,15 @@ Rectangle { onDoubleClicked: QbzPlayer.playArtistTrack(row.id) onClicked: mouse.accepted = false } + // Right press opens the SAME menu at the pointer — the invariant + // controls/QbzContextMenu.qml:20-22 states. Declared last so it sits + // on top, RIGHT-only so every left click still falls through. + MouseArea { + id: popRcArea + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + } } // Sidebar link row (SidebarLink). `navigable` false = informational row: @@ -2428,7 +2511,22 @@ Rectangle { if (a === "shuffle-all") QbzPlayer.playArtistTop(true) else if (a === "next-all") QbzPlayer.playArtistTop(false) else if (a === "queue-all") QbzPlayer.enqueueArtistTop() - // playlist-all: inert (no picker) — POC-NOTE. + else if (a === "playlist-all") { + // ArtistPageView.slint:797-802 + // `top-tracks-menu-action("playlist-all")` — + // the picker over the section's tracks. `root + // .topTracks` is the FILTERED list, which is + // what `ArtistState.top-tracks` holds too + // (artist.rs filters into the state, the view + // never sees the raw list). Every row here is + // a Qobuz catalog track (`/artist/page` + // top_tracks), so the catalog arm is correct. + var ids = [] + for (var i = 0; i < root.topTracks.length; i++) + ids.push(String(root.topTracks[i].id)) + if (ids.length > 0) + QbzPlaylistPicker.openForTracks(JSON.stringify(ids)) + } } } } diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 2c8f87d62..4f7e0553c 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -14,13 +14,15 @@ // - JUMP TO is NOT sticky. ArtistView.qml:1204 documents the same call for // the .slint's sticky clamp; matching it keeps the two detail pages // consistent. This is the one accepted visual delta. -// - NO multi-select. qml/rows/TrackRow.qml has no multi-select arm, and one -// of the .slint MultiSelectBar's seven actions (add-to-playlist) has no -// bridge seam at all (TrackRow.qml:107-121), so a full port would ship a -// dead entry. (add-to-mixtape IS live now — the per-row menu reaches -// QbzMyQbzAdd.open; only the BULK form is missing, for want of a -// selection.) The select disc renders DIMMED and inert (the ArtistView -// "Radio" precedent) and the bar is absent. +// - NO multi-select — and the ORIGINAL reason is now STALE on both halves: +// rows/TrackRow.qml:116 DOES have the `selectMode` arm, and add-to-playlist +// DOES have a bridge seam (QbzPlaylistPicker.openForTracks, +// src/playlist_picker_bridge.rs). What is still missing is only the +// selection STATE and the bar itself (LibraryView.qml's `tracksSelected` +// map is the pattern to copy). Until then the select disc renders DIMMED +// and inert (the ArtistView "Radio" precedent) and the bar is absent. +// Per-ROW add-to-playlist and add-to-mixtape are both LIVE on this page +// already, through the shared TrackRow menu. // - NO "In library" tab. The Qt library feed carries no label id, so // `libraryCount` is always 0 and the SegmentedTabBar never mounts (see // src/label_qt.rs). A toggle that switches to an empty tab is worse. diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index e1f449ff2..970cb61f6 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -9,32 +9,45 @@ // libraryArtworkWindow, and artMap entries far outside the viewport are // pruned (the Slint eviction policy, QML-side). // -// POC-NOTEs: -// - Multi-select, group modes, alpha jumps, play-all / shuffle bulk -// actions, playlists-random, artists sidepanel, albums LIST mode, per-tab -// persist of view/sort state: out of scope (stubs or omitted; the All -// grid+list is the measured 1:1 focus). -// - Filter by genre IS wired, on the All toolbar only — that is the single -// place the Slint FavoritesView draws it (FavoritesView.slint:864, -// `context: "library-all"`). It filters CLIENT-side over the feed, exactly -// like library_all.rs::derive: an item shows when its (lowercased) genre -// contains one of the selected genre names (+ their sub-genres), so rows -// with no genre at all (artist / label / playlist) drop out while a genre -// is selected. The selection lives in the shared per-context store and -// persists to genre_filter.json. +// THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door +// (track rule 2 — it used to be 1,881 lines): +// library/LibraryToolbar.qml the whole 56px chrome band +// library/FeedListRow.qml the All feed's mixed LIST row +// library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar +// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode +// Each takes `view: root`, the seam views/local/*.qml already uses. +// +// Still out of scope, and named rather than implied: // - Offline: the generic OfflinePlaceholder replica mounts (the Slint -// offline RAIL of playable cached favorites needs the offline cache — -// not wired). -// - Sort/search/source state is session-local (no ui_prefs persistence). +// offline RAIL of playable cached favorites needs the offline cache — +// not wired; PARITY-DEBT #17). +// - The Library "All" local SCOPE ("favorites" vs the whole local library) +// is PARITY-DEBT #6: the pref is read and PRESERVED on disk by +// library_prefs.rs, but neither the Settings row nor the `all` branch of +// the feed exists here yet. +// +// Filter by genre IS wired, on the All / Tracks / Albums toolbars — the three +// places the Slint FavoritesView draws it (FavoritesView.slint:609, :652, +// :864, `context: "library-all"`). It filters CLIENT-side over the feed, +// exactly like library_all.rs::derive: an item shows when its (lowercased) +// genre contains one of the selected genre names (+ their sub-genres), so rows +// with no genre at all (artist / label / playlist) drop out while a genre is +// selected. The selection lives in the shared per-context store and persists +// to genre_filter.json. +// +// Toolbar choices PERSIST (favorites_prefs.rs parity): albums view/sort/group, +// tracks group, playlists view, artists group/view and the All hard-drive +// toggle all go through `setPref` into the same favorites_ui.json the shipping +// Slint build writes. Search queries stay transient — deliberately, there too. import QtQuick import QtQuick.Controls import QtQuick.Window import com.blitzfc.qbz -import "../cards" import "../controls" import "../rows" import "../theme" +import "library" Rectangle { id: root @@ -42,7 +55,7 @@ Rectangle { // HomeView.slint:163: the frosted content panel shows through). color: ambientOn ? "transparent" : theme.surfaceMain readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack - + // Round to the AppShell content-frame bezel (Radius.md): QML clips // are rectangular, so the frame's own rounding never reaches the // view — the view's own fill must round instead. @@ -93,13 +106,77 @@ Rectangle { // All-tab derive state (LibraryAllState semantics). property string search: "" + // Group modes (favorites.rs derive_tracks / derive_albums / artists). + // Grouping REORDERS rows; for the tracks list it also injects separator + // rows — see visibleItems / withGroupHeaders. + property string tracksGroup: "off" // "off" | "album" | "artist" | "name" + property string albumsGroup: "off" // "off" | "alpha" | "artist" + property string artistsGroup: "off" // "off" | "alpha" property string sortBy: "date" // "date" | "title" | "artist" property bool sortAsc: false // date: false = newest first property bool showPurchases: true property bool showFavorites: true property bool showFollowing: true property bool showLocal: true - property string viewMode: "grid" // "grid" | "list" + property string viewMode: "grid" // All tab: "grid" | "list" + property string albumsView: "grid" // "grid" | "list" + property string playlistsView: "grid" // "grid" | "list" + property string artistsView: "grid" // "grid" | "sidepanel" + + // --- persisted toolbar choices (library_prefs.rs) -------------------- + // Seeded from the bridge document rather than BOUND to it: these are + // user-editable properties, and a binding would fight every click. The + // Rust setter never republishes the document, so there is no loop. + function applyPrefs() { + var p + try { + p = JSON.parse(QbzLibrary.libraryPrefsJson) + } catch (e) { + return + } + // The default "{}" reaches here before `boot` seeds the real one. + if (!p || p.albumsView === undefined) return + root.albumsView = p.albumsView + root.albumsSort = p.albumsSort + root.albumsGroup = p.albumsGroup + root.tracksGroup = p.tracksGroup + root.playlistsView = p.playlistsView + root.artistsGroup = p.artistsGroup + root.artistsView = p.artistsView + root.showLocal = p.allShowLocal === true + } + Component.onCompleted: root.applyPrefs() + Connections { + target: QbzLibrary + function onLibraryPrefsJsonChanged() { root.applyPrefs() } + } + + /// Write one toolbar choice AND persist it. The toolbar calls this instead + /// of assigning the property, so the two can never drift apart. + function setPref(key, value) { + if (key === "albumsView") { + root.albumsView = value + // Multi-select is LIST-only (FavoritesView.slint:514); leaving + // list mode with a live selection would strand the bulk bar. + if (value !== "list") root.setAlbumsMultiSelect(false) + } else if (key === "albumsSort") root.albumsSort = value + else if (key === "albumsGroup") root.albumsGroup = value + else if (key === "tracksGroup") root.tracksGroup = value + else if (key === "playlistsView") root.playlistsView = value + else if (key === "artistsGroup") root.artistsGroup = value + else if (key === "artistsView") root.artistsView = value + else { + console.warn("[qbz-qt] LibraryView.setPref: unknown key " + key) + return + } + QbzLibrary.setLibraryPref(key, String(value)) + } + /// The one SOURCE switch the reference persists (favorites_prefs.rs + /// `all_show_local`); the other three are session-local there too. + function setShowLocal(on) { + root.showLocal = on + QbzLibrary.setLibraryPref("allShowLocal", on ? "true" : "false") + } // --- shared genre filter, "library-all" context ---------------------- // Read STRAIGHT off the bridge singleton, not off libGenrePopup: the @@ -126,12 +203,22 @@ Rectangle { out.push(String(src[i]).toLowerCase()) return out } + /// The toolbar's genre buttons reach the popup through here — it is + /// declared LAST in this file and cannot be named from a child component. + function toggleGenrePopup() { libGenrePopup.toggle() } // Other-tab state. property string tabSearch: "" property string albumsSort: "default" // default|title-asc|title-desc|artist-asc property string playlistsSubTab: "favorites" + /// The All tab's search field debounces; the toolbar routes through here + /// so the timer stays next to the window report it feeds. + function setAllSearch(v) { + root.search = v + allSearchDebounce.restart() + } + // ONE shared per-tab query, reset on entry — that IS the reference. // In Slint every entry into one of the five tabs runs // navigate_favorites -> load_favorites -> apply_favorites, which assigns @@ -145,7 +232,11 @@ Rectangle { // collapsed magnifier — quirk included. onActiveTabChanged: { tabSearch = "" - if (tabSearchBox) tabSearchBox.closeSearch() + if (toolbar) toolbar.closeTabSearch() + // A selection cannot survive leaving its own tab: the bulk bar would + // still be armed over rows that are no longer on screen. + setTracksMultiSelect(false) + setAlbumsMultiSelect(false) } // Per-tab emptiness gate. Slint gates each per-tab toolbar (search AND @@ -223,6 +314,50 @@ Rectangle { // is taken. readonly property var visibleRows: visibleItems() + /// A-Z bucket for a title: its first letter uppercased, "#" for anything + /// that is not a letter (a leading digit, "*", punctuation). Matches the + /// jump strip's own buckets, so a letter in the strip always has a + /// separator to land on. + function alphaKey(title) { + var t = (title || "").trim() + if (t.length === 0) return "#" + var c = t.charAt(0).toUpperCase() + return (c >= "A" && c <= "Z") ? c : "#" + } + + /// Inject a `group-header` pseudo-row before each run of rows sharing a + /// group key. The rows are ALREADY ordered by that key (see the `by(...)` + /// sorts above), so this is one linear pass. + /// + /// Album and artist grouping match the reference's separators. NAME + /// grouping gets them too — the reference draws only the A-Z jump strip + /// there and no separators, which the owner asked to close (2026-07-31): + /// a strip that jumps to a letter with nothing labelling it leaves the + /// user guessing where a bucket starts. + function withGroupHeaders(rows, mode) { + var out = [] + var last = null + var n = 1 + for (var i = 0; i < rows.length; i++) { + var r = rows[i] + var key = mode === "album" ? (r.album || "") + : mode === "artist" ? (r.artist || "") + : alphaKey(r.title) + if (key !== last) { + out.push({ "kind": "group-header", "title": key, "id": "hdr:" + mode + ":" + key }) + last = key + } + // The visible track NUMBER keeps running across groups (the + // reference numbers 1,2,3… straight through its separators), so it + // cannot be the model index once headers share the model. Carried + // on a shallow COPY — the feed's row objects are shared with the + // grid and the artwork map and must not be mutated. + out.push(Object.assign({}, r, { "_no": n })) + n++ + } + return out + } + function visibleItems() { var items = [] var i @@ -279,6 +414,8 @@ Rectangle { || it.title.toLowerCase().indexOf(tabNeedle) >= 0 || it.artist.toLowerCase().indexOf(tabNeedle) >= 0 } + var tabGenres = (activeTab === "tracks" || activeTab === "albums") + ? root.genreNames : [] for (i = 0; i < feed.length; i++) { var x = feed[i] var keep = false @@ -288,6 +425,13 @@ Rectangle { else if (activeTab === "labels") keep = x.kind === "label" else if (activeTab === "playlists") keep = x.kind === "playlist" && (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites") + if (keep && tabGenres.length > 0) { + var tg = (x.genre || "").toLowerCase() + keep = false + for (var tgi = 0; tgi < tabGenres.length; tgi++) { + if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break } + } + } if (keep && hit(x)) items.push(x) } if (activeTab === "albums" && albumsSort !== "default") { @@ -299,9 +443,165 @@ Rectangle { }) if (albumsSort.slice(-4) === "desc") items.reverse() } + // --- Group-by (favorites.rs::derive_tracks / derive_albums) ------ + // Grouping REORDERS the rows so a group's entries sit together. The + // TRACKS list also gets separator rows (see withGroupHeaders); the + // albums and artists GRIDS do not — a uniform-cell GridView has no + // room for a full-width header, which is exactly why the reference + // scrolls those two PROPORTIONALLY from the A-Z strip instead of + // landing on a separator (FavoritesView.slint:1996, :2008). + var lc = function (s) { return (s || "").toLowerCase() } + var by = function (keys) { + items.sort(function (a, b) { + for (var k = 0; k < keys.length; k++) { + var av = lc(a[keys[k]]), bv = lc(b[keys[k]]) + if (av !== bv) return av < bv ? -1 : 1 + } + return 0 + }) + } + if (activeTab === "tracks" && tracksGroup !== "off") { + if (tracksGroup === "album") by(["album", "title"]) + else if (tracksGroup === "artist") by(["artist", "album", "title"]) + else if (tracksGroup === "name") by(["title"]) + items = withGroupHeaders(items, tracksGroup) + } else if (activeTab === "albums" && albumsGroup !== "off") { + // "alpha" is the album title's initial, which title-order gives + // for free; "artist" groups by artist then title. + if (albumsGroup === "alpha") by(["title"]) + else if (albumsGroup === "artist") by(["artist", "title"]) + } else if (activeTab === "artists" && artistsGroup === "alpha") { + by(["title"]) + } return items } + // ------------------------ A-Z jump strip ----------------------------- + // FavoritesView.slint:1680-1691 (tracks, name grouping — index-accurate + // because the rows are uniform), :1988-1999 (albums, alpha grouping) and + // :2000-2011 (artists, alpha grouping). + // + // ONE deliberate improvement over the reference on the two GRIDS: it + // scrolls proportionally (`ord / (jumps-1) * maxScroll`) because a Slint + // Flickable cannot address a cell; a QML GridView can, so the strip lands + // on the letter's FIRST card instead of near it. Same strip, same + // buckets, an exact landing. + readonly property bool alphaActive: + (activeTab === "tracks" && tracksGroup === "name") + || (activeTab === "albums" && albumsGroup === "alpha") + || (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid") + readonly property var alphaJumps: { + if (!root.alphaActive) return [] + var rows = root.visibleRows + var out = [] + var last = null + for (var i = 0; i < rows.length; i++) { + // The tracks list already carries a separator per bucket; jumping + // to the SEPARATOR (not the first track) is what puts the letter + // itself at the top of the viewport. + if (activeTab === "tracks") { + if (rows[i].kind !== "group-header") continue + out.push({ "letter": rows[i].title, "index": i }) + continue + } + var key = root.alphaKey(rows[i].title) + if (key !== last) { + out.push({ "letter": key, "index": i }) + last = key + } + } + return out + } + readonly property bool alphaVisible: root.alphaActive && root.alphaJumps.length > 0 + function alphaJumpTo(index) { + if (activeTab === "tracks") list.positionViewAtIndex(index, ListView.Beginning) + else if (activeTab === "albums" && albumsView === "list") albumsList.positionAt(index) + else grid.positionViewAtIndex(index, GridView.Beginning) + } + + // ------------------------ multi-select ------------------------------- + // FavoritesView.slint:465-523 (the two toggles) + :1570 / :1734 (the two + // bars). The SELECTION lives here, in QML, exactly like the Local Library + // grid/table bars: "select-all" and "clear" never reach Rust, everything + // else goes down as a JSON id array (src/library_bulk.rs). + property bool tracksMultiSelect: false + property var tracksSelected: ({}) + readonly property int tracksSelectedCount: Object.keys(root.tracksSelected).length + property bool albumsMultiSelect: false + property var albumsSelected: ({}) + readonly property int albumsSelectedCount: Object.keys(root.albumsSelected).length + + function setTracksMultiSelect(on) { + root.tracksMultiSelect = on + if (!on) root.tracksSelected = ({}) + } + function setAlbumsMultiSelect(on) { + root.albumsMultiSelect = on + if (!on) root.albumsSelected = ({}) + } + // A rebind needs a NEW object reference — mutating the map in place + // notifies nothing (the same rule artMap and every row's `favorite` + // property follow). + function toggleTrackSelected(id) { + var m = root.tracksSelected + if (m[id] === true) delete m[id] + else m[id] = true + root.tracksSelected = Object.assign({}, m) + } + function toggleAlbumSelected(id) { + var m = root.albumsSelected + if (m[id] === true) delete m[id] + else m[id] = true + root.albumsSelected = Object.assign({}, m) + } + + /// Selected ids in VISIBLE order — never `Object.keys(map)`. Qobuz ids are + /// numeric strings, and a JS object iterates integer-like keys in ASCENDING + /// NUMERIC order, so the keys of the map are not the order the user sees + /// and "play next" would insert a re-sorted block. + function selectedIdsInOrder(kind, map) { + var rows = root.visibleRows + var out = [] + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === kind && map[rows[i].id] === true) out.push(rows[i].id) + return out + } + + function tracksBulkAction(action) { + if (action === "select-all") { + var m = {} + var rows = root.visibleRows + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === "track") m[rows[i].id] = true + root.tracksSelected = m + return + } + if (action === "clear") { root.tracksSelected = ({}); return } + var ids = root.selectedIdsInOrder("track", root.tracksSelected) + if (ids.length === 0) return + QbzLibrary.libraryBulkAction("track", JSON.stringify(ids), action) + // The Slint clears after an enqueue and after remove-selected, and + // KEEPS the selection while a picker is still open (a failed write is + // retried from the same modal) — local_bulk.rs::apply says the same. + if (action !== "add-to-playlist" && action !== "add-to-mixtape") + root.tracksSelected = ({}) + } + function albumsBulkAction(action) { + if (action === "select-all") { + var m = {} + var rows = root.visibleRows + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === "album") m[rows[i].id] = true + root.albumsSelected = m + return + } + if (action === "clear") { root.albumsSelected = ({}); return } + var ids = root.selectedIdsInOrder("album", root.albumsSelected) + if (ids.length === 0) return + QbzLibrary.libraryBulkAction("album", JSON.stringify(ids), action) + root.albumsSelected = ({}) + } + // --------------------- windowed artwork ----------------------------- // Report the mounted window as artKeys; prune far-away covers. function reportWindow(visibleArray, first, last) { @@ -313,10 +613,14 @@ Rectangle { var span = last - first + 1 var keepLo = Math.max(0, first - span) var keepHi = Math.min(visibleArray.length - 1, last + span) - for (var i = keepLo; i <= keepHi; i++) keep[visibleArray[i].artKey] = true + // A group-header pseudo-row has no artwork of its own; skipping it here + // keeps `undefined` out of the keep-set and the pending count. + for (var i = keepLo; i <= keepHi; i++) + if (visibleArray[i].kind !== "group-header") keep[visibleArray[i].artKey] = true var m = artMap var pending = 0 for (i = first; i <= last; i++) { + if (visibleArray[i].kind === "group-header") continue var k = visibleArray[i].artKey if (visibleArray[i].imageUrl !== "") { keys.push(k) @@ -358,7 +662,8 @@ Rectangle { // user back to row 0. Nothing on screen needed the re-derive: the // grid cards each listen to this same signal for their own key // (cards/AlbumCard.qml, TrackCard.qml, PlaylistCard.qml, - // ArtistCard.qml) and the list rows do the same (FeedListRow below). + // ArtistCard.qml) and the list rows do the same + // (library/FeedListRow.qml). // The in-place mutation is for the delegates built LATER — when the // user scrolls, re-filters or switches tab — and it reaches them // because `visibleRows` pushes the very same row objects. @@ -409,107 +714,6 @@ Rectangle { onTriggered: root.queueWindowReport(0, 59) } - // ============================ components ============================= - - // Segmented tab (SegmentedTabBar's Segment) with count badge. - // Small toolbar toggle button (ToggleButton sm): 30px, active = accent. - component ToolToggle: Rectangle { - property string name: "" - property bool active: false - signal clicked() - width: 30 - height: 30 - radius: 6 - color: active ? theme.surfaceElevated - : ttArea.containsMouse ? theme.surfaceHover : "transparent" - QbzIcon { - name: parent.name - width: 16 - height: 16 - anchors.centerIn: parent - tintName: parent.active ? "accent" - : ttArea.containsMouse ? "textPrimary" : "secondary" - } - MouseArea { - id: ttArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: parent.clicked() - } - } - - // Filter-by-genre trigger (FavoritesView.slint's FavGenreButton, sm): - // accent fill + "N genres" while the "library-all" selection is active. - component GenreToolButton: Rectangle { - id: gtb - readonly property bool active: root.genreCount > 0 - width: gtbRow.width - height: 30 - radius: 6 - color: gtb.active ? theme.accent - : gtbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated - Row { - id: gtbRow - height: parent.height - leftPadding: 10 - rightPadding: 12 - spacing: 7 - QbzIcon { - name: "list-filter" - width: 13 - height: 13 - anchors.verticalCenter: parent.verticalCenter - // Same pair-of-halves fix as controls/BrowseGenreButton.qml: - // the glyph said "primary" (legacy alias of a literal - // #ffffff) next to an accent-text label, i.e. a white glyph - // beside a black label on the pale-accent themes. - // favorites/FavoritesView.slint:301 + :309 are consistently - // #ffffff, but that white is 1.70:1 on high-contrast, 1.74 - // on ikari, 1.82 on wcag-dark and under 2.6:1 on 16 of the - // 35 palettes — deliberate divergence from both lines. - // theme/QbzTheme.qml, "ON AN ACCENT FILL". - tintName: gtb.active ? theme.accentGlyphTint : "secondary" - } - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.genreCount === 0 - ? QbzSession.tr("Filter by genre", QbzSession.trRev) - : root.genreCount === 1 - ? QbzSession.tr("1 genre", QbzSession.trRev) - : QbzSession.tr("{} genres", QbzSession.trRev) - .replace("{}", root.genreCount) - // The colour twin of the glyph's tint above — accent-text on - // 34 of the 35 palettes. - color: gtb.active ? theme.accentGlyphColor : theme.textSecondary - font.pixelSize: 12 - } - } - MouseArea { - id: gtbArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: libGenrePopup.toggle() - } - } - - // (The inline toolbar SearchBox is gone — both toolbars now mount the - // shared controls/QbzLineEdit in its expandable arm, which IS - // primitives/ExpandableSearch.slint 1:1.) - - // Card heart (live favorite toggle). - - - // Quality mini-badge (hi-res image / cd box). - // ===================== card families (Slint homologation) ============= - // Phase 13/21: every Library surface mounts the SAME card family the - // Slint mounts (FavoritesView.slint): track -> discover/TrackCard, - // album -> discover/AlbumCard, artist -> discover/ArtistGridCard, - // playlist -> discover/PlaylistCard, label -> discover/LabelCard — the - // shared qml/ files (TrackCard/AlbumCard/ArtistCard/PlaylistCard/ - // LabelCard.qml). The old one-size FeedGridCard is gone. - // ------------------- per-row play (PARITY-DEBT #5) ------------------- // Every track row in this view queues the list the user is LOOKING AT, // anchored on the clicked row — it does NOT play one track and stop. @@ -535,15 +739,7 @@ Rectangle { return ids } function playTrackInContext(trackId) { - // Feature-detect the seam so the view degrades to the old one-track - // play instead of throwing if the invokable is not there yet. - if (typeof QbzLibrary.libraryPlayVisible === "function") { - QbzLibrary.libraryPlayVisible(JSON.stringify(visibleTrackIds()), trackId) - return - } - console.warn("[qbz-qt] LibraryView: QbzLibrary.libraryPlayVisible is missing;" - + " falling back to a one-track queue (PARITY-DEBT #5)") - QbzPlayer.playTrack(trackId) + QbzLibrary.libraryPlayVisible(JSON.stringify(visibleTrackIds()), trackId) } // Track context-menu model (TrackCard.slint track-menu) + dispatch — @@ -578,304 +774,6 @@ Rectangle { else if (a === "favorite") row.toggleFavorite() } - // --- All-feed LIST row (FavoritesView inline row, homologated) -------- - component FeedListRow: Rectangle { - id: feedRow - property var item: ({}) - // Viewport-relative position, for the skeleton's animated cap. - property int rowIndex: 0 - height: 44 - radius: 6 - color: rowArea.containsMouse ? theme.surfaceHover : "transparent" - - // Live heart, as a real property — `item.isFavorite = !…` notified - // nothing (plain JS object; see rows/TrackRow.qml for the measurement) - // so this row's glyph and its menu label never moved on click. The - // binding is re-established on every new row object, so a republished - // feed still wins. - property bool favorite: feedRow.item.isFavorite === true - onItemChanged: feedRow.favorite = Qt.binding(function () { - return feedRow.item.isFavorite === true - }) - function toggleFavorite() { - feedRow.favorite = !feedRow.favorite - QbzLibrary.libraryToggleFavorite(feedRow.item.kind, feedRow.item.id) - } - // Settle + rollback + cross-surface walk. `artKey` IS - // `library_qt::feed_key(kind, id)`, the very key the signal carries — - // which is also why root's own handler can patch the backing row by - // comparing against it. - Connections { - target: QbzLibrary - function onLibraryFavoriteChanged(key, value) { - if ((feedRow.item.artKey || "") !== "" && key === feedRow.item.artKey) - feedRow.favorite = value - } - } - - // Row body — click plays/opens by kind. Declared BEFORE the cells so - // the ⋯ button and art-play win their clicks. - MouseArea { - id: rowArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (item.kind === "track") root.playTrackInContext(item.id) - else if (item.kind === "album") QbzAlbum.openAlbum(item.id) - else if (item.kind === "artist") QbzArtist.openArtist(item.id) - // playlist/label pages: out of scope (POC-NOTE). - } - } - - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - spacing: 12 - // Col — artwork (round for artists) + hover play. - Rectangle { - width: 44 - height: 44 - anchors.verticalCenter: parent.verticalCenter - radius: item.kind === "artist" ? 22 : 6 - color: theme.surfaceElevated - clip: true - RoundedImage { - anchors.fill: parent - visible: !lrCollage.visible - source: root.artMap[item.artKey] || "" - radius: item.kind === "artist" ? 22 : 6 - // A label logo is contain-fitted (cropping cuts the - // wordmark) on a FLAT surface — those are transparent - // PNGs whose edges carry no colour to derive from. - // A playlist takes "auto": its own graphic is the 800x380 - // `image_rectangle` and pads with an image-derived - // gradient, while a square member cover still crops. The - // flag is not required for that any more, but it stays - // authoritative where it IS published (this feed). - fit: item.kind === "label" ? "contain" - : item.kind === "playlist" - ? (item.playlistOwnImage === true ? "pad" : "auto") - : "crop" - } - // User playlists have no graphic of their own, so they show the - // member-cover mosaic instead of a blank tile. - PlaylistCollage { - id: lrCollage - anchors.fill: parent - visible: item.kind === "playlist" - && item.playlistOwnImage !== true - && (root.artMap[item.artKey] || "") === "" - urls: item.covers || [] - radius: 6 - } - // Per-row cover placeholder — same progressive rule as the - // grid: it clears when THIS row's cover lands. Skipped for - // the collage arm (a playlist mosaic is real content) and - // for artists/labels (round designed placeholders). - QbzSkeleton { - variant: "art" - anchors.fill: parent - blockRadius: 6 - visible: (item.kind === "track" || item.kind === "album") - && item.imageUrl !== "" - && (root.artMap[item.artKey] || "") === "" - phase: root.skelPhase - cellIndex: rowIndex - } - Rectangle { - visible: item.kind === "track" || item.kind === "album" || item.kind === "playlist" - anchors.fill: parent - radius: item.kind === "artist" ? 22 : 6 - color: "#a6000000" - opacity: lrPlayArea.containsMouse ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: 150 } } - // On the #a6000000 artwork scrim — dark under every theme. - QbzIcon { name: "play-fill"; width: 16; height: 16; anchors.centerIn: parent; tintName: "white" } - MouseArea { - id: lrPlayArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (item.kind === "track") root.playTrackInContext(item.id) - else if (item.kind === "album") QbzPlayer.playAlbum(item.id) - // playlist play: out of scope (POC-NOTE). - } - } - } - } - // Col — title + subtitle (artist link for track/album). - Column { - width: parent.width - 44 - 116 - (srcCol.visible ? 44 : 0) - - 150 - 18 - 30 - 6 * 12 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Text { - width: parent.width - height: 18 - text: item.title - color: lrTitleArea.containsMouse ? theme.accent : theme.textPrimary - font.pixelSize: 14 - font.weight: theme.weightMedium - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - MouseArea { - id: lrTitleArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (item.kind === "track") root.playTrackInContext(item.id) - else if (item.kind === "album") QbzAlbum.openAlbum(item.id) - else if (item.kind === "artist") QbzArtist.openArtist(item.id) - } - } - } - Text { - visible: item.subtitle !== "" - width: parent.width - height: 16 - text: item.subtitle - color: (item.artistId !== "" && (item.kind === "track" || item.kind === "album") - && lrSubArea.containsMouse) ? theme.accent : theme.textMuted - font.pixelSize: 12 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - MouseArea { - id: lrSubArea - anchors.fill: parent - hoverEnabled: true - cursorShape: (item.artistId !== "" && (item.kind === "track" || item.kind === "album")) - ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (item.artistId !== "") QbzArtist.openArtist(item.artistId) - } - } - } - // Col — type (icon + caps label). - Rectangle { - width: 116 - height: parent.height - color: "transparent" - Row { - spacing: 6 - anchors.verticalCenter: parent.verticalCenter - QbzIcon { - name: item.kind === "track" ? "music" - : item.kind === "playlist" ? "list-music" - : item.kind === "artist" ? "user" - : item.kind === "label" ? "disc-3" : "disc" - width: 13 - height: 13 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - Text { - text: item.kind === "track" ? QbzSession.tr("Track", QbzSession.trRev) - : item.kind === "album" ? QbzSession.tr("Album", QbzSession.trRev) - : item.kind === "artist" ? QbzSession.tr("Artist", QbzSession.trRev) - : item.kind === "playlist" ? QbzSession.tr("Playlist", QbzSession.trRev) - : QbzSession.tr("Label", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 10 - font.weight: theme.weightSemibold - font.letterSpacing: 1.2 - verticalAlignment: Text.AlignVCenter - } - } - } - // Col — source glyph (only when local/Plex can appear). - Rectangle { - id: srcCol - visible: root.showLocal - width: 44 - height: parent.height - color: "transparent" - QbzIcon { - visible: item.source === "local" || item.source === "plex" - name: "hard-drive" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - // Host is a transparent column over the theme row (NOT an - // artwork scrim), so this matches its siblings - // local/LocalAlbumRow.qml and local/LocalTrackRow.qml. - tintName: item.source === "plex" ? "accent" : "muted" - } - } - // Col — quality (albums + tracks). - Rectangle { - width: 150 - height: parent.height - color: "transparent" - QualityMini { - visible: (item.kind === "album" || item.kind === "track") && item.qualityTier !== "" - tier: item.qualityTier - anchors.verticalCenter: parent.verticalCenter - } - } - // Col — favorite / follow indicator. - QbzIcon { - name: item.kind === "artist" ? "user-plus" - : (feedRow.favorite ? "heart-filled" : "heart") - width: 18 - height: 18 - anchors.verticalCenter: parent.verticalCenter - tintName: "accent" - } - // Col — context-menu button. - Rectangle { - width: 30 - height: 30 - radius: 6 - anchors.verticalCenter: parent.verticalCenter - color: lrMenuArea.containsMouse ? theme.surfaceElevated : "transparent" - QbzIcon { name: "ellipsis"; width: 16; height: 16; anchors.centerIn: parent; tintName: "secondary" } - MouseArea { - id: lrMenuArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { lrMenu.openAtCursor(lrMenuArea, mouse.x, mouse.y) } - } - } - } - CardMenu { - id: lrMenu - menuWidth: 196 - entries: item.kind === "track" ? root.trackMenuModel(item, feedRow.favorite) - : item.kind === "album" ? [ - { "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" }, - { "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" }, - { "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), - "icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" }, - ] - : item.kind === "artist" ? [ - { "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }, - ] - : [ - { "label": QbzSession.tr("Open", QbzSession.trRev), "icon": "list-music", "action": "open" }, - { "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), - "icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" }, - ] - onPicked: function (a) { - if (item.kind === "track") { root.trackAction(feedRow, a); return } - if (a === "open") { - if (item.kind === "album") QbzAlbum.openAlbum(item.id) - // playlist/label pages: out of scope (POC-NOTE). - } else if (a === "play") { - if (item.kind === "album") QbzPlayer.playAlbum(item.id) - } else if (a === "go-artist") { - QbzArtist.openArtist(item.id) - } else if (a === "favorite") { - feedRow.toggleFavorite() - } - } - } - } - - // --- Tracks-tab row (primitives/TrackRow.slint, 50px) ----------------- // ============================ view =================================== // Offline gate (OfflinePlaceholder replica; the Slint offline RAIL is @@ -892,297 +790,10 @@ Rectangle { spacing: 0 visible: !QbzSession.offline - // --- Toolbar: tab menu (left) + per-tab controls (right) --------- - Item { + LibraryToolbar { + id: toolbar width: parent.width - height: 56 - - // FavTabMenu (SegmentedTabBar with count badges). - Rectangle { - x: 32 - y: 25 - height / 2 - width: tabRow.width - height: tabRow.height - color: theme.surfaceElevated - radius: 6 - Row { - id: tabRow - padding: 3 - spacing: 4 - QbzTabBar { - counts: true - underline: true - activeId: root.activeTab - tabs: [ - { "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": counts.all || 0 }, - { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": counts.tracks || 0 }, - { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": counts.albums || 0 }, - { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": counts.artists || 0 }, - { "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": counts.playlists || 0 }, - { "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": counts.labels || 0 }, - ] - onSelected: function (id) { root.activeTab = id } - } - } - } - - // Per-tab controls. - Row { - x: parent.width - width - 32 - y: 25 - height / 2 - height: 30 - spacing: 8 - - // ===== All toolbar ===== - Row { - visible: root.activeTab === "all" - spacing: 8 - height: parent.height - // FavoritesView.slint:801-813 — collapsed magnifier that - // opens LEFT over the tab bar; the All tab is the only - // one that debounces after the keystroke. - QbzLineEdit { - searchMode: true - expandable: true - sm: true - elevated: false // ExpandableSearch fill = surface-card - openWidth: 196 // = max-open-width - placeholder: QbzSession.tr("Search your library", QbzSession.trRev) - onEdited: function (v) { - root.search = v - allSearchDebounce.restart() - } - } - // Source switches (tooltips are the Slint copies). - ToolToggle { name: "shopping-bag"; active: root.showPurchases; onClicked: root.showPurchases = !root.showPurchases } - ToolToggle { name: "heart"; active: root.showFavorites; onClicked: root.showFavorites = !root.showFavorites } - ToolToggle { name: "user-plus"; active: root.showFollowing; onClicked: root.showFollowing = !root.showFollowing } - ToolToggle { name: "hard-drive"; active: root.showLocal; onClicked: root.showLocal = !root.showLocal } - // Filter by genre — shared popup, own "library-all" - // context (FavoritesView.slint:864). - GenreToolButton { } - // Grid / list toggle. - ToolToggle { - name: root.viewMode === "list" ? "layout-grid" : "list" - active: false - onClicked: root.viewMode = root.viewMode === "list" ? "grid" : "list" - } - // Sort menu (PlaylistView-style: field + direction caret). - Rectangle { - width: allSortRow.width - height: 30 - radius: 6 - color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated - Row { - id: allSortRow - height: parent.height - leftPadding: 10 - rightPadding: 10 - spacing: 6 - Text { - anchors.verticalCenter: parent.verticalCenter - text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + ( - root.sortBy === "title" ? QbzSession.tr("Title", QbzSession.trRev) - : root.sortBy === "artist" ? QbzSession.tr("Artist", QbzSession.trRev) - : QbzSession.tr("Date added", QbzSession.trRev)) - color: theme.textSecondary - font.pixelSize: 12 - } - QbzIcon { - name: root.sortAsc ? "chevron-up" : "chevron-down" - width: 12 - height: 12 - anchors.verticalCenter: parent.verticalCenter - tintName: "secondary" - } - } - MouseArea { - id: allSortArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: allSortMenu.openBelowRight(allSortArea) - } - QbzContextMenu { - id: allSortMenu - menuWidth: 172 - Repeater { - model: [ - { "field": "date", "label": QbzSession.tr("Date added", QbzSession.trRev) }, - { "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) }, - { "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) }, - ] - delegate: Rectangle { - required property var modelData - width: parent ? parent.width : 0 - height: 33 - radius: 5 - color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent" - Row { - anchors.fill: parent - anchors.leftMargin: 8 - spacing: 6 - Text { - width: parent.width - 26 - height: parent.height - text: modelData.label - color: theme.textSecondary - font.pixelSize: 13 - font.weight: root.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular - verticalAlignment: Text.AlignVCenter - } - QbzIcon { - visible: root.sortBy === modelData.field - name: root.sortAsc ? "chevron-up" : "chevron-down" - width: 12 - height: 12 - anchors.verticalCenter: parent.verticalCenter - tintName: "accent" - } - } - MouseArea { - id: sortOptArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - // Re-pick flips direction; a - // new field resets to its - // natural default. - if (root.sortBy === modelData.field) { - root.sortAsc = !root.sortAsc - } else { - root.sortBy = modelData.field - root.sortAsc = modelData.field !== "date" - } - allSortMenu.close() - } - } - } - } - } - } - } - - // ===== Other-tab toolbars ===== - // ONE instance for the five tabs: the reference clears the - // query on every tab entry (see onActiveTabChanged), so - // per-tab instances would only implement a persistence that - // does not exist. A Row skips `visible: false` children, so - // the 30px slot appears/disappears cleanly and, because the - // ROOT width never leaves collapsedSize while the field - // opens, nothing to its right ever moves. - QbzLineEdit { - id: tabSearchBox - visible: root.activeTab !== "all" && root.tabHasItems - searchMode: true - expandable: true - sm: true - elevated: false - openWidth: 196 - placeholder: QbzSession.tr("Search", QbzSession.trRev) - onEdited: function (v) { root.tabSearch = v } - } - // Albums sort popup (real, JS-side). - Rectangle { - visible: root.activeTab === "albums" && root.tabHasItems - width: albumsSortRow.width - height: 30 - radius: 6 - color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated - Row { - id: albumsSortRow - height: parent.height - leftPadding: 10 - rightPadding: 10 - spacing: 6 - Text { - anchors.verticalCenter: parent.verticalCenter - text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + root.albumsSort - color: theme.textSecondary - font.pixelSize: 12 - } - QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } - } - MouseArea { - id: albumsSortArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: albumsSortMenu.openBelowRight(albumsSortArea) - } - QbzContextMenu { - id: albumsSortMenu - menuWidth: 172 - Repeater { - model: ["default", "title-asc", "title-desc", "artist-asc"] - delegate: Rectangle { - required property string modelData - width: parent ? parent.width : 0 - height: 33 - radius: 5 - color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent" - Text { - anchors.fill: parent - anchors.leftMargin: 8 - text: modelData - color: theme.textSecondary - font.pixelSize: 13 - font.weight: root.albumsSort === modelData ? theme.weightSemibold : theme.weightRegular - verticalAlignment: Text.AlignVCenter - } - MouseArea { - id: abSortOptArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { root.albumsSort = modelData; albumsSortMenu.close() } - } - } - } - } - } - // Playlists sub-tab (Library / Following). - Rectangle { - visible: root.activeTab === "playlists" && root.tabHasItems - width: plSubRow.width - height: 30 - radius: 6 - color: theme.surfaceElevated - Row { - id: plSubRow - padding: 3 - spacing: 4 - Repeater { - model: [ - { "id": "favorites", "label": QbzSession.tr("Library", QbzSession.trRev) }, - { "id": "following", "label": QbzSession.tr("Following", QbzSession.trRev) }, - ] - delegate: Rectangle { - required property var modelData - property bool active: root.playlistsSubTab === modelData.id - width: plSubText.implicitWidth + 20 - height: 24 - radius: 4 - color: active ? theme.surfaceMain : "transparent" - Text { - id: plSubText - anchors.centerIn: parent - text: modelData.label - color: parent.active ? theme.textPrimary : theme.textMuted - font.pixelSize: 12 - font.weight: theme.weightMedium - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.playlistsSubTab = modelData.id - } - } - } - } - } - } + view: root } Rectangle { width: parent.width @@ -1192,10 +803,16 @@ Rectangle { // ===================== SCROLLING CONTENT ========================= Item { + id: content width: parent.width height: parent.height - 57 clip: true + // The tracks bulk bar is pinned above the list (the Slint scrolls + // it away with the page; here the list IS the scrolling view). + readonly property int barInset: + (root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0 + // Loading skeleton — a deliberate ADDITION: the Slint mounts a // bare centred 36px LoadingSpinner here (FavoritesView.slint: // 956/1012), which says "busy" but not "this is the shape of @@ -1219,9 +836,13 @@ Rectangle { readonly property bool listShape: (root.activeTab === "all" && root.viewMode === "list") || root.activeTab === "tracks" + || (root.activeTab === "albums" && root.albumsView === "list") + || (root.activeTab === "playlists" && root.playlistsView === "list") readonly property int cols: Math.max(1, Math.floor(width / 220)) readonly property int rows: Math.max(1, Math.ceil(height / 266)) - readonly property int rowHeight: root.activeTab === "tracks" ? 50 : 44 + readonly property int rowHeight: root.activeTab === "tracks" ? 50 + : root.activeTab === "albums" ? 64 + : root.activeTab === "playlists" ? 60 : 44 Grid { columns: skelLayer.cols @@ -1301,16 +922,31 @@ Rectangle { } } + readonly property bool ready: + !QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" + // Which surface owns the tab right now — one predicate per body, + // so no two can mount at once. + readonly property bool showAlbumsList: + content.ready && root.activeTab === "albums" && root.albumsView === "list" + readonly property bool showPlaylistsList: + content.ready && root.activeTab === "playlists" && root.playlistsView === "list" + readonly property bool showArtistsPanel: + content.ready && root.activeTab === "artists" && root.artistsView === "sidepanel" + readonly property bool showList: + content.ready && ((root.activeTab === "all" && root.viewMode === "list") + || root.activeTab === "tracks") + readonly property bool showGrid: + content.ready && !content.showList && !content.showAlbumsList + && !content.showPlaylistsList && !content.showArtistsPanel + // ============ GRID (all tabs; All in grid mode) ============== GridView { id: grid anchors.fill: parent anchors.leftMargin: 32 - anchors.rightMargin: 32 + anchors.rightMargin: root.alphaVisible ? 52 : 32 anchors.topMargin: 16 - visible: !QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" - && (root.activeTab !== "all" || root.viewMode === "grid") - && root.activeTab !== "tracks" + visible: content.showGrid && root.activeTab !== "tracks" cellWidth: 220 cellHeight: 266 cacheBuffer: 266 * 2 @@ -1323,113 +959,14 @@ Rectangle { onWidthChanged: root.gridWindowReport() Component.onCompleted: root.gridWindowReport() - delegate: Item { + delegate: FeedGridCell { required property var modelData required property int index - width: 200 - height: 246 - - Component { - id: albumCardComp - AlbumCard { - albumId: modelData.id - title: modelData.title - artist: modelData.artist - artistId: modelData.artistId - genre: modelData.genre - year: modelData.year - qualityTier: modelData.qualityTier - artSource: root.artMap[modelData.artKey] || "" - isFavorite: modelData.isFavorite - isPinned: modelData.isPinned - // The pin payload's display snapshot: the REMOTE - // url, never `artMap` (a local file:// cache path - // that means nothing after a cache wipe). Without - // it every album pinned from the Library grid - // landed in the Home Pinned rail as a permanent - // grey placeholder, across restarts. - artworkUrl: modelData.imageUrl || "" - source: modelData.source - } - } - Component { - id: trackCardComp - TrackCard { - item: modelData - artSource: root.artMap[modelData.artKey] || "" - showSourceBadge: root.showLocal - } - } - Component { - id: artistCardComp - ArtistCard { - item: modelData - artSource: root.artMap[modelData.artKey] || "" - isPinned: modelData.isPinned === true - // Snapshot url for the pin payload (see the album - // card above). - artworkUrl: modelData.imageUrl || "" - // The Slint reference SPLITS here, and this one - // component serves both of its call sites: the - // ARTISTS tab grids pass follow-mode "none" - // (FavoritesView.slint:1820/1854 — every artist in - // that grid is followed by definition, so the chip - // would be a permanent tick), while the mixed ALL - // feed keeps the default and seeds `following` - // from `is-favorite` (:1143-1147), which is - // exactly the second spelling ArtistCard reads. - followMode: root.activeTab === "artists" ? "none" : "toggle" - } - } - Component { - id: playlistCardComp - PlaylistCard { - // artworkUrl is not passed on purpose: the card - // defaults it to `item.imageUrl`, which IS this - // row's remote cover (FeedItem.image_url). - item: modelData - artSource: root.artMap[modelData.artKey] || "" - isPinned: modelData.isPinned === true - } - } - Component { - id: labelCardComp - LabelCard { - item: modelData - artSource: root.artMap[modelData.artKey] || "" - } - } - Loader { - anchors.fill: parent - sourceComponent: modelData.kind === "album" ? albumCardComp - : modelData.kind === "track" ? trackCardComp - : modelData.kind === "artist" ? artistCardComp - : modelData.kind === "playlist" ? playlistCardComp - : labelCardComp - } - // THE fix for "many grey squares that fill in unevenly": - // each pending cover shimmers on its own and stops the - // moment ITS file:// path lands in artMap, so the grid - // resolves progressively instead of looking like a wall - // of dead tiles. Artists and labels are excluded — their - // cards already draw a designed round gradient+glyph - // portrait placeholder (ArtistGridCard/LabelCard). - // A bare Rectangle: it does not take pointer events, so - // the card's hover/click areas keep working underneath. - QbzSkeleton { - variant: "art" - width: 200 - height: 200 - visible: (modelData.kind === "album" - || modelData.kind === "track" - || modelData.kind === "playlist") - && modelData.imageUrl !== "" - && (root.artMap[modelData.artKey] || "") === "" - phase: root.skelPhase - // Viewport-relative so the 48-instance cap follows - // the window, not the (possibly 10k-item) model. - cellIndex: Math.max(0, index - root.artWindowFirst) - } + view: root + item: modelData + // Viewport-relative so the skeleton's 48-instance cap + // follows the window, not the (possibly 10k-item) model. + cellIndex: Math.max(0, index - root.artWindowFirst) } } @@ -1438,11 +975,9 @@ Rectangle { id: list anchors.fill: parent anchors.leftMargin: 32 - anchors.rightMargin: 32 - anchors.topMargin: 10 - visible: !QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" - && ((root.activeTab === "all" && root.viewMode === "list") - || root.activeTab === "tracks") + anchors.rightMargin: root.alphaVisible ? 52 : 32 + anchors.topMargin: 10 + content.barInset + visible: content.showList cacheBuffer: 44 * 10 clip: true boundsBehavior: Flickable.StopAtBounds @@ -1455,12 +990,32 @@ Rectangle { required property var modelData required property int index width: list.width - height: root.activeTab === "tracks" ? 50 : 44 + height: modelData && modelData.kind === "group-header" + ? 34 + : (root.activeTab === "tracks" ? 50 : 44) + Component { + id: listHeaderComp + Item { + Text { + x: 2 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 4 + text: modelData.title + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + } + } Component { id: trackRowComp TrackRow { item: modelData - number: index + 1 + number: modelData._no || (index + 1) + selectMode: root.tracksMultiSelect + checked: root.tracksSelected[modelData.id] === true + onToggleSelect: root.toggleTrackSelected(modelData.id) onPlayRequested: root.playTrackInContext(item.id) onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) } // MyQBZ "Add to mixtape" — the HOST builds the @@ -1497,18 +1052,127 @@ Rectangle { Component { id: feedRowComp FeedListRow { + view: root item: modelData rowIndex: Math.max(0, index - root.artWindowFirst) } } - sourceComponent: root.activeTab === "tracks" ? trackRowComp : feedRowComp + sourceComponent: (modelData && modelData.kind === "group-header") + ? listHeaderComp + : (root.activeTab === "tracks" ? trackRowComp : feedRowComp) } } + // ============ Albums LIST mode =============================== + LibraryAlbumsList { + id: albumsList + visible: content.showAlbumsList + anchors.fill: parent + anchors.leftMargin: 32 + anchors.rightMargin: root.alphaVisible ? 52 : 32 + anchors.topMargin: 16 + view: root + rows: content.showAlbumsList ? root.visibleRows : [] + } + + // ============ Playlists LIST mode ============================ + // FavoritesView.slint:1935 — the same ViewToggle the albums tab + // has, over primitives/PlaylistListRow. + ListView { + id: playlistsList + visible: content.showPlaylistsList + anchors.fill: parent + anchors.leftMargin: 32 + anchors.rightMargin: 32 + anchors.topMargin: 16 + clip: true + spacing: 2 + cacheBuffer: 60 * 8 + boundsBehavior: Flickable.StopAtBounds + model: content.showPlaylistsList ? root.visibleRows : [] + + function report() { + var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4) + var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4 + root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last)) + } + onContentYChanged: playlistsList.report() + onModelChanged: playlistsList.report() + onHeightChanged: playlistsList.report() + Component.onCompleted: playlistsList.report() + + delegate: PlaylistListRow { + required property var modelData + required property int index + width: playlistsList.width + item: modelData + rowIndex: index + artSource: root.artMap[modelData.artKey] || "" + covers: modelData.playlistOwnImage === true ? [] : (modelData.covers || []) + } + } + + // ============ Artists SIDEPANEL mode ========================= + // Behind a Loader so LEAVING the mode really destroys it: the + // panel drops its Rust-side selection in Component.onDestruction, + // and it also stops rebuilding its rail on every feed change while + // the user is on another tab. + Loader { + anchors.fill: parent + active: content.showArtistsPanel + sourceComponent: LibraryArtistsPanel { view: root } + } + + // Declared AFTER every scrolling body: declaration order IS + // z-order, and this bar overlays the top of the content area. + // ---- Tracks multi-select bar (FavoritesView.slint:1570) ----- + QbzMultiSelectBar { + id: tracksBar + visible: root.activeTab === "tracks" && root.tracksMultiSelect && content.ready + anchors.top: parent.top + anchors.topMargin: 10 + anchors.left: parent.left + anchors.leftMargin: 32 + anchors.right: parent.right + anchors.rightMargin: 32 + selectedCount: root.tracksSelectedCount + // The reference's inventory MINUS "Make available offline": + // this port brings up no offline cache at all (src/ + // library_bulk.rs names the check), and a bulk bar must not + // render a control that no-ops. Everything else is wired. + // "heart-crack" is likewise not in this port's icon set, so + // "Remove from Library" uses the same heart + danger pair + // views/local/LocalTracksTab.qml already ships. + actions: [ + { "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false }, + { "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true }, + { "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true }, + { "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true }, + { "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true }, + { "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true }, + { "id": "remove-selected", "label": QbzSession.tr("Remove from Library", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true }, + { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }, + ] + onAction: function (id) { root.tracksBulkAction(id) } + } + + // A-Z jump strip, right edge (FavoritesView.slint:1683 / :1992 / + // :2004 all pin it at `parent.width - self.width - 20px`). + QbzAlphaStrip { + visible: root.alphaVisible + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.top: parent.top + anchors.topMargin: 16 + anchors.bottom: parent.bottom + jumps: root.alphaJumps + onJump: function (ordinal, index) { root.alphaJumpTo(index) } + } + // Thin auto-hiding scrollbars (ListScrollbar replica). QbzScrollBar { anchors.right: parent.right - anchors.rightMargin: 4 + anchors.rightMargin: root.alphaVisible ? 34 : 4 anchors.top: parent.top anchors.bottom: parent.bottom target: grid @@ -1516,12 +1180,21 @@ Rectangle { } QbzScrollBar { anchors.right: parent.right - anchors.rightMargin: 4 + anchors.rightMargin: root.alphaVisible ? 34 : 4 anchors.top: parent.top anchors.bottom: parent.bottom target: list visible: list.visible && list.contentHeight > list.height } + QbzScrollBar { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + anchors.bottom: parent.bottom + target: playlistsList + visible: playlistsList.visible + && playlistsList.contentHeight > playlistsList.height + } } } diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index 1ac6f7b0c..89f673190 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -11,6 +11,12 @@ // Local actions ONLY: play all / shuffle / edit tags / add to playlist / // add to Mixtape. A multi-artist album gets the "+N more artists" expander; // a multi-disc album gets the disc dividers with their per-disc ⋯ menu. +// +// ONE deliberate ADDITION over the reference (owner, 2026-07-31): the +// toolbar's multi-select toggle and its bulk bar. The Slint puts those on the +// QOBUZ album page (AlbumPageView.slint:752-808) and not on the local one, +// which is an asymmetry, not a design — the local page is where the owner +// asked for them. See the `multiSelect` block below for the wiring. import QtQuick import QtQuick.Window @@ -73,6 +79,58 @@ Rectangle { } return out } + // ------------------------- multi-select ------------------------------ + // The Slint's LOCAL album page has no bulk bar; its QOBUZ one does + // (album/AlbumPageView.slint:752-808 — the 30x30 square toggle in the + // toolbar beside the search box, then MultiSelectBar over the rows). + // Owner call 2026-07-31: close that asymmetry here, in the local page. + // + // Selection lives in QML, exactly as the two Local Library tabs keep + // theirs (LocalLibraryView.qml:684-720) — the ids go down per action, so + // `select-all` / `clear` never reach Rust. Scope is "track": the album + // detail's rows ARE `LocalState.detail_raw` (local_album_actions.rs:154 + // caches every version's rows there), which is one of the two caches + // `local_bulk::resolve_blocking` reads for that scope, so a Plex row with + // no DB id resolves too. + property bool multiSelect: false + property var selected: ({}) + readonly property int selectedCount: Object.keys(selected).length + // A republish means the track list changed underneath the selection — a + // different album, or the version picker swapping every row for another + // physical copy's. Both invalidate the ids, so the selection goes and + // select mode with it (the same "leaving drops the selection" contract + // `local_bulk::set_select_mode` documents for the tree rail). + onDocChanged: { multiSelect = false; selected = ({}) } + function toggleSelected(id) { + var s = Object.assign({}, selected) + if (s[id]) delete s[id]; else s[id] = true + selected = s + } + /// Selected ids in the order they are ON SCREEN, so a bulk enqueue lands + /// in disc/track order. `Object.keys` on the selection map would sort the + /// numeric-looking keys ascending by row id, which is the INSERT order of + /// the scan, not the album's. Iterates `tracks`, not `visibleTracks`, so a + /// selection made before typing in the search box still enqueues whole. + function selectedIdsInOrder() { + var out = [] + for (var i = 0; i < tracks.length; i++) { + if (selected[tracks[i].id] === true) out.push(tracks[i].id) + } + return out + } + function bulkAction(action) { + if (action === "clear") { selected = ({}); return } + if (action === "select-all") { + // What the user can SEE: the search box is a view filter, so + // "select all" means the filtered set (LocalLibraryView.qml:715). + var s = {} + for (var i = 0; i < visibleTracks.length; i++) s[visibleTracks[i].id] = true + selected = s + return + } + QbzLocal.bulkAction("track", JSON.stringify(selectedIdsInOrder()), action) + } + // Disc divider before the first row of each disc on a multi-disc album // (0 = flat list, as the Slint's disc-header-number). function discHeader(i) { @@ -221,7 +279,12 @@ Rectangle { phase: root.skelPhase } - // ---- Toolbar: quality badge + track search ---- + // ---- Toolbar: quality badge + track search + select toggle ---- + // AlbumPageView.slint:686-786 numbers: 52px band, the search box + // cut from 280 to 168 "so the toolbar gives room to the square + // select button beside it", 16px gap, a 30x30 radius-6 square + // (NOT a round CircleAction) that goes accent while select mode + // is on. Item { width: parent.width height: 52 @@ -231,53 +294,118 @@ Rectangle { tier: root.album ? (root.album.qualityTier || "") : "" detail: root.album ? (root.album.qualityDetail || "") : "" } - Rectangle { + Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: 280 - height: 34 - radius: 6 - border.width: 1 - border.color: theme.borderSubtle - color: theme.surfaceElevated - Row { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 7 - QbzIcon { - name: "search" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - Item { - width: parent.width - 21 - height: parent.height - clip: true - TextInput { - id: searchInput - anchors.fill: parent - color: theme.textPrimary - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - selectByMouse: true - onTextEdited: root.trackQuery = text + spacing: 16 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 168 + height: 34 + radius: 6 + border.width: 1 + border.color: theme.borderSubtle + color: theme.surfaceElevated + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 + QbzIcon { + name: "search" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" } - Text { - visible: searchInput.text === "" - anchors.fill: parent - text: QbzSession.tr("Search tracks...", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter + Item { + width: parent.width - 21 + height: parent.height + clip: true + TextInput { + id: searchInput + anchors.fill: parent + color: theme.textPrimary + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + selectByMouse: true + onTextEdited: root.trackQuery = text + } + Text { + visible: searchInput.text === "" + anchors.fill: parent + text: QbzSession.tr("Search tracks...", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } + } + } + } + // Multi-select toggle. Unlike the Qobuz AlbumView.qml's + // (dimmed and inert — that bridge has no bulk seam), this + // one is LIVE: QbzLocal.bulkAction already carries the + // "track" scope this page needs. + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 30 + height: 30 + radius: 6 + border.width: 1 + border.color: root.multiSelect ? theme.accent : theme.borderSubtle + color: (root.multiSelect || selectArea.containsMouse) + ? theme.surfaceHover + : theme.surfaceElevated + QbzIcon { + name: "square-check-big" + width: 15 + height: 15 + anchors.centerIn: parent + tintName: root.multiSelect + ? "accent" + : (selectArea.containsMouse ? "textPrimary" : "secondary") + } + MouseArea { + id: selectArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.multiSelect = !root.multiSelect + if (!root.multiSelect) root.selected = ({}) } } } } } + // ---- Bulk action bar ---- + // Only actions with a LIVE backend, in a context where they mean + // something. Dropped from the Slint album bar's seven: + // * add-to-favorites / remove-favorites — local hearts are not + // wired at all (local_bulk.rs's arm is a log-only no-op, and + // the store is keyed by file path behind a private handle); + // * make-offline — these tracks ARE the local files. + // Added: add-to-mixtape, which the two Local Library bars already + // offer and which `local_bulk::apply` serves live. + QbzMultiSelectBar { + visible: root.multiSelect + width: parent.width + selectedCount: root.selectedCount + actions: [ + { "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false }, + { "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true }, + { "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true }, + { "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true }, + { "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true }, + { "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true }, + { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }, + ] + onAction: function (id) { root.bulkAction(id) } + } + // The Slint's 8px gutter under the bar (AlbumPageView.slint:808). + Item { visible: root.multiSelect; width: 1; height: 8 } + // ---- Column header ---- // rows/TrackListHeader.qml, on rows/TrackCols.qml geometry — the // same object the LocalTrackRows below lay their cells out from. @@ -375,11 +503,17 @@ Rectangle { ? trackBlock.modelData.number : trackBlock.index + 1 showAlbum: false showArtwork: false + // The checkbox LocalTrackRow already draws over + // the number cell for the Tracks tab — no fork, + // no new component (rule 5). + selectMode: root.multiSelect + checked: root.selected[trackBlock.modelData.id] === true onPlayRequested: QbzLocal.playAlbumTrack( root.album.id, trackBlock.modelData.id) onEnqueueRequested: function (m) { QbzLocal.enqueue("track", trackBlock.modelData.id, m) } + onToggleSelect: root.toggleSelected(trackBlock.modelData.id) } } } diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 53bca2e6f..b2de21741 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -90,7 +90,12 @@ Rectangle { // Tracks tab. property string tracksSearch: "" - property string tracksGroup: "off" // off | album | artist | name + // off | album | artist | name — PERSISTED, so it is owned by the bridge + // exactly like the Tracks sort and the album-identity mode, not by a QML + // default. It used to be a plain `property string tracksGroup: "off"`: + // `locallibrary_ui.json` carried the user's choice across restarts and + // nothing ever read it back into the UI (PARITY-DEBT #13). + readonly property string tracksGroup: QbzLocal.localTracksGroup property bool tracksMultiSelect: false property var tracksSelected: ({}) readonly property int tracksSelectedCount: Object.keys(tracksSelected).length @@ -343,9 +348,40 @@ Rectangle { sortRows(filterRows(folders, foldersSearch), foldersSort) readonly property var foldersGrouped: groupRows(foldersVisible, foldersGroup) - // Tracks: search + sort are SERVER-side (pagination order), so the - // visible set is the loaded pages as published. - readonly property var tracksVisible: tracks + // Tracks: search + sort are SERVER-side (they define the pagination + // order), so the loaded pages arrive already ordered and the visible set + // is the pages as published. + // + // The GROUP modes are the exception: they are a CLIENT-side visual + // reorder layered on top of that order (`derive_tracks`, + // local_library.rs:1196-1247, sorts the rows by the group key before the + // headers go in; commit e379aa65 calls it out as "group modes keep their + // client-side visual reorder on top"). Doing it here rather than inside + // LocalTracksTab keeps ONE definition of "the visible order" — the tab's + // entry model, the artwork window, select-all and the play seam all read + // this same array, which is what makes the queue match the screen + // (PARITY-DEBT #14). + function cmpStr(a, b) { return a < b ? -1 : a > b ? 1 : 0 } + readonly property var tracksVisible: { + var rows = tracks + if (tracksGroup === "off" || rows.length === 0) return rows + var lc = function (v) { return (v || "").toString().toLowerCase() } + var out = rows.slice() + if (tracksGroup === "album") { + out.sort(function (a, b) { + return cmpStr(lc(a.album), lc(b.album)) || cmpStr(lc(a.title), lc(b.title)) + }) + } else if (tracksGroup === "artist") { + out.sort(function (a, b) { + return cmpStr(lc(a.artist), lc(b.artist)) + || cmpStr(lc(a.album), lc(b.album)) + || cmpStr(lc(a.title), lc(b.title)) + }) + } else if (tracksGroup === "name") { + out.sort(function (a, b) { return cmpStr(lc(a.title), lc(b.title)) }) + } + return out + } readonly property var artistsVisible: { var q = artistsSearch.trim().toLowerCase() @@ -375,16 +411,37 @@ Rectangle { return out } - // The selected artist's albums — client-side over the loaded album set - // (`allArtists` is the comma-joined contributor list the DB aggregates). + // The selected artist's albums. + // + // The MATCH lives in Rust — `QbzLocal.artistAlbumIds` -> + // local_artist_match::album_matches_artist, the port of + // local_library.rs:3368-3390 — because it is the same normalized-name rule + // the Artists merge uses and there must be exactly one of it. It compares + // NORMALIZED PARTS for exact equality against the primary artist, every + // `allArtists` comma part and every split-credit part (`,&/;`, feat/ft/ + // featuring/with), plus the "various artists" special case. + // + // What it replaced: a lowercase equality on `artist` OR a SUBSTRING + // `indexOf` on `allArtists`. That listed "Airbourne" and "Blair" under + // "Air", and an album credited "A & B" never appeared under "B" + // (PARITY-DEBT #8). + // + // The ids come back rather than the rows, so the pane renders the very + // objects this view already parsed, in the published order. readonly property var artistAlbums: { if (selectedArtist === "") return [] - var needle = selectedArtist.toLowerCase() + var rows = albums // binding dependency: re-derive on every republish + var ids = {} + try { + var arr = JSON.parse(QbzLocal.artistAlbumIds(selectedArtist)) + for (var j = 0; j < arr.length; j++) ids[arr[j]] = true + } catch (e) { + console.warn("[qbz-qt] local: bad artist album ids — " + e) + return [] + } var out = [] - for (var i = 0; i < albums.length; i++) { - var a = albums[i] - if ((a.artist || "").toLowerCase() === needle - || (a.allArtists || "").toLowerCase().indexOf(needle) >= 0) out.push(a) + for (var i = 0; i < rows.length; i++) { + if (ids[rows[i].id]) out.push(rows[i]) } return out } diff --git a/crates/qbz-qt/qml/views/MixView.qml b/crates/qbz-qt/qml/views/MixView.qml index 4762bd9f1..ca9680c06 100644 --- a/crates/qbz-qt/qml/views/MixView.qml +++ b/crates/qbz-qt/qml/views/MixView.qml @@ -28,13 +28,16 @@ // rows/TrackCols.qml for both. // // POC-NOTEs, each deliberate and each with a precedent in this port: -// - NO multi-select bar. qml/rows/TrackRow.qml has no multi-select arm, and -// one of the .slint bar's seven actions (add-to-playlist) still has no -// bridge seam at all — LabelView.qml made the same call. (add-to-mixtape -// IS live now: the per-row menu reaches QbzMyQbzAdd.open; it is only the -// BULK form that is missing, for want of a selection.) The .slint header's -// select disc renders DIMMED and inert here (the ArtistView / LabelView -// precedent). +// - NO multi-select bar — and the ORIGINAL reason is now STALE on both +// halves: rows/TrackRow.qml:116 DOES have the `selectMode` arm, and +// add-to-playlist DOES have a bridge seam (QbzPlaylistPicker.openForTracks, +// src/playlist_picker_bridge.rs). What is still missing here is only the +// selection STATE and the bar itself — LibraryView.qml's `tracksSelected` +// map + qml/controls/MultiSelectBar-style host is the pattern to copy. +// Until then the .slint header's select disc renders DIMMED and inert here +// (the ArtistView / LabelView precedent). Per-ROW add-to-playlist and +// add-to-mixtape are both LIVE on this page already (the shared TrackRow +// menu). // - The "Add to playlist" disc (list-plus) is inert in the .slint too — it // carries no `clicked` there (MixView.slint:182-188) — so it is inert // here, dimmed the same way, rather than silently absent. diff --git a/crates/qbz-qt/qml/views/PlaylistListRow.qml b/crates/qbz-qt/qml/views/PlaylistListRow.qml index ca02fd001..37715cd85 100644 --- a/crates/qbz-qt/qml/views/PlaylistListRow.qml +++ b/crates/qbz-qt/qml/views/PlaylistListRow.qml @@ -15,6 +15,7 @@ import QtQuick import com.blitzfc.qbz +import "../cards" import "../controls" import "../theme" @@ -22,6 +23,17 @@ Rectangle { id: root property var item: ({}) + /// Cover override for hosts whose rows carry no `artPath` of their own. + /// The Library feed is id-keyed (`artKey` -> a decoded file:// path in + /// LibraryView's artMap); "" falls straight back to `item.artPath`, so the + /// browse pages are untouched. + property string artSource: "" + /// Up to four member-track covers — the 2x2 mosaic a USER playlist shows + /// when it has no artwork of its own (`library_qt::FeedItem.covers`). The + /// browse pages publish none, so they keep the designed glyph placeholder. + property var covers: [] + readonly property string artResolved: + root.artSource !== "" ? root.artSource : (root.item.artPath || "") /// Row index — drives the even/odd zebra (coherent with TrackRow). /// NOT named `index`: a Repeater injects one into the delegate context /// and a same-named property here would shadow it. @@ -88,12 +100,20 @@ Rectangle { // and still crops the square `image.covers[0]` fallback. RoundedImage { anchors.fill: parent - source: root.item.artPath || "" + visible: !rowCollage.visible + source: root.artResolved radius: 6 fit: "auto" } + PlaylistCollage { + id: rowCollage + anchors.fill: parent + visible: root.artResolved === "" && (root.covers || []).length > 0 + urls: root.covers || [] + radius: 6 + } QbzIcon { - visible: (root.item.artPath || "") === "" + visible: root.artResolved === "" && !rowCollage.visible name: "list-music" width: 20 height: 20 diff --git a/crates/qbz-qt/qml/views/PlaylistManagerView.qml b/crates/qbz-qt/qml/views/PlaylistManagerView.qml new file mode 100644 index 000000000..73fef3c8e --- /dev/null +++ b/crates/qbz-qt/qml/views/PlaylistManagerView.qml @@ -0,0 +1,358 @@ +// PlaylistManagerView — the Playlist Manager, the port of +// playlist/PlaylistManagerView.slint (1498 lines). Route id +// "playlistmanager", reached from Sidebar > Playlists ⋯ > Manage playlists. +// +// A fixed 56 px title/toolbar bar over ONE scrolling surface with three view +// modes (grid / list / tree) and two toggles (folder mode, folders collapsed). +// Every row arrives ready to render from Rust — this file does no per-row map +// lookup, no pluralisation and no filtering. +// +// ── THE SCROLL ARCHITECTURE (contract D1) ───────────────────────────────── +// ONE `ListView` in all three modes, with the whole page chrome as its +// `header`, a footer spacer, and one delegate per ROW: a row of grid cards, a +// list row, or a tree row. NOT a GridView inside a Flickable — nesting +// Flickables gives nested flicking and two scrollbars, and buys no recycling +// (an inner view sized to its full content has a viewport equal to its content, +// so every delegate instantiates anyway). One ListView is also the only shape +// that keeps the scroll position across a view-mode switch, which the +// reference's single Flickable does for free. +// +// `ListView.spacing` is 0 and every gap lives INSIDE a delegate's height, +// because ListView.spacing would also apply between the header and row 0, where +// the reference has 14 px, not 16. The footer is therefore `100 − gap` per mode +// (84 / 96 / 98): the last delegate contributes its own trailing gap. +// +// `model: 0` while `pmLoading` is what reproduces the reference's `!loading` +// gate on the whole body; the header carries its own `!loading` gates. +// +// ── NEVER WRAPPED IN QbzOfflinePlaceholder (contract §5.12) ─────────────── +// Seven views wrap themselves and one (PlayHistoryView) explicitly refuses +// because it reads a local store. This is the second refusal, and the stronger +// one: the manager keeps working offline, and for a user with no Qobuz account +// the LOCAL playlists it lists are the only library they have. An implementer +// following the majority would hide exactly the view that user needs. +// +// The two counts in the counter row come from the document; the folder cards' +// `count` is TOTAL membership while a tree folder row's is the POST-FILTER +// member count (§5.7) — two different questions from one field name, on +// purpose. +// +// Scroll-position restore is an INHERITED GAP: the reference restores and +// reports `viewport-y` for scope "playlist-manager" (`:1285-1296`); no view in +// this port restores scroll and `nav_qt.rs` has no equivalent seam. Recorded +// (contract D30), not silently dropped. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" +import "playlistmanager" + +Rectangle { + id: root + + // Round to the AppShell content-frame bezel: QML clips are rectangular, so + // the frame's rounding never reaches the view — the view's own fill must + // round (the literal in all 14 painting view roots). + radius: 12 + color: root.ambientOn ? "transparent" : theme.surfaceMain + readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack + + QbzTheme { id: theme } + + // ============================== documents ============================= + // Both parses are GUARDED — a raw JSON.parse in a binding throws on the + // pre-publish frame. + + readonly property var doc: { + try { + return JSON.parse(QbzPlaylistManager.managerJson) + } catch (e) { + return ({}) + } + } + /// The CANONICAL folder array, its own property (contract D5): the sidebar + /// needs it before — and without — the manager document. + readonly property var folders: { + try { + return JSON.parse(QbzPlaylistManager.foldersJson) + } catch (e) { + return [] + } + } + + // `!== false`, NOT `=== true`, on the two that default TRUE upstream: a `{}` + // first frame would otherwise flash flat mode and a descending sort on every + // mount. + readonly property string searchSeed: root.doc.search || "" + readonly property string filter: root.doc.filter || "all" + readonly property string sort: root.doc.sort || "name" + readonly property bool sortAsc: root.doc.sortAsc !== false + readonly property string viewMode: root.doc.viewMode || "grid" + readonly property bool folderMode: root.doc.folderMode !== false + readonly property bool foldersCollapsed: root.doc.foldersCollapsed === true + /// Computed in RUST (`sort == "custom" && sortAsc && query empty`, D9); + /// QML must never re-derive it. + readonly property bool canReorder: root.doc.canReorder === true + readonly property int folderCount: root.doc.folderCount || 0 + readonly property int playlistCount: root.doc.playlistCount || 0 + readonly property var playlists: root.doc.playlists || [] + /// EMPTY unless folderMode && viewMode === "tree". + readonly property var tree: root.doc.tree || [] + + // ---- geometry -------------------------------------------------------- + readonly property int contentW: Math.max(0, pmList.width - 64) + readonly property int gridCols: Math.max(1, Math.floor((root.contentW + 16) / 176)) + readonly property int cardH: root.canReorder ? 250 : 220 + readonly property int rowsInBody: + root.viewMode === "tree" ? root.tree.length : root.playlists.length + readonly property int rowCount: + QbzPlaylistManager.pmLoading ? 0 + : root.viewMode === "grid" + ? Math.ceil(root.playlists.length / Math.max(1, root.gridCols)) + : root.rowsInBody + /// The page's 100 px bottom padding, minus the trailing gap the last + /// delegate already carries. + readonly property int footerH: + root.viewMode === "grid" ? 84 : root.viewMode === "list" ? 96 : 98 + + // `navigate()` records the route and raises the spinner but does NOT load; + // this is the single load, and it is also the back/forward re-entry path + // (nav_qt::back()/forward() republish `currentView` and run no per-view + // load, so `navigate()` never ran in that case). + Component.onCompleted: QbzPlaylistManager.reload() + + // ============================ fixed header ============================ + Item { + id: headerBar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 56 + + // The reference paints this bar an opaque `surface-main` while the view + // root goes transparent (`:1089`, `:1097`) — under the dynamic + // background that is a solid band across the ambient field. Every Qt + // view's fixed header takes the A30 treatment instead. + Rectangle { + anchors.fill: parent + color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain + } + + // x 48, not the reference's 32: this port has NO per-view NavButtons + // (nav history is the global HeaderBar, the treatment + // BlacklistManagerView.qml:23-26 and settings/SettingsView.qml:24-25 + // already document; ADR-004 is satisfied there). 48 is the number + // views/PlaylistBrowseView.qml:143 chose for the byte-identical Slint + // header, so the port's two 56 px headers align. + Text { + x: 48 + y: 25 - height / 2 + width: Math.max(0, tools.x - 48 - 16) + text: QbzSession.tr("Playlist manager", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + elide: Text.ElideRight + } + + PmToolbar { + id: tools + x: Math.max(0, parent.width - width - 32) + y: 25 - height / 2 + searchSeed: root.searchSeed + filter: root.filter + sort: root.sort + sortAsc: root.sortAsc + viewMode: root.viewMode + folderMode: root.folderMode + } + } + + // ================================ body ================================ + Item { + id: bodyHost + anchors.left: parent.left + anchors.right: parent.right + anchors.top: headerBar.bottom + anchors.bottom: parent.bottom + + ListView { + id: pmList + anchors.fill: parent + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 0 + // ~2 grid rows of pre-instantiation past the viewport. + cacheBuffer: 400 + reuseItems: true + + // An INT model in all three modes: the delegates read + // `root.playlists[index]` / `root.tree[index]`, so a republish that + // keeps the row count re-evaluates their bindings instead of + // rebuilding the list, and the grid's row delegate can slice. + model: root.rowCount + + header: PmPageHead { + width: pmList.width + loading: QbzPlaylistManager.pmLoading + folders: root.folders + viewMode: root.viewMode + folderMode: root.folderMode + foldersCollapsed: root.foldersCollapsed + canReorder: root.canReorder + folderCount: root.folderCount + playlistCount: root.playlistCount + rowsInBody: root.rowsInBody + } + footer: Item { + width: 1 + height: root.footerH + } + + // Assigning `delegate` from a ternary re-creates every delegate on a + // mode switch — which is exactly right here, and cheaper than a + // per-delegate Loader. + delegate: root.viewMode === "grid" ? gridRowDelegate + : root.viewMode === "tree" ? treeRowDelegate + : listRowDelegate + } + + // The control's own rule is `visible: maxScroll > 0` (its auto-hide, = + // the Slint ListScrollbar's `auto-hide: true`). NEVER assign `visible` + // at this call site — that replaces the binding and a short page gets a + // full-height thumb on gutter hover. + QbzScrollBar { + target: pmList + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + + // ============================= delegates ============================== + + // GRID — one delegate is one ROW of cards. This is what makes a card grid + // windowed without a GridView (which could not carry a header this tall and + // would force one uniform cell height across the 220/250 split). + Component { + id: gridRowDelegate + + Item { + id: cell + required property int index + + x: 32 + width: root.contentW + height: root.cardH + 16 + + // A BINDING, not a function call: it must re-evaluate when the + // window resizes (gridCols) or the document republishes. + readonly property var slice: root.playlists.slice( + cell.index * root.gridCols, (cell.index + 1) * root.gridCols) + + Row { + spacing: 16 + + Repeater { + model: cell.slice + + delegate: PmGridCard { + required property var modelData + required property int index + + item: modelData + // ABSOLUTE index in the visible order — the reorder + // arrows need the position in the whole set, not the + // column within this row. + itemIndex: cell.index * root.gridCols + index + canReorder: root.canReorder + } + } + } + } + } + + // LIST — 64 px row + the 4 px inter-row gap. + Component { + id: listRowDelegate + + Item { + id: lrow + required property int index + + readonly property var entry: root.playlists[lrow.index] || ({}) + + width: pmList.width + height: 68 + + PmListRow { + x: 32 + width: root.contentW + item: lrow.entry + itemIndex: lrow.index + canReorder: root.canReorder + foldersCount: root.folders.length + // The ONE menu lives at the view level (D17) — the row asks, + // the view opens. + onFolderMenuRequested: function (anchorItem) { + folderMenu.openFor(String(lrow.entry.id || ""), + String(lrow.entry.folderId || ""), + anchorItem) + } + } + } + } + + // TREE — 44 px folder rows and 40 px playlist rows, + the 2 px gap. A + // Loader picks the arm so a folder row never instantiates a collage and a + // playlist row never instantiates a folder tile. + Component { + id: treeRowDelegate + + Item { + id: trow + required property int index + + readonly property var entry: root.tree[trow.index] || ({}) + readonly property bool isFolder: trow.entry.kind === "folder" + + width: pmList.width + height: (trow.isFolder ? 44 : 40) + 2 + + Loader { + x: 32 + width: root.contentW + height: trow.isFolder ? 44 : 40 + sourceComponent: trow.isFolder ? treeFolderArm : treePlaylistArm + } + + Component { + id: treeFolderArm + + PmTreeFolderRow { + folder: trow.entry.folder || ({}) + expanded: trow.entry.expanded === true + } + } + Component { + id: treePlaylistArm + + PmTreePlaylistRow { + item: trow.entry.playlist || ({}) + indent: trow.entry.indent === true + } + } + } + } + + // ======================== the ONE folder menu ========================= + // Declared last (declaration order is z-order) and mounted ONCE: with + // `reuseItems: true` a per-row menu would give every recycled delegate its + // own Popup. + PmFolderMenu { + id: folderMenu + folders: root.folders + } +} diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 2e6fc08a8..5c8f0518e 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -9,15 +9,21 @@ // more (the shared AppShell text modal), owner • N tracks • duration, // the action row: Play / Shuffle / heart (owned, wired) / pin (wired) / // follow (foreign, subscribe API) / copy (foreign, create+add) / edit -// (owner: rename + delete modal). Right edge: in-playlist search + the +// (owner: opens the SHARED controls/PlaylistEditModal.qml through +// QbzPlaylistEdit — this view mounts no editor of its own; the inline +// rename+delete Popup that used to live at the bottom of this file is gone, +// contract D20). Right edge: in-playlist search + the // sort dropdown (Default/Title/Artist/Album/Duration/Date added/Custom). // // Track list: the exact PlaylistView row (# / 36px art / title+artist / // Album 220px link / Duration 70 / Quality 92 / heart / cloud reserve / // ⋯ menu with Remove-from-playlist for owners), column header, empty and // loading states. Per-row ⋯: Play / Play next / Play later / Add to -// queue / Go to artist / Go to album / Add|Remove from Library / Remove -// from playlist (owner). +// queue / Add to mixtape / Add to playlist / Go to artist / Go to album / +// Add|Remove from Library / Remove from playlist (owner). "Add to playlist" +// routes by the ROW's source: a Qobuz row takes the catalog arm, a local / +// Plex row of an adopted LOCAL playlist takes the local-mode one +// (QbzPlaylistPicker.openForLocalRow -> local_picker_ref_for_row). // // Drag & drop (issue #589): the row BODY is the drag source (press-drag // >6px — the same gesture that drops onto sidebar playlists). A release @@ -65,6 +71,29 @@ Rectangle { readonly property string sortField: doc.sortField || "default" readonly property bool sortAsc: doc.sortAsc === true readonly property string searchQuery: doc.search || "" + /// A LOCAL detail (`local:`) adopted into this view + /// (local_playlist_qt.rs -> playlist_qt::adopt_doc). It drops the Qobuz- + /// only affordances and, for the reorder below, it changes WHICH sort is + /// the reorderable one. + readonly property bool isLocal: doc.isLocalPlaylist === true + + // --- Reorderable? (owner findings 5 + 8) ----------------------------- + // PlaylistView.slint:1094-1096 states the rule: the reorder affordance is + // offered under the CUSTOM sort — "or, on a LOCAL playlist, the Default + // sort: its natural order IS the editable repo order (B2), written via + // repo::reorder". A reorder against a COMPUTED order (title, duration, …) + // is meaningless, which is why it is a sort test and not an ownership one + // alone. + // + // The empty-search term is this port's own, and it is load-bearing: the + // in-playlist search filters `root.tracks` HERE, in QML, while the Rust + // arms index into the unfiltered document. Offering a reorder while a + // filter is active would hand a filtered index to a document index — the + // Playlist Manager takes the same precaution (`playlist_manager_qt.rs:236` + // requires `query.is_empty()` before it lights its arrows). + readonly property bool canReorder: root.isOwner + && root.searchQuery.trim() === "" + && (root.isLocal ? root.sortField === "default" : root.sortField === "custom") // --- Settled state from Rust (`libraryFavoriteChanged` / `pinChanged`) - // Two jobs, and the first one is why this exists at all. @@ -440,7 +469,18 @@ Rectangle { visible: root.isOwner name: "pen-line" anchors.verticalCenter: parent.verticalCenter - onClicked: editModal.open() + // The ONE shared editor (controls/PlaylistEditModal.qml, + // mounted in AppShell), which replaced the inline Popup + // that used to live at the bottom of this file: that one + // could express neither a description nor the + // offline-only flag, and it wiped nothing only because + // it never sent one (contract D20). + // + // The id comes from `root.doc.id`. This view has no + // dedicated id property of its own, and a missing one + // would read `undefined`, convert to an empty QString + // and open the editor on no playlist, silently. + onClicked: QbzPlaylistEdit.open(String(root.doc.id || "")) } Item { width: parent.width - 40 - 32 * 6 - 7 * 12 - 220 - 140; height: 1 } @@ -521,11 +561,20 @@ Rectangle { { "field": "album", "label": QbzSession.tr("Album", QbzSession.trRev) }, { "field": "duration", "label": QbzSession.tr("Duration", QbzSession.trRev) }, { "field": "added", "label": QbzSession.tr("Date added", QbzSession.trRev) }, - { "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true }, + // Manual order — enables the per-row + // reorder chevrons. HIDDEN on a LOCAL + // playlist (PlaylistView.slint:945-952, + // B2): its Default order IS the editable + // repo order, so a separate custom + // sidecar is moot — and the sidecar is + // u64-keyed, which a local row's id is + // not. + { "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true, "qobuzOnly": true }, ] delegate: Rectangle { required property var modelData - visible: modelData.ownerOnly !== true || root.isOwner + visible: (modelData.ownerOnly !== true || root.isOwner) + && (modelData.qobuzOnly !== true || !root.isLocal) width: parent ? parent.width : 0 height: visible ? 33 : 0 radius: 5 @@ -599,6 +648,9 @@ Rectangle { showArtwork: true showAlbum: true showDownload: true + // MIRROR the delegate's arm, or the labels slide by 22 + 14 the + // moment the list becomes reorderable (TrackListHeader's rule 2). + showReorder: root.canReorder } Rectangle { visible: root.tracks.length > 0; width: 1; height: 3; color: "transparent" } Rectangle { visible: root.tracks.length > 0; width: parent.width; height: 1; color: theme.borderSubtle } @@ -642,30 +694,66 @@ Rectangle { showAlbum: true showDownload: true menuShowRemove: root.isOwner + // Reorder chevrons (owner findings 5 + 8). `index` is the + // index into `root.tracks`, which under `canReorder` is + // the unfiltered document — the empty-search term of that + // predicate is what guarantees it. + showReorder: root.canReorder + canMoveUp: index > 0 + canMoveDown: index < root.tracks.length - 1 + onMoveUpRequested: QbzBridge.playlistMoveRow(String(item.id), -1) + onMoveDownRequested: QbzBridge.playlistMoveRow(String(item.id), 1) onPlayRequested: QbzBridge.playlistPlayTrack(item.id) onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) } - onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId) + // The DISPLAY row id, as a string. For a Qobuz row it IS + // the membership id (`playlist_qt.rs:363-364` sets both + // from `track.id`); for a LOCAL row `playlistTrackId` is a + // queue id — or 0 on an unresolved one — and the removal + // has to be keyed on the id the position map knows. + onRemoveRequested: QbzBridge.playlistRemoveTrack(String(item.id)) + // "Add to playlist" — this view serves BOTH a Qobuz + // playlist and a LOCAL one (`local_playlist_qt::load` + // publishes into this same document through + // `playlist_qt::adopt_doc`), so the row's id space is not + // uniform here and the routing reads the row's own + // `source` word ("" = Qobuz, "local" | "plex" = a + // library.db / Plex ref). The local arm hands the DISPLAY + // id to Rust, which resolves it through + // `local_playlist_qt::local_picker_ref_for_row` — a Plex + // row's rating key only exists in the open detail's queue + // snapshot and QML cannot build the ref itself. + routePlaylistAddExternally: modelData.source === "local" + || modelData.source === "plex" + onPlaylistAddRequested: QbzPlaylistPicker.openForLocalRow(item.id) // MyQBZ "Add to mixtape" — the HOST builds the AddItem // array (TrackRow does not know itemType/source). // - // SOURCE: `PlaylistTrackRow` (playlist_qt.rs:47-78) has no - // source field because this whole view has one source — - // LOCAL playlists are out of scope everywhere in it - // (playlist_qt.rs:23-25), the document is mapped from - // Qobuz `Track` values, and `playlistTrackId` is the - // Qobuz membership row id. `item.id` is a Qobuz catalog - // id by construction of the view, not by assumption here. - // If local playlists are ever ported, the row must start - // carrying its source and this must read it. + // SOURCE comes off the ROW, never a literal: a Qobuz + // playlist's rows carry no `source` and are catalog rows, + // while a LOCAL playlist's rows carry "local" | "plex" + // (playlist_qt.rs PlaylistTrackRow.source). "plex" folds + // into "local" because `AddItem.source` is + // "qobuz" | "local" and `source_from_str` + // (myqbz_add_qt.rs:85-90) maps anything that is not + // "local" back to Qobuz — passing "plex" verbatim would + // store a Plex row under a catalog id again. onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{ - "itemType": "track", "source": "qobuz", + "itemType": "track", + "source": (item.source === "local" || item.source === "plex") + ? "local" : "qobuz", "sourceItemId": item.id, "title": item.title || "", "subtitle": item.artist || "", "artworkUrl": "", "year": null, "trackCount": null }])) onBodyDragStarted: function (n) { // #589: report the source index BEFORE the shared drag. - if (root.isOwner) { + // Gated on `canReorder`, not on ownership alone: under + // a computed sort (or an active search) this drag is + // ONLY the add-to-a-sidebar-playlist gesture, and + // claiming a source index would let a release inside + // the list commit a move against an order that is not + // the stored one. + if (root.canReorder) { root.reorderFrom = index root.reorderOver = -1 root.reorderDropPlaylist = "" @@ -699,110 +787,4 @@ Rectangle { } } } - - // --- Edit modal (rename + delete; owner) --------------------------------- - Popup { - id: editModal - parent: Overlay.overlay - anchors.centerIn: parent - width: 380 - padding: 20 - closePolicy: Popup.CloseOnEscape - background: Rectangle { - color: theme.surfaceCard - radius: theme.radiusMd - border.width: 1 - border.color: theme.borderSubtle - } - contentItem: Column { - spacing: 14 - Text { - text: QbzSession.tr("Edit playlist", QbzSession.trRev) - color: theme.textPrimary - font.pixelSize: theme.fontHeading - font.weight: theme.weightSemibold - } - Rectangle { - width: parent.width - height: 36 - radius: 6 - color: theme.surfaceElevated - border.width: 1 - border.color: theme.borderSubtle - TextInput { - id: nameInput - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - text: root.doc.name || "" - color: theme.textPrimary - font.pixelSize: 14 - verticalAlignment: Text.AlignVCenter - clip: true - } - } - Row { - spacing: 10 - anchors.right: parent.right - Rectangle { - width: delText.implicitWidth + 28 - height: 34 - radius: 6 - color: delArea.containsMouse ? "#33ef4444" : "transparent" - border.width: 1 - border.color: "#66ef4444" - Text { - id: delText - anchors.centerIn: parent - text: QbzSession.tr("Delete", QbzSession.trRev) - color: "#ef4444" - font.pixelSize: 13 - font.weight: theme.weightMedium - } - MouseArea { - id: delArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - editModal.close() - QbzBridge.playlistDelete() - } - } - } - Rectangle { - width: saveText.implicitWidth + 28 - height: 34 - radius: 6 - color: saveArea.containsMouse ? theme.accentHover : theme.accent - Text { - id: saveText - anchors.centerIn: parent - text: QbzSession.tr("Save", QbzSession.trRev) - // Was a literal #ffffff, 1:1 with - // primitives/EditPlaylistModal.slint:171 — and that - // white is 1.70:1 on high-contrast, 1.74 on ikari, - // 1.82 on wcag-dark, under 2.6:1 on 16 of the 35 - // palettes, on the SAME accent fill every other - // on-accent site was just fixed against. The colour - // twin of the glyph selector; see - // theme/QbzTheme.qml, "ON AN ACCENT FILL". - color: theme.accentGlyphColor - font.pixelSize: 13 - font.weight: theme.weightMedium - } - MouseArea { - id: saveArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - editModal.close() - QbzBridge.playlistRename(nameInput.text) - } - } - } - } - } - } } diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml new file mode 100644 index 000000000..575530248 --- /dev/null +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -0,0 +1,146 @@ +// FeedGridCell — one 200x246 slot of the Library GRID, dispatched by `kind` +// to the SAME card family the Slint mounts (FavoritesView.slint:1084-1187): +// track -> discover/TrackCard, album -> discover/AlbumCard, artist -> +// discover/ArtistGridCard, playlist -> discover/PlaylistCard, label -> +// discover/LabelCard. +// +// EXTRACTED from views/LibraryView.qml's GridView delegate (track rule 2). +// The per-cell cover skeleton rides along, because it is a property of the +// SLOT (it must sit above the card and below nothing) rather than of the view. + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../controls" +import "../../theme" + +Item { + id: cell + + QbzTheme { id: theme } + + /// The LibraryView root (artMap, skelPhase, showLocal, activeTab). + property var view: null + /// The feed row this slot renders. + property var item: ({}) + /// Index INSIDE the reported artwork window — the skeleton's 48-instance + /// animation cap counts from the viewport, never from a 10k-row model. + property int cellIndex: 0 + + width: 200 + height: 246 + + Component { + id: albumCardComp + AlbumCard { + albumId: cell.item.id + title: cell.item.title + artist: cell.item.artist + artistId: cell.item.artistId + genre: cell.item.genre + year: cell.item.year + qualityTier: cell.item.qualityTier + artSource: cell.view.artMap[cell.item.artKey] || "" + isFavorite: cell.item.isFavorite + isPinned: cell.item.isPinned + // The pin payload's display snapshot: the REMOTE url, never + // `artMap` (a local file:// cache path that means nothing after a + // cache wipe). Without it every album pinned from the Library grid + // landed in the Home Pinned rail as a permanent grey placeholder, + // across restarts. + artworkUrl: cell.item.imageUrl || "" + source: cell.item.source + } + } + // Group separator. These are pseudo-rows injected into the SAME flat model + // by `visibleItems`, so the list stays virtualized and the windowed + // artwork report is unaffected. Only the tracks LIST produces them today; + // the arm stays so a grouped grid cannot render a card for a header row. + Component { + id: groupHeaderComp + Item { + Text { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + text: cell.item.title + color: theme.textSecondary + font.pixelSize: 13 + font.weight: theme.weightSemibold + elide: Text.ElideRight + width: parent.width + } + } + } + Component { + id: trackCardComp + TrackCard { + item: cell.item + artSource: cell.view.artMap[cell.item.artKey] || "" + showSourceBadge: cell.view.showLocal + } + } + Component { + id: artistCardComp + ArtistCard { + item: cell.item + artSource: cell.view.artMap[cell.item.artKey] || "" + isPinned: cell.item.isPinned === true + // Snapshot url for the pin payload (see the album card above). + artworkUrl: cell.item.imageUrl || "" + // The Slint reference SPLITS here, and this one component serves + // both of its call sites: the ARTISTS tab grids pass follow-mode + // "none" (FavoritesView.slint:1831/1865 — every artist in that + // grid is followed by definition, so the chip would be a permanent + // tick), while the mixed ALL feed keeps the default and seeds + // `following` from `is-favorite` (:1143-1147), which is exactly + // the second spelling ArtistCard reads. + followMode: cell.view.activeTab === "artists" ? "none" : "toggle" + } + } + Component { + id: playlistCardComp + PlaylistCard { + // artworkUrl is not passed on purpose: the card defaults it to + // `item.imageUrl`, which IS this row's remote cover. + item: cell.item + artSource: cell.view.artMap[cell.item.artKey] || "" + isPinned: cell.item.isPinned === true + } + } + Component { + id: labelCardComp + LabelCard { + item: cell.item + artSource: cell.view.artMap[cell.item.artKey] || "" + } + } + Loader { + anchors.fill: parent + sourceComponent: cell.item.kind === "group-header" ? groupHeaderComp + : cell.item.kind === "album" ? albumCardComp + : cell.item.kind === "track" ? trackCardComp + : cell.item.kind === "artist" ? artistCardComp + : cell.item.kind === "playlist" ? playlistCardComp + : labelCardComp + } + // THE fix for "many grey squares that fill in unevenly": each pending + // cover shimmers on its own and stops the moment ITS file:// path lands in + // artMap, so the grid resolves progressively instead of looking like a + // wall of dead tiles. Artists and labels are excluded — their cards + // already draw a designed round gradient+glyph portrait placeholder + // (ArtistGridCard/LabelCard). A bare Rectangle: it does not take pointer + // events, so the card's hover/click areas keep working underneath. + QbzSkeleton { + variant: "art" + width: 200 + height: 200 + visible: (cell.item.kind === "album" + || cell.item.kind === "track" + || cell.item.kind === "playlist") + && cell.item.imageUrl !== "" + && (cell.view.artMap[cell.item.artKey] || "") === "" + phase: cell.view.skelPhase + cellIndex: cell.cellIndex + } +} diff --git a/crates/qbz-qt/qml/views/library/FeedListRow.qml b/crates/qbz-qt/qml/views/library/FeedListRow.qml new file mode 100644 index 000000000..b2d515b5f --- /dev/null +++ b/crates/qbz-qt/qml/views/library/FeedListRow.qml @@ -0,0 +1,330 @@ +// FeedListRow — the Library "All" feed's LIST row (FavoritesView.slint:1212+, +// the windowed mixed row: cover · title/subtitle · TYPE · source glyph · +// quality · favourite indicator · ⋯). +// +// EXTRACTED from views/LibraryView.qml, where it was an inline `component` +// ~300 lines long inside a 1,880-line file (track rule 2). Behaviour is +// unchanged; the only edit is that the six things it used to read off `root` +// now come through `view` — the LibraryView instance — which is the same seam +// views/local/*.qml uses for LocalLibraryView. + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../controls" +import "../../theme" + +Rectangle { + id: feedRow + + /// The LibraryView root: artMap, skelPhase, showLocal and the three + /// shared play/menu helpers live there (never duplicated per row). + property var view: null + property var item: ({}) + /// Viewport-relative position, for the skeleton's animated cap. + property int rowIndex: 0 + + QbzTheme { id: theme } + + height: 44 + radius: 6 + color: rowArea.containsMouse ? theme.surfaceHover : "transparent" + + // Live heart, as a real property — `item.isFavorite = !…` notified + // nothing (plain JS object; see rows/TrackRow.qml for the measurement) + // so this row's glyph and its menu label never moved on click. The + // binding is re-established on every new row object, so a republished + // feed still wins. + property bool favorite: feedRow.item.isFavorite === true + onItemChanged: feedRow.favorite = Qt.binding(function () { + return feedRow.item.isFavorite === true + }) + function toggleFavorite() { + feedRow.favorite = !feedRow.favorite + QbzLibrary.libraryToggleFavorite(feedRow.item.kind, feedRow.item.id) + } + // Settle + rollback + cross-surface walk. `artKey` IS + // `library_qt::feed_key(kind, id)`, the very key the signal carries — + // which is also why LibraryView's own handler can patch the backing row by + // comparing against it. + Connections { + target: QbzLibrary + function onLibraryFavoriteChanged(key, value) { + if ((feedRow.item.artKey || "") !== "" && key === feedRow.item.artKey) + feedRow.favorite = value + } + } + + // Row body — click plays/opens by kind. Declared BEFORE the cells so + // the ⋯ button and art-play win their clicks. + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id) + else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id) + else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id) + else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id) + else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id) + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + // Col — artwork (round for artists) + hover play. + Rectangle { + width: 44 + height: 44 + anchors.verticalCenter: parent.verticalCenter + radius: feedRow.item.kind === "artist" ? 22 : 6 + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + visible: !lrCollage.visible + source: feedRow.view.artMap[feedRow.item.artKey] || "" + radius: feedRow.item.kind === "artist" ? 22 : 6 + // A label logo is contain-fitted (cropping cuts the + // wordmark) on a FLAT surface — those are transparent + // PNGs whose edges carry no colour to derive from. + // A playlist takes "auto": its own graphic is the 800x380 + // `image_rectangle` and pads with an image-derived + // gradient, while a square member cover still crops. The + // flag is not required for that any more, but it stays + // authoritative where it IS published (this feed). + fit: feedRow.item.kind === "label" ? "contain" + : feedRow.item.kind === "playlist" + ? (feedRow.item.playlistOwnImage === true ? "pad" : "auto") + : "crop" + } + // User playlists have no graphic of their own, so they show the + // member-cover mosaic instead of a blank tile. + PlaylistCollage { + id: lrCollage + anchors.fill: parent + visible: feedRow.item.kind === "playlist" + && feedRow.item.playlistOwnImage !== true + && (feedRow.view.artMap[feedRow.item.artKey] || "") === "" + urls: feedRow.item.covers || [] + radius: 6 + } + // Per-row cover placeholder — same progressive rule as the + // grid: it clears when THIS row's cover lands. Skipped for + // the collage arm (a playlist mosaic is real content) and + // for artists/labels (round designed placeholders). + QbzSkeleton { + variant: "art" + anchors.fill: parent + blockRadius: 6 + visible: (feedRow.item.kind === "track" || feedRow.item.kind === "album") + && feedRow.item.imageUrl !== "" + && (feedRow.view.artMap[feedRow.item.artKey] || "") === "" + phase: feedRow.view.skelPhase + cellIndex: feedRow.rowIndex + } + Rectangle { + visible: feedRow.item.kind === "track" || feedRow.item.kind === "album" + || feedRow.item.kind === "playlist" + anchors.fill: parent + radius: feedRow.item.kind === "artist" ? 22 : 6 + color: "#a6000000" + opacity: lrPlayArea.containsMouse ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + // On the #a6000000 artwork scrim — dark under every theme. + QbzIcon { name: "play-fill"; width: 16; height: 16; anchors.centerIn: parent; tintName: "white" } + MouseArea { + id: lrPlayArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id) + else if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id) + // `playPlaylistById`, NOT `playPlaylist` — the latter + // plays the OPEN playlist page and takes no id + // (player_bridge.rs:189 is the by-id entry). + else if (feedRow.item.kind === "playlist") QbzPlayer.playPlaylistById(feedRow.item.id) + } + } + } + } + // Col — title + subtitle (artist link for track/album). + Column { + width: parent.width - 44 - 116 - (srcCol.visible ? 44 : 0) + - 150 - 18 - 30 - 6 * 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + height: 18 + text: feedRow.item.title + color: lrTitleArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: lrTitleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (feedRow.item.kind === "track") feedRow.view.playTrackInContext(feedRow.item.id) + else if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id) + else if (feedRow.item.kind === "artist") QbzArtist.openArtist(feedRow.item.id) + else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id) + else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id) + } + } + } + Text { + visible: feedRow.item.subtitle !== "" + width: parent.width + height: 16 + text: feedRow.item.subtitle + color: (feedRow.item.artistId !== "" + && (feedRow.item.kind === "track" || feedRow.item.kind === "album") + && lrSubArea.containsMouse) ? theme.accent : theme.textMuted + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: lrSubArea + anchors.fill: parent + hoverEnabled: true + cursorShape: (feedRow.item.artistId !== "" + && (feedRow.item.kind === "track" || feedRow.item.kind === "album")) + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (feedRow.item.artistId !== "") QbzArtist.openArtist(feedRow.item.artistId) + } + } + } + // Col — type (icon + caps label). + Rectangle { + width: 116 + height: parent.height + color: "transparent" + Row { + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + QbzIcon { + name: feedRow.item.kind === "track" ? "music" + : feedRow.item.kind === "playlist" ? "list-music" + : feedRow.item.kind === "artist" ? "user" + : feedRow.item.kind === "label" ? "disc-3" : "disc" + width: 13 + height: 13 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Text { + text: feedRow.item.kind === "track" ? QbzSession.tr("Track", QbzSession.trRev) + : feedRow.item.kind === "album" ? QbzSession.tr("Album", QbzSession.trRev) + : feedRow.item.kind === "artist" ? QbzSession.tr("Artist", QbzSession.trRev) + : feedRow.item.kind === "playlist" ? QbzSession.tr("Playlist", QbzSession.trRev) + : QbzSession.tr("Label", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 10 + font.weight: theme.weightSemibold + font.letterSpacing: 1.2 + verticalAlignment: Text.AlignVCenter + } + } + } + // Col — source glyph (only when local/Plex can appear). + Rectangle { + id: srcCol + visible: feedRow.view.showLocal + width: 44 + height: parent.height + color: "transparent" + QbzIcon { + visible: feedRow.item.source === "local" || feedRow.item.source === "plex" + name: "hard-drive" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + // Host is a transparent column over the theme row (NOT an + // artwork scrim), so this matches its siblings + // local/LocalAlbumRow.qml and local/LocalTrackRow.qml. + tintName: feedRow.item.source === "plex" ? "accent" : "muted" + } + } + // Col — quality (albums + tracks). + Rectangle { + width: 150 + height: parent.height + color: "transparent" + QualityMini { + visible: (feedRow.item.kind === "album" || feedRow.item.kind === "track") + && feedRow.item.qualityTier !== "" + tier: feedRow.item.qualityTier + anchors.verticalCenter: parent.verticalCenter + } + } + // Col — favorite / follow indicator. + QbzIcon { + name: feedRow.item.kind === "artist" ? "user-plus" + : (feedRow.favorite ? "heart-filled" : "heart") + width: 18 + height: 18 + anchors.verticalCenter: parent.verticalCenter + tintName: "accent" + } + // Col — context-menu button. + Rectangle { + width: 30 + height: 30 + radius: 6 + anchors.verticalCenter: parent.verticalCenter + color: lrMenuArea.containsMouse ? theme.surfaceElevated : "transparent" + QbzIcon { name: "ellipsis"; width: 16; height: 16; anchors.centerIn: parent; tintName: "secondary" } + MouseArea { + id: lrMenuArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: function (mouse) { lrMenu.openAtCursor(lrMenuArea, mouse.x, mouse.y) } + } + } + } + CardMenu { + id: lrMenu + menuWidth: 196 + entries: feedRow.item.kind === "track" + ? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite) + : feedRow.item.kind === "album" ? [ + { "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" }, + { "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" }, + { "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), + "icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" }, + ] + : feedRow.item.kind === "artist" ? [ + { "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }, + ] + : [ + { "label": QbzSession.tr("Open", QbzSession.trRev), "icon": "list-music", "action": "open" }, + { "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), + "icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" }, + ] + onPicked: function (a) { + if (feedRow.item.kind === "track") { feedRow.view.trackAction(feedRow, a); return } + if (a === "open") { + if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id) + else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id) + else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id) + } else if (a === "play") { + if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id) + } else if (a === "go-artist") { + QbzArtist.openArtist(feedRow.item.id) + } else if (a === "favorite") { + feedRow.toggleFavorite() + } + } + } +} diff --git a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml new file mode 100644 index 000000000..c84864254 --- /dev/null +++ b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml @@ -0,0 +1,134 @@ +// LibraryAlbumsList — the Library > Albums tab in LIST mode +// (FavoritesView.slint:1762 `AlbumCollectionView { view-mode: … }` with +// `albums-view-mode == "list"`), plus its multi-select bulk bar (:1734). +// +// REUSE, not a fork: the row IS views/AlbumListRow.qml and the column header +// IS views/AlbumListHeader.qml — the pair built for Discover Browse / Label +// Releases. Two properties were added to the row rather than copying it +// (`artSource`, because this feed is id-keyed through LibraryView's artMap +// instead of carrying a baked `artPath`; and the `selectMode`/`checked` +// /`toggleSelect` trio the reference's LIST-only multi-select needs). Both +// default to the previous behaviour, so the two catalog call sites are +// untouched. +// +// The A-Z jump strip is NOT here: one strip serves grid AND list (and the +// tracks list), so it lives in views/LibraryView.qml next to the surfaces it +// scrolls. + +import QtQuick +import com.blitzfc.qbz +import ".." +import "../../controls" +import "../../theme" + +Item { + id: root + + /// The LibraryView root (artMap, the selection map, the window reporter). + property var view: null + /// The derived album rows for this tab — `view.visibleRows`, passed in so + /// this file never re-derives and both surfaces see the same array. + property var rows: [] + + QbzTheme { id: theme } + + readonly property int rowH: 64 + readonly property int rowGap: 4 + + /// A-Z / index jump target for the host's strip. + function positionAt(index) { + list.positionViewAtIndex(index, ListView.Beginning) + } + + Column { + anchors.fill: parent + spacing: 8 + + QbzMultiSelectBar { + visible: root.view.albumsMultiSelect + width: parent.width + selectedCount: root.view.albumsSelectedCount + // FavoritesView.slint:1737-1743, verbatim. "Make available + // offline" is NOT in the reference's ALBUM bar (only the tracks + // one), so nothing is omitted here. + actions: [ + { "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false }, + { "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true }, + { "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true }, + { "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true }, + // The .slint asks for `heart-crack`; this port's icon set has + // no such glyph (checked: assets/icons/*/heart*.svg is heart + // and heart-filled only), and a missing name renders NOTHING. + // "heart" + danger:true is what views/local/LocalTracksTab.qml + // already ships for the same action, so the two bars match. + { "id": "remove-selected", "label": QbzSession.tr("Remove from Library", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true }, + { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }, + ] + onAction: function (id) { root.view.albumsBulkAction(id) } + } + + Item { + width: parent.width + height: parent.height - (root.view.albumsMultiSelect ? 52 : 0) + + AlbumListHeader { + id: header + width: parent.width + selectMode: root.view.albumsMultiSelect + } + + ListView { + id: list + anchors.top: header.bottom + anchors.topMargin: 4 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + // No strip inset here: the A-Z strip lives in LibraryView and + // the HOST already widened this component's right margin for + // it. Insetting again would double-count it. + clip: true + spacing: root.rowGap + cacheBuffer: 64 * 8 + boundsBehavior: Flickable.StopAtBounds + model: root.rows + + // Windowed artwork, same contract as the grid: never read + // `list.model` back off the view (views/LibraryView.qml + // documents the SIGSEGV), always the array from the host. + function report() { + var stride = root.rowH + root.rowGap + var first = Math.max(0, Math.floor(list.contentY / stride) - 4) + var last = Math.ceil((list.contentY + list.height) / stride) + 4 + root.view.queueWindowReport(first, + Math.min(root.rows.length - 1, last)) + } + onContentYChanged: list.report() + onModelChanged: list.report() + onHeightChanged: list.report() + Component.onCompleted: list.report() + + delegate: AlbumListRow { + required property var modelData + required property int index + width: list.width + item: modelData + rowIndex: index + artSource: root.view.artMap[modelData.artKey] || "" + selectMode: root.view.albumsMultiSelect + checked: root.view.albumsSelected[modelData.id] === true + onToggleSelect: root.view.toggleAlbumSelected(modelData.id) + } + } + + QbzScrollBar { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: list.top + anchors.bottom: list.bottom + target: list + visible: list.contentHeight > list.height + } + } + } +} diff --git a/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml new file mode 100644 index 000000000..554069674 --- /dev/null +++ b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml @@ -0,0 +1,402 @@ +// LibraryArtistsPanel — the Artists tab's SIDEPANEL mode +// (FavoritesView.slint:2014-2185): a 300px A-Z master rail of the favourite +// artists on the left, the selected artist's releases on the right, each +// column scrolling on its own. +// +// LEFT is derived here from the rows the Library feed already carries — no +// fetch. RIGHT is `QbzLibrary.sidepanelJson` (src/library_sidepanel.rs), whose +// sections come from the SAME `/artist/page` classifier the full artist page +// uses, so the two can never disagree about what an artist released. Its five +// states are the reference's: nothing selected · loading · error · no albums · +// the sections + a "Go to artist page" footer. +// +// The rail row is written here rather than reusing views/local/LocalArtistRow: +// that one is 60px, subtitles "N albums · M tracks" and reads its avatar gate +// off LocalLibraryView. The reference agrees — it declares its own +// `SidepanelArtistRow` (:218) beside the local one for exactly these reasons. + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../controls" +import "../../theme" + +Item { + id: root + + /// The LibraryView root (visibleRows, artMap, skelPhase). + property var view: null + + QbzTheme { id: theme } + + readonly property int railW: 300 + readonly property int rowH: 56 + readonly property int headerH: 26 + + // ------------------------------- left rail --------------------------- + // Entry model — { t: 0, label } | { t: 1, item } — so ONE ListView + // windows both the letter headers and the rows (the LocalArtistsTab + // shape), instead of a Repeater per section. + property var entries: [] + property var alphaJumps: [] + function rebuild() { + var rows = root.view ? root.view.visibleRows : [] + var artists = [] + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === "artist") artists.push(rows[i]) + // The sidepanel rail is ALWAYS A-Z grouped (the reference reads + // `artists-grouped` unconditionally at :2038, which is why its + // Group select is hidden in this mode). + artists.sort(function (a, b) { + var av = (a.title || "").toLowerCase(), bv = (b.title || "").toLowerCase() + return av < bv ? -1 : av > bv ? 1 : 0 + }) + var out = [] + var jumps = [] + var last = null + for (i = 0; i < artists.length; i++) { + var key = root.view.alphaKey(artists[i].title) + if (key !== last) { + jumps.push({ "letter": key, "index": out.length }) + out.push({ "t": 0, "label": key }) + last = key + } + out.push({ "t": 1, "item": artists[i] }) + } + root.entries = out + root.alphaJumps = jumps + } + Component.onCompleted: root.rebuild() + Connections { + target: root.view + function onVisibleRowsChanged() { root.rebuild() } + } + // Leaving the mode drops the selection, so re-entering does not paint a + // stale artist's discography under a rail with nothing highlighted. + Component.onDestruction: QbzLibrary.sidepanelClear() + + // ------------------------------ right pane --------------------------- + readonly property var panel: { + try { + return JSON.parse(QbzLibrary.sidepanelJson) + } catch (e) { + return {} + } + } + readonly property string selectedId: panel.artistId || "" + + // Right-pane covers are URL-keyed, exactly like the artist page's + // (views/ArtistView.qml): `QbzShell.sidebarArtworkWindow` asks for a list + // of urls and `QbzLibrary.libraryArtworkReady` answers with (url, path). + // Arrivals are coalesced into ONE rebind per frame — per-arrival rebinding + // is O(n²) over a big discography. + property var coverMap: ({}) + property var _coverInbox: ({}) + Timer { + id: coverFlush + interval: 16 + repeat: false + onTriggered: { + var m = Object.assign({}, root.coverMap, root._coverInbox) + root._coverInbox = ({}) + root.coverMap = m + } + } + Connections { + target: QbzLibrary + function onLibraryArtworkReady(key, path) { + root._coverInbox[key] = path + if (!coverFlush.running) coverFlush.start() + } + } + onPanelChanged: { + var urls = [] + var sections = root.panel.sections || [] + for (var s = 0; s < sections.length; s++) { + var albums = sections[s].albums || [] + for (var i = 0; i < albums.length; i++) { + var u = albums[i].artUrl || "" + if (u !== "" && !root.coverMap[u]) urls.push(u) + } + } + if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls)) + } + + // =========================== layout ================================== + Item { + id: rail + x: 32 + y: 16 + width: root.railW + height: parent.height - 16 + clip: true + + ListView { + id: railList + anchors.fill: parent + anchors.rightMargin: 22 + clip: true + cacheBuffer: 56 * 8 + boundsBehavior: Flickable.StopAtBounds + model: root.entries + + delegate: Loader { + required property var modelData + width: railList.width + height: modelData.t === 0 ? root.headerH : root.rowH + sourceComponent: modelData.t === 0 ? letterComp : artistComp + + Component { + id: letterComp + Item { + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.label + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + } + } + } + Component { + id: artistComp + Rectangle { + id: railRow + readonly property bool picked: root.selectedId === modelData.item.id + width: railList.width + height: root.rowH + radius: 8 + color: railRow.picked ? theme.surfaceElevated + : railRowArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 10 + Rectangle { + width: 40 + height: 40 + anchors.verticalCenter: parent.verticalCenter + radius: 20 + color: theme.surfaceElevated + clip: true + RoundedImage { + anchors.fill: parent + source: root.view.artMap[modelData.item.artKey] || "" + radius: 20 + } + QbzIcon { + visible: (root.view.artMap[modelData.item.artKey] || "") === "" + name: "user" + width: 18 + height: 18 + anchors.centerIn: parent + tintName: "muted" + } + } + Text { + width: parent.width - 40 - 10 + height: parent.height + text: modelData.item.title || "" + color: railRow.picked ? theme.textPrimary : theme.textSecondary + font.pixelSize: theme.fontBody + font.weight: railRow.picked ? theme.weightSemibold : theme.weightRegular + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + MouseArea { + id: railRowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzLibrary.sidepanelSelectArtist( + modelData.item.id, modelData.item.title || "") + } + } + } + } + } + QbzAlphaStrip { + visible: root.alphaJumps.length > 0 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + jumps: root.alphaJumps + onJump: function (ordinal, index) { + railList.positionViewAtIndex(index, ListView.Beginning) + } + } + } + + Rectangle { + x: rail.x + rail.width + y: 16 + width: 1 + height: parent.height - 16 + color: theme.borderSubtle + } + + Item { + id: pane + x: rail.x + rail.width + 1 + y: 16 + width: parent.width - x - 32 + height: parent.height - 16 + clip: true + + // 1 — nothing selected. + Column { + visible: root.selectedId === "" + anchors.centerIn: parent + spacing: 10 + QbzIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "user" + width: 44 + height: 44 + tintName: "muted" + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzSession.tr("Select an artist to see their albums.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } + // 2 — loading. + QbzSpinner { + visible: root.selectedId !== "" && root.panel.loading === true + anchors.centerIn: parent + size: 32 + } + // 3 — error. + Text { + visible: root.selectedId !== "" && root.panel.loading !== true + && (root.panel.error || "") !== "" + anchors.centerIn: parent + text: QbzSession.tr("Couldn't load albums.", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + // 4 — nothing to show. + Text { + visible: root.selectedId !== "" && root.panel.loading !== true + && (root.panel.error || "") === "" && (root.panel.total || 0) === 0 + anchors.centerIn: parent + text: QbzSession.tr("No albums found.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + // 5 — the sections. + Flickable { + id: paneFlick + visible: root.selectedId !== "" && root.panel.loading !== true + && (root.panel.error || "") === "" && (root.panel.total || 0) > 0 + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: paneCol.height + boundsBehavior: Flickable.StopAtBounds + + Column { + id: paneCol + x: 20 + width: paneFlick.width - 32 + spacing: 18 + + Repeater { + model: root.panel.sections || [] + delegate: Column { + id: sec + required property var modelData + width: paneCol.width + spacing: 8 + Text { + text: sec.modelData.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + // Explicit grid, not AlbumCollection: that component + // reads a baked `artPath` off each row, and these rows + // carry `artUrl` only (the covers arrive url-keyed — + // see coverMap). Same column math, same 200x246 card. + Item { + id: grid + width: parent.width + readonly property int cardW: 200 + readonly property int cardH: 246 + readonly property int gap: 24 + readonly property var albums: sec.modelData.albums || [] + readonly property int columns: Math.max( + 1, Math.floor((grid.width + grid.gap) / (grid.cardW + grid.gap))) + readonly property int rowCount: Math.ceil(grid.albums.length / grid.columns) + height: grid.rowCount > 0 + ? grid.rowCount * grid.cardH + (grid.rowCount - 1) * grid.gap + : 0 + Repeater { + model: grid.albums + delegate: Item { + id: gcell + required property var modelData + required property int index + x: (gcell.index % grid.columns) * (grid.cardW + grid.gap) + y: Math.floor(gcell.index / grid.columns) * (grid.cardH + grid.gap) + width: grid.cardW + height: grid.cardH + AlbumCard { + albumId: gcell.modelData.id + title: gcell.modelData.title + artist: gcell.modelData.artist + artistId: gcell.modelData.artistId + genre: gcell.modelData.genre + year: gcell.modelData.year + qualityTier: gcell.modelData.qualityTier + artSource: root.coverMap[gcell.modelData.artUrl] || "" + // The pin payload persists the REMOTE + // url, never the file:// cache path. + artworkUrl: gcell.modelData.artUrl || "" + isPinned: gcell.modelData.isPinned === true + isFavorite: gcell.modelData.isFavorite === true + } + } + } + } + } + } + // Footer — the full artist page (compilations and + // other-artist content live there). + Item { + width: parent.width + height: 40 + Text { + id: footerText + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Go to artist page", QbzSession.trRev) + color: theme.accent + font.pixelSize: theme.fontBody + } + MouseArea { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: footerText.implicitWidth + height: 24 + cursorShape: Qt.PointingHandCursor + onClicked: QbzArtist.openArtist(root.selectedId) + } + } + Item { width: 1; height: 60 } + } + } + QbzScrollBar { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + anchors.bottom: parent.bottom + target: paneFlick + visible: paneFlick.visible && paneFlick.contentHeight > paneFlick.height + } + } +} diff --git a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml new file mode 100644 index 000000000..2b70bf05b --- /dev/null +++ b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml @@ -0,0 +1,666 @@ +// LibraryToolbar — the Library view's fixed chrome row: the six-tab menu on +// the left, the per-tab ACTION cluster beside it, and the per-tab controls on +// the right (FavoritesView.slint:444-942, rows 1 and 2 folded into one 56px +// band because this port's header carries no "Library" title). +// +// EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881 +// lines). Everything it reads and writes goes through `view`, the LibraryView +// instance, exactly as views/local/LocalToolbar.qml does for LocalLibraryView. +// Toolbar choices are written through `view.setPref(...)` rather than straight +// onto the property, because they PERSIST (library_prefs.rs -> the same +// favorites_ui.json the Slint build writes). + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Item { + id: root + + /// The LibraryView root. + property var view: null + + QbzTheme { id: theme } + + height: 56 + + // Small toolbar toggle button (ToggleButton sm): 30px, active = accent. + component ToolToggle: Rectangle { + property string name: "" + property bool active: false + signal clicked() + width: 30 + height: 30 + radius: 6 + color: active ? theme.surfaceElevated + : ttArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + name: parent.name + width: 16 + height: 16 + anchors.centerIn: parent + tintName: parent.active ? "accent" + : ttArea.containsMouse ? "textPrimary" : "secondary" + } + MouseArea { + id: ttArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: parent.clicked() + } + } + + // Filter-by-genre trigger (FavoritesView.slint's FavGenreButton, sm): + // accent fill + "N genres" while the "library-all" selection is active. + component GenreToolButton: Rectangle { + id: gtb + readonly property bool active: root.view.genreCount > 0 + width: gtbRow.width + height: 30 + radius: 6 + color: gtb.active ? theme.accent + : gtbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Row { + id: gtbRow + height: parent.height + leftPadding: 10 + rightPadding: 12 + spacing: 7 + QbzIcon { + name: "list-filter" + width: 13 + height: 13 + anchors.verticalCenter: parent.verticalCenter + // Same pair-of-halves fix as controls/BrowseGenreButton.qml: + // the glyph said "primary" (legacy alias of a literal + // #ffffff) next to an accent-text label, i.e. a white glyph + // beside a black label on the pale-accent themes. + // favorites/FavoritesView.slint:301 + :309 are consistently + // #ffffff, but that white is 1.70:1 on high-contrast, 1.74 + // on ikari, 1.82 on wcag-dark and under 2.6:1 on 16 of the + // 35 palettes — deliberate divergence from both lines. + // theme/QbzTheme.qml, "ON AN ACCENT FILL". + tintName: gtb.active ? theme.accentGlyphTint : "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.view.genreCount === 0 + ? QbzSession.tr("Filter by genre", QbzSession.trRev) + : root.view.genreCount === 1 + ? QbzSession.tr("1 genre", QbzSession.trRev) + : QbzSession.tr("{} genres", QbzSession.trRev) + .replace("{}", root.view.genreCount) + // The colour twin of the glyph's tint above — accent-text on + // 34 of the 35 palettes. + color: gtb.active ? theme.accentGlyphColor : theme.textSecondary + font.pixelSize: 12 + } + } + MouseArea { + id: gtbArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.view.toggleGenrePopup() + } + } + + // --- Group-by selects (FavoritesView.slint:604-670, 760-775). + // One control, three tabs: each carries its own option set and writes its + // own pref. The reference draws a QbzSelect per tab; this is the same menu + // shape the sort popups beside it already use, so the toolbar stays + // visually of a piece. + component GroupSelect: Rectangle { + id: gsRoot + property var options: [] // [{value, label}] + property string current: "off" + signal picked(string value) + visible: false + width: gsRow.width + height: 30 + radius: 6 + color: gsArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + readonly property string currentLabel: { + for (var i = 0; i < options.length; i++) + if (options[i].value === current) return options[i].label + return "" + } + Row { + id: gsRow + height: parent.height + leftPadding: 10 + rightPadding: 10 + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: gsRoot.currentLabel + color: theme.textSecondary + font.pixelSize: 12 + } + QbzIcon { + name: "chevron-down" + width: 12 + height: 12 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + } + MouseArea { + id: gsArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: gsMenu.openBelowRight(gsArea) + } + QbzContextMenu { + id: gsMenu + menuWidth: 156 + Repeater { + model: gsRoot.options + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 33 + radius: 5 + color: gsOptArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + anchors.fill: parent + anchors.leftMargin: 8 + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + font.weight: gsRoot.current === modelData.value + ? theme.weightSemibold : theme.weightRegular + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + MouseArea { + id: gsOptArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { gsRoot.picked(modelData.value); gsMenu.close() } + } + } + } + } + } + + // FavTabMenu (SegmentedTabBar with count badges). + Rectangle { + x: 32 + y: 25 - height / 2 + width: tabRow.width + height: tabRow.height + color: theme.surfaceElevated + radius: 6 + Row { + id: tabRow + padding: 3 + spacing: 4 + QbzTabBar { + counts: true + underline: true + activeId: root.view.activeTab + tabs: [ + { "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 }, + { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 }, + { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 }, + { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 }, + { "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.counts.playlists || 0 }, + { "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 }, + ] + onSelected: function (id) { root.view.activeTab = id } + } + } + } + + // --- Per-tab ACTION cluster (FavoritesView.slint:462-565) ------------- + // The reference draws these in its own row, to the right of a "Library" + // title this port's header does not have; they sit beside the tab bar + // instead. FUNCTION is 1:1 — this is issue #554 ("users reported the + // feature as removed"), so what matters is that Play all / Shuffle / the + // per-tab random / the multi-select toggles exist and work. + // + // Each gates on its tab having something to act on, exactly as the + // reference's `*-visible.length > 0` guards do: an action that would play + // nothing is not drawn. + Row { + id: actionRow + x: 32 + tabRow.width + 12 + y: 25 - height / 2 + height: 30 + spacing: 8 + + function randomVisibleId(kind) { + var pool = [] + var rows = root.view.visibleRows + for (var i = 0; i < rows.length; i++) + if (rows[i].kind === kind) pool.push(rows[i]) + if (pool.length === 0) return null + return pool[Math.floor(Math.random() * pool.length)] + } + + // Tracks — play all / shuffle all / select multiple. + QbzIconButton { + visible: root.view.activeTab === "tracks" && root.view.tabHasItems + btnSize: 30 + name: "play-fill" + onClicked: QbzLibrary.libraryPlayAll( + JSON.stringify(root.view.visibleTrackIds()), false) + } + QbzIconButton { + visible: root.view.activeTab === "tracks" && root.view.tabHasItems + btnSize: 30 + name: "shuffle" + onClicked: QbzLibrary.libraryPlayAll( + JSON.stringify(root.view.visibleTrackIds()), true) + } + ToolToggle { + visible: root.view.activeTab === "tracks" && root.view.tabHasItems + name: "square-check-big" + active: root.view.tracksMultiSelect + onClicked: root.view.setTracksMultiSelect(!root.view.tracksMultiSelect) + } + // Albums — play a RANDOM album (the reference's `albums_shuffle`, + // which picks one visible album, not a shuffled play of every album). + QbzIconButton { + visible: root.view.activeTab === "albums" && root.view.tabHasItems + btnSize: 30 + name: "shuffle" + onClicked: { + var pick = actionRow.randomVisibleId("album") + if (pick) QbzPlayer.playAlbum(pick.id) + } + } + // Albums multi-select — LIST mode ONLY (owner 2026-07-24, + // FavoritesView.slint:512-523): the grid card has no checkbox slot. + ToolToggle { + visible: root.view.activeTab === "albums" && root.view.tabHasItems + && root.view.albumsView === "list" + name: "square-check-big" + active: root.view.albumsMultiSelect + onClicked: root.view.setAlbumsMultiSelect(!root.view.albumsMultiSelect) + } + // Artists — open a random artist. + QbzIconButton { + visible: root.view.activeTab === "artists" && root.view.tabHasItems + btnSize: 30 + name: "shuffle" + onClicked: { + var pick = actionRow.randomVisibleId("artist") + if (pick) QbzArtist.openArtist(pick.id) + } + } + // Playlists — play a random playlist. + QbzIconButton { + visible: root.view.activeTab === "playlists" && root.view.tabHasItems + btnSize: 30 + name: "shuffle" + onClicked: { + var pick = actionRow.randomVisibleId("playlist") + if (pick) QbzBridge.openPlaylist(pick.id) + } + } + // Labels — open a random label's landing. + QbzIconButton { + visible: root.view.activeTab === "labels" && root.view.tabHasItems + btnSize: 30 + name: "shuffle" + onClicked: { + var pick = actionRow.randomVisibleId("label") + if (pick) QbzHome.openLabel(pick.id) + } + } + } + + // Per-tab controls. + Row { + x: parent.width - width - 32 + y: 25 - height / 2 + height: 30 + spacing: 8 + + // ===== All toolbar ===== + Row { + visible: root.view.activeTab === "all" + spacing: 8 + height: parent.height + // FavoritesView.slint:801-813 — collapsed magnifier that + // opens LEFT over the tab bar; the All tab is the only + // one that debounces after the keystroke. + QbzLineEdit { + searchMode: true + expandable: true + sm: true + elevated: false // ExpandableSearch fill = surface-card + openWidth: 196 // = max-open-width + placeholder: QbzSession.tr("Search your library", QbzSession.trRev) + onEdited: function (v) { root.view.setAllSearch(v) } + } + // Source switches (tooltips are the Slint copies). + ToolToggle { name: "shopping-bag"; active: root.view.showPurchases; onClicked: root.view.showPurchases = !root.view.showPurchases } + ToolToggle { name: "heart"; active: root.view.showFavorites; onClicked: root.view.showFavorites = !root.view.showFavorites } + ToolToggle { name: "user-plus"; active: root.view.showFollowing; onClicked: root.view.showFollowing = !root.view.showFollowing } + // The ONE source switch the reference persists (favorites_prefs.rs + // `all_show_local`) — the other three are session-local there too. + ToolToggle { + name: "hard-drive" + active: root.view.showLocal + onClicked: root.view.setShowLocal(!root.view.showLocal) + } + // Filter by genre — shared popup, own "library-all" + // context (FavoritesView.slint:864). + GenreToolButton { } + // Grid / list toggle. + ToolToggle { + name: root.view.viewMode === "list" ? "layout-grid" : "list" + active: false + onClicked: root.view.viewMode = root.view.viewMode === "list" ? "grid" : "list" + } + // Sort menu (PlaylistView-style: field + direction caret). + Rectangle { + width: allSortRow.width + height: 30 + radius: 6 + color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Row { + id: allSortRow + height: parent.height + leftPadding: 10 + rightPadding: 10 + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + ( + root.view.sortBy === "title" ? QbzSession.tr("Title", QbzSession.trRev) + : root.view.sortBy === "artist" ? QbzSession.tr("Artist", QbzSession.trRev) + : QbzSession.tr("Date added", QbzSession.trRev)) + color: theme.textSecondary + font.pixelSize: 12 + } + QbzIcon { + name: root.view.sortAsc ? "chevron-up" : "chevron-down" + width: 12 + height: 12 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + } + MouseArea { + id: allSortArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: allSortMenu.openBelowRight(allSortArea) + } + QbzContextMenu { + id: allSortMenu + menuWidth: 172 + Repeater { + model: [ + { "field": "date", "label": QbzSession.tr("Date added", QbzSession.trRev) }, + { "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) }, + { "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) }, + ] + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 33 + radius: 5 + color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 6 + Text { + width: parent.width - 26 + height: parent.height + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + font.weight: root.view.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular + verticalAlignment: Text.AlignVCenter + } + QbzIcon { + visible: root.view.sortBy === modelData.field + name: root.view.sortAsc ? "chevron-up" : "chevron-down" + width: 12 + height: 12 + anchors.verticalCenter: parent.verticalCenter + tintName: "accent" + } + } + MouseArea { + id: sortOptArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + // Re-pick flips direction; a new field + // resets to its natural default. + if (root.view.sortBy === modelData.field) { + root.view.sortAsc = !root.view.sortAsc + } else { + root.view.sortBy = modelData.field + root.view.sortAsc = modelData.field !== "date" + } + allSortMenu.close() + } + } + } + } + } + } + } + + // ===== Other-tab toolbars ===== + // ONE search instance for the five tabs: the reference clears the + // query on every tab entry (see LibraryView.onActiveTabChanged), so + // per-tab instances would only implement a persistence that does not + // exist. A Row skips `visible: false` children, so the 30px slot + // appears/disappears cleanly and, because the ROOT width never leaves + // collapsedSize while the field opens, nothing to its right ever moves. + QbzLineEdit { + id: tabSearchBox + objectName: "libraryTabSearch" + visible: root.view.activeTab !== "all" && root.view.tabHasItems + searchMode: true + expandable: true + sm: true + elevated: false + openWidth: 196 + placeholder: QbzSession.tr("Search", QbzSession.trRev) + onEdited: function (v) { root.view.tabSearch = v } + } + + // Filter by genre — the SAME shared popup the All toolbar uses. The + // reference draws it on Tracks and Albums too (FavoritesView.slint: + // 609, :652); this port had it on All only, so the two biggest tabs + // could not be filtered at all. + GenreToolButton { + visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums") + && root.view.tabHasItems + } + GroupSelect { + visible: root.view.activeTab === "tracks" && root.view.tabHasItems + current: root.view.tracksGroup + options: [ + { "value": "off", "label": QbzSession.tr("Group: Off", QbzSession.trRev) }, + { "value": "album", "label": QbzSession.tr("Group: Album", QbzSession.trRev) }, + { "value": "artist", "label": QbzSession.tr("Group: Artist", QbzSession.trRev) }, + { "value": "name", "label": QbzSession.tr("Group: Name", QbzSession.trRev) }, + ] + onPicked: function (v) { root.view.setPref("tracksGroup", v) } + } + GroupSelect { + visible: root.view.activeTab === "albums" && root.view.tabHasItems + current: root.view.albumsGroup + options: [ + { "value": "off", "label": QbzSession.tr("Group: Off", QbzSession.trRev) }, + { "value": "alpha", "label": QbzSession.tr("Group: A-Z", QbzSession.trRev) }, + { "value": "artist", "label": QbzSession.tr("Group: Artist", QbzSession.trRev) }, + ] + onPicked: function (v) { root.view.setPref("albumsGroup", v) } + } + // Artists group (A-Z) — GRID mode only, like the reference + // (FavoritesView.slint:763: "Tauri hides it in sidepanel"; the + // sidepanel's left rail is ALWAYS A-Z grouped). + GroupSelect { + visible: root.view.activeTab === "artists" && root.view.tabHasItems + && root.view.artistsView === "grid" + current: root.view.artistsGroup + options: [ + { "value": "off", "label": QbzSession.tr("Group: Off", QbzSession.trRev) }, + { "value": "alpha", "label": QbzSession.tr("Group: A-Z", QbzSession.trRev) }, + ] + onPicked: function (v) { root.view.setPref("artistsGroup", v) } + } + + // Albums sort popup (real, JS-side). + Rectangle { + visible: root.view.activeTab === "albums" && root.view.tabHasItems + width: albumsSortRow.width + height: 30 + radius: 6 + color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Row { + id: albumsSortRow + height: parent.height + leftPadding: 10 + rightPadding: 10 + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + ( + root.view.albumsSort === "title-asc" ? QbzSession.tr("Title A-Z", QbzSession.trRev) + : root.view.albumsSort === "title-desc" ? QbzSession.tr("Title Z-A", QbzSession.trRev) + : root.view.albumsSort === "artist-asc" ? QbzSession.tr("Artist A-Z", QbzSession.trRev) + : QbzSession.tr("Default", QbzSession.trRev)) + color: theme.textSecondary + font.pixelSize: 12 + } + QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } + } + MouseArea { + id: albumsSortArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: albumsSortMenu.openBelowRight(albumsSortArea) + } + QbzContextMenu { + id: albumsSortMenu + menuWidth: 172 + Repeater { + model: [ + { "value": "default", "label": QbzSession.tr("Default", QbzSession.trRev) }, + { "value": "title-asc", "label": QbzSession.tr("Title A-Z", QbzSession.trRev) }, + { "value": "title-desc", "label": QbzSession.tr("Title Z-A", QbzSession.trRev) }, + { "value": "artist-asc", "label": QbzSession.tr("Artist A-Z", QbzSession.trRev) }, + ] + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 0 + height: 33 + radius: 5 + color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + anchors.fill: parent + anchors.leftMargin: 8 + text: modelData.label + color: theme.textSecondary + font.pixelSize: 13 + font.weight: root.view.albumsSort === modelData.value ? theme.weightSemibold : theme.weightRegular + verticalAlignment: Text.AlignVCenter + } + MouseArea { + id: abSortOptArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.view.setPref("albumsSort", modelData.value) + albumsSortMenu.close() + } + } + } + } + } + } + // Albums grid / list toggle (FavoritesView.slint:697-705 ViewToggle). + ToolToggle { + visible: root.view.activeTab === "albums" && root.view.tabHasItems + name: root.view.albumsView === "list" ? "layout-grid" : "list" + active: false + onClicked: root.view.setPref("albumsView", + root.view.albumsView === "list" ? "grid" : "list") + } + // Playlists grid / list toggle (FavoritesView.slint:737-745). + ToolToggle { + visible: root.view.activeTab === "playlists" && root.view.tabHasItems + name: root.view.playlistsView === "list" ? "layout-grid" : "list" + active: false + onClicked: root.view.setPref("playlistsView", + root.view.playlistsView === "list" ? "grid" : "list") + } + // Artists grid / sidepanel toggle (FavoritesView.slint:780-793 — + // `active: false` there too, "keeps the two icon states visually + // identical"). + ToolToggle { + visible: root.view.activeTab === "artists" && root.view.tabHasItems + name: root.view.artistsView === "sidepanel" ? "layout-grid" : "list" + active: false + onClicked: root.view.setPref("artistsView", + root.view.artistsView === "sidepanel" ? "grid" : "sidepanel") + } + // Playlists sub-tab (Library / Following). + Rectangle { + visible: root.view.activeTab === "playlists" && root.view.tabHasItems + width: plSubRow.width + height: 30 + radius: 6 + color: theme.surfaceElevated + Row { + id: plSubRow + padding: 3 + spacing: 4 + Repeater { + model: [ + { "id": "favorites", "label": QbzSession.tr("Library", QbzSession.trRev) }, + { "id": "following", "label": QbzSession.tr("Following", QbzSession.trRev) }, + ] + delegate: Rectangle { + required property var modelData + property bool active: root.view.playlistsSubTab === modelData.id + width: plSubText.implicitWidth + 20 + height: 24 + radius: 4 + color: active ? theme.surfaceMain : "transparent" + Text { + id: plSubText + anchors.centerIn: parent + text: modelData.label + color: parent.active ? theme.textPrimary : theme.textMuted + font.pixelSize: 12 + font.weight: theme.weightMedium + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.view.playlistsSubTab = modelData.id + } + } + } + } + } + } + + /// Collapse the shared per-tab search field (LibraryView calls this on + /// every tab change — the reference destroys and re-creates it collapsed). + function closeTabSearch() { + if (tabSearchBox) tabSearchBox.closeSearch() + } +} diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml index 450ffe99d..750372e49 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml @@ -127,7 +127,7 @@ Item { onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) } onToggleSelect: function (id) { root.view.toggleAlbumSelected(id) } } - AlphaStrip { + QbzAlphaStrip { visible: root.view.albumsGroup === "alpha" && collection.alphaJumps.length > 0 anchors.right: parent.right diff --git a/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml b/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml index 602c6b23b..0501c423b 100644 --- a/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml @@ -197,7 +197,7 @@ Item { } } } - AlphaStrip { + QbzAlphaStrip { visible: root.alphaJumps.length > 0 anchors.right: parent.right anchors.top: parent.top diff --git a/crates/qbz-qt/qml/views/local/LocalToolbar.qml b/crates/qbz-qt/qml/views/local/LocalToolbar.qml index c330773fd..cb680bc08 100644 --- a/crates/qbz-qt/qml/views/local/LocalToolbar.qml +++ b/crates/qbz-qt/qml/views/local/LocalToolbar.qml @@ -234,7 +234,12 @@ Row { options: root.tracksGroupLabels marked: root.view.tracksGroup !== "off" currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup)) - onSelected: function (i) { root.view.tracksGroup = root.tracksGroupIds[i] } + // Through the bridge, like the sort above and the album identity + // below it: the choice is PERSISTED (locallibrary_ui.json, shared + // with the Slint build). Writing the QML property directly is what + // made it reset on every restart (PARITY-DEBT #13). No re-query — + // grouping is a client-side reorder, not a new ORDER BY. + onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) } } } diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml index 5becd937b..623dffe55 100644 --- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml @@ -18,11 +18,22 @@ // is: Play now, Play next, Play later, Add to queue, Add to mixtape, Add to // playlist, Go to album, Go to artist. // -// Add to mixtape and Add to playlist are OMITTED here, not disabled: this -// port has no Mixtape/Collection store and no playlist picker (the Rust side -// says so itself — `local_album_actions.rs` keeps them as LOGGED SEAMS), and -// a menu row that silently does nothing is worse than an absent one. Every -// row that IS shown is wired. +// Add to playlist is LIVE (QbzPlaylistPicker). It goes through +// `QbzLocal.bulkAction("track", [id], "add-to-playlist")` — the LOCAL-mode +// arm — and not through the picker singleton directly, because only +// `local_bulk::resolve_blocking` can turn a Tracks-table row id into the +// `LocalTrack` that `local_picker_ref_for_track` needs; a Plex row's ref is +// "plex:" and its numeric display id means a different track the +// moment anything reads it as a catalog id. The bulk entry point takes a JSON +// id array, so a single row is a one-element array — same code path, no +// second resolver. +// +// Add to mixtape rides the SAME entry point for the same reason +// (`local_bulk.rs::apply` "add-to-mixtape" -> `myqbz_add_qt::open_items( +// track_items_from_local(rows))`), which restores the .slint's order: +// Play now · Play next · Play later · Add to queue · Add to mixtape · +// Add to playlist · Go to album · Go to artist. Every row that IS shown is +// wired; nothing here renders and no-ops. // // The shared row's own ⋯ is turned OFF (`showMenu: false`) because its // entries are the Qobuz set: its Go-to-album/artist call QbzAlbum.openAlbum / @@ -91,26 +102,26 @@ Item { // the sidebar drop handler forwards it as a Qobuz catalog id. draggable: false qualityStyle: "icon" + // Multi-select is the SHARED row's arm now (rows/TrackRow.qml:116, + // the port of TrackRow.slint:58 `multi-select-mode`). This file used + // to overlay its own SelectCheck on the number cell because the + // shared row had no such arm — it does now, and the overlay was + // strictly worse: it drew a checkbox but left `sharedRow.selectMode` + // false, so the row BODY kept its normal gestures and a single click + // anywhere off the 14px disc PLAYED the track instead of selecting + // it. TrackRow.slint:174 is explicit that in select mode "the WHOLE + // row body is a selection target", and LocalAlbumRow.qml:55 already + // does that for the albums tab. Forwarding the three members gets the + // reference behaviour for free: body-click toggles, double-click no + // longer plays, the number/play cell stands down, and the checkbox is + // the 14px accent disc the .slint actually specifies. + selectMode: root.selectMode + checked: root.checked + onToggleSelect: root.toggleSelect() onPlayRequested: root.playRequested() onEnqueueRequested: function (m) { root.enqueueRequested(m) } } - // Multi-select checkbox over the number cell (x 12..44 in TrackRow). - Rectangle { - visible: root.selectMode - x: 12 - width: 32 - height: 40 - anchors.verticalCenter: parent.verticalCenter - color: theme.surfaceMain - radius: 4 - SelectCheck { - anchors.centerIn: parent - on: root.checked - onToggled: root.toggleSelect() - } - } - // ⋯ menu button — the shared row's own gutter, re-drawn with the local // action set behind it. Rectangle { @@ -153,9 +164,16 @@ Item { // (TrackRow.slint's `open-track-menu(..., at-pointer: true)`). Declared // last so it sits on top, and RIGHT-only so every left click still falls // through to the row body / checkbox / ⋯ underneath. + // + // Suppressed in select mode — TrackRow.slint:201 gates the same gesture on + // `!root.multi-select-mode` ("a right-click toggles nothing there"), and + // the shared row's own trArea already does it (rows/TrackRow.qml:926). + // Disabled rather than hidden so the press falls through to the row body + // instead of landing on nothing. MouseArea { id: rcArea anchors.fill: parent + enabled: !root.selectMode acceptedButtons: Qt.RightButton onClicked: function (mouse) { rowMenu.openAtCursor(rcArea, mouse.x, mouse.y) } } @@ -169,6 +187,8 @@ Item { { "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }, { "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }, { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, + { "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" }, + { "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" }, ] if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" }) if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }) @@ -176,6 +196,12 @@ Item { } onPicked: function (a) { if (a === "play") root.playRequested() + // LOCAL-mode adds: one-element selection through the bulk entry + // point, which owns the only source-aware row -> ref resolver + // (see the file header). + else if (a === "add-to-playlist" || a === "add-to-mixtape") { + QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a) + } else if (a === "go-album") { QbzLocal.openAlbum(root.item.albumId) QbzShell.navigateTo("localalbum") diff --git a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml index af6c6f510..cff68f89e 100644 --- a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml @@ -71,6 +71,28 @@ Item { function onArtworkRefresh() { root.reportSoon() } } + // --------------------------- row play --------------------------------- + // PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL + // page order — and find the clicked row in it, so with a group mode on + // (a CLIENT-side reorder, see LocalLibraryView.tracksVisible) the user + // heard a different order than the one on screen. The visible order only + // exists QML-side, so it goes down as a JSON id array and the raw rows + // play the part of the authoritative cache + // (local_playback::order_by_visible, the local twin of + // library_qt::order_by_visible). + // + // `view.tracksVisible` is THE array `entries` is built from — never read + // `list.model` back off the view (see views/LibraryView.qml). + function visibleTrackIds() { + var rows = root.view ? root.view.tracksVisible : [] + var out = [] + for (var i = 0; i < rows.length; i++) out.push(rows[i].id) + return out + } + function playRow(id) { + QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id) + } + // First page = the shape of the 50px track rows (the Slint mounts a bare // 36px LoadingSpinner). ONE instance for the whole viewport. QbzSkeleton { @@ -115,7 +137,11 @@ Item { { "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true }, { "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true }, { "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true }, - { "id": "remove-favorites", "label": QbzSession.tr("Remove from favorites", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true }, + // NO "Remove from favorites": this tab is the whole local + // library, not a favourites surface — removing from a + // collection the user is not looking at is not an action this + // context can offer. (It was doubly wrong: local hearts are + // unwired, so `local_bulk.rs`'s arm is a log-only no-op.) { "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true }, { "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true }, { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }, @@ -220,7 +246,7 @@ Item { showArtwork: root.view.trackArtwork selectMode: root.view.tracksMultiSelect checked: root.view.tracksSelected[modelData.row.id] === true - onPlayRequested: QbzLocal.playTrack(modelData.row.id) + onPlayRequested: root.playRow(modelData.row.id) onEnqueueRequested: function (m) { QbzLocal.enqueue("track", modelData.row.id, m) } @@ -247,7 +273,7 @@ Item { } } } - AlphaStrip { + QbzAlphaStrip { visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0 anchors.right: parent.right anchors.top: parent.top diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmActionButton.qml b/crates/qbz-qt/qml/views/playlistmanager/PmActionButton.qml new file mode 100644 index 000000000..afeef1cee --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmActionButton.qml @@ -0,0 +1,68 @@ +// PmActionButton — the Playlist Manager's small hover action button +// (PlaylistManagerView.slint:234-258): a transparent r5 square that fills +// `surface-hover` under the pointer, holding one tinted glyph. +// +// Used by every playlist surface in the manager: the grid card's reorder +// chevrons (22/14) and its footer four (24/14), the list row's reorder pair +// (20/13) and its action strip (26/15), and the tree rows (26/15 default). +// +// INTERFACE NOTE (contract §2.5). The contract declares `name`, `filled`, +// `tintName` and `clicked()`. `btnSize` / `glyph` are ADDED here because the +// reference component takes exactly those two `in property `s +// (`:237-238`) and the call sites use FOUR distinct pairs (03 §4.2) — without +// them the grid's 22 px reorder chevrons and its 24 px footer would render at +// 26 px and the card's own height arithmetic (pad 10 + 22 + 8 + 140 + …) would +// stop adding up. Both keep the reference's defaults, so a call site that omits +// them is the 26/15 form the contract describes. Reported as an interface +// addition rather than assumed silently. +// +// Rejected: controls/QbzIconButton.qml (radiusSm 8 not 5, its own 34 px +// footprint and a `secondary` idle tint) and controls/QbzToolButton.qml (a +// bordered 30/34 px elevated chip — the opposite look). This one is +// transparent, r5, and exists at four sizes. + +import QtQuick +import com.blitzfc.qbz +import "../../theme" + +Rectangle { + id: root + + /// Icon name (QbzIcon `name`, ".svg" appended by the control). + property string name: "" + /// The "on" state (a favourited heart): the glyph stays `accent` even at + /// rest and does NOT brighten on hover — `:250`. + property bool filled: false + /// Idle tint for the glyph when `filled` is false. + property string tintName: "muted" + /// Button footprint / glyph size (see the interface note above). + property int btnSize: 26 + property int glyph: 15 + signal clicked() + + QbzTheme { id: theme } + + width: root.btnSize + height: root.btnSize + radius: 5 + color: btnArea.containsMouse ? theme.surfaceHover : "transparent" + + QbzIcon { + anchors.centerIn: parent + name: root.name + width: root.glyph + height: root.glyph + // `filled ? accent : (hover ? text-primary : text-muted)` — the filled + // arm wins in BOTH hover states (:250). + tintName: root.filled ? "accent" + : (btnArea.containsMouse ? "textPrimary" : root.tintName) + } + + MouseArea { + id: btnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml new file mode 100644 index 000000000..d6f2ac85c --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml @@ -0,0 +1,120 @@ +// PmFolderCard — the GRID-mode folder cell (PlaylistManagerView.slint:291-373). +// 160 × 150, r10, elevated → surface-hover on body hover, 0.6 opacity while the +// folder is hidden (the ONLY hidden affordance on a folder — no eye glyph, no +// badge). +// +// BOTH click targets open the folder EDITOR (contract D7): the reference wires +// `open =>` and `edit =>` to the SAME callback, `edit-folder(folder.id)` +// (`:1364-1365`). You cannot enter a folder from the grid — folder navigation +// is the tree. Do not "fix" this into a drill-down. +// +// Hover semantics are the reference's OR (`ta.has-hover || edit-ta.has-hover`, +// `:349`): the pencil's own MouseArea sits above the card's and would otherwise +// steal `containsMouse`, dropping the pencil to opacity 0 under the pointer +// while it still accepts the click. Declaration order is z-order in QML and +// `propagateComposedEvents` does not rescue `pressed`, so the pencil is +// declared AFTER the card's MouseArea, deliberately. + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Rectangle { + id: root + + /// One `QbzPlaylistManager.foldersJson` entry (contract §4.1). + property var folder: ({}) + /// Emitted by the body AND by the pencil — one action, two targets (D7). + signal openRequested() + + QbzTheme { id: theme } + + readonly property bool hot: cardArea.containsMouse || pencilArea.containsMouse + + width: 160 + height: 150 + radius: 10 + color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 + + // padding 16 / spacing 8 / centred column. + Column { + anchors.centerIn: parent + width: parent.width - 32 + spacing: 8 + + PmFolderIcon { + anchors.horizontalCenter: parent.horizontalCenter + size: 64 + iconPreset: root.folder.iconPreset || "folder" + iconColor: root.folder.iconColor || "" + hasColor: root.folder.hasColor === true + hasCustomImage: root.folder.hasCustomImage === true + customImagePath: root.folder.customImagePath || "" + } + Text { + width: parent.width + height: 18 + text: root.folder.name || "" + color: theme.textPrimary + font.pixelSize: 13 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + width: parent.width + // Rust never pluralises this one — the reference picks the string in + // the markup (`:332-334`), so the two msgids are resolved here. An + // empty folder reads "0 playlists"; there is no empty state. + text: (root.folder.count === 1) + ? QbzSession.tr("1 playlist", QbzSession.trRev) + : QbzSession.tr("{} playlists", QbzSession.trRev) + .replace("{}", root.folder.count || 0) + color: theme.textMuted + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + MouseArea { + id: cardArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openRequested() + } + + // Hover pencil, top-right. Declared after the card area so it wins the + // press (see the header). + Rectangle { + x: parent.width - width - 8 + y: 8 + width: 24 + height: 24 + radius: 4 + opacity: root.hot ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + // Goes DARKER than the card on its own hover — deliberate upstream + // (`:351`). + color: pencilArea.containsMouse ? theme.surfaceCard : theme.surfaceElevated + + QbzIcon { + anchors.centerIn: parent + name: "pen-line" + width: 12 + height: 12 + tintName: pencilArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: pencilArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openRequested() + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml new file mode 100644 index 000000000..0c6471256 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml @@ -0,0 +1,127 @@ +// PmFolderChip — the LIST-mode folder cell (PlaylistManagerView.slint:380-466). +// 52 px tall, r8, elevated → surface-hover, 0.6 opacity while hidden. The mount +// forces `width: 184` (`:1382`) so every cell in the wrapping grid has the same +// pitch; the component's own intrinsic width is never used. +// +// Body and pencil both open the folder EDITOR (contract D7), like the card. +// +// Two hover-revealed children — the count and the pencil — at opacity 0/1 with +// the reference's 150 ms fade. OPACITY, not visibility: the count keeps its +// layout slot at all times (the same trap qml/shell/Sidebar.qml:996-1008 +// documents). Hover is the reference's OR over the chip's and the pencil's own +// areas (`:437`), because the pencil's MouseArea sits above the chip's and would +// otherwise steal `containsMouse` from under the pointer. +// +// LAYOUT DEVIATION, stated: the reference is a `HorizontalLayout` whose +// children are content-sized inside a container the mount widens to 184, so +// Slint distributes the slack through the wrapper layouts. QML has no +// equivalent distribution here, so the icon + name + count are content-packed +// from the left gutter and the pencil is pinned to the right gutter — which is +// where a 24 px hover affordance belongs in a fixed-pitch chip. Every metric +// (12/12 padding, spacing 8, icon 36/glyph 20/r8, name 13/500 capped at 120 and +// elided, count 11) is the reference's. + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Rectangle { + id: root + + /// One `QbzPlaylistManager.foldersJson` entry (contract §4.1). + property var folder: ({}) + signal openRequested() + + QbzTheme { id: theme } + + readonly property bool hot: chipArea.containsMouse || pencilArea.containsMouse + + height: 52 + radius: 8 + color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 + + Row { + id: chipRow + x: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + PmFolderIcon { + anchors.verticalCenter: parent.verticalCenter + size: 36 + iconPreset: root.folder.iconPreset || "folder" + iconColor: root.folder.iconColor || "" + hasColor: root.folder.hasColor === true + hasCustomImage: root.folder.hasCustomImage === true + customImagePath: root.folder.customImagePath || "" + } + Text { + id: nameText + anchors.verticalCenter: parent.verticalCenter + // `max-width: 120px` + elide (`:415`), clamped to what is left + // between the icon and the right-gutter pencil. + width: Math.max(0, Math.min(120, + root.width - 12 - 36 - 8 - 8 - countText.width - 8 - 24 - 12)) + text: root.folder.name || "" + color: theme.textPrimary + font.pixelSize: 13 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Text { + id: countText + anchors.verticalCenter: parent.verticalCenter + // The raw integer, not the pluralised string the CARD renders + // (`:422`). + text: root.folder.count === undefined ? "0" : String(root.folder.count) + color: theme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + opacity: root.hot ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + } + + // The body area stops 32 px short of the right edge — the pencil's gutter + // (`:459`). + MouseArea { + id: chipArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Math.max(0, parent.width - 32) + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openRequested() + } + + Rectangle { + x: parent.width - width - 12 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + radius: 4 + opacity: root.hot ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + // TRANSPARENT at rest here, unlike the card's elevated plate (`:439`). + color: pencilArea.containsMouse ? theme.surfaceCard : "transparent" + + QbzIcon { + anchors.centerIn: parent + name: "pen-line" + width: 12 + height: 12 + tintName: pencilArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: pencilArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openRequested() + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml new file mode 100644 index 000000000..acf2677c8 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml @@ -0,0 +1,168 @@ +// PmFolderMenu — the manager's move-to-folder dropdown +// (PlaylistManagerView.slint:803-866). ONE instance, mounted at the VIEW level +// (contract D17): with `reuseItems: true` a per-row menu would give every +// recycled list delegate its own Popup, so the row emits +// `folderMenuRequested(anchorItem)` and the view calls `openFor()`. +// +// It is the ONLY menu on a playlist row in this surface: no Play, no Play next, +// no Add to queue, no Delete, no Copy link, no Follow. +// +// Reuse: `controls/QbzContextMenu.qml`, exactly the way controls/CardMenu.qml +// does — the popup surface, the `Overlay.overlay` parenting and the 8 px window +// clamp all come for free. Accepted, recorded surface deltas (contract D15): +// `surfaceMain` vs the reference's `surface-card`, `borderMuted` vs +// `border-subtle`, padding 5 vs 6, and NO drop shadow (port-wide). Escape +// closes, which the Slint `PopupWindow` cannot. +// +// The name filter runs CLIENT-SIDE (contract D4). The reference's Rust round +// trip exists for one stated reason — Slint strings have no `contains` +// (playlist_manager.rs:652-654) — and it costs one invokable, one published +// model and a round trip per keystroke. Do NOT "restore consistency" with +// PlaylistPickerModal, which made the opposite call for its own reasons. +// +// The playlist's CURRENT folder is excluded in the MARKUP (`shown:`), where the +// reference has it (`:857`). Its height math counts that excluded row and +// leaves one row of dead space (`:850` vs `:857`); this port counts the rows it +// will actually show instead — recorded as an improvement. + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +QbzContextMenu { + id: menuRoot + + /// `QbzPlaylistManager.foldersJson`, UNFILTERED — hidden folders are legal + /// move targets here (contract D11; the reference's `menu_folders` is + /// unfiltered too). + property var folders: [] + + /// Set by `openFor()`; never read from a delegate. + property string playlistId: "" + property string currentFolderId: "" + property string folderQuery: "" + + QbzTheme { id: theme } + + menuWidth: 220 + // The reference's 1 px row gap. `QbzContextMenu`'s Column defaults to 0 and + // its `col` id is not reachable from a call site — this alias is what makes + // the gap settable at all (contract D15). + contentSpacing: 1 + + readonly property int rowW: menuRoot.menuWidth - 10 // minus padding 5+5 + + readonly property var filteredFolders: { + const q = menuRoot.folderQuery.trim().toLowerCase() + const src = menuRoot.folders || [] + if (q === "") + return src + var out = [] + for (var i = 0; i < src.length; i++) { + if (String(src[i].name || "").toLowerCase().indexOf(q) >= 0) + out.push(src[i]) + } + return out + } + + /// Rows that will actually render — the current folder collapses to 0. + readonly property int shownCount: { + const src = menuRoot.filteredFolders + var n = 0 + for (var i = 0; i < src.length; i++) { + if (String(src[i].id || "") !== menuRoot.currentFolderId) + n++ + } + return n + } + + /// Opened by the VIEW, never by a delegate. The folder query resets on + /// EVERY open (§5.16) even though the filter is client-side now. + function openFor(pid, fid, anchorItem) { + menuRoot.playlistId = String(pid || "") + menuRoot.currentFolderId = String(fid || "") + menuRoot.folderQuery = "" + menuRoot.openBelowRight(anchorItem) + } + + // Caption — not a row: no hover, no click (`:817-822`). + Text { + width: menuRoot.rowW + height: 18 + text: QbzSession.tr("Move to folder", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + } + + // Folder search — the sidebar's >= 8 threshold (`:826`, verified). + Item { + visible: (menuRoot.folders || []).length >= 8 + width: menuRoot.rowW + height: visible ? 32 : 0 + + QbzLineEdit { + anchors.verticalCenter: parent.verticalCenter + // The plain arm is a FIXED width: 240 — a modal/menu must set it. + width: menuRoot.rowW + // And its height defaults to `collapsedSize` (34), which would + // OVERFLOW this 32 px slot by 1 px top and bottom and touch the + // caption above and the `No folder` row below (the panel's row gap + // is 1). The reference field is 28 inside a 32 px Rectangle + // (`:828-830`); the sidebar row menu sets its own height the same + // way (shell/SidebarRowMenu.qml:305). + height: 28 + searchMode: true + elevated: false + placeholder: QbzSession.tr("Search folders", QbzSession.trRev) + text: menuRoot.folderQuery + onEdited: function (v) { menuRoot.folderQuery = v } + } + } + + // Root target. The ONLY row that can ever be `selected` in this menu — the + // current folder's row is excluded, which makes the folder arm's selected + // state dead by construction. Do not "fix" it. + PmMenuRow { + width: menuRoot.rowW + label: QbzSession.tr("No folder", QbzSession.trRev) + selected: menuRoot.currentFolderId === "" + onActivated: { + QbzPlaylistManager.moveToFolder(menuRoot.playlistId, "") + menuRoot.close() + } + } + + // At most 4 rows visible (30 px each), then it scrolls. A ListView, not a + // Repeater in a Flickable: 4 rows are VISIBLE but the model is every folder + // the user has (contract D16). + ListView { + width: menuRoot.rowW + height: Math.min(menuRoot.shownCount * 30, 120) + clip: true + boundsBehavior: Flickable.StopAtBounds + // ZERO, not the panel's 1. The reference's 1 px gap is the OUTER + // VerticalLayout's (`:815`, = `contentSpacing` here); the folder rows + // live in the ScrollView's own inner VerticalLayout, which declares no + // spacing (`:854`). With 1 here the `min(n * 30, 120)` cap — the + // reference's own formula, kept — stops being a whole number of rows + // and the fourth one is sliced 3 px short. + spacing: 0 + model: menuRoot.filteredFolders + + delegate: PmMenuRow { + required property var modelData + width: menuRoot.rowW + // The current-folder exclusion, in the markup (D4). + shown: String(modelData.id || "") !== menuRoot.currentFolderId + label: modelData.name || "" + selected: menuRoot.currentFolderId === String(modelData.id || "") + onActivated: { + QbzPlaylistManager.moveToFolder(menuRoot.playlistId, + String(modelData.id || "")) + menuRoot.close() + } + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml b/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml new file mode 100644 index 000000000..ab9d69678 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmGridCard.qml @@ -0,0 +1,203 @@ +// PmGridCard — the GRID-mode playlist cell (PlaylistManagerView.slint:470-618). +// 160 wide; 220 tall, or 250 when the reorder header shows. r8, `surface-card` +// → `surface-hover` on BODY hover (the footer buttons are outside the body +// area, exactly like the reference's `ta`), 0.6 opacity while hidden. +// +// Affordances (contract D27): open (body), favourite, visibility, +// edit-playlist, add-to-mixtape, move-up, move-down. NO move-to-folder — that +// one exists only on the list row. The four footer buttons are ALWAYS visible; +// there is no hover reveal, no play affordance, no pin badge and no overlay row +// on this card (that is cards/PlaylistCard.qml, a different component). +// +// TWO local-row suppressions, both contract decisions: +// * D28 — the cassette is NOT RENDERED on a `local:` row. `accepts_item` +// answers `Accepts::NONE` for (Playlist, Local) and `qbz_mixtape::enqueue` +// hard-errors, so the row can never resolve; a dimmed control would promise +// "temporarily unavailable". `Row` skips invisible children, so the footer +// simply narrows. +// * D29 — the reorder chevrons are NOT RENDERED on a `local:` row (custom +// positions are u64-keyed and `reorder_step` drops locals), but the 22 px +// header IS still reserved: `canReorder` switches EVERY card 220 → 250 +// globally, so hiding the header would leave a 30 px hole in this one card. +// +// The grip glyph between the chevrons is DECORATIVE — no MouseArea. The +// reference has no drag and drop anywhere in this view (contract D30) and the +// counter row says so in words ("Use the arrows to reorder playlists"). + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../theme" + +Rectangle { + id: root + + /// One `PlaylistRow` (contract §4.3). + property var item: ({}) + /// ABSOLUTE index in the visible order (the grid delegate is a ROW of + /// cards, so the Repeater's own index is the column — D1). + property int itemIndex: 0 + /// Drives BOTH the 220 → 250 height and the reserved header (D29). + property bool canReorder: false + + QbzTheme { id: theme } + + readonly property string itemId: String(root.item.id || "") + readonly property bool isLocal: root.item.isLocal === true + + width: 160 + height: root.canReorder ? 250 : 220 + radius: 8 + color: bodyArea.containsMouse ? theme.surfaceHover : theme.surfaceCard + opacity: (root.item.isHidden === true) ? 0.6 : 1.0 + + Column { + x: 10 + y: 10 + width: 140 + spacing: 8 + + // Reorder header — the SLOT is reserved whenever `canReorder`, the + // controls only render on a Qobuz row (D29). + Item { + visible: root.canReorder + width: 140 + height: 22 + + PmActionButton { + visible: !root.isLocal + anchors.left: parent.left + name: "chevron-up" + btnSize: 22 + glyph: 14 + onClicked: QbzPlaylistManager.moveUp(root.itemId) + } + QbzIcon { + visible: !root.isLocal + anchors.centerIn: parent + name: "grip-vertical" + width: 13 + height: 13 + tintName: "muted" + } + PmActionButton { + visible: !root.isLocal + anchors.right: parent.right + name: "chevron-down" + btnSize: 22 + glyph: 14 + onClicked: QbzPlaylistManager.moveDown(root.itemId) + } + } + + // Artwork + availability badge. + Item { + width: 140 + height: 140 + + PlaylistCollage { + anchors.fill: parent + // The manager's collage law: 1-3 covers collapse to ONE + // full-bleed tile, 4+ go to a 2×2 with a 1 px seam. + layout: "pm" + urls: root.item.covers || [] + radius: 8 + } + PmLocalBadge { + x: parent.width - width - 6 + y: 6 + status: root.item.localStatus || "" + isLocal: root.isLocal + offlineOnly: root.item.offlineOnly === true + } + } + + Text { + width: 140 + height: 18 + text: root.item.name || "" + color: theme.textPrimary + font.pixelSize: 13 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // Footer: meta on the left, the action strip on the right. + Item { + width: 140 + height: 22 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - actions.width) + // Pluralised in RUST (`qbz_i18n::tf`) — QML never pluralises. + text: root.item.tracksLine || "" + color: theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + Row { + id: actions + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + PmActionButton { + name: root.item.isFavorite === true ? "heart-filled" : "heart" + filled: root.item.isFavorite === true + btnSize: 24 + glyph: 14 + onClicked: QbzPlaylistManager.toggleFavorite(root.itemId) + } + PmActionButton { + name: root.item.isHidden === true ? "eye-off" : "eye" + btnSize: 24 + glyph: 14 + onClicked: QbzPlaylistManager.toggleHidden(root.itemId) + } + PmActionButton { + name: "pen-line" + btnSize: 24 + glyph: 14 + // The SHARED editor singleton (contract D2/D20) — the id + // lives in Rust from here on, so a republish can never make + // the modal save under a different playlist. + onClicked: QbzPlaylistEdit.open(root.itemId) + } + PmActionButton { + visible: !root.isLocal + name: "cassette-tape" + btnSize: 24 + glyph: 14 + // The payload is assembled AT THE CALL SITE (D3) — the same + // seam every other surface uses. `source` is always "qobuz" + // because this button is absent on a local row (D28). + onClicked: QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "playlist", "source": "qobuz", + "sourceItemId": root.itemId, + "title": root.item.name || "", + "subtitle": "", "artworkUrl": (root.item.covers || [])[0] || "", + "year": null, "trackCount": root.item.totalCount || 0 + }])) + } + } + } + } + + // Body hit area: artwork + name only (pad 10 + collage 140 + gap 8 + name + // 18 = 176), below the reorder header and above the footer, so both keep + // their own clicks (`:605-617`). + MouseArea { + id: bodyArea + y: root.canReorder ? 30 : 0 + width: parent.width + height: 176 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // Routes `local:` ids to the local loader and works OFFLINE + // (main.rs:926-955). There is no manager-local twin (D3). + onClicked: QbzBridge.openPlaylist(root.itemId) + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmListRow.qml b/crates/qbz-qt/qml/views/playlistmanager/PmListRow.qml new file mode 100644 index 000000000..0dbc7060c --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmListRow.qml @@ -0,0 +1,252 @@ +// PmListRow — the LIST-mode playlist row (PlaylistManagerView.slint:622-875). +// 64 px, r6, `surface-card` → `surface-hover` on BODY hover, 0.6 opacity while +// hidden. No zebra striping and no separators. +// +// Affordances (contract D27): the grid card's seven PLUS the `⋯` +// move-to-folder menu, whose button renders only when at least one folder +// exists (`:791`). All of them are always visible — no hover reveal. +// +// Local-row suppressions: no cassette (D28) and no reorder chevrons (D29), with +// the 20 px reorder column still RESERVED when `canReorder` so the row's +// columns do not shift between a Qobuz and a local row. +// +// THE MENU IS NOT OURS TO OPEN (contract D17). One `PmFolderMenu` is mounted at +// the VIEW level — with `reuseItems: true` a per-row menu would give every +// recycled delegate its own Popup — and a delegate cannot see the view's ids, +// so the row ASKS: `folderMenuRequested(anchorItem)`. +// +// Right-press parity (D18): a right press anywhere on the row body opens the +// SAME menu the `⋯` opens, and only when folders exist. That is the port-wide +// invariant QbzContextMenu.qml:20-22 states; the reference has no right-click +// here, so this is the port's convention winning, recorded. +// +// The row body's hit area is ANCHORED to the left of the action strip rather +// than copying the reference's hardcoded `parent.width - 184px` (`:868`), which +// is off by a few pixels whenever the `⋯` is absent or the play-count badge is +// wide. Same intent, correct arithmetic. + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../theme" + +Rectangle { + id: root + + /// One `PlaylistRow` (contract §4.3). + property var item: ({}) + /// Absolute index in the visible order. + property int itemIndex: 0 + property bool canReorder: false + /// `foldersJson.length` — the `⋯` renders only when > 0 (§5.16). + property int foldersCount: 0 + /// Asks the VIEW to open its single PmFolderMenu against `anchorItem`. + signal folderMenuRequested(var anchorItem) + + QbzTheme { id: theme } + + readonly property string itemId: String(root.item.id || "") + readonly property bool isLocal: root.item.isLocal === true + /// Reorder column + its trailing 12 px gap, or nothing. + readonly property int reorderSlot: root.canReorder ? 32 : 0 + + height: 64 + radius: 6 + color: bodyArea.containsMouse ? theme.surfaceHover : theme.surfaceCard + opacity: (root.item.isHidden === true) ? 0.6 : 1.0 + + // ---- reorder column (20 × 40, chevrons stacked, spacing 0) ------------- + Column { + visible: root.canReorder + x: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + PmActionButton { + visible: !root.isLocal + name: "chevron-up" + btnSize: 20 + glyph: 13 + onClicked: QbzPlaylistManager.moveUp(root.itemId) + } + PmActionButton { + visible: !root.isLocal + name: "chevron-down" + btnSize: 20 + glyph: 13 + onClicked: QbzPlaylistManager.moveDown(root.itemId) + } + } + + // ---- collage ---------------------------------------------------------- + PlaylistCollage { + id: collage + x: 10 + root.reorderSlot + anchors.verticalCenter: parent.verticalCenter + width: 48 + height: 48 + layout: "pm" + urls: root.item.covers || [] + radius: 6 + } + + // ---- name + meta ------------------------------------------------------ + Column { + id: textCol + x: collage.x + 48 + 12 + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, (badge.visible ? badge.x : actions.x) - 12 - x) + spacing: 2 + + Row { + width: parent.width + spacing: 6 + + QbzIcon { + visible: root.isLocal + anchors.verticalCenter: parent.verticalCenter + name: "hard-drive" + width: 13 + height: 13 + tintName: root.item.offlineOnly === true ? "accent" : "muted" + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - (root.isLocal ? 19 : 0)) + text: root.item.name || "" + color: theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + elide: Text.ElideRight + } + } + Row { + width: parent.width + spacing: 6 + + Text { + text: root.item.tracksLine || "" + color: theme.textMuted + font.pixelSize: 12 + } + Text { + visible: (root.item.durationLine || "") !== "" + text: "·" + color: theme.textMuted + font.pixelSize: 12 + } + Text { + visible: (root.item.durationLine || "") !== "" + text: root.item.durationLine || "" + color: theme.textMuted + font.pixelSize: 12 + } + Text { + visible: (root.item.localLine || "") !== "" + text: root.item.localLine || "" + // A LITERAL, not `theme.success` (#3fae6a) — a different green, + // and the reference pins this one (`:726`). + color: "#22c55e" + font.pixelSize: 12 + } + } + } + + // ---- play-count badge ------------------------------------------------- + Rectangle { + id: badge + visible: (root.item.playCount || 0) > 0 + x: actions.x - 12 - width + anchors.verticalCenter: parent.verticalCenter + width: pcRow.width + 16 + height: 22 + radius: 6 + color: theme.surfaceElevated + + Row { + id: pcRow + x: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "chart-no-axes-column" + width: 12 + height: 12 + tintName: "muted" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: String(root.item.playCount || 0) + color: theme.textMuted + font.pixelSize: 11 + } + } + } + + // ---- actions ---------------------------------------------------------- + Row { + id: actions + x: root.width - 12 - width + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + PmActionButton { + name: root.item.isFavorite === true ? "heart-filled" : "heart" + filled: root.item.isFavorite === true + onClicked: QbzPlaylistManager.toggleFavorite(root.itemId) + } + PmActionButton { + name: root.item.isHidden === true ? "eye-off" : "eye" + onClicked: QbzPlaylistManager.toggleHidden(root.itemId) + } + PmActionButton { + name: "pen-line" + onClicked: QbzPlaylistEdit.open(root.itemId) + } + PmActionButton { + visible: !root.isLocal + name: "cassette-tape" + onClicked: QbzMyQbzAdd.open(JSON.stringify([{ + "itemType": "playlist", "source": "qobuz", + "sourceItemId": root.itemId, + "title": root.item.name || "", + "subtitle": "", "artworkUrl": (root.item.covers || [])[0] || "", + "year": null, "trackCount": root.item.totalCount || 0 + }])) + } + PmActionButton { + id: moreBtn + visible: root.foldersCount > 0 + name: "ellipsis" + onClicked: root.folderMenuRequested(moreBtn) + } + } + + // ---- body hit area ---------------------------------------------------- + // Stops short of the badge / action strip on the right, and carries `z: -1` + // for the LEFT end: the reorder chevrons sit INSIDE this width, and + // declaration order is z-order in QML, so a last-declared area at the + // default z would swallow their presses (`propagateComposedEvents` does not + // rescue `pressed`). + MouseArea { + id: bodyArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Math.max(0, (badge.visible ? badge.x : actions.x) - 4) + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + if (root.foldersCount > 0) + root.folderMenuRequested(root) + return + } + QbzBridge.openPlaylist(root.itemId) + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmLocalBadge.qml b/crates/qbz-qt/qml/views/playlistmanager/PmLocalBadge.qml new file mode 100644 index 000000000..4b30f891b --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmLocalBadge.qml @@ -0,0 +1,58 @@ +// PmLocalBadge — the 22 px availability disc on a grid card's artwork +// (PlaylistManagerView.slint:261-287). Three mutually exclusive glyphs: +// +// isLocal -> hard-drive, tint `accent` when the local playlist is +// offline-only, else `text-secondary` +// status all_local -> wifi, literal #22c55e +// status some_local -> cloud-download, literal #f59e0b +// +// INTERFACE NOTE (contract §2.5). The contract declares `status` only; +// `isLocal` / `offlineOnly` are ADDED because the reference badge takes both +// (`is-local-pl` :265, `offline-only` :266) and they select the glyph AND its +// tint — a first-class local playlist publishes `localStatus: ""` (§4.3), so +// with `status` alone the badge would be invisible on every local row. Reported +// as an interface addition. +// +// COLOUR TRAP (contract §0.1). Slint's 8-digit literals are #RRGGBBAA, Qt's are +// #AARRGGBB: the reference plate `#00000099` (:272) is black at 60 % alpha and +// is written `#99000000` here. Copied verbatim it would have been a nearly +// opaque near-black-blue. +// +// The two brand glyph colours are NOT theme tokens and QbzIcon takes a tint +// NAME, not a hex — they ride the `green` / `orange` one-off tint dirs the +// asset pass adds (contract §7.3), following the `amber` precedent. + +import QtQuick +import com.blitzfc.qbz +import "../../theme" + +Rectangle { + id: root + + /// `"no" | "some_local" | "all_local"`; `""` on a first-class local row. + property string status: "no" + /// True for a `local:` playlist (the hard-drive arm). + property bool isLocal: false + /// Locals only: an offline-only playlist tints its marker `accent`. + property bool offlineOnly: false + + visible: root.isLocal || root.status === "all_local" + || root.status === "some_local" + + width: 22 + height: 22 + radius: 11 + // Slint #00000099 -> Qt #AARRGGBB. See the header. + color: "#99000000" + + QbzIcon { + anchors.centerIn: parent + width: 13 + height: 13 + name: root.isLocal ? "hard-drive" + : (root.status === "all_local" ? "wifi" : "cloud-download") + tintName: root.isLocal + ? (root.offlineOnly ? "accent" : "secondary") + : (root.status === "all_local" ? "green" : "orange") + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmMenuRow.qml b/crates/qbz-qt/qml/views/playlistmanager/PmMenuRow.qml new file mode 100644 index 000000000..e8417f5df --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmMenuRow.qml @@ -0,0 +1,86 @@ +// PmMenuRow — ONE row of the Playlist Manager's three menus: the toolbar's +// filter menu, its sort menu (with the #657 direction caret) and the +// move-to-folder menu. The port of `PmMenuItem` +// (PlaylistManagerView.slint:113-158). +// +// 30 px, NOT the port's 33 px context-menu convention: rule 3 says take the +// reference's number. `QbzContextMenu`'s own header states its rows are +// per-site, so there is no shared row component to reuse +// (QbzContextMenu.qml:16-18); controls/CardMenu.qml was rejected as the base — +// it is a flat entries model with a leading icon, no `selected` state, no +// trailing caret and no collapse arm. +// +// The `shown` arm is the reference's collapse (a hidden row is height 0, not +// removed) — Slint 1.16 disallows `for x: if c:`, so the move-to-folder menu +// hides the playlist's CURRENT folder that way (`:857`). Kept 1:1 rather than +// filtering the model, so the two menus stay one component. +// +// The trailing glyph renders ONLY when `selected`, in the reference: an +// unselected row has no check and no caret (`:140-150`). + +import QtQuick +import com.blitzfc.qbz +import "../../theme" + +Rectangle { + id: root + + property string label: "" + property bool selected: false + /// false => height 0 (the collapse arm). The row keeps its slot in the + /// Column but takes no space and no input. + property bool shown: true + /// `"check"` | `"caret"` | `"none"` — what the SELECTED row trails with. + property string trailing: "check" + /// Only read when `trailing === "caret"`: up = ascending, down = descending. + property bool sortAsc: true + /// The handler MUST end with the owning menu's `close()` — Slint's + /// PopupWindow closes on any inside click and Qt's CloseOnPressOutside + /// does not (contract D15). + signal activated() + + QbzTheme { id: theme } + + height: root.shown ? 30 : 0 + visible: root.shown + radius: 5 + color: rowArea.containsMouse ? theme.surfaceHover : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - (trailingGlyph.visible ? 13 + 8 : 0)) + text: root.label + color: root.selected ? theme.accent : theme.textSecondary + font.pixelSize: 13 + font.weight: root.selected ? theme.weightSemibold : theme.weightRegular + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + QbzIcon { + id: trailingGlyph + anchors.verticalCenter: parent.verticalCenter + visible: root.selected && root.trailing !== "none" + width: 13 + height: 13 + name: root.trailing === "caret" + ? (root.sortAsc ? "chevron-up" : "chevron-down") + : "check" + tintName: "accent" + } + } + + MouseArea { + id: rowArea + anchors.fill: parent + enabled: root.shown + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmPageHead.qml b/crates/qbz-qt/qml/views/playlistmanager/PmPageHead.qml new file mode 100644 index 000000000..1a9fa6a97 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmPageHead.qml @@ -0,0 +1,218 @@ +// PmPageHead — everything above the playlist body, mounted as the ONE +// `ListView.header` (contract D1): the counter/hint row, the loading spinner, +// the whole FOLDERS section, the PLAYLISTS header and BOTH empty states. +// +// It rides in the header rather than in a second scroller because the page is a +// single scroll surface in the reference (one Flickable over the lot, +// `:1281`) — and a ListView header is instantiated once, which is also why the +// FOLDERS section is deliberately NOT windowed (stated decision: the reference +// does not window it either and this file's own "many folders" threshold is 8). +// +// It INSETS ITSELF: the reference page is +// `VerticalLayout { padding-left: 32; padding-right: 32; padding-top: 6; +// padding-bottom: 100 }` (`:1300-1303`), so the content sits at x 32 with a +// 6 px lead — without that the counter row is flush against the toolbar. The +// bottom 100 lives in the ListView's footer, minus the last delegate's own +// trailing gap. +// +// `implicitHeight` is EXPLICIT and includes the 6 px: a zero-height +// `ListView.header` is silent, and this one's height is content-derived. +// +// Empty state: a plain left-aligned 15 px muted `Text`, NOT +// `controls/QbzEmptyState.qml` — that control is a centred Column with an 18 px +// `fontSection` title, so reusing it would silently change the size, the weight +// and the alignment. One of the few places where reuse is the parity break. +// +// There is no error state anywhere: the loader swallows every failure, so a +// network outage renders as "No playlists found." That is the reference +// (contract §5.13 / §10 Q4). + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Item { + id: root + + property bool loading: false + /// `QbzPlaylistManager.foldersJson`, parsed (contract §4.1). + property var folders: [] + property string viewMode: "grid" + property bool folderMode: true + property bool foldersCollapsed: false + property bool canReorder: false + /// `managerJson.folderCount` — equal to `folders.length` by construction, + /// and what the COUNTER row reads (the section header reads the array). + property int folderCount: 0 + property int playlistCount: 0 + /// `playlists.length`, or `tree.length` in tree mode — what decides the + /// empty state. + property int rowsInBody: 0 + + QbzTheme { id: theme } + + readonly property bool foldersVisible: !root.loading && root.folderMode + && root.viewMode !== "tree" && (root.folders || []).length > 0 + + implicitHeight: 6 + col.height + height: implicitHeight + + Column { + id: col + x: 32 + y: 6 + width: Math.max(0, root.width - 64) + spacing: 0 + + // ---- counter + reorder hint (h 24) -------------------------------- + Item { + width: parent.width + height: 24 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + // ONE string with TWO placeholders (`:1314`), not two strings. + // `String.replace` with a string pattern replaces only the + // FIRST occurrence, so the two calls chain. + text: QbzSession.tr("{} folders, {} playlists", QbzSession.trRev) + .replace("{}", root.folderCount) + .replace("{}", root.playlistCount) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Text { + visible: root.canReorder + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Use the arrows to reorder playlists", + QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + + // Unconditional 14 px (`:1332`). + Item { width: 1; height: 14 } + + // ---- loading spinner ---------------------------------------------- + Item { + visible: root.loading + width: parent.width + height: visible ? 36 : 0 + + QbzSpinner { + anchors.centerIn: parent + size: 36 + } + } + + // ---- FOLDERS section ---------------------------------------------- + // The WHOLE section disappears with zero folders — no header, no + // placeholder (`:1343`). It is also the only collapsible section, and + // collapsing does not rebuild anything. + Column { + visible: root.foldersVisible + width: parent.width + spacing: 12 + + QbzSectionHeader { + title: QbzSession.tr("Folders ({})", QbzSession.trRev) + .replace("{}", (root.folders || []).length) + // The section header's count is the ARRAY length (`:1346`), + // while the counter row above reads `folderCount`. Same number, + // two bindings, exactly as upstream. + titleSecondary: true + collapsible: true + collapsed: root.foldersCollapsed + // DEFAULTS TO TRUE — without this the carousel page chevrons + // would render on a page that has no carousel. + showChevrons: false + onToggled: QbzPlaylistManager.toggleFoldersCollapsed() + } + + // GRID mode — the big folder cards, hand-wrapped (Slint has no + // flex-wrap and neither does a plain QML positioner at fixed pitch). + Item { + id: folderGrid + visible: !root.foldersCollapsed && root.viewMode === "grid" + width: parent.width + readonly property int cols: Math.max(1, Math.floor((width + 16) / 176)) + readonly property int rows: + Math.ceil((root.folders || []).length / Math.max(1, cols)) + height: visible && rows > 0 + ? rows * 150 + Math.max(0, rows - 1) * 16 : 0 + + Repeater { + model: root.folders || [] + delegate: PmFolderCard { + required property var modelData + required property int index + x: (index % folderGrid.cols) * 176 + y: Math.floor(index / folderGrid.cols) * 166 + folder: modelData + onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || "")) + } + } + } + + // LIST mode — the compact chips, uniform 184 px cells. + Item { + id: folderChips + visible: !root.foldersCollapsed && root.viewMode === "list" + width: parent.width + readonly property int cols: Math.max(1, Math.floor((width + 8) / 192)) + readonly property int rows: + Math.ceil((root.folders || []).length / Math.max(1, cols)) + height: visible && rows > 0 + ? rows * 52 + Math.max(0, rows - 1) * 8 : 0 + + Repeater { + model: root.folders || [] + delegate: PmFolderChip { + required property var modelData + required property int index + x: (index % folderChips.cols) * 192 + y: Math.floor(index / folderChips.cols) * 60 + width: 184 + folder: modelData + onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || "")) + } + } + } + + // The trailing spacer is a CHILD of a spacing-12 column, so the real + // gap before the PLAYLISTS header is 36, not 24 (`:1388`). + Item { width: 1; height: 24 } + } + + // ---- PLAYLISTS header (never in tree mode) ------------------------- + QbzSectionHeader { + visible: !root.loading && root.viewMode !== "tree" + title: QbzSession.tr("Playlists ({})", QbzSession.trRev) + .replace("{}", root.playlistCount) + titleSecondary: true + showChevrons: false + } + Item { + visible: !root.loading && root.viewMode !== "tree" + width: 1 + height: visible ? 14 : 0 + } + + // ---- the two empty states ------------------------------------------ + Text { + visible: !root.loading && root.viewMode !== "tree" && root.rowsInBody === 0 + text: QbzSession.tr("No playlists found.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + Text { + visible: !root.loading && root.viewMode === "tree" && root.rowsInBody === 0 + text: QbzSession.tr("No playlists found.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmToolbar.qml b/crates/qbz-qt/qml/views/playlistmanager/PmToolbar.qml new file mode 100644 index 000000000..1b661b7f3 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmToolbar.qml @@ -0,0 +1,228 @@ +// PmToolbar — the Playlist Manager's right-hand tool cluster +// (PlaylistManagerView.slint:1123-1277): search · filter dropdown · sort +// dropdown (with the #657 direction caret) · folder/flat toggle · view-mode +// cycle · New folder. Spacing 8, everything 34 px tall. +// +// EVERY input it renders is declared on THIS root (contract §2.5). QML gives a +// component no access to another file's ids, so a binding written against the +// view's `root` would resolve to `undefined` here — the filter button would +// read "All" forever and the cycle button would get an empty icon name, both +// silently. +// +// Reuse, with the rejected alternatives named: +// * search — `controls/QbzLineEdit.qml` in its `searchMode` arm. Accepted +// sub-2 px deltas inherited from the port's ONE consolidated search box: +// glyph 14 vs 15, padding L9/R5 vs L10/R6, spacing 7 vs 8, text 12 vs +// 13 px, clear disc 24 vs 22. Re-tuning them here would fragment exactly +// what QbzLineEdit.qml:17-19 consolidated. +// * the four buttons — `controls/QbzToolButton.qml` with its `large` arm +// (contract D14). Rejected: `ViewModeToggle` (a two-segment grid/list pair +// that cannot express three modes or the next-icon affordance), +// `QbzSegToggle` / `QbzTabBar` (no segmented control and no tabs exist +// here), `QbzIconButton` (transparent idle, r8, no accent-fill state) and +// `QbzToggleButton` (its active fill is `surfaceHover` on a transparent +// idle — the exact inverse). +// * the two dropdown panels — `controls/QbzContextMenu.qml` with +// `openBelowLeft(trigger)`, which places at `x: 0, y: height + 4`; with a +// 34 px trigger that is EXACTLY the reference's `x: 0; y: 38px`. +// +// SEARCH TEXT IS QML-LOCAL (contract D8). It is seeded ONCE from +// `managerJson.search` so a session query survives re-entry, and it is never +// bound to the document: every keystroke re-serialises the whole document, and +// a late publish landing mid-typing would fight the user. Rust debounces the +// rebuild by 150 ms; every other toolbar action publishes immediately. +// +// NO ANIMATIONS anywhere in this half — the reference snaps. Do not add a +// `Behavior on color` to the buttons. + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Item { + id: root + + /// Seeds the QML-local field ONCE, on mount (D8). + property string searchSeed: "" + property string filter: "all" + property string sort: "name" + property bool sortAsc: true + property string viewMode: "grid" + property bool folderMode: true + + /// The cycle button shows the NEXT mode's icon (`:1080-1087`). The + /// unreachable `!folderMode && viewMode === "tree"` state shows a grid icon + /// while yielding "list" — dead code in the reference, ported as written. + readonly property string nextViewIcon: + root.viewMode === "grid" ? "list" + : root.viewMode === "list" ? (root.folderMode ? "network" : "layout-grid") + : "layout-grid" + readonly property string nextViewMode: + root.folderMode + ? (root.viewMode === "grid" ? "list" + : root.viewMode === "list" ? "tree" : "grid") + : (root.viewMode === "list" ? "grid" : "list") + + /// The live field text. Seeded from `searchSeed`, then owned here. + property string searchText: "" + Component.onCompleted: root.searchText = root.searchSeed + + QbzTheme { id: theme } + + implicitWidth: tools.implicitWidth + implicitHeight: 34 + width: implicitWidth + height: implicitHeight + + // A plain Row, never a RowLayout: the New-folder slot appears and + // disappears with folder mode, and `Row` skips invisible children so the + // right-anchored cluster shifts as one block (the reference is + // right-anchored too — `:1124`). + Row { + id: tools + height: parent.height + spacing: 8 + + QbzLineEdit { + anchors.verticalCenter: parent.verticalCenter + width: 168 + searchMode: true + placeholder: QbzSession.tr("Search playlists…", QbzSession.trRev) + // Bound to the LOCAL state, which is also what its focus-loss + // re-seed Binding reads — binding it to the document would put the + // last published query back under the user's cursor. + text: root.searchText + onEdited: function (v) { + root.searchText = v + QbzPlaylistManager.searchChanged(v) + } + } + + QbzToolButton { + id: filterBtn + anchors.verticalCenter: parent.verticalCenter + large: true + fillActive: true + name: "list-filter" + label: root.filter === "visible" + ? QbzSession.tr("Visible", QbzSession.trRev) + : root.filter === "hidden" + ? QbzSession.tr("Hidden", QbzSession.trRev) + : QbzSession.tr("All", QbzSession.trRev) + onClicked: filterMenu.openBelowLeft(filterBtn) + } + + QbzToolButton { + id: sortBtn + anchors.verticalCenter: parent.verticalCenter + large: true + fillActive: true + name: "arrow-up-down" + // The closed trigger shows NO direction indicator — the caret lives + // inside the open menu, on the active row only (§5.17). + label: root.sort === "recent" + ? QbzSession.tr("Recent", QbzSession.trRev) + : root.sort === "playcount" + ? QbzSession.tr("Play Count", QbzSession.trRev) + : root.sort === "tracks" + ? QbzSession.tr("Track Count", QbzSession.trRev) + : root.sort === "custom" + ? QbzSession.tr("Custom", QbzSession.trRev) + : QbzSession.tr("Name A-Z", QbzSession.trRev) + onClicked: sortMenu.openBelowLeft(sortBtn) + } + + // Folder / flat. The ONLY toolbar button with an active (accent) state. + QbzToolButton { + anchors.verticalCenter: parent.verticalCenter + large: true + fillActive: true + label: "" + name: root.folderMode ? "folder-open" : "rows-3" + active: root.folderMode + onClicked: QbzPlaylistManager.toggleFolderMode() + } + + // View-mode cycle. + QbzToolButton { + anchors.verticalCenter: parent.verticalCenter + large: true + fillActive: true + label: "" + name: root.nextViewIcon + onClicked: QbzPlaylistManager.setViewMode(root.nextViewMode) + } + + // New folder — folder mode only (`:1269`). + QbzToolButton { + visible: root.folderMode + anchors.verticalCenter: parent.verticalCenter + large: true + fillActive: true + name: "folder-plus" + label: QbzSession.tr("New folder", QbzSession.trRev) + onClicked: QbzFolderEdit.openNewFolder() + } + } + + // ---- the two dropdown panels ----------------------------------------- + // Every row handler ends with `close()`: Slint's PopupWindow closes on any + // inside click, Qt's `CloseOnPressOutside` does not (contract D15). This is + // the single most common silent divergence in this block. + + QbzContextMenu { + id: filterMenu + menuWidth: 160 + contentSpacing: 1 + + Repeater { + model: [ + { "id": "all", "label": QbzSession.tr("All", QbzSession.trRev) }, + { "id": "visible", "label": QbzSession.tr("Visible", QbzSession.trRev) }, + { "id": "hidden", "label": QbzSession.tr("Hidden", QbzSession.trRev) } + ] + delegate: PmMenuRow { + required property var modelData + width: filterMenu.menuWidth - 10 + label: modelData.label + selected: root.filter === modelData.id + trailing: "check" + onActivated: { + QbzPlaylistManager.setFilter(modelData.id) + filterMenu.close() + } + } + } + } + + QbzContextMenu { + id: sortMenu + menuWidth: 180 + contentSpacing: 1 + + Repeater { + model: [ + { "id": "name", "label": QbzSession.tr("Name A-Z", QbzSession.trRev) }, + { "id": "recent", "label": QbzSession.tr("Recent", QbzSession.trRev) }, + { "id": "playcount", "label": QbzSession.tr("Play Count", QbzSession.trRev) }, + { "id": "tracks", "label": QbzSession.tr("Track Count", QbzSession.trRev) }, + { "id": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev) } + ] + delegate: PmMenuRow { + required property var modelData + width: sortMenu.menuWidth - 10 + label: modelData.label + selected: root.sort === modelData.id + // The 13 px accent chevron replaces the check on the ACTIVE + // row; re-selecting it is what flips the direction, in Rust. + trailing: "caret" + sortAsc: root.sortAsc + onActivated: { + QbzPlaylistManager.setSort(modelData.id) + sortMenu.close() + } + } + } + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmTreeFolderRow.qml b/crates/qbz-qt/qml/views/playlistmanager/PmTreeFolderRow.qml new file mode 100644 index 000000000..f83dd374a --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmTreeFolderRow.qml @@ -0,0 +1,102 @@ +// PmTreeFolderRow — a folder row of the TREE mode +// (PlaylistManagerView.slint:879-945). 44 px, r8, transparent → +// `surface-hover` on body hover. +// +// The two click targets are DIFFERENT here, unlike the card and the chip +// (contract D7): the body toggles the folder open/closed, the pencil opens the +// folder editor. +// +// ONE fixed asymmetry (contract D26): the reference gives a hidden folder no +// `opacity: 0.6` in the tree although the card and the chip both do. Applied +// here — the same folder must not read as hidden in one view mode and normal in +// another. +// +// The count on this row is the POST-FILTER visible member count, while the +// card's and the chip's is TOTAL membership (§5.7). Two different questions, +// deliberately; do not unify them. + +import QtQuick +import com.blitzfc.qbz +import "../../controls" +import "../../theme" + +Rectangle { + id: root + + /// `managerJson.tree[i].folder` — a folder object whose `count` is the + /// post-filter member count (contract §4.2). + property var folder: ({}) + property bool expanded: false + + QbzTheme { id: theme } + + readonly property string folderId: String(root.folder.id || "") + + height: 44 + radius: 8 + color: bodyArea.containsMouse ? theme.surfaceHover : "transparent" + opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 + + QbzIcon { + id: chevron + x: 8 + anchors.verticalCenter: parent.verticalCenter + name: root.expanded ? "chevron-down" : "chevron-right" + width: 15 + height: 15 + tintName: "muted" + } + + PmFolderIcon { + id: tile + x: chevron.x + 15 + 10 + anchors.verticalCenter: parent.verticalCenter + size: 28 + iconPreset: root.folder.iconPreset || "folder" + iconColor: root.folder.iconColor || "" + hasColor: root.folder.hasColor === true + hasCustomImage: root.folder.hasCustomImage === true + customImagePath: root.folder.customImagePath || "" + } + + Text { + x: tile.x + 28 + 10 + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, countText.x - 10 - x) + text: root.folder.name || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + + Text { + id: countText + x: pencil.x - 10 - width + anchors.verticalCenter: parent.verticalCenter + text: root.folder.count === undefined ? "0" : String(root.folder.count) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + + PmActionButton { + id: pencil + x: root.width - 12 - width + anchors.verticalCenter: parent.verticalCenter + name: "pen-line" + onClicked: QbzFolderEdit.openEditor(root.folderId) + } + + // Body: everything left of the pencil's 40 px reserve (`:938`). + MouseArea { + id: bodyArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Math.max(0, parent.width - 40) + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzPlaylistManager.toggleTreeFolder(root.folderId) + } +} diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmTreePlaylistRow.qml b/crates/qbz-qt/qml/views/playlistmanager/PmTreePlaylistRow.qml new file mode 100644 index 000000000..1ff713ae8 --- /dev/null +++ b/crates/qbz-qt/qml/views/playlistmanager/PmTreePlaylistRow.qml @@ -0,0 +1,128 @@ +// PmTreePlaylistRow — a playlist row of the TREE mode +// (PlaylistManagerView.slint:947-1013). 40 px, r6, transparent → +// `surface-hover` on body hover, 0.6 opacity while hidden. A row inside a +// folder indents to 36 px, a root row starts at 10 (`:957`). +// +// Affordances (contract D27): open (body), favourite and edit-playlist ONLY — +// no visibility toggle, no mixtape, no move-to-folder, no reorder chevrons. The +// two buttons are HOVER-REVEALED, which the grid card's and the list row's are +// not. +// +// TWO deliberate deviations, both recorded in the contract: +// +// * D26 — the reference tree carries no `hard-drive` marker for a local +// playlist although the list row does. Added, with the list row's tint rule +// (`accent` when offline-only, else `text-muted`). +// * D27 — the reference binds the buttons' opacity to the ROW TouchArea, +// which is `parent.width - 90px` and therefore EXCLUDES the buttons: moving +// the pointer onto a button drops it to opacity 0 while it still accepts +// the click. Here a `HoverHandler` on the row keeps them lit under the +// pointer. The row FILL still follows the body area, exactly like the +// reference. +// +// INTERFACE NOTE (contract §2.5): the contract declares `item` only. `indent` +// is ADDED because the indentation is a property of the TREE ROW +// (`tree[i].indent`, §4.2), not of the playlist — without it every nested row +// would sit at the root gutter and the tree would read flat. + +import QtQuick +import com.blitzfc.qbz +import "../../cards" +import "../../theme" + +Rectangle { + id: root + + /// `managerJson.tree[i].playlist` — one `PlaylistRow` (contract §4.3). + property var item: ({}) + /// `tree[i].indent` — true for a folder member. + property bool indent: false + + QbzTheme { id: theme } + + readonly property string itemId: String(root.item.id || "") + readonly property bool isLocal: root.item.isLocal === true + + height: 40 + radius: 6 + color: bodyArea.containsMouse ? theme.surfaceHover : "transparent" + opacity: (root.item.isHidden === true) ? 0.6 : 1.0 + + HoverHandler { id: rowHover } + + PlaylistCollage { + id: collage + x: root.indent ? 36 : 10 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + layout: "pm" + urls: root.item.covers || [] + radius: 5 + } + + Row { + id: nameRow + x: collage.x + 28 + 10 + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, tracksText.x - 10 - x) + spacing: 6 + + QbzIcon { + visible: root.isLocal + anchors.verticalCenter: parent.verticalCenter + name: "hard-drive" + width: 13 + height: 13 + tintName: root.item.offlineOnly === true ? "accent" : "muted" + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - (root.isLocal ? 19 : 0)) + text: root.item.name || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + elide: Text.ElideRight + } + } + + Text { + id: tracksText + x: actions.x - 10 - width + anchors.verticalCenter: parent.verticalCenter + text: root.item.tracksLine || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + + Row { + id: actions + x: root.width - 12 - width + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + opacity: rowHover.hovered ? 1.0 : 0.0 + + PmActionButton { + name: root.item.isFavorite === true ? "heart-filled" : "heart" + filled: root.item.isFavorite === true + onClicked: QbzPlaylistManager.toggleFavorite(root.itemId) + } + PmActionButton { + name: "pen-line" + onClicked: QbzPlaylistEdit.open(root.itemId) + } + } + + // Body: everything left of the two buttons' 90 px reserve (`:1006`). + MouseArea { + id: bodyArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Math.max(0, parent.width - 90) + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.openPlaylist(root.itemId) + } +} diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index 2116f6045..321011545 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -233,6 +233,22 @@ fn disk_path(key: &str, fetch: &str) -> Option { return Some(path); } let path = cache().and_then(|c| c.lock().ok()?.get(fetch))?; + // A ZERO-BYTE entry is a failed download that still landed a file, and the + // cache counts it as a hit FOREVER — that cover is then permanently broken + // and QML logs `Error decoding: ....img` on every paint. Treat it as a + // miss (and do not memo it), so the next window pass re-fetches it. The + // local arm of `cached_path` already stats for the same class of problem; + // this is the remote arm's half. Found live: one 0-byte file in the image + // cache, one undecodable sidebar cover (2026-07-31). + match std::fs::metadata(&path) { + Ok(meta) if meta.len() > 0 => {} + Ok(_) => { + log::debug!("[qbz-qt] artwork: empty cache entry for {fetch}, re-fetching"); + let _ = std::fs::remove_file(&path); + return None; + } + Err(_) => return None, + } memo_put(key, path.clone()); Some(path) } @@ -538,9 +554,33 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option { // (`artwork.rs` `decode_rgba`). Measured at 200px it is within RMSE 1.3 of // a Lanczos reference and carries the same edge energy (Laplacian variance // 334.5 vs 337.3), i.e. no visible softening and no ringing. - img.thumbnail(w, h) - .save_with_format(&out, image::ImageFormat::Png) - .ok()?; + // ATOMIC: encode into a unique sibling, then rename. Writing straight to + // `out` publishes the path the instant the file is created, so a QML + // `Image` that resolves it mid-encode opens a TRUNCATED png and reports + // "Unsupported image format" — and Qt caches that failure against the URL, + // so the cover stays dead for the rest of the session even though the file + // on disk ends up perfectly valid. Observed in the offscreen smoke + // (2026-07-31): two covers failed to decode while `file(1)` called the very + // same paths valid 200x200 PNGs seconds later. `rename` within one + // directory is atomic, so a reader sees either nothing or the whole image. + // Same discipline `local_state.rs` already uses for the prefs JSON. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let tmp = out.with_extension(format!( + "part{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if img + .thumbnail(w, h) + .save_with_format(&tmp, image::ImageFormat::Png) + .is_err() + || std::fs::rename(&tmp, &out).is_err() + { + // Never leave a partial behind: `prune` bills the directory by total + // bytes, so an orphan would eat the budget without ever being a hit. + let _ = std::fs::remove_file(&tmp); + return None; + } Some(out) } diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index d7daebd1d..aeda42eec 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -191,10 +191,18 @@ pub mod qbz_bridge { fn playlist_play_track(self: Pin<&mut QbzBridge>, track_id: QString); #[qinvokable] fn playlist_enqueue_track(self: Pin<&mut QbzBridge>, track_id: QString, mode: QString); + /// "Remove from playlist" by DISPLAY row id (`item.id`). A string, + /// not the numeric membership id: a LOCAL playlist's rows carry + /// library-row / `"plex:"` / path ids too, and the numeric + /// invokable this replaced could not express them. #[qinvokable] - fn playlist_remove_track(self: Pin<&mut QbzBridge>, playlist_track_id: f64); + fn playlist_remove_track(self: Pin<&mut QbzBridge>, row_id: QString); + /// Drag-reorder drop: visible row `from` -> insertion slot `slot`. #[qinvokable] fn playlist_reorder(self: Pin<&mut QbzBridge>, from: i32, slot: i32); + /// Per-row reorder chevrons: -1 = up, +1 = down. + #[qinvokable] + fn playlist_move_row(self: Pin<&mut QbzBridge>, row_id: QString, delta: i32); /// Card-level playlist actions (LibPlaylistCard overlay + menu). #[qinvokable] @@ -436,12 +444,16 @@ impl qbz_bridge::QbzBridge { crate::playlist_enqueue_track(track_id.to_string(), mode.to_string()); } - pub fn playlist_remove_track(self: Pin<&mut Self>, playlist_track_id: f64) { - crate::playlist_remove_track(playlist_track_id as u64); + pub fn playlist_remove_track(self: Pin<&mut Self>, row_id: QString) { + crate::playlist_remove_track(row_id.to_string()); } pub fn playlist_reorder(self: Pin<&mut Self>, from: i32, slot: i32) { - crate::playlist_qt::reorder_track(from.max(0) as usize, slot.max(0) as usize); + crate::playlist_reorder(from, slot); + } + + pub fn playlist_move_row(self: Pin<&mut Self>, row_id: QString, delta: i32) { + crate::playlist_move_row(row_id.to_string(), delta); } pub fn playlist_set_follow_by_id(self: Pin<&mut Self>, playlist_id: QString, follow: bool) { diff --git a/crates/qbz-qt/src/folder_edit_bridge.rs b/crates/qbz-qt/src/folder_edit_bridge.rs new file mode 100644 index 000000000..f531fde21 --- /dev/null +++ b/crates/qbz-qt/src/folder_edit_bridge.rs @@ -0,0 +1,245 @@ +//! QbzFolderEdit — the playlist-FOLDER modals' QML singleton (contract §3.2). +//! +//! One of the THREE singletons the Playlist Manager surface owns (D2), not one +//! god object: `QbzPlaylistManager` holds the manager document and the +//! playlist-organisation writes, `QbzPlaylistEdit` owns the shared +//! rename/description/delete modal, and this one owns the two folder panels — +//! the sidebar's small "New folder" create panel and the full folder editor +//! (name · icon preset · colour · custom image · hidden · delete). +//! +//! It is a SEPARATE singleton for the same reason `QbzMyQbzAdd` is separate +//! from `QbzMyQbz`: its callers live outside the manager view. The sidebar's +//! `...` menu opens the create panel, the sidebar's row context menu opens the +//! editor and flips `hidden`, and in both of those sessions the manager view +//! has typically never been mounted — so nothing here may be conditional on +//! the manager's cache (§5.3). +//! +//! TWO documents, because the two panels are independent surfaces with +//! independent open flags (§4.4 / §4.5): +//! * `createJson` — `{ open, creating }`. The NAME is not in it: text state +//! is QML-local and `createSubmit(name)` takes it as an argument +//! (`MyQbzModals.qml`'s convention). The create panel owns its own draft +//! so reopening it can never show whatever was last typed in the editor. +//! * `editJson` — the editor's seeds plus the two RUST-OWNED fields the file +//! dialog writes (`hasCustomImage` / `customImagePath`), `busy`, and the +//! preset + swatch constants, published rather than hardcoded in QML so +//! the list has exactly one definition (§4.5). +//! +//! **No `#[qsignal]`** — outcomes are republishes and toasts +//! (`playlist_picker_bridge.rs:13`). +//! +//! `busy` HOLDS THE PANEL OPEN (D22). The reference closes the editor before +//! the write, which makes a failed create invisible and leaves its own `busy` +//! flag dead. Here a save or delete disarms the buttons, keeps the panel up, +//! and only closes on success; a failure stays open and toasts. +//! +//! The module is `qbz_folder_edit_bridge` — a bridge module is never named +//! after a workspace crate (E0659); the QML type name comes from the QObject, +//! so QML is unaffected. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_folder_edit_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // The small create panel: `{ "open": false, "creating": false }`. + #[qproperty(QString, create_json)] + // The full folder editor (§4.5). Parseable default so QML's + // JSON.parse never throws on the pre-publish frame. + #[qproperty(QString, edit_json)] + + type QbzFolderEdit = super::QbzFolderEditRust; + + /// Registers this object's Qt-thread hop. Without it every `ui()` + /// publish from Rust is dropped on the floor, silently and forever: + /// the panels mount, no document ever arrives, and nothing is logged + /// on either side. + #[qinvokable] + fn boot(self: Pin<&mut QbzFolderEdit>); + + // -- create panel ------------------------------------------------ + /// Sidebar `...` -> "New folder": the small name-only create panel. + #[qinvokable] + fn open_create(self: Pin<&mut QbzFolderEdit>); + + /// Create the folder. The name is QML-local and arrives as an + /// argument; an empty / whitespace-only name and a re-entrant submit + /// are both refused. + #[qinvokable] + fn create_submit(self: Pin<&mut QbzFolderEdit>, name: QString); + + /// Dismiss the create panel. Refused while the create is in flight. + #[qinvokable] + fn create_close(self: Pin<&mut QbzFolderEdit>); + + // -- editor ------------------------------------------------------ + /// Manager toolbar -> "New folder": the FULL editor in create mode + /// (`id == ""`, title "New folder", no Delete button). The toolbar and + /// the sidebar deliberately open two different surfaces — that is the + /// reference's split, not an accident. + #[qinvokable] + fn open_new_folder(self: Pin<&mut QbzFolderEdit>); + + /// Open the editor on an existing folder. Reads + /// `folders_qt::load_folders_full()` DIRECTLY — never a manager cache, + /// which is filled only by a network fetch and is empty in exactly the + /// sessions the sidebar's Edit row runs in. + #[qinvokable] + fn open_editor(self: Pin<&mut QbzFolderEdit>, folder_id: QString); + + /// Native file picker for a custom folder image. Cancel is a silent + /// no-op (reference parity). + #[qinvokable] + fn pick_image(self: Pin<&mut QbzFolderEdit>); + + /// Drop the custom image and fall back to the preset glyph. Called by + /// the panel alongside its own preset write — which is what makes the + /// reference's declared-but-never-called `clear-image` callback live. + #[qinvokable] + fn clear_image(self: Pin<&mut QbzFolderEdit>); + + /// Persist the editor. Name / preset / colour / hidden are QML-local + /// drafts and arrive as arguments; the image path and the folder id + /// are Rust-owned state, so a republish can never make the panel save + /// under the wrong id. + #[qinvokable] + fn edit_save( + self: Pin<&mut QbzFolderEdit>, + name: QString, + icon_preset: QString, + icon_color: QString, + is_hidden: bool, + ); + + /// Delete the open folder. The confirm sub-modal is QML-local + /// (`QbzConfirmModal`); Rust only ever sees the confirmed outcome. + #[qinvokable] + fn edit_delete_confirmed(self: Pin<&mut QbzFolderEdit>); + + /// Dismiss the editor. Refused while a save or delete is in flight. + #[qinvokable] + fn edit_close(self: Pin<&mut QbzFolderEdit>); + + /// Sidebar row menu -> "Hide from sidebar" on a FOLDER row. Flips the + /// flag without opening anything, leaving every other field untouched + /// (`set_folder_hidden`'s whole reason to exist). + #[qinvokable] + fn set_hidden(self: Pin<&mut QbzFolderEdit>, folder_id: QString, hidden: bool); + } + + impl cxx_qt::Threading for QbzFolderEdit {} +} + +use qbz_folder_edit_bridge::QbzFolderEdit; + +/// Rust side of the folder-edit bridge (plain storage, phase-1 pattern). +pub struct QbzFolderEditRust { + create_json: QString, + edit_json: QString, +} + +impl Default for QbzFolderEditRust { + fn default() -> Self { + Self { + // Both panels start closed, and both defaults are parseable so a + // binding that reads `doc.open` on frame 1 cannot throw. + create_json: QString::from("{\"open\":false,\"creating\":false}"), + edit_json: QString::from("{\"open\":false}"), + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue a folder-modal mutation onto the Qt event loop (no-op before `boot()` +/// registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzFolderEdit>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_folder_edit_bridge::QbzFolderEdit { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] folder edit Qt thread already registered"); + } + // Publish the closed documents synchronously, so the preset + swatch + // constants are on the QML side before the first open rather than one + // event-loop turn after it. + crate::folder_edit_qt::publish_all(); + } + + pub fn open_create(self: Pin<&mut Self>) { + crate::folder_edit_qt::open_create(); + } + + pub fn create_submit(self: Pin<&mut Self>, name: QString) { + crate::folder_edit_qt::create_submit(&name.to_string()); + } + + pub fn create_close(self: Pin<&mut Self>) { + crate::folder_edit_qt::create_close(); + } + + pub fn open_new_folder(self: Pin<&mut Self>) { + crate::folder_edit_qt::open_new_folder(); + } + + pub fn open_editor(self: Pin<&mut Self>, folder_id: QString) { + crate::folder_edit_qt::open_editor(&folder_id.to_string()); + } + + pub fn pick_image(self: Pin<&mut Self>) { + crate::folder_edit_qt::pick_image(); + } + + pub fn clear_image(self: Pin<&mut Self>) { + crate::folder_edit_qt::clear_image(); + } + + pub fn edit_save( + self: Pin<&mut Self>, + name: QString, + icon_preset: QString, + icon_color: QString, + is_hidden: bool, + ) { + crate::folder_edit_qt::edit_save( + &name.to_string(), + &icon_preset.to_string(), + &icon_color.to_string(), + is_hidden, + ); + } + + pub fn edit_delete_confirmed(self: Pin<&mut Self>) { + crate::folder_edit_qt::edit_delete_confirmed(); + } + + pub fn edit_close(self: Pin<&mut Self>) { + crate::folder_edit_qt::edit_close(); + } + + pub fn set_hidden(self: Pin<&mut Self>, folder_id: QString, hidden: bool) { + crate::folder_edit_qt::set_hidden(&folder_id.to_string(), hidden); + } +} diff --git a/crates/qbz-qt/src/folder_edit_qt.rs b/crates/qbz-qt/src/folder_edit_qt.rs new file mode 100644 index 000000000..560b696b9 --- /dev/null +++ b/crates/qbz-qt/src/folder_edit_qt.rs @@ -0,0 +1,644 @@ +//! Folder modals controller — the Rust half of `QbzFolderEdit` (contract +//! block 2). Port of `crates/qbz/src/main.rs`'s `CreateFolderActions` / +//! `FolderEditActions` handlers (`:5143-5183`, `:5600-5740`) plus +//! `primitives/CreateFolderModal.slint` and `primitives/FolderEditModal.slint`. +//! +//! # What is state here and what is state in QML +//! +//! Text, preset, colour and the hidden switch are QML-LOCAL drafts; every +//! submit invokable takes them as arguments (`MyQbzModals.qml`'s convention — +//! a colour click has no business making a round trip). What lives HERE is +//! only what QML cannot own: +//! * the folder id being edited — held in Rust so a republish can never make +//! the panel save under a different folder; +//! * the custom image path — the file dialog is native and lives on this +//! side; +//! * `busy` / `creating` — they gate the writes, so the writer owns them; +//! * the preset and swatch constants, published in the document so the list +//! has exactly ONE definition (§4.5). +//! +//! # `busy` HOLDS THE PANEL OPEN (D22) +//! +//! The reference closes the editor BEFORE the write (`main.rs:5654`), which +//! makes a failed create invisible and unrecoverable and leaves its own `busy` +//! flag dead — nothing ever sets it. Here: `busy: true`, panel stays up, +//! buttons disarmed; close on success, stay open and toast on failure. `close` +//! is refused while busy. That is a deliberate, recorded divergence and it is +//! what gives `busy` a meaning. +//! +//! # Nothing here may be conditional on the manager cache (§5.3) +//! +//! Both panels are reachable from the SIDEBAR, in sessions where the Playlist +//! Manager view has never been mounted and `playlist_manager_qt::CACHE` is +//! `None`. So the DB write, `refresh_folders()` and the sidebar hop run +//! UNCONDITIONALLY; only `reload_if_loaded()` is allowed to early-return, and +//! it does that itself. +//! +//! # The sidebar hop is the OFFLINE-SAFE one (D10) +//! +//! `crate::reload_sidebar()` opens with an `is_offline()` early return, so +//! after creating or deleting a folder it is a no-op for exactly the +//! account-less, offline population local playlists exist for. Every folder +//! mutation here goes through `crate::reload_sidebar_including_local()`, which +//! runs the same rebuild with no gate. +//! +//! # Thread rules (§5.19) +//! +//! `LibraryDatabase` wraps a `!Send` rusqlite connection and every `folders_qt` +//! call opens the DB, so none of them may run on the Qt thread. An invokable +//! only mutates a `Mutex`, publishes, and `crate::spawn`s the rest. + +use std::sync::{LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use serde::Serialize; + +use crate::folders_qt; + +// --------------------------------------------------------------------------- +// Constants — published, not hardcoded in QML (§4.5) +// --------------------------------------------------------------------------- + +/// The seven icon presets, in the reference's order (`main.rs:5143-5160`). +/// +/// Two upstream quirks are INTENTIONAL and are kept: `headphones` renders +/// `audio-lines.svg`, and `folder` is not a case in the id -> glyph chain at +/// all — it falls through to the default arm, which is also the unknown-id +/// arm. Both are upstream intent (§4.5). +const PRESETS: [&str; 7] = [ + "heart", + "star", + "music", + "folder", + "disc", + "library", + "headphones", +]; + +/// The eleven colour swatches, ONE row of 28 px circles (`main.rs:5161-5183`). +/// `""` is the "use the theme accent" entry — the `.slint` comment claiming +/// "Two rows" is wrong; the grid is 11 wide and 388 px across. +const SWATCHES: [&str; 11] = [ + "", "#ef4444", "#f97316", "#f59e0b", "#10b981", "#06b6d4", "#3b82f6", "#a855f7", "#ec4899", + "#f43f5e", "#64748b", +]; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// The small name-only create panel (the sidebar's "New folder"). +#[derive(Clone, Debug, Default)] +struct CreateState { + open: bool, + creating: bool, +} + +/// The full editor. `id == ""` is CREATE mode (title "New folder", no Delete +/// button) — the manager toolbar's "New folder" opens this surface, the +/// sidebar's opens the small one. That split is the reference's. +#[derive(Clone, Debug)] +struct EditState { + open: bool, + id: String, + /// SEEDs, consumed once by QML on the open transition. + name: String, + icon_preset: String, + icon_color: String, + is_hidden: bool, + /// RUST-OWNED, read live by QML: the native picker writes them. + has_custom_image: bool, + /// The RAW filesystem path. It is percent-encoded into a `file://` URL + /// only at publish time (D25) — the DB stores the raw path, matching the + /// shipped Slint build, so the column stays compatible between the two. + custom_image_path: String, + busy: bool, +} + +impl Default for EditState { + fn default() -> Self { + Self { + open: false, + id: String::new(), + name: String::new(), + icon_preset: "folder".into(), + icon_color: String::new(), + is_hidden: false, + has_custom_image: false, + custom_image_path: String::new(), + busy: false, + } + } +} + +static CREATE: LazyLock> = LazyLock::new(|| Mutex::new(CreateState::default())); +static EDIT: LazyLock> = LazyLock::new(|| Mutex::new(EditState::default())); + +// --------------------------------------------------------------------------- +// Documents +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct CreateDoc { + open: bool, + creating: bool, +} + +/// One swatch: `{ "value": "#ef4444", "isAccent": false }`. The ACCENT entry +/// is the one whose value is `""` — QML paints it `theme.accent` and stores +/// `""`, which is what "use the theme accent" means all the way down to the DB +/// (D24). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SwatchDoc { + value: &'static str, + is_accent: bool, +} + +impl SwatchDoc { + fn all() -> Vec { + SWATCHES + .iter() + .map(|v| SwatchDoc { + value: v, + is_accent: v.is_empty(), + }) + .collect() + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EditDoc { + open: bool, + id: String, + name: String, + icon_preset: String, + icon_color: String, + is_hidden: bool, + has_custom_image: bool, + /// `file://…` percent-encoded, `""` whenever `has_custom_image` is false. + /// A bare `/home/...` string would be resolved by QML against the + /// component's `qrc:` base and fail silently. + custom_image_path: String, + busy: bool, + presets: [&'static str; 7], + swatches: Vec, +} + +fn create_doc() -> String { + let st = CREATE.lock().unwrap_or_else(|e| e.into_inner()).clone(); + serde_json::to_string(&CreateDoc { + open: st.open, + creating: st.creating, + }) + .unwrap_or_else(|_| "{\"open\":false,\"creating\":false}".to_string()) +} + +fn edit_doc() -> String { + let st = EDIT.lock().unwrap_or_else(|e| e.into_inner()).clone(); + // `hasCustomImage` is the ONLY question the view asks, and the path is + // emitted as "" whenever it is false — a stale path is reachable, because + // `update_folder_full`'s `None` means "leave the image unchanged", so + // "custom vs preset" must never be derived from the path alone (D25). + let url = if st.has_custom_image && !st.custom_image_path.is_empty() { + crate::artwork_qt::file_url(&st.custom_image_path) + } else { + String::new() + }; + serde_json::to_string(&EditDoc { + open: st.open, + id: st.id, + name: st.name, + icon_preset: st.icon_preset, + icon_color: st.icon_color, + is_hidden: st.is_hidden, + has_custom_image: st.has_custom_image && !st.custom_image_path.is_empty(), + custom_image_path: url, + busy: st.busy, + presets: PRESETS, + swatches: SwatchDoc::all(), + }) + .unwrap_or_else(|_| "{\"open\":false}".to_string()) +} + +fn publish_create() { + let json = create_doc(); + crate::folder_edit_bridge::ui(move |mut b| { + b.as_mut().set_create_json(QString::from(json.as_str())); + }); +} + +fn publish_edit() { + let json = edit_doc(); + crate::folder_edit_bridge::ui(move |mut b| { + b.as_mut().set_edit_json(QString::from(json.as_str())); + }); +} + +/// Both documents. Called from `boot()` so the preset + swatch constants are +/// on the QML side before the first open. +pub(crate) fn publish_all() { + publish_create(); + publish_edit(); +} + +// --------------------------------------------------------------------------- +// The shared post-write refresh +// --------------------------------------------------------------------------- + +/// Everything a folder mutation has to touch, in the order D10 requires: the +/// write has already landed by the time this runs, so nothing reads pre-write +/// state. +/// +/// * `refresh_folders()` — cache-INDEPENDENT (D5): a folder created from the +/// sidebar must reach `foldersJson` in a session where the manager view was +/// never opened. +/// * `reload_if_loaded()` — the ONE call allowed to early-return on a cold +/// manager cache; it costs nothing when the manager has no reader. +/// * `reload_sidebar_including_local()` — the OFFLINE-SAFE sidebar verb. +/// `reload_sidebar()` bails while offline, which would make creating or +/// deleting a folder change nothing on screen for the account-less +/// population this whole feature exists for. +fn after_write() { + crate::playlist_manager_qt::refresh_folders(); + crate::playlist_manager_qt::reload_if_loaded(); + crate::reload_sidebar_including_local(); +} + +// --------------------------------------------------------------------------- +// Create panel +// --------------------------------------------------------------------------- + +/// Sidebar `...` -> "New folder". +pub(crate) fn open_create() { + { + let mut st = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + st.open = true; + st.creating = false; + } + publish_create(); +} + +/// The name is QML-local and arrives here as an argument. Validation stays +/// deliberately weak (§5.25): no duplicate check, no maximum length — the one +/// gate is a non-blank name, and the button reads that same gate so a +/// whitespace-only name shows as disabled instead of lying. +pub(crate) fn create_submit(name: &str) { + let trimmed = name.trim().to_string(); + { + let mut st = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + if !st.open || st.creating || trimmed.is_empty() { + return; + } + st.creating = true; + } + publish_create(); + + crate::spawn(async move { + // Preset "folder" + colour "" (= the theme accent). Since D24's fix + // that empty string REACHES the DB, so a sidebar-created folder is + // accent-coloured instead of the DB default indigo — intended. + let created = tokio::task::spawn_blocking(move || { + folders_qt::create_folder_full(&trimmed, "folder", "").is_some() + }) + .await + .unwrap_or(false); + + { + let mut st = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + st.creating = false; + // Failure keeps the panel OPEN so the name is not lost (D22). + st.open = !created; + } + publish_create(); + + if created { + after_write(); + } else { + log::warn!("[qbz-qt] folder create failed"); + crate::toast_qt::error(qbz_i18n::t("Failed to create folder")); + } + }); +} + +/// Dismiss. Refused mid-create — the panel is the only thing reporting that a +/// create is in flight. +pub(crate) fn create_close() { + { + let mut st = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + if st.creating { + return; + } + st.open = false; + } + publish_create(); +} + +// --------------------------------------------------------------------------- +// Editor +// --------------------------------------------------------------------------- + +/// Manager toolbar -> "New folder": the FULL editor in create mode. +pub(crate) fn open_new_folder() { + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + *st = EditState { + open: true, + ..EditState::default() + }; + } + publish_edit(); +} + +/// Open the editor on an existing folder. +/// +/// Reads `folders_qt::load_folders_full()` DIRECTLY. The reference resolves +/// the row out of a manager cache that only a network fetch fills +/// (`playlist_manager.rs`), so even in Slint the sidebar's "Edit folder" +/// silently does nothing until the manager has been opened once in that +/// session. That bug is not ported. +pub(crate) fn open_editor(folder_id: &str) { + let id = folder_id.to_string(); + if id.is_empty() { + return; + } + crate::spawn(async move { + let found = tokio::task::spawn_blocking(move || { + folders_qt::load_folders_full() + .into_iter() + .find(|f| f.id == id) + }) + .await + .ok() + .flatten(); + + let Some(f) = found else { + // Deleted underneath us (or the DB is unreadable). Opening an + // empty editor on a folder that no longer exists would be worse + // than doing nothing. + log::warn!("[qbz-qt] folder editor: folder not found"); + return; + }; + + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + let path = f.custom_image_path.unwrap_or_default(); + // BOTH conditions, matching `playlist_manager.rs:505`. + let has_image = f.icon_type == "custom" && !path.is_empty(); + *st = EditState { + open: true, + id: f.id, + name: f.name, + icon_preset: if f.icon_preset.is_empty() { + "folder".into() + } else { + f.icon_preset + }, + icon_color: f.icon_color, + is_hidden: f.is_hidden, + has_custom_image: has_image, + custom_image_path: if has_image { path } else { String::new() }, + busy: false, + }; + } + publish_edit(); + }); +} + +/// Native picker. Cancel is a silent no-op (reference parity). +/// +/// The filter carries `webp` and `gif` as the contract's §5.24 snippet does. +/// This crate builds `image` with `jpeg` + `png` only, so those two can never +/// produce a `QbzSession.artScaled` derivative — `artwork_qt::scaled_path` +/// returns `None` and the host keeps the original source, i.e. the image still +/// displays, at full decode size. Recorded rather than silently narrowed. +pub(crate) fn pick_image() { + crate::spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title(&qbz_i18n::t("Choose a folder image")) + .add_filter( + &qbz_i18n::t("Images"), + &["png", "jpg", "jpeg", "webp", "gif"], + ) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + // The panel may have been dismissed while the native dialog was + // up; writing into a closed editor would surface on its NEXT open. + if !st.open { + return; + } + st.custom_image_path = path; + st.has_custom_image = true; + } + publish_edit(); + }); +} + +/// Drop the custom image. QML calls this alongside its own local preset write +/// — which is what makes the reference's declared-but-never-called +/// `clear-image` callback (`main.rs:5611-5619`) actually live. +pub(crate) fn clear_image() { + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + if !st.open { + return; + } + st.custom_image_path.clear(); + st.has_custom_image = false; + } + publish_edit(); +} + +/// The image tri-state and the two create-mode gaps (§5.22). +/// +/// `icon_type` / `custom_image_path` are always ASSERTED, never left unchanged: +/// `None` (= leave alone) is reserved for `set_folder_hidden`, which must not +/// disturb the image. +/// +/// `create_folder_full` cannot take an image or the hidden flag, so a custom +/// image picked in create mode is silently lost in the reference and +/// `is_hidden` is dropped. One extra DB write closes both gaps — cheaper than +/// an affordance that offers a picker and discards the result. +fn save_blocking( + id: &str, + name: &str, + preset: &str, + color: &str, + image: &str, + hidden: bool, +) -> bool { + let icon_type = if image.is_empty() { "preset" } else { "custom" }; + let img = if image.is_empty() { + Some(None) + } else { + Some(Some(image)) + }; + if id.is_empty() { + match folders_qt::create_folder_full(name, preset, color) { + Some(f) => { + if image.is_empty() && !hidden { + true + } else { + folders_qt::update_folder_full( + &f.id, name, icon_type, preset, color, img, hidden, + ) + } + } + None => false, + } + } else { + folders_qt::update_folder_full(id, name, icon_type, preset, color, img, hidden) + } +} + +pub(crate) fn edit_save(name: &str, icon_preset: &str, icon_color: &str, is_hidden: bool) { + let trimmed = name.trim().to_string(); + let (id, image) = { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + if !st.open || st.busy || trimmed.is_empty() { + return; + } + st.busy = true; + (st.id.clone(), st.custom_image_path.clone()) + }; + publish_edit(); + + let is_create = id.is_empty(); + let preset = icon_preset.to_string(); + let color = icon_color.to_string(); + crate::spawn(async move { + let ok = tokio::task::spawn_blocking(move || { + save_blocking(&id, &trimmed, &preset, &color, &image, is_hidden) + }) + .await + .unwrap_or(false); + + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + st.busy = false; + // Stay OPEN on failure, with everything the user typed intact. + st.open = !ok; + } + publish_edit(); + + if ok { + after_write(); + } else { + log::warn!("[qbz-qt] folder save failed (create={is_create})"); + crate::toast_qt::error(qbz_i18n::t(if is_create { + "Failed to create folder" + } else { + "Failed to save folder" + })); + } + }); +} + +/// The confirm sub-modal is QML-local (`QbzConfirmModal`); Rust only ever sees +/// the confirmed outcome. +/// +/// `folders_qt::delete_folder` does TWO writes — the folder row and +/// `local_playlists::clear_folder` — because the app keeps `PRAGMA +/// foreign_keys` OFF, so the `ON DELETE SET NULL` never fires for +/// `local_playlists.folder_id`. Nothing else is deleted: the members move back +/// to the root, which is what the confirm copy promises, for both kinds. +pub(crate) fn edit_delete_confirmed() { + let id = { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + if !st.open || st.busy || st.id.is_empty() { + return; + } + st.busy = true; + st.id.clone() + }; + publish_edit(); + + crate::spawn(async move { + let ok = tokio::task::spawn_blocking(move || folders_qt::delete_folder(&id)) + .await + .unwrap_or(false); + + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + st.busy = false; + st.open = !ok; + } + publish_edit(); + + if ok { + after_write(); + } else { + log::warn!("[qbz-qt] folder delete failed"); + crate::toast_qt::error(qbz_i18n::t("Failed to delete folder")); + } + }); +} + +/// Dismiss. Refused while a save or delete is in flight (D22). +pub(crate) fn edit_close() { + { + let mut st = EDIT.lock().unwrap_or_else(|e| e.into_inner()); + if st.busy { + return; + } + st.open = false; + } + publish_edit(); +} + +/// Sidebar row menu -> "Hide from sidebar" on a FOLDER row. No panel, no +/// `busy`: it is a one-field flip, and `set_folder_hidden` passes `None` for +/// every other column precisely so the image and the colour survive it. +pub(crate) fn set_hidden(folder_id: &str, hidden: bool) { + let id = folder_id.to_string(); + if id.is_empty() { + return; + } + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || folders_qt::set_folder_hidden(&id, hidden)) + .await; + after_write(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_swatch_document_marks_exactly_the_empty_value_as_the_accent() { + let all = SwatchDoc::all(); + assert_eq!(all.len(), 11); + assert!(all[0].is_accent); + assert_eq!(all[0].value, ""); + assert!(all[1..].iter().all(|s| !s.is_accent)); + } + + #[test] + fn the_preset_list_is_the_references_seven_in_order() { + assert_eq!( + PRESETS, + ["heart", "star", "music", "folder", "disc", "library", "headphones"] + ); + } + + #[test] + fn the_closed_edit_document_parses_and_carries_the_constants() { + // The default state must serialise to something a QML binding can read + // on the pre-publish frame. + let json = edit_doc(); + let v: serde_json::Value = serde_json::from_str(&json).expect("edit doc parses"); + assert_eq!(v["open"], serde_json::Value::Bool(false)); + assert_eq!(v["presets"].as_array().map(|a| a.len()), Some(7)); + assert_eq!(v["swatches"].as_array().map(|a| a.len()), Some(11)); + assert_eq!(v["swatches"][0]["isAccent"], serde_json::Value::Bool(true)); + assert_eq!(v["customImagePath"], serde_json::Value::String(String::new())); + } +} diff --git a/crates/qbz-qt/src/folders_qt.rs b/crates/qbz-qt/src/folders_qt.rs new file mode 100644 index 000000000..187f64be1 --- /dev/null +++ b/crates/qbz-qt/src/folders_qt.rs @@ -0,0 +1,206 @@ +//! Playlist folders — port of `crates/qbz/src/folders.rs`. +//! +//! Local-only organization stored in the per-user `library.db` (the SAME file +//! the reference build writes, so a user's folders survive switching between +//! the two). Folders are FLAT — no nesting; a playlist belongs to at most one +//! folder via `playlist_settings.folder_id`. +//! +//! Every call opens the DB, so they are all blocking; async callers wrap them +//! in `spawn_blocking`. `sidebar_qt.rs` already had its own inline copy of the +//! three reads it needs — that one stays, because it opens the DB once and +//! answers four questions in a single pass, which is the right shape for a +//! per-publish hot path. + +use std::collections::HashMap; + +use crate::library_db_qt::with_db; + +/// Full folder record for the Playlist Manager (icon + color + hidden). +/// +/// `Debug` because `playlist_manager_rows::PmData` derives it and holds a +/// `Vec`. +#[derive(Clone, Debug, Default)] +pub struct FolderFull { + pub id: String, + pub name: String, + pub icon_type: String, + pub icon_preset: String, + pub icon_color: String, + pub custom_image_path: Option, + pub is_hidden: bool, +} + +/// Per-playlist local settings the manager merges onto the remote list. +#[derive(Clone, Default)] +pub struct PlaylistSettingsLite { + pub hidden: bool, + pub is_favorite: bool, + pub position: i32, + pub folder_id: Option, +} + +/// All folders with their full icon/color records, ordered by position. +pub fn load_folders_full() -> Vec { + with_db(false, |db| db.get_all_playlist_folders()) + .unwrap_or_default() + .into_iter() + .map(|f| FolderFull { + id: f.id, + name: f.name, + icon_type: f.icon_type, + icon_preset: f.icon_preset, + icon_color: f.icon_color, + custom_image_path: f.custom_image_path, + is_hidden: f.is_hidden, + }) + .collect() +} + +/// playlist id -> its local settings (hidden / favorite / position / folder). +pub fn playlist_settings_map() -> HashMap { + with_db(false, |db| db.get_all_playlist_settings()) + .unwrap_or_default() + .into_iter() + .map(|s| { + ( + s.qobuz_playlist_id, + PlaylistSettingsLite { + hidden: s.hidden, + is_favorite: s.is_favorite, + position: s.position, + folder_id: s.folder_id, + }, + ) + }) + .collect() +} + +/// playlist id -> play count (the "Play Count" sort + the list badge). +pub fn playlist_play_counts() -> HashMap { + with_db(false, |db| db.get_all_playlist_stats()) + .unwrap_or_default() + .into_iter() + .map(|s| (s.qobuz_playlist_id, s.play_count)) + .collect() +} + +/// playlist id -> local (non-Qobuz) track count. +pub fn playlist_local_counts() -> HashMap { + with_db(false, |db| db.get_all_playlist_local_track_counts()).unwrap_or_default() +} + +/// Create a folder with an icon preset + colour (the manager's create path). +/// +/// `create: true` — a fresh account has no library.db until the first local +/// flag is written, and "create your first folder" must not be the one action +/// that silently does nothing on a new install. +pub fn create_folder_full(name: &str, icon_preset: &str, icon_color: &str) -> Option { + let preset = Some(icon_preset); + // `""` means "use the theme accent" and it has to REACH the DB as an empty + // string. Mapping it to `None` made the DB substitute its own default + // (`#6366f1`, database.rs:3814), so picking the Accent swatch on a NEW + // folder silently stored indigo — the modal closed, the tile came back + // purple and nothing reported why. `""` reads back through the Slint + // build's `parse_color("")` -> None -> has_color false -> accent fallback, + // so both builds render it identically and nothing needs migrating + // (contract D24). + let color = Some(icon_color); + with_db(true, |db| { + db.create_playlist_folder(name, Some("preset"), preset, color) + }) + .map(|f| FolderFull { + id: f.id, + name: f.name, + icon_type: f.icon_type, + icon_preset: f.icon_preset, + icon_color: f.icon_color, + custom_image_path: f.custom_image_path, + is_hidden: f.is_hidden, + }) +} + +/// Update a folder (name, icon type/preset, colour, custom image, hidden). +/// +/// `custom_image_path` is `Some(Some(p))` to set, `Some(None)` to clear and +/// `None` to leave unchanged — the DB signature, kept rather than flattened so +/// "clear the image" and "do not touch the image" stay distinguishable. +/// +/// Returns whether the write landed, so the folder editor can hold its panel +/// open and toast on a failure instead of closing over a silent no-op (D22 / +/// D23). The reference has no such signal and logs at most a warning. +#[allow(clippy::too_many_arguments)] +pub fn update_folder_full( + id: &str, + name: &str, + icon_type: &str, + icon_preset: &str, + icon_color: &str, + custom_image_path: Option>, + is_hidden: bool, +) -> bool { + // `""` -> `Some("")`, exactly as in `create_folder_full` above and for the + // same reason: on UPDATE the DB reads `None` as "leave unchanged" + // (database.rs:3925), so picking the Accent swatch on an EXISTING folder + // did nothing at all (contract D24). + let color = Some(icon_color); + with_db(true, |db| { + db.update_playlist_folder( + id, + Some(name), + Some(icon_type), + Some(icon_preset), + color, + custom_image_path, + Some(is_hidden), + ) + }) + .is_some() +} + +/// Set a folder's hidden flag, leaving every other field unchanged. +pub fn set_folder_hidden(id: &str, hidden: bool) { + with_db(true, |db| { + db.update_playlist_folder(id, None, None, None, None, None, Some(hidden)) + }); +} + +/// Delete a folder. +/// +/// The shared `playlist_folders` FK is `ON DELETE SET NULL`, but the app's +/// connections keep the `foreign_keys` pragma OFF, so the LOCAL members' +/// `folder_id` has to be nulled explicitly — the Qobuz side is handled inside +/// `delete_playlist_folder`. Dropping that second call leaves local playlists +/// pointing at a folder that no longer exists. +/// +/// Returns whether the FOLDER ROW itself was deleted (D23's failure toast). +/// The `clear_folder` sweep is best-effort: a folder row that is gone with a +/// stale `folder_id` left behind is already handled downstream — every reader +/// filters `folder_id` through the live folder id set (§5.18) — whereas a +/// folder row that survives is the failure the user has to be told about. +pub fn delete_folder(id: &str) -> bool { + let deleted = with_db(true, |db| db.delete_playlist_folder(id)).is_some(); + with_db(true, |db| { + Ok(db.with_connection(|conn| qbz_library::local_playlists::clear_folder(conn, id))) + }); + deleted +} + +/// Move a playlist into `folder_id`, or to root when `None`. +pub fn move_playlist(playlist_id: u64, folder_id: Option<&str>) { + with_db(true, |db| db.move_playlist_to_folder(playlist_id, folder_id)); +} + +/// Set a playlist's favorite flag. +pub fn set_favorite(playlist_id: u64, favorite: bool) { + with_db(true, |db| db.set_playlist_favorite(playlist_id, favorite)); +} + +/// Set a playlist's hidden flag. +pub fn set_hidden(playlist_id: u64, hidden: bool) { + with_db(true, |db| db.set_playlist_hidden(playlist_id, hidden)); +} + +/// Persist a custom playlist order (the custom-sort positions). +pub fn reorder_playlists(playlist_ids: &[u64]) { + with_db(true, |db| db.reorder_playlists(playlist_ids)); +} diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index 3139c7dfd..946d7c918 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -1002,7 +1002,7 @@ fn to_row(track: &Track) -> TrackRow { /// mix is a FLAT list, so no container context is stamped — the now-playing /// "playing from" falls back to each track's own album, 1:1 with the Slint /// `play_tracks_ctx(.., None)` radio/mix path. -fn to_queue_track(track: &Track) -> QueueTrack { +pub(crate) fn to_queue_track(track: &Track) -> QueueTrack { let (album_id, album_title, album_artwork) = match track.album.as_ref() { Some(album) => ( album.id.clone(), diff --git a/crates/qbz-qt/src/icon_tint_qt.rs b/crates/qbz-qt/src/icon_tint_qt.rs index a466ce248..1a3ace129 100644 --- a/crates/qbz-qt/src/icon_tint_qt.rs +++ b/crates/qbz-qt/src/icon_tint_qt.rs @@ -144,10 +144,11 @@ pub struct QbzIconTintRust { /// Every icon the app can ask for, as the `primary/` bake's source text. /// -/// This IS the shipped `qml/assets/icons/primary/` directory (99 files), +/// This IS the shipped `qml/assets/icons/primary/` directory (111 files), /// compiled in. Two consequences worth knowing: /// - every runtime tint is COMPLETE, so the partial-directory trap the qrc -/// bakes have (`favorite/` = 56 files, `amber/` = 4) does not exist here; +/// bakes have (`favorite/` = 57 files, `amber/` = 6, plus the two +/// single-glyph brand dirs `green/` and `orange/`) does not exist here; /// - a new icon dropped into `primary/` needs a line ADDED here, otherwise /// that one glyph silently drops back to its qrc bake (visible, just not /// theme-correct). `QbzIcon.qml` handles the miss; it does not go blank. @@ -155,6 +156,7 @@ pub struct QbzIconTintRust { const MASTERS: &[(&str, &str)] = &[ ("add-to-list", include_str!("../qml/assets/icons/primary/add-to-list.svg")), ("arrow-up-down", include_str!("../qml/assets/icons/primary/arrow-up-down.svg")), + ("audio-lines", include_str!("../qml/assets/icons/primary/audio-lines.svg")), ("award", include_str!("../qml/assets/icons/primary/award.svg")), ("blind-eye", include_str!("../qml/assets/icons/primary/blind-eye.svg")), ("book-open", include_str!("../qml/assets/icons/primary/book-open.svg")), @@ -162,6 +164,7 @@ const MASTERS: &[(&str, &str)] = &[ ("cassette-tape", include_str!("../qml/assets/icons/primary/cassette-tape.svg")), ("cast", include_str!("../qml/assets/icons/primary/cast.svg")), ("cd", include_str!("../qml/assets/icons/primary/cd.svg")), + ("chart-no-axes-column", include_str!("../qml/assets/icons/primary/chart-no-axes-column.svg")), ("check", include_str!("../qml/assets/icons/primary/check.svg")), ("chevron-down", include_str!("../qml/assets/icons/primary/chevron-down.svg")), ("chevron-left", include_str!("../qml/assets/icons/primary/chevron-left.svg")), @@ -169,6 +172,7 @@ const MASTERS: &[(&str, &str)] = &[ ("chevron-up", include_str!("../qml/assets/icons/primary/chevron-up.svg")), ("circle-alert", include_str!("../qml/assets/icons/primary/circle-alert.svg")), ("circle-check-big", include_str!("../qml/assets/icons/primary/circle-check-big.svg")), + ("circle-stop", include_str!("../qml/assets/icons/primary/circle-stop.svg")), ("clock", include_str!("../qml/assets/icons/primary/clock.svg")), ("cloud-download", include_str!("../qml/assets/icons/primary/cloud-download.svg")), ("cloud-off", include_str!("../qml/assets/icons/primary/cloud-off.svg")), @@ -184,16 +188,19 @@ const MASTERS: &[(&str, &str)] = &[ ("folder-open", include_str!("../qml/assets/icons/primary/folder-open.svg")), ("folder-plus", include_str!("../qml/assets/icons/primary/folder-plus.svg")), ("folder", include_str!("../qml/assets/icons/primary/folder.svg")), + ("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")), ("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")), ("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")), ("heart", include_str!("../qml/assets/icons/primary/heart.svg")), ("home-gear", include_str!("../qml/assets/icons/primary/home-gear.svg")), ("image", include_str!("../qml/assets/icons/primary/image.svg")), + ("image-plus", include_str!("../qml/assets/icons/primary/image-plus.svg")), ("import", include_str!("../qml/assets/icons/primary/import.svg")), ("infinity", include_str!("../qml/assets/icons/primary/infinity.svg")), ("info", include_str!("../qml/assets/icons/primary/info.svg")), ("layers", include_str!("../qml/assets/icons/primary/layers.svg")), ("layout-grid", include_str!("../qml/assets/icons/primary/layout-grid.svg")), + ("library", include_str!("../qml/assets/icons/primary/library.svg")), ("library-big", include_str!("../qml/assets/icons/primary/library-big.svg")), ("link", include_str!("../qml/assets/icons/primary/link.svg")), ("list-end", include_str!("../qml/assets/icons/primary/list-end.svg")), @@ -217,6 +224,7 @@ const MASTERS: &[(&str, &str)] = &[ ("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")), ("music-library-2", include_str!("../qml/assets/icons/primary/music-library-2.svg")), ("music", include_str!("../qml/assets/icons/primary/music.svg")), + ("network", include_str!("../qml/assets/icons/primary/network.svg")), ("panel-left", include_str!("../qml/assets/icons/primary/panel-left.svg")), ("panel-right-close", include_str!("../qml/assets/icons/primary/panel-right-close.svg")), ("pause", include_str!("../qml/assets/icons/primary/pause.svg")), @@ -241,9 +249,19 @@ const MASTERS: &[(&str, &str)] = &[ ("skip-forward", include_str!("../qml/assets/icons/primary/skip-forward.svg")), ("sliders-horizontal", include_str!("../qml/assets/icons/primary/sliders-horizontal.svg")), ("square-check-big", include_str!("../qml/assets/icons/primary/square-check-big.svg")), + ("star", include_str!("../qml/assets/icons/primary/star.svg")), ("sun-moon", include_str!("../qml/assets/icons/primary/sun-moon.svg")), ("sun", include_str!("../qml/assets/icons/primary/sun.svg")), ("thumbs-down", include_str!("../qml/assets/icons/primary/thumbs-down.svg")), + // The Tidal wordmark. The ONE brand mark in this table on purpose: it is + // monochrome (a black `fill` in the upstream asset), so the "never tint a + // logo, it becomes an accent silhouette" rule that keeps the Qobuz / Plex / + // Discogs marks in qml/assets/brand/ does not apply — there is no colour to + // lose. The reference tints it too (PlaylistImportModal.slint:271-279: + // "Tidal's SVG is black — tint white so it reads on dark surfaces"); going + // through the tint table instead of a fixed white rendition is what makes it + // legible on the 11 light themes as well. + ("tidal-tidal", include_str!("../qml/assets/icons/primary/tidal-tidal.svg")), ("translate", include_str!("../qml/assets/icons/primary/translate.svg")), ("trash-2", include_str!("../qml/assets/icons/primary/trash-2.svg")), ("trash-list", include_str!("../qml/assets/icons/primary/trash-list.svg")), @@ -253,18 +271,45 @@ const MASTERS: &[(&str, &str)] = &[ ("user", include_str!("../qml/assets/icons/primary/user.svg")), ("volume-2", include_str!("../qml/assets/icons/primary/volume-2.svg")), ("volume-x", include_str!("../qml/assets/icons/primary/volume-x.svg")), + ("wifi", include_str!("../qml/assets/icons/primary/wifi.svg")), ("x", include_str!("../qml/assets/icons/primary/x.svg")), ]; -/// The only paint literal in a `primary/` master (verified over all 99 files: -/// 109 occurrences, no `#fff` short form). The uppercase form is accepted too +/// The only paint literal in a `primary/` master (verified over all 111 files: +/// 121 occurrences, no `#fff` short form). The uppercase form is accepted too /// so a hand-added master cannot silently skip the substitution. const MASTER_HEX_LOWER: &str = "#ffffff"; const MASTER_HEX_UPPER: &str = "#FFFFFF"; -/// Written last into a tint directory; its presence means "complete". +/// Written last into a tint directory; its presence means "complete" AND its +/// CONTENT names the icon set that was written. +/// +/// It used to hold the hex, which made "complete" mean "complete for whatever +/// the icon list was when this directory was baked". Adding a glyph to +/// `MASTERS` then never reached an already-baked cache — the directory still +/// stamped complete, so the new icon resolved to a `file://` URL that does not +/// exist and `QbzIcon` logged `Cannot open: .../.svg` on EVERY repaint, +/// for every user who had ever run an older build. Found by adding +/// `circle-stop` for the queue's stop-after marker (2026-07-31); the fix is +/// generic, so the next added glyph cannot repeat it. const STAMP: &str = ".complete"; +/// Fingerprint of the icon SET, stored in `STAMP`. A directory whose stamp +/// does not match the running binary's set is stale and is re-baked. +/// +/// The names, not the bodies: an EDITED glyph keeps its name, and re-baking +/// every cache dir on an artwork tweak is not worth the I/O — a changed +/// drawing still renders, a missing file does not. +fn master_set_fingerprint() -> String { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + MASTERS.len().hash(&mut h); + for (name, _) in MASTERS { + name.hash(&mut h); + } + format!("{:016x}", h.finish()) +} + /// How many tinted sets to keep on disk (~390 KB each). 36 themes x 10 tokens /// is the theoretical ceiling, but the palettes share most of their text ramp, /// so this covers ordinary theme-hopping without unbounded growth. @@ -316,6 +361,12 @@ fn token_for(tint: &str) -> Option<&'static str> { // every theme (scrims, gradients, accent fills) // "black": a dark glyph on a host painted light // "amber": #e0b341, the audio stamp's brand accent + // "green" / "orange": #22c55e / #f59e0b, PmLocalBadge's two + // availability states. Literal brand colours, + // not theme tokens — falling through to `None` + // here is what makes QbzIcon serve them from + // the one-glyph qrc dirs green/ and orange/. + // Do NOT "complete" them with a token. _ => return None, }) } @@ -400,9 +451,17 @@ fn tints_from_json(json: &str) -> HashMap<&'static str, String> { /// looks complete. fn bake(root: &Path, hex: &str) -> bool { let dir = root.join(hex); - if dir.join(STAMP).is_file() { + let fingerprint = master_set_fingerprint(); + if stamp_matches(&dir, &fingerprint) { return true; } + // Stale (or hex-stamped by an older build): drop it so the rename below + // lands. Leaving it would make every future run take the rename-lost + // branch and keep serving the incomplete set. + if dir.exists() { + log::info!("[qbz-qt] icon tint {hex}: icon set changed, re-baking"); + let _ = fs::remove_dir_all(&dir); + } let tmp = root.join(format!(".tmp-{hex}-{}", std::process::id())); let _ = fs::remove_dir_all(&tmp); if let Err(e) = fs::create_dir_all(&tmp) { @@ -423,7 +482,7 @@ fn bake(root: &Path, hex: &str) -> bool { return false; } } - if fs::write(tmp.join(STAMP), hex).is_err() { + if fs::write(tmp.join(STAMP), &fingerprint).is_err() { let _ = fs::remove_dir_all(&tmp); return false; } @@ -433,13 +492,22 @@ fn bake(root: &Path, hex: &str) -> bool { true } Err(_) => { - // Another run won the race; keep whatever is already there. + // Another run won the race; keep whatever is already there — but + // only if it is the CURRENT set, or we would report success for a + // directory missing the very glyph that triggered this bake. let _ = fs::remove_dir_all(&tmp); - dir.join(STAMP).is_file() + stamp_matches(&dir, &fingerprint) } } } +/// True when `dir` carries a stamp for the running binary's icon set. +fn stamp_matches(dir: &Path, fingerprint: &str) -> bool { + fs::read_to_string(dir.join(STAMP)) + .map(|s| s.trim() == fingerprint) + .unwrap_or(false) +} + /// Keep the cache bounded: drop the oldest complete sets past `MAX_TINT_DIRS`. /// The set just written has the newest mtime, so it is never the victim. /// diff --git a/crates/qbz-qt/src/library_bridge.rs b/crates/qbz-qt/src/library_bridge.rs index 3247f825a..67cde4c4f 100644 --- a/crates/qbz-qt/src/library_bridge.rs +++ b/crates/qbz-qt/src/library_bridge.rs @@ -38,6 +38,14 @@ pub mod qbz_library_bridge { #[qproperty(QString, library_json)] // {tracks, albums, artists, playlists, labels, all} — tab badges. #[qproperty(QString, library_counts_json)] + // Persisted toolbar choices (library_prefs.rs — the SAME + // favorites_ui.json the shipping Slint build writes). Seeded in + // `boot`, written back one key at a time through `set_library_pref`. + #[qproperty(QString, library_prefs_json)] + // Artists SIDEPANEL: the selected artist's release sections + // (library_sidepanel.rs). Its own document, never folded into + // `library_json`: a selection must not re-serialize a 10k-row feed. + #[qproperty(QString, sidepanel_json)] type QbzLibrary = super::QbzLibraryRust; @@ -85,6 +93,38 @@ pub mod qbz_library_bridge { #[qsignal] fn library_favorite_changed(self: Pin<&mut QbzLibrary>, key: QString, value: bool); + /// Library header: play (or shuffle) the whole VISIBLE track set. + /// `shuffle` reorders this queue only — it never latches the player's + /// shuffle mode (PARITY-DEBT #17). + #[qinvokable] + fn library_play_all(self: Pin<&mut QbzLibrary>, visible_ids_json: QString, shuffle: bool); + + /// Persist ONE toolbar choice. `key` is a camelCase name from + /// `library_prefs::to_json` ("albumsView", "tracksGroup", …); `value` + /// is always a string ("true"/"false" for the booleans). + #[qinvokable] + fn set_library_pref(self: Pin<&mut QbzLibrary>, key: QString, value: QString); + + /// Multi-select bulk bar. `scope` = "track" | "album"; `ids_json` is + /// the JSON array the bar built from its own selection map (the QML + /// owns the selection, so "select-all" / "clear" never arrive here). + #[qinvokable] + fn library_bulk_action( + self: Pin<&mut QbzLibrary>, + scope: QString, + ids_json: QString, + action: QString, + ); + + /// Artists sidepanel: load + show one artist's releases in the right + /// pane. Result lands on `sidepanel_json`. + #[qinvokable] + fn sidepanel_select_artist(self: Pin<&mut QbzLibrary>, id: QString, name: QString); + + /// Artists sidepanel: drop the selection (leaving the mode). + #[qinvokable] + fn sidepanel_clear(self: Pin<&mut QbzLibrary>); + /// AlbumCard pin badge: toggle pin (album/artist/playlist). #[qinvokable] fn toggle_pin(self: Pin<&mut QbzLibrary>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString); @@ -104,6 +144,8 @@ pub struct QbzLibraryRust { library_error: QString, library_json: QString, library_counts_json: QString, + library_prefs_json: QString, + sidepanel_json: QString, } impl Default for QbzLibraryRust { @@ -113,6 +155,10 @@ impl Default for QbzLibraryRust { library_error: QString::default(), library_json: QString::from("[]"), library_counts_json: QString::from("{}"), + // Parseable defaults so QML's JSON.parse never throws on frame 1 + // (the real values arrive in `boot`). + library_prefs_json: QString::from("{}"), + sidepanel_json: QString::from("{}"), } } } @@ -132,10 +178,42 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLibrary>) + Send + 'static) { } impl qbz_library_bridge::QbzLibrary { - pub fn boot(self: Pin<&mut Self>) { + pub fn boot(mut self: Pin<&mut Self>) { if QT_THREAD.set(self.qt_thread()).is_err() { log::warn!("[qbz-qt] library Qt thread already registered"); } + // Seed the persisted toolbar choices so the FIRST paint of the Library + // is the view mode / sort / group the user last picked, not the + // defaults followed by a visible snap (`local_bridge::boot` does the + // same for the Local Library). Cheap: one small json read. + let prefs = crate::library_prefs::to_json(); + self.as_mut() + .set_library_prefs_json(QString::from(prefs.as_str())); + } + + pub fn set_library_pref(self: Pin<&mut Self>, key: QString, value: QString) { + crate::library_prefs::set(&key.to_string(), &value.to_string()); + } + + pub fn library_bulk_action( + self: Pin<&mut Self>, + scope: QString, + ids_json: QString, + action: QString, + ) { + crate::library_bulk::bulk_action( + scope.to_string(), + ids_json.to_string(), + action.to_string(), + ); + } + + pub fn sidepanel_select_artist(self: Pin<&mut Self>, id: QString, name: QString) { + crate::library_sidepanel::select(id.to_string(), name.to_string()); + } + + pub fn sidepanel_clear(self: Pin<&mut Self>) { + crate::library_sidepanel::clear(); } pub fn reload_library(self: Pin<&mut Self>) { @@ -158,6 +236,10 @@ impl qbz_library_bridge::QbzLibrary { crate::library_qt::play_from_visible(visible_ids_json.to_string(), clicked_id.to_string()); } + pub fn library_play_all(self: Pin<&mut Self>, visible_ids_json: QString, shuffle: bool) { + crate::library_qt::play_visible_all(visible_ids_json.to_string(), shuffle); + } + pub fn toggle_pin(self: Pin<&mut Self>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString) { crate::toggle_pin( kind.to_string(), diff --git a/crates/qbz-qt/src/library_bulk.rs b/crates/qbz-qt/src/library_bulk.rs new file mode 100644 index 000000000..35d4dadd1 --- /dev/null +++ b/crates/qbz-qt/src/library_bulk.rs @@ -0,0 +1,252 @@ +//! Library (Favorites) BULK actions — the two multi-select bars. +//! +//! Reference: `crates/qbz/src/main.rs` `FavoritesActions::on_bulk_action` +//! (:22981, the Tracks tab) and `on_albums_bulk_action` (:22669, the Albums +//! tab in LIST mode only — owner 2026-07-24). +//! +//! Shape follows `local_bulk.rs`: the SELECTION lives in QML (it is a set of +//! rows the QML already holds), so `select-all` / `clear` never reach Rust; +//! every other action arrives as a JSON id array plus a scope word. Resolution +//! goes through the merged Library feed, which is this port's `FAV_CURRENT`. +//! +//! WHAT IS NOT OFFERED, and why — the bar must never render a control that +//! no-ops (the discipline `local_bulk.rs` set when it omitted rather than +//! shipped a dead toggle): +//! +//! - **"Make available offline"** (`make-offline`, the Slint's +//! `offline_cache::cache_tracks`). This port brings up no offline cache at +//! all — `cast_qt.rs:32` and `local_playlist_qt.rs:345` both say so, and +//! there is no `OfflineCacheState`, no download sink and no cached-id store +//! to write into. The row is omitted from the Tracks bar rather than logged +//! and dropped. +//! +//! Everything else the reference offers IS wired: play-next / play-later / +//! add-to-queue, add-to-playlist (the app-wide `QbzPlaylistPicker` modal, which +//! `local_bulk.rs` could not reach when it was written), add-to-mixtape +//! (`QbzMyQbzAdd`), and remove-from-Library. + +use qbz_models::QueueTrack; + +use crate::library_qt::{self, FeedItem}; +use crate::myqbz_add_qt::AddItem; +use crate::playback_qt::{self, PlayContext}; + +/// QML entry point. `scope` = "track" | "album", `ids_json` = the JSON string +/// array the bar built from its own selection map, `action` = the bar's row id. +pub fn bulk_action(scope: String, ids_json: String, action: String) { + let ids: Vec = serde_json::from_str(&ids_json).unwrap_or_default(); + if ids.is_empty() { + log::debug!("[qbz-qt] library bulk {action}: empty {scope} selection, ignored"); + return; + } + match scope.as_str() { + "track" => tracks_action(ids, &action), + "album" => albums_action(ids, &action), + other => log::warn!("[qbz-qt] library bulk: unknown scope '{other}'"), + } +} + +// --------------------------------------------------------------------------- +// Resolution off the feed +// --------------------------------------------------------------------------- + +/// The selected ids as feed rows, IN SELECTION ORDER (the order the ids +/// arrived, which is the order the bar's own model produced them in — the +/// visible order). A row that is not in the feed is dropped, same contract as +/// `library_qt::order_by_visible`. +fn rows_for(kind: &str, ids: &[String]) -> Vec { + library_qt::with_library(|d| { + ids.iter() + .filter_map(|id| { + d.feed + .iter() + .find(|i| i.kind == kind && &i.id == id) + .cloned() + }) + .collect::>() + }) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Tracks bar +// --------------------------------------------------------------------------- + +fn tracks_action(ids: Vec, action: &str) { + match action { + "queue" | "play-next" | "play-later" => { + let queue: Vec = rows_for("track", &ids) + .iter() + .filter_map(library_qt::feed_track_to_queue) + .collect(); + if queue.is_empty() { + log::info!("[qbz-qt] library bulk {action}: nothing resolvable in the selection"); + return; + } + // The bar's vocabulary vs the queue helper's: "play-next" -> "next" + // (insert at the cursor, fed REVERSED so a multi-track insert keeps + // its order), "play-later" -> "later" (append to the manual block's + // tail), anything else appends. + let mode = match action { + "play-next" => "next", + "play-later" => "later", + _ => "queue", + } + .to_string(); + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = playback_qt::enqueue_track_list_mode(&runtime, queue, &mode).await { + log::error!("[qbz-qt] library bulk {mode} failed: {e}"); + } + }); + } + "add-to-playlist" => { + // The picker takes decimal Qobuz track ids and filters the rest + // itself; passing the SELECTION rather than the resolved rows keeps + // it identical to the Slint's `playlist_picker::open_multi(&ids)`. + let runtime = crate::app(); + crate::playlist_picker_qt::open_for_ids(&runtime, ids); + } + "add-to-mixtape" => { + let items = mixtape_items(&rows_for("track", &ids)); + if items.is_empty() { + return; + } + crate::myqbz_add_qt::open_items(items); + } + "remove-selected" => remove_favorites("track", ids), + other => log::warn!("[qbz-qt] library bulk tracks: unknown action {other}"), + } +} + +/// Feed track rows -> MyQBZ payload items (`myqbz_add_qt::track_items_from_local` +/// twin for the Qobuz side). +/// +/// `source` comes off the ROW, never a literal: the Tracks tab holds Qobuz rows +/// today, but a local/Plex row must never be stored as a Qobuz id — and +/// `source_from_str` maps anything that is not "local" back to Qobuz, so "plex" +/// has to be folded here (the same fold `LibraryView.qml`'s single-row +/// "Add to mixtape" does). +fn mixtape_items(rows: &[FeedItem]) -> Vec { + rows.iter() + .map(|item| AddItem { + item_type: "track".to_string(), + source: if item.source == "local" || item.source == "plex" { + "local".to_string() + } else { + "qobuz".to_string() + }, + source_item_id: item.id.clone(), + title: item.title.clone(), + subtitle: Some(item.artist.clone()), + artwork_url: if item.image_url.is_empty() { + None + } else { + Some(item.image_url.clone()) + }, + year: None, + track_count: None, + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Albums bar (LIST mode only) +// --------------------------------------------------------------------------- + +fn albums_action(ids: Vec, action: &str) { + match action { + "queue" | "play-next" | "play-later" => { + let mode = match action { + "play-next" => "next", + "play-later" => "later", + _ => "queue", + } + .to_string(); + let runtime = crate::app(); + crate::spawn(async move { + let mut queue: Vec = Vec::new(); + for id in &ids { + // A local / Plex group key is not a catalog id — the album + // bar only ever holds Qobuz favourites (the Albums tab + // keeps `group === "favorites"`), but the guard is what + // stops a widened filter from 404-ing later. + if library_qt::is_local_album_key(id) { + log::info!("[qbz-qt] library bulk albums: skipping local key {id}"); + continue; + } + match playback_qt::fetch_album_queue(&runtime, id).await { + Ok(tracks) => { + // Stamp EACH album's tracks with their own origin + // before concatenating: the "playing from" glyph + // must name the album the track came from, and + // `stamp_context` skips rows that already carry one, + // so the single stamp inside the enqueue helper + // cannot overwrite these. + queue.extend(playback_qt::stamped(tracks, PlayContext::album(id))); + } + Err(e) => log::error!("[qbz-qt] library bulk albums: {id}: {e}"), + } + } + if queue.is_empty() { + log::info!("[qbz-qt] library bulk albums {mode}: nothing resolvable"); + return; + } + if let Err(e) = playback_qt::enqueue_track_list_mode(&runtime, queue, &mode).await { + log::error!("[qbz-qt] library bulk albums {mode} failed: {e}"); + } + }); + } + "remove-selected" => remove_favorites("album", ids), + other => log::warn!("[qbz-qt] library bulk albums: unknown action {other}"), + } +} + +// --------------------------------------------------------------------------- +// Remove from Library +// --------------------------------------------------------------------------- + +/// Un-favourite every selected row, then reload the Library. +/// +/// Three things the reference does that are NOT optional here (main.rs:23040+): +/// - offline is read-only for hearts (spec 4.3) — refuse with an info toast +/// rather than firing writes that cannot land; +/// - an up-front toast on the TRACKS path, because a big selection takes +/// visible seconds (owner 2026-07-26). The albums path deliberately has +/// none — the reference does not toast there either, and the string is a +/// tracks-specific msgid, so inventing a generic one would add a +/// translation to all eight catalogues for a message upstream never shows; +/// - a 150 ms courtesy delay between calls, so a 200-row selection is not a +/// burst at Qobuz. +/// +/// The feed row and the id cache are both updated per id (the same pair +/// `library_qt::toggle_favorite` writes), so every OTHER surface's heart +/// settles even before the reload lands. +fn remove_favorites(kind: &str, ids: Vec) { + if crate::offline_fwd::engine().status().is_offline() { + crate::toast_qt::info(qbz_i18n::t("Not available offline")); + return; + } + let kind = kind.to_string(); + let count = ids.len(); + if kind == "track" { + let n = count.to_string(); + crate::toast_qt::info(qbz_i18n::t_args( + "Removing {} tracks from favorites…", + &[n.as_str()], + )); + } + let runtime = crate::app(); + crate::spawn(async move { + for id in &ids { + if let Err(e) = runtime.core().remove_favorite(&kind, id).await { + log::error!("[qbz-qt] library bulk remove {kind}:{id} failed: {e}"); + } + crate::fav_cache_qt::set(&kind, id, false); + crate::library_qt::set_feed_favorite(&kind, id, false); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + } + log::info!("[qbz-qt] library bulk remove: {count} {kind}(s)"); + crate::reload_library(); + }); +} diff --git a/crates/qbz-qt/src/library_db_qt.rs b/crates/qbz-qt/src/library_db_qt.rs index 784efb888..524adcbfc 100644 --- a/crates/qbz-qt/src/library_db_qt.rs +++ b/crates/qbz-qt/src/library_db_qt.rs @@ -21,8 +21,30 @@ use qbz_library::{LibraryDatabase, LibraryError}; /// `/qbz/users//library.db` — the same per-user path the /// reference uses, so the local organization data is shared between builds. +/// +/// `unwrap_or(0)` — the GUEST profile, not a bail. This used to `?` out when +/// `last_user_id` was absent, which is the state of a machine that has never +/// completed a Qobuz login: for that user `db_path()` was permanently `None`, +/// so `with_db` answered `None` to every call and the ENTIRE local-playlist +/// feature (plus folders, local favourites and the mixtape tables) simply did +/// not exist — for exactly the population local playlists are FOR. It survived +/// review because every developer machine has logged in at least once. +/// +/// `users/0/` is the designed home for that data, not an invention: +/// `AppRuntime::activate_offline` resolves its own user with the SAME +/// `load_last_user_id().unwrap_or(0)`, and `adopt_guest_profile` (#553) +/// renames `users/0/` onto the account on first login precisely so work done +/// logged-off follows the user in. The reference has no such hole because it +/// binds a session-scoped id instead (`library_db::set_user(user_id)` from +/// `init_shell_for_user`, which qbz/src/main.rs:172 documents as shared by the +/// ONLINE AND OFFLINE entries) — this is the port's substitution of +/// `load_last_user_id()` for that id, made to agree with it in the one case +/// where they diverged. +/// +/// Reads still pass `create: false`, so nothing is conjured by listing; only a +/// write brings `users/0/library.db` into existence. fn db_path() -> Option { - let uid = UserDataPaths::load_last_user_id()?; + let uid = UserDataPaths::load_last_user_id().unwrap_or(0); Some( dirs::data_dir()? .join("qbz") diff --git a/crates/qbz-qt/src/library_prefs.rs b/crates/qbz-qt/src/library_prefs.rs new file mode 100644 index 000000000..476126f83 --- /dev/null +++ b/crates/qbz-qt/src/library_prefs.rs @@ -0,0 +1,217 @@ +//! Library (Favorites) toolbar prefs — the Qt half of +//! `crates/qbz/src/favorites_prefs.rs`. +//! +//! SAME document, same keys: `/qbz/favorites_ui.json`. That is +//! deliberate — a user who moves between the shipping Slint build and this one +//! keeps their view mode, sort direction and source toggles instead of finding +//! them reset. `locallibrary_ui.json` is co-owned exactly the same way +//! (`local_state.rs`), and this file follows ITS writer, not the Slint's: +//! +//! - the Slint `save()` serializes the whole struct through `fs::write` +//! (O_TRUNC — a reader that lands mid-write sees an empty file, answers +//! `Prefs::default()` and writes those defaults back); +//! - here every write is a read-modify-write of the parsed OBJECT, published +//! through `settings_qt::write_json_object_atomic` (temp file + `rename(2)`), +//! so a concurrent reader sees the whole old document or the whole new one, +//! and any key the Slint gains later survives our writes untouched. +//! +//! `all_local_scope` is one such key: it is PARITY-DEBT #6 (the Settings row +//! and the `all` branch of the feed are not ported), so nothing here ever sets +//! it — but because the writer merges instead of replacing, a scope the user +//! picked in the Slint build is preserved rather than reset to "favorites" on +//! the first toolbar click here. +//! +//! Transport to QML is ONE json document on `QbzLibrary.libraryPrefsJson` +//! (camelCase keys, the port's QML convention), seeded at `boot()`; QML writes +//! back one key at a time through `QbzLibrary.setLibraryPref`. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// The on-disk shape. Field names are the SLINT's — do not rename them. +#[derive(Serialize, Deserialize)] +pub struct Prefs { + #[serde(default = "d_grid")] + pub albums_view: String, + #[serde(default = "d_default")] + pub albums_sort: String, + #[serde(default = "d_off")] + pub albums_group: String, + #[serde(default = "d_off")] + pub tracks_group: String, + #[serde(default = "d_grid")] + pub playlists_view: String, + #[serde(default)] + pub artists_group: bool, + #[serde(default = "d_grid")] + pub artists_view: String, + /// Library "All": include local files + Plex in the feed. Default ON. + #[serde(default = "d_true")] + pub all_show_local: bool, + /// Library "All": what "local" means — "favorites" | "all". Read and + /// PRESERVED here, never written (PARITY-DEBT #6). + #[serde(default = "d_favorites")] + pub all_local_scope: String, +} + +fn d_grid() -> String { + "grid".to_string() +} +fn d_default() -> String { + "default".to_string() +} +fn d_off() -> String { + "off".to_string() +} +fn d_true() -> bool { + true +} +fn d_favorites() -> String { + "favorites".to_string() +} + +impl Default for Prefs { + fn default() -> Self { + Self { + albums_view: d_grid(), + albums_sort: d_default(), + albums_group: d_off(), + tracks_group: d_off(), + playlists_view: d_grid(), + artists_group: false, + artists_view: d_grid(), + all_show_local: true, + all_local_scope: d_favorites(), + } + } +} + +fn store_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json")) +} + +pub fn read() -> Prefs { + let Some(path) = store_path() else { + return Prefs::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Prefs::default(), + } +} + +/// The document QML seeds its toolbar state from (camelCase, the port's QML +/// convention — the disk keys stay snake_case for the Slint). +/// +/// `allLocalScope` rides along read-only so a later round can wire the feed's +/// `all` branch without a second transport. +pub fn to_json() -> String { + let p = read(); + serde_json::json!({ + "albumsView": p.albums_view, + "albumsSort": p.albums_sort, + "albumsGroup": p.albums_group, + "tracksGroup": p.tracks_group, + "playlistsView": p.playlists_view, + "artistsGroup": if p.artists_group { "alpha" } else { "off" }, + "artistsView": p.artists_view, + "allShowLocal": p.all_show_local, + "allLocalScope": p.all_local_scope, + }) + .to_string() +} + +/// Read-modify-write ONE key. `edit` sees the prefs as they are on disk RIGHT +/// NOW (`local_state::update_prefs` shape) — the `read()` -> mutate -> +/// `write()` alternative has the torn-read hole this file exists to close. +fn update(edit: impl FnOnce(&mut Prefs)) { + let Some(path) = store_path() else { + return; + }; + let Some(mut doc) = crate::settings_qt::read_json_object(&path) else { + return; + }; + let mut prefs: Prefs = serde_json::from_value(serde_json::Value::Object(doc.clone())) + .unwrap_or_else(|e| { + log::warn!("[qbz-qt] favorites_ui.json keys unreadable ({e}) — using defaults"); + Prefs::default() + }); + edit(&mut prefs); + if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&prefs) { + for (key, value) in map { + doc.insert(key, value); + } + } + crate::settings_qt::write_json_object_atomic(&path, &doc); +} + +/// QML setter. `key` is the camelCase name from `to_json`; `value` is always a +/// string (`"true"` / `"false"` for the two booleans) so ONE invokable covers +/// the whole toolbar. +/// +/// An unknown key is logged and dropped rather than silently ignored: this is +/// the seam a typo in QML would otherwise vanish into (QML resolves lazily — +/// nothing else would ever report it). +pub fn set(key: &str, value: &str) { + let v = value.to_string(); + match key { + "albumsView" => update(|p| p.albums_view = v), + "albumsSort" => update(|p| p.albums_sort = v), + "albumsGroup" => update(|p| p.albums_group = v), + "tracksGroup" => update(|p| p.tracks_group = v), + "playlistsView" => update(|p| p.playlists_view = v), + "artistsGroup" => update(|p| p.artists_group = v == "alpha" || v == "true"), + "artistsView" => update(|p| p.artists_view = v), + "allShowLocal" => update(|p| p.all_show_local = v == "true"), + other => log::warn!("[qbz-qt] library pref: unknown key '{other}' (value '{value}')"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_slint_document() { + let p = Prefs::default(); + assert_eq!(p.albums_view, "grid"); + assert_eq!(p.albums_sort, "default"); + assert_eq!(p.albums_group, "off"); + assert_eq!(p.tracks_group, "off"); + assert_eq!(p.playlists_view, "grid"); + assert!(!p.artists_group); + assert_eq!(p.artists_view, "grid"); + assert!(p.all_show_local); + assert_eq!(p.all_local_scope, "favorites"); + } + + #[test] + fn a_partial_document_keeps_the_defaults_for_the_missing_keys() { + // The Slint writes every key; a hand-edited or older file may not. + let p: Prefs = serde_json::from_str(r#"{"albums_view":"list"}"#).expect("parses"); + assert_eq!(p.albums_view, "list"); + assert_eq!(p.albums_sort, "default"); + assert!(p.all_show_local); + } + + #[test] + fn the_disk_keys_stay_snake_case_for_the_slint() { + // The Slint's favorites_prefs.rs reads these EXACT names; renaming one + // silently splits the two frontends' stored choices. + let json = serde_json::to_value(Prefs::default()).expect("serializes"); + for key in [ + "albums_view", + "albums_sort", + "albums_group", + "tracks_group", + "playlists_view", + "artists_group", + "artists_view", + "all_show_local", + "all_local_scope", + ] { + assert!(json.get(key).is_some(), "missing disk key {key}"); + } + } +} diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index 889bcc822..0c17de1e9 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -1062,7 +1062,7 @@ pub(crate) fn is_local_album_key(id: &str) -> bool { /// `find` updated only one of them, and since `is_favorite` answers with /// `any(...)`, an un-favourite that left the second row lit kept reading /// "favourite" and the next click re-added it. -fn set_feed_favorite(kind: &str, id: &str, value: bool) { +pub(crate) fn set_feed_favorite(kind: &str, id: &str, value: bool) { with_library_mut(|d| { for item in d.feed.iter_mut().filter(|i| i.kind == kind && i.id == id) { item.is_favorite = value; @@ -1133,7 +1133,7 @@ pub(crate) fn apply_pin_change(kind: &str, id: &str, pinned: bool) { /// key as their id, not a numeric one, and must not be resolved through the /// Qobuz track resolver. The Slint cannot hit this at all — `FAV_CURRENT` is /// Qobuz-only — so dropping them is the same queue it would have built. -fn feed_track_to_queue(item: &FeedItem) -> Option { +pub(crate) fn feed_track_to_queue(item: &FeedItem) -> Option { let id = item.id.parse::().ok()?; Some(QueueTrack { id, @@ -1211,6 +1211,36 @@ fn order_by_visible( /// play_track`): the queue is built synchronously off the feed, the play is /// spawned. Blacklisted rows are dropped and the start index remapped inside /// `playback_qt::set_queue_stamped`, so nothing to do here. +/// Library header "Play all tracks" / "Shuffle all tracks" — the visible set +/// as the queue, from the top (`FavoritesActions.play_all_tracks` / +/// `shuffle_tracks`). +/// +/// `shuffle` REORDERS this queue; it does not switch the player's shuffle mode +/// (PARITY-DEBT #17 — the reference's `favorites::shuffled_tracks()` mixes the +/// Vec and plays index 0, and latching the global toggle here would change +/// everything played afterwards). +pub fn play_visible_all(visible_ids_json: String, shuffle: bool) { + let visible_ids: Vec = serde_json::from_str(&visible_ids_json).unwrap_or_default(); + let Some(first) = visible_ids.first().cloned() else { + return; + }; + let queued = with_library(|d| order_by_visible(&d.feed, &visible_ids, &first)).flatten(); + let Some((queue, _)) = queued else { + log::info!("[qbz-qt] library play-all: the visible list resolved to nothing"); + return; + }; + log::info!( + "[qbz-qt] library play-all: queueing {} visible track(s) (shuffle={shuffle})", + queue.len() + ); + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, 0, shuffle).await { + log::error!("[qbz-qt] library play-all failed: {e}"); + } + }); +} + pub fn play_from_visible(visible_ids_json: String, clicked_id: String) { let visible_ids: Vec = serde_json::from_str(&visible_ids_json).unwrap_or_default(); let queued = with_library(|d| order_by_visible(&d.feed, &visible_ids, &clicked_id)).flatten(); diff --git a/crates/qbz-qt/src/library_sidepanel.rs b/crates/qbz-qt/src/library_sidepanel.rs new file mode 100644 index 000000000..cabf14f14 --- /dev/null +++ b/crates/qbz-qt/src/library_sidepanel.rs @@ -0,0 +1,125 @@ +//! Library > Artists **sidepanel** — the two-column mode of the Artists tab +//! (`FavoritesView.slint:2014-2185`): the A-Z grouped favourite artists on the +//! left, the SELECTED artist's releases on the right. +//! +//! Reference: `main.rs::FavoritesActions::on_select_artist` (:22298) + +//! `favorites::apply_selected_artist` (`favorites.rs:846`). Both reuse the +//! standalone artist page's `/artist/page` `release_type` classifier rather +//! than re-deriving sections, "per the user's decision, so the sections are +//! server-authoritative (Discography / EPs & Singles / Live / Compilations / +//! Others)". This does the same: `artist_qt::load_artist` is the port of that +//! very loader, so the sidepanel and the full artist page can never disagree +//! about what an artist released. +//! +//! The LEFT column is pure QML — it groups the artist rows the Library feed +//! already carries, exactly like the grid's own A-Z grouping. +//! +//! Artwork: the published rows carry `artUrl` only, and QML resolves covers +//! through the SAME url-keyed pair the artist page uses +//! (`QbzShell.sidebarArtworkWindow` -> `QbzLibrary.libraryArtworkReady`). No +//! second artwork mechanism. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use cxx_qt_lib::QString; +use serde::Serialize; + +use crate::album_qt::AlbumCardData; + +#[derive(Clone, Serialize)] +pub struct SidepanelSection { + pub title: String, + pub albums: Vec, +} + +#[derive(Clone, Default, Serialize)] +pub struct SidepanelDoc { + #[serde(rename = "artistId")] + pub artist_id: String, + #[serde(rename = "artistName")] + pub artist_name: String, + pub loading: bool, + pub error: String, + /// Total release cards across the sections — the reference's + /// `selected_albums_total`, which is what tells the right pane to draw + /// "No albums found." instead of an empty scroller. + pub total: usize, + pub sections: Vec, +} + +/// Monotonic id for the selection currently on screen. A load carries the +/// generation it was started for and is dropped when the user has clicked on — +/// the same guard `artist_qt`'s progressive passes use, and the reason a slow +/// reply cannot repaint a later artist. +static GEN: AtomicU64 = AtomicU64::new(0); + +fn publish(doc: SidepanelDoc) { + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + crate::library_bridge::ui(move |mut b| { + b.as_mut().set_sidepanel_json(QString::from(json.as_str())); + }); +} + +/// Drop the selection (leaving the sidepanel, logout, user switch). +pub fn clear() { + GEN.fetch_add(1, Ordering::SeqCst); + publish(SidepanelDoc::default()); +} + +/// Select an artist in the left rail: paint the loading state immediately, +/// then fetch the release sections off the UI thread. +pub fn select(artist_id: String, artist_name: String) { + let generation = GEN.fetch_add(1, Ordering::SeqCst) + 1; + publish(SidepanelDoc { + artist_id: artist_id.clone(), + artist_name: artist_name.clone(), + loading: true, + ..Default::default() + }); + let runtime = crate::app(); + crate::spawn(async move { + let result = crate::artist_qt::load_artist(&runtime, &artist_id).await; + // The user clicked another artist while this was in flight. + if GEN.load(Ordering::SeqCst) != generation { + log::debug!("[qbz-qt] sidepanel artist {artist_id}: superseded, dropping"); + return; + } + match result { + Ok(data) => { + let sections: Vec = data + .release_sections + .into_iter() + .filter(|s| !s.cards.is_empty()) + .map(|s| SidepanelSection { + title: s.title, + albums: s.cards, + }) + .collect(); + let total: usize = sections.iter().map(|s| s.albums.len()).sum(); + log::info!( + "[qbz-qt] sidepanel artist {artist_id}: {total} release(s) in {} section(s)", + sections.len() + ); + publish(SidepanelDoc { + artist_id, + artist_name, + loading: false, + error: String::new(), + total, + sections, + }); + } + Err(e) => { + log::error!("[qbz-qt] sidepanel artist {artist_id} load failed: {e}"); + publish(SidepanelDoc { + artist_id, + artist_name, + loading: false, + error: e, + total: 0, + sections: Vec::new(), + }); + } + } + }); +} diff --git a/crates/qbz-qt/src/local_album_actions.rs b/crates/qbz-qt/src/local_album_actions.rs index c0b17c7f1..03954ce19 100644 --- a/crates/qbz-qt/src/local_album_actions.rs +++ b/crates/qbz-qt/src/local_album_actions.rs @@ -18,11 +18,11 @@ //! that. The split is cached in `LocalState` so the picker switches with NO DB //! round-trip (the Slint's `ALBUM_VERSIONS` static, 1:1). //! -//! NOT WIRED (deliberate, reported): `album_edit_tags` and -//! `album_add_to_playlist` are LOGGED SEAMS. The Qt port has no tag-editor -//! modal and no playlist picker, and a menu item must never write tags to disk -//! with no UI in front of it. `album_add_to_mixtape` is LIVE since the MyQBZ -//! domain landed — it opens the Mixtape/Collection picker. +//! NOT WIRED (deliberate, reported): `album_edit_tags` is a LOGGED SEAM — the +//! Qt port has no tag-editor modal, and a menu item must never write tags to +//! disk with no UI in front of it. `album_add_to_mixtape` is LIVE since the +//! MyQBZ domain landed, and `album_add_to_playlist` since QbzPlaylistPicker +//! did: it opens the picker in LOCAL MODE over the selected version's tracks. use std::collections::HashMap; use std::sync::Arc; @@ -372,15 +372,24 @@ pub fn edit_tags(id: String) { ); } -/// Album header +: the Slint opens the playlist picker over the version's -/// tracks. The Qt port has no picker modal and `playlist_qt::add_tracks` only -/// speaks Qobuz track ids, so this is a logged seam. +/// Album header +: the picker over the SELECTED version's tracks, in LOCAL +/// MODE (the Slint's `playlist_picker::open_for_ids(.., local = true)`). +/// +/// The refs are built by `local_picker_ref_for_track`, never by hand: a Plex +/// row rides `plex:` and everything else its library row id, and +/// the `Payload::LocalRefs` variant is what keeps either of them from reaching +/// a Qobuz endpoint, where the same number means a different track. pub fn add_to_playlist(id: String) { - let n = current_version_tracks().len(); - log::info!( - "[qbz-qt] local album add-to-playlist: no playlist picker in the Qt port yet \ - (album '{id}', {n} tracks) — seam only" - ); + let tracks = current_version_tracks(); + if tracks.is_empty() { + log::warn!("[qbz-qt] local album add-to-playlist: no version open for album '{id}'"); + return; + } + let refs: Vec = tracks + .iter() + .map(crate::local_playlist_qt::local_picker_ref_for_track) + .collect(); + crate::playlist_picker_qt::open_for_local_refs(&crate::app(), refs); } /// Album header 📼: ONE `album` payload (source "local", no artwork_url, no diff --git a/crates/qbz-qt/src/local_albums.rs b/crates/qbz-qt/src/local_albums.rs index 70e556592..7e94f12ce 100644 --- a/crates/qbz-qt/src/local_albums.rs +++ b/crates/qbz-qt/src/local_albums.rs @@ -21,6 +21,9 @@ use qbz_library::album_grouping::AlbumGroupMode; use qbz_library::LocalTrack; use crate::local_album_actions::AlbumDetailDoc; +use crate::local_artist_match::{ + album_matches_artist, merge_artists, normalize_artist, AlbumCredit, ArtistInput, +}; use crate::local_rows::{ artist_key, map_album, map_track, AlbumRow, ArtistRow, LocalCounts, TrackRow, }; @@ -84,68 +87,127 @@ pub fn load_folders_blocking() -> Result, String> { Ok(rows) } -/// Artists tab: the on-disk artists, with the aggregated Plex artists folded -/// in by case-insensitive name (a local + Plex artist of the same name counts -/// ONCE, with summed album/track counts — the Slint's `merge_artists` rule). +/// Artists tab: the on-disk artists merged by NORMALIZED name, with the +/// aggregated Plex artists folded into the same buckets. +/// +/// PARITY-DEBT #7 — this used to key the local<->Plex merge on a raw +/// `trim().to_lowercase()`, so "Beyoncé" and "Beyonce" (or "Sigur Rós" and +/// "Sigur Ros") were two rows with the user's albums and tracks split between +/// them. The key is `local_artist_match::normalize_artist` now (lowercase + +/// diacritic fold + punctuation collapse), which is also what lets the whole +/// reference merge come across: canonical spelling, summed track counts, +/// album counts recomputed from the album set (so a cross-credited album +/// counts for every contributor) and the custom -> Plex thumb -> album cover +/// portrait chain. Reference: `local_library.rs:3261-3358 merge_artists` and +/// its caller at :3540-3607. pub fn load_artists_blocking() -> Result, String> { - let artists = with_db(|db| { - db.get_artists_with_filter( + let plex_on = crate::local_plex::is_enabled(); + let mode = group_mode(); + let plex_path = crate::local_plex::cache_db_path(); + + // ONE db open for the three reads the merge needs. The album set is the + // SAME query the Albums tab runs (Plex-aware union when the toggle is on), + // so an artist's album count matches the grid the user sees. + let (artists, albums, custom) = with_db(|db| { + let artists = db.get_artists_with_filter( /* include_qobuz_downloads */ true, /* exclude_network_folders */ false, - ) + )?; + let albums = if plex_on { + db.get_albums_metadata_page( + 0, + 100_000, + None, + "artist", + "asc", + /* include_qobuz_downloads */ true, + /* exclude_network_folders */ false, + plex_path.as_deref(), + mode, + )? + .albums + } else { + db.get_albums_with_full_filter( + /* include_hidden */ false, + /* include_qobuz_downloads */ true, + /* exclude_network_folders */ false, + )? + }; + // Custom AND previously-cached (Qobuz-fetched) portraits. A missing + // `artist_images` table must not fail the whole tab. + let custom = db.get_all_artist_image_urls().unwrap_or_default(); + Ok((artists, albums, custom)) }) .ok_or_else(|| "local library not available".to_string())?; - let mut rows: Vec = artists + let mut inputs: Vec = artists .into_iter() - .map(|a| ArtistRow { - art_key: artist_key(&a.name), + .map(|a| ArtistInput { name: a.name, album_count: a.album_count, track_count: a.track_count, - source: "local".into(), + source: "local", }) .collect(); - if crate::local_plex::is_enabled() { - let mut index: HashMap = rows - .iter() - .enumerate() - .map(|(i, r)| (r.name.trim().to_lowercase(), i)) - .collect(); - let plex_artists = crate::local_plex::cached_artists(); - with_art(|art| { - for pa in plex_artists { - let key = pa.name.trim().to_lowercase(); - if key.is_empty() { - continue; - } - let art_key = artist_key(&pa.name); - if let Some(thumb) = pa.artwork_path.as_ref().filter(|p| !p.is_empty()) { - art.entry(art_key.clone()).or_insert_with(|| thumb.clone()); - } - match index.get(&key) { - Some(&i) => { - rows[i].album_count += pa.album_count; - rows[i].track_count += pa.track_count; - rows[i].source = "mixed".into(); - } - None => { - index.insert(key, rows.len()); - rows.push(ArtistRow { - art_key, - name: pa.name, - album_count: pa.album_count, - track_count: pa.track_count, - source: "plex".into(), - }); - } - } + // Plex artists join the SAME merge input (not a second pass keyed by a + // different rule), so a local and a Plex "Radiohead" collapse to one row. + let mut plex_portraits: HashMap = HashMap::new(); + if plex_on { + for pa in crate::local_plex::cached_artists() { + let n = normalize_artist(&pa.name); + if n.is_empty() { + continue; } - }); - rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + if let Some(path) = pa.artwork_path.clone().filter(|p| !p.is_empty()) { + plex_portraits.entry(n).or_insert(path); + } + inputs.push(ArtistInput { + name: pa.name, + album_count: pa.album_count, + track_count: pa.track_count, + source: "plex", + }); + } } + let credits: Vec> = albums + .iter() + .map(|a| AlbumCredit { + id: a.id.as_str(), + artist: a.artist.as_str(), + all_artists: a.all_artists.as_str(), + artwork_path: a.artwork_path.as_deref().unwrap_or(""), + }) + .collect(); + + let merged = merge_artists(inputs, &credits, &custom, &plex_portraits, plex_on); + + // The artwork index is keyed on the DISPLAY name (`artist:{name}`), so the + // portrait is registered under the CANONICAL spelling the row carries — + // registering it under the Plex spelling is what left a merged row with a + // key nobody had a source for. Overwrite rather than `or_insert`: this + // pass has just recomputed the chain, and a stale entry from a previous + // album-identity mode must not win. + let rows: Vec = with_art(|art| { + merged + .into_iter() + .map(|m| { + let key = artist_key(&m.name); + if !m.image_path.is_empty() { + art.insert(key.clone(), m.image_path); + } + ArtistRow { + art_key: key, + name: m.name, + album_count: m.album_count, + track_count: m.track_count, + source: m.source, + } + }) + .collect() + }); + state(|s| { s.counts.artists = rows.len() as i64; s.artists = rows.clone(); @@ -153,6 +215,32 @@ pub fn load_artists_blocking() -> Result, String> { Ok(rows) } +/// The ids of the CACHED album rows that credit `artist`, as a JSON array — +/// the Artists tab's right pane (PARITY-DEBT #8). +/// +/// The match lives here, not in QML, because it is the same normalized-part +/// rule the merge above uses and there must be exactly one of it. The QML did +/// a lowercase equality on `artist` OR a SUBSTRING `indexOf` on `allArtists`, +/// which listed "Airbourne" and "Blair" under "Air" and hid an album credited +/// "A & B" from "B". Reference: `local_library.rs:3368-3390`. +/// +/// Reads `state.albums` — the very document the QML renders — so the ids it +/// returns always resolve to rows the view already has. +pub fn artist_album_ids(artist: &str) -> String { + let nsel = normalize_artist(artist); + if nsel.is_empty() { + return "[]".to_string(); + } + let ids: Vec = state(|s| { + s.albums + .iter() + .filter(|a| album_matches_artist(&a.artist, &a.all_artists, &nsel)) + .map(|a| a.id.clone()) + .collect() + }); + serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()) +} + /// Tracks tab, ONE page. `reset` clears the accumulator (a new search or /// sort); otherwise the page is appended (load-more on scroll). pub fn load_tracks_page_blocking(reset: bool) -> Result<(Vec, bool), String> { diff --git a/crates/qbz-qt/src/local_artist_match.rs b/crates/qbz-qt/src/local_artist_match.rs new file mode 100644 index 000000000..dbe29a92d --- /dev/null +++ b/crates/qbz-qt/src/local_artist_match.rs @@ -0,0 +1,585 @@ +//! Artist IDENTITY for the Local Library: name normalization, credit +//! splitting, "does this album credit that artist", and the spelling merge +//! behind the Artists tab. +//! +//! Ported 1:1 from the shipping Slint controller +//! (`crates/qbz/src/local_library.rs`): `fold_diacritic` (:3170), +//! `normalize_artist` (:3188), `split_credit` (:3206), +//! `build_artist_album_ids` (:3218), `merge_artists` (:3261) and +//! `album_matches_artist` (:3368). Everything here is a PURE function — no +//! Qt, no DB, no globals — which is why the whole module is unit-tested at +//! the bottom instead of only being exercised through the UI. +//! +//! # Why this is not `qbz_mixtape::shuffle::normalize_artist` +//! +//! That one exists to answer a DIFFERENT question (are these two queue rows +//! the same song by the same act) and deliberately PRESERVES punctuation and +//! parentheses — its doc comment says so: "`Foo (band)` must not collapse to +//! `Foo`". Artist identity here needs the opposite: the Slint rule collapses +//! every run of non-alphanumerics to a single space so `Sigur Rós`, +//! `Sigur Ros` and `sigur ros` are one artist, and so `Beyoncé` merges with +//! `Beyonce`. The two diacritic tables differ as well (mixtape's strips a +//! wider Unicode range, this one is the hand-written Latin table the Slint +//! ships). Reusing it would silently change which artists merge, so this is a +//! second function on purpose, not an accidental third copy. +//! +//! `local_rows::artist_key` is NOT a normalizer either — it is the artwork +//! cache key (`artist:{name}`) and must stay keyed on the DISPLAY name, since +//! that is what the rows carry. + +use std::collections::{HashMap, HashSet}; + +// --------------------------------------------------------------------------- +// Normalization +// --------------------------------------------------------------------------- + +/// Fold a common Latin accented char to its ASCII base (best-effort, no +/// `unicode-normalization` dep). Covers Spanish/European music metadata; the +/// uncovered tail just won't merge across diacritics. +/// (`local_library.rs:3170`, table copied verbatim.) +fn fold_diacritic(c: char) -> char { + match c { + 'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' => 'a', + 'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ė' => 'e', + 'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i', + 'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ō' | 'ø' => 'o', + 'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u', + 'ñ' => 'n', + 'ç' => 'c', + 'ý' | 'ÿ' => 'y', + 'ß' => 's', + _ => c, + } +} + +/// Normalize an artist name for merge/match: lowercase, fold diacritics, +/// collapse every run of non-alphanumerics to a single space, trim. So +/// "Alice In Chains" and "alice in chains" both -> "alice in chains". +pub fn normalize_artist(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut prev_space = false; + for ch in name.to_lowercase().chars() { + let c = fold_diacritic(ch); + if c.is_ascii_alphanumeric() { + out.push(c); + prev_space = false; + } else if !prev_space { + out.push(' '); + prev_space = true; + } + } + out.trim().to_string() +} + +/// Split a credit string into individual artist names on the usual +/// separators (comma already handled by the caller for `all_artists`). +pub fn split_credit(s: &str) -> Vec { + s.split([',', '&', '/', ';']) + .flat_map(|p| { + p.split(" feat ") + .flat_map(|q| q.split(" ft ")) + .flat_map(|q| q.split(" featuring ")) + .flat_map(|q| q.split(" with ")) + .map(|q| q.to_string()) + .collect::>() + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Album <-> artist matching +// --------------------------------------------------------------------------- + +/// The album facts the two predicates below need. A borrowed view rather than +/// `qbz_library::LocalAlbum` so the caller can feed EITHER the raw query rows +/// (the artists loader) or the cached transport rows the Artists tab renders +/// (`local_rows::AlbumRow`) without a conversion pass. +pub struct AlbumCredit<'a> { + pub id: &'a str, + pub artist: &'a str, + /// Comma-separated contributor list the DB aggregates per album ("" when + /// the query did not populate it). + pub all_artists: &'a str, + /// On-disk cover / Plex thumb path ("" when the album has none). + pub artwork_path: &'a str, +} + +/// Does this album credit the artist whose normalized name is `nsel` — as +/// primary, inside `all_artists`, or as one part of a multi-artist credit? +/// +/// EXACT comparison of NORMALIZED PARTS, never a substring test: "Air" must +/// not match "Airbourne" or "Blair", and an album credited "A & B" must +/// appear under "B". +pub fn album_matches_artist(artist: &str, all_artists: &str, nsel: &str) -> bool { + if nsel == "various artists" { + return normalize_artist(artist) == "various artists"; + } + if normalize_artist(artist) == nsel { + return true; + } + for part in all_artists.split(',') { + if normalize_artist(part) == nsel { + return true; + } + } + for part in split_credit(artist) { + if normalize_artist(&part) == nsel { + return true; + } + } + false +} + +/// Build the per-normalized-artist set of album ids, so merged rows get an +/// accurate unique album count independent of per-track spelling. +pub fn build_artist_album_ids(albums: &[AlbumCredit<'_>]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for al in albums { + if !al.all_artists.is_empty() { + for part in al.all_artists.split(',') { + let n = normalize_artist(part); + if n.is_empty() || n == "various artists" { + continue; + } + map.entry(n).or_default().insert(al.id.to_string()); + } + } else { + let n = normalize_artist(al.artist); + if !n.is_empty() && n != "various artists" { + map.entry(n).or_default().insert(al.id.to_string()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// The spelling merge +// --------------------------------------------------------------------------- + +/// One artist as it goes IN to the merge (a `qbz_library::LocalArtist` row, or +/// an aggregated Plex artist). +pub struct ArtistInput { + pub name: String, + pub album_count: u32, + pub track_count: u32, + /// "local" | "plex" — the provenance hint the rail badge reads back. + pub source: &'static str, +} + +/// One artist as it comes OUT: a single canonical row per normalized name. +pub struct MergedArtist { + pub name: String, + pub album_count: u32, + pub track_count: u32, + /// Portrait SOURCE (custom/cached url or path, Plex thumb, album cover); + /// "" when nothing is known and the row falls back to the placeholder. + pub image_path: String, + /// "local" | "plex" | "mixed". + pub source: String, +} + +/// Collapse normalized-equal artist spellings into ONE canonical row and +/// attach accurate album counts + a portrait path. +/// +/// Canonical = the variant with the most albums (tie: most tracks); merged +/// track count = the sum across variants; album count = the size of the +/// album-id set for that normalized name (which cross-lists `all_artists`), +/// falling back to the DB's own per-variant count when the album set is empty +/// (the artists tab loaded before any album did). +/// +/// Portrait chain: custom/cached image -> representative Plex thumb -> +/// representative album cover. The last arm is GATED on +/// `album_thumb_fallback` (set only when Plex is ON), 1:1 with the reference: +/// with Plex off a local artist with no custom portrait keeps `image_path` +/// empty, which is what leaves the Qobuz portrait fetch its slot. +pub fn merge_artists( + artists: Vec, + albums: &[AlbumCredit<'_>], + custom_images: &HashMap, + plex_portraits: &HashMap, + album_thumb_fallback: bool, +) -> Vec { + let album_ids = build_artist_album_ids(albums); + let norm_imgs: HashMap = custom_images + .iter() + .map(|(k, v)| (normalize_artist(k), v.clone())) + .collect(); + let mut album_thumbs: HashMap = HashMap::new(); + if album_thumb_fallback { + for al in albums { + if !al.artwork_path.is_empty() { + album_thumbs + .entry(normalize_artist(al.artist)) + .or_insert_with(|| al.artwork_path.to_string()); + } + } + } + + let mut groups: HashMap> = HashMap::new(); + let mut order: Vec = Vec::new(); + for a in artists { + let n = normalize_artist(&a.name); + if n.is_empty() { + continue; + } + if !groups.contains_key(&n) { + order.push(n.clone()); + } + groups.entry(n).or_default().push(a); + } + + let mut out: Vec = Vec::with_capacity(order.len()); + for n in order { + let Some(variants) = groups.remove(&n) else { + continue; + }; + let album_set_len = album_ids.get(&n).map(|s| s.len()).unwrap_or(0) as u32; + let has_local = variants.iter().any(|v| v.source == "local"); + let has_plex = variants.iter().any(|v| v.source == "plex"); + let source = if has_local && has_plex { + "mixed" + } else if has_plex { + "plex" + } else { + "local" + }; + let (canonical, album_count, track_count) = if variants.len() == 1 { + let v = &variants[0]; + let ac = if album_set_len > 0 { + album_set_len + } else { + v.album_count + }; + (v.name.clone(), ac, v.track_count) + } else { + // `max_by` keeps the LAST maximum, like the reference. + let canon = variants + .iter() + .max_by(|a, b| { + a.album_count + .cmp(&b.album_count) + .then(a.track_count.cmp(&b.track_count)) + }) + .expect("non-empty group"); + let total_tracks: u32 = variants.iter().map(|v| v.track_count).sum(); + let ac = if album_set_len > 0 { + album_set_len + } else { + canon.album_count + }; + (canon.name.clone(), ac, total_tracks) + }; + let image_path = norm_imgs + .get(&n) + .or_else(|| plex_portraits.get(&n)) + .or_else(|| album_thumbs.get(&n)) + .cloned() + .unwrap_or_default(); + out.push(MergedArtist { + name: canonical, + album_count, + track_count, + image_path, + source: source.to_string(), + }); + } + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + out +} + +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn credit<'a>(id: &'a str, artist: &'a str, all: &'a str, art: &'a str) -> AlbumCredit<'a> { + AlbumCredit { + id, + artist, + all_artists: all, + artwork_path: art, + } + } + + fn input(name: &str, albums: u32, tracks: u32, source: &'static str) -> ArtistInput { + ArtistInput { + name: name.to_string(), + album_count: albums, + track_count: tracks, + source, + } + } + + // --- normalize_artist -------------------------------------------------- + + #[test] + fn normalize_lowercases_and_collapses_whitespace() { + assert_eq!(normalize_artist("Alice In Chains"), "alice in chains"); + assert_eq!(normalize_artist("alice in chains"), "alice in chains"); + assert_eq!(normalize_artist(" Air "), "air"); + } + + /// PARITY-DEBT #7: the merge key used to be `trim().to_lowercase()`, so + /// these were two artists with split album/track counts. + #[test] + fn normalize_folds_diacritics() { + assert_eq!(normalize_artist("Beyoncé"), normalize_artist("Beyonce")); + assert_eq!(normalize_artist("Sigur Rós"), "sigur ros"); + assert_eq!(normalize_artist("Mötley Crüe"), "motley crue"); + assert_eq!(normalize_artist("Françoise"), "francoise"); + } + + #[test] + fn normalize_collapses_punctuation_to_one_space() { + assert_eq!( + normalize_artist("Godspeed You! Black Emperor"), + "godspeed you black emperor" + ); + assert_eq!(normalize_artist("Sunn O)))"), "sunn o"); + // A run of separators collapses to ONE space, and the ends are trimmed. + assert_eq!( + normalize_artist("...And You Will Know Us..."), + "and you will know us" + ); + // This is exactly what makes it different from the mixtape normalizer, + // which keeps the parens. + assert_eq!(normalize_artist("Foo (band)"), "foo band"); + } + + #[test] + fn normalize_empty_and_punctuation_only() { + assert_eq!(normalize_artist(""), ""); + assert_eq!(normalize_artist(" "), ""); + assert_eq!(normalize_artist("-- / --"), ""); + } + + // --- split_credit ------------------------------------------------------ + + #[test] + fn split_credit_splits_on_every_separator() { + assert_eq!(split_credit("A & B"), vec!["A ", " B"]); + assert_eq!(split_credit("A; B/C"), vec!["A", " B", "C"]); + assert_eq!( + split_credit("Massive Attack feat Tracey Thorn"), + vec!["Massive Attack", "Tracey Thorn"] + ); + assert_eq!(split_credit("A ft B"), vec!["A", "B"]); + assert_eq!(split_credit("A featuring B"), vec!["A", "B"]); + assert_eq!(split_credit("A with B"), vec!["A", "B"]); + } + + #[test] + fn split_credit_leaves_a_plain_name_alone() { + assert_eq!(split_credit("Radiohead"), vec!["Radiohead"]); + } + + // --- album_matches_artist --------------------------------------------- + + /// PARITY-DEBT #8, the false positives: the QML matched with a SUBSTRING + /// `indexOf` over `allArtists`, so selecting "Air" listed everything whose + /// credit merely CONTAINED "air". + #[test] + fn no_substring_false_positives() { + let nsel = normalize_artist("Air"); + assert!(!album_matches_artist("Airbourne", "Airbourne", &nsel)); + assert!(!album_matches_artist("Blair", "Blair", &nsel)); + assert!(!album_matches_artist("Air Supply", "Air Supply", &nsel)); + assert!(album_matches_artist("Air", "Air", &nsel)); + } + + /// PARITY-DEBT #8, the misses: an album credited "A & B" never appeared + /// under "B" because the old rule only did an exact compare on `artist`. + #[test] + fn split_credits_are_matched_part_by_part() { + let b = normalize_artist("Burial"); + assert!(album_matches_artist("Four Tet & Burial", "", &b)); + assert!(album_matches_artist("Four Tet feat Burial", "", &b)); + assert!(album_matches_artist("Four Tet/Burial", "", &b)); + assert!(album_matches_artist("Four Tet; Burial", "", &b)); + assert!(album_matches_artist("Four Tet with Burial", "", &b)); + } + + #[test] + fn all_artists_parts_are_matched_exactly() { + let n = normalize_artist("Portishead"); + assert!(album_matches_artist( + "Various", + "Massive Attack,Portishead,Tricky", + &n + )); + assert!(!album_matches_artist( + "Various", + "Massive Attack,Tricky", + &n + )); + // Diacritic + spacing differences still merge. + assert!(album_matches_artist( + "x", + "Sigur Ros", + &normalize_artist("Sigur Rós") + )); + } + + #[test] + fn various_artists_is_its_own_bucket() { + let va = normalize_artist("Various Artists"); + assert!(album_matches_artist("Various Artists", "A,B,C", &va)); + // A compilation whose primary credit is a real artist is NOT "Various + // Artists", even though its contributor list is long. + assert!(!album_matches_artist( + "Nina Simone", + "Various Artists,Nina Simone", + &va + )); + } + + // --- merge_artists ----------------------------------------------------- + + /// PARITY-DEBT #7: "Beyoncé" and "Beyonce" are ONE artist, with summed + /// track counts and an album count taken from the album set. + #[test] + fn spelling_variants_merge_into_one_row() { + let albums = vec![ + credit("a1", "Beyoncé", "Beyoncé", ""), + credit("a2", "Beyonce", "Beyonce", ""), + ]; + let merged = merge_artists( + vec![ + input("Beyoncé", 1, 12, "local"), + input("Beyonce", 1, 4, "local"), + ], + &albums, + &HashMap::new(), + &HashMap::new(), + false, + ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].track_count, 16); + // Two DISTINCT album ids under one normalized name. + assert_eq!(merged[0].album_count, 2); + assert_eq!(merged[0].source, "local"); + } + + #[test] + fn canonical_spelling_is_the_one_with_most_albums() { + let merged = merge_artists( + vec![ + input("beyonce", 1, 4, "local"), + input("Beyoncé", 3, 12, "local"), + ], + &[], + &HashMap::new(), + &HashMap::new(), + false, + ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].name, "Beyoncé"); + // No album set -> the canonical variant's own DB count is kept. + assert_eq!(merged[0].album_count, 3); + } + + #[test] + fn local_and_plex_same_artist_merge_as_mixed() { + let merged = merge_artists( + vec![ + input("Radiohead", 2, 20, "local"), + input("radiohead", 1, 10, "plex"), + ], + &[], + &HashMap::new(), + &HashMap::new(), + false, + ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].source, "mixed"); + assert_eq!(merged[0].track_count, 30); + } + + #[test] + fn album_count_cross_lists_all_artists() { + // One album, credited to two artists: BOTH get a count of 1. + let albums = vec![credit("a1", "Four Tet", "Four Tet,Burial", "")]; + let merged = merge_artists( + vec![ + input("Four Tet", 1, 3, "local"), + input("Burial", 0, 3, "local"), + ], + &albums, + &HashMap::new(), + &HashMap::new(), + false, + ); + let burial = merged + .iter() + .find(|m| m.name == "Burial") + .expect("burial row"); + assert_eq!(burial.album_count, 1); + } + + #[test] + fn portrait_chain_prefers_custom_then_plex_then_cover() { + let albums = vec![credit("a1", "Air", "Air", "/covers/air.jpg")]; + let mut custom = HashMap::new(); + custom.insert("AIR".to_string(), "/custom/air.png".to_string()); + let mut plex = HashMap::new(); + plex.insert("air".to_string(), "/library/metadata/1/thumb".to_string()); + + // custom wins (and its KEY is normalized too, so "AIR" matches "air") + let m = merge_artists( + vec![input("Air", 1, 9, "local")], + &albums, + &custom, + &plex, + true, + ); + assert_eq!(m[0].image_path, "/custom/air.png"); + + // no custom -> the Plex thumb + let m = merge_artists( + vec![input("Air", 1, 9, "local")], + &albums, + &HashMap::new(), + &plex, + true, + ); + assert_eq!(m[0].image_path, "/library/metadata/1/thumb"); + + // neither -> the representative album cover, but ONLY with the + // fallback enabled (Plex on). + let m = merge_artists( + vec![input("Air", 1, 9, "local")], + &albums, + &HashMap::new(), + &HashMap::new(), + true, + ); + assert_eq!(m[0].image_path, "/covers/air.jpg"); + let m = merge_artists( + vec![input("Air", 1, 9, "local")], + &albums, + &HashMap::new(), + &HashMap::new(), + false, + ); + assert_eq!(m[0].image_path, ""); + } + + #[test] + fn nameless_artists_are_dropped_and_output_is_name_sorted() { + let merged = merge_artists( + vec![ + input("zz top", 1, 1, "local"), + input(" ", 1, 1, "local"), + input("ABBA", 1, 1, "local"), + ], + &[], + &HashMap::new(), + &HashMap::new(), + false, + ); + let names: Vec<&str> = merged.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["ABBA", "zz top"]); + } +} diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 309350dea..e74dc9862 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -23,8 +23,8 @@ use cxx_qt::Threading as _; use cxx_qt_lib::QString; use crate::local_bridge_ops::{ - emit_artwork, emit_artwork_one, load_tab_impl, load_tracks, publish_plex_state, publish_tree, - reload_browse, run_sync, + emit_artwork, emit_artwork_one, invalidate_artists, load_tab_impl, load_tracks, + publish_plex_state, publish_tree, reload_browse, run_sync, }; use crate::local_library_qt as lib; use crate::local_plex as plex; @@ -82,6 +82,11 @@ pub mod qbz_local { #[qproperty(bool, local_tracks_loading_more)] #[qproperty(bool, local_tracks_has_more)] #[qproperty(QString, local_tracks_sort)] + /// Tracks-tab grouping: "off" | "album" | "artist" | "name". A + /// CLIENT-side visual reorder (unlike the sort, which is the SQL + /// ORDER BY), but persisted in the SAME locallibrary_ui.json key the + /// Slint writes — so it survives a restart and both frontends agree. + #[qproperty(QString, local_tracks_group)] #[qproperty(QString, local_tracks_json)] // --- Local album detail (the album pane) --------------------------- @@ -147,6 +152,11 @@ pub mod qbz_local { /// Toolbar sort (SQL ORDER BY; resets to page 1). #[qinvokable] fn tracks_set_sort(self: Pin<&mut QbzLocal>, sort: QString); + /// Toolbar grouping ("off" | "album" | "artist" | "name"): persist + + /// republish. NO re-query — the group modes reorder what is already + /// loaded, on top of the SQL sort. + #[qinvokable] + fn tracks_set_group(self: Pin<&mut QbzLocal>, mode: QString); /// Infinite scroll: append the next page. #[qinvokable] fn tracks_load_more(self: Pin<&mut QbzLocal>); @@ -195,6 +205,12 @@ pub mod qbz_local { /// "Go to artist" on a local/Plex album — a NAME route, not an id. #[qinvokable] fn open_artist_by_name(self: Pin<&mut QbzLocal>, name: QString); + /// Artists tab, right pane: the ids of the CACHED album rows that + /// credit `artist`, as a JSON array (PARITY-DEBT #8). SYNCHRONOUS and + /// cheap — it is a pass over the album document already in memory, and + /// QML uses it to filter the very array it renders. + #[qinvokable] + fn artist_album_ids(self: &QbzLocal, artist: QString) -> QString; /// Album header pencil — LOGGED SEAM (no tag-editor modal yet). #[qinvokable] fn album_edit_tags(self: Pin<&mut QbzLocal>, id: QString); @@ -244,9 +260,16 @@ pub mod qbz_local { /// queue, starting at the clicked row. #[qinvokable] fn play_folder_track(self: Pin<&mut QbzLocal>, path: QString, track_id: QString); - /// Tracks-tab row click: the loaded page set becomes the queue. + /// Tracks-tab row click: the loaded page set becomes the queue, in the + /// order the tab is RENDERING it (PARITY-DEBT #14). + /// `visible_ids_json` = the JSON array of the ids on screen, in render + /// order; `track_id` = the row that was clicked. #[qinvokable] - fn play_track(self: Pin<&mut QbzLocal>, track_id: QString); + fn play_tracks_visible( + self: Pin<&mut QbzLocal>, + visible_ids_json: QString, + track_id: QString, + ); /// Context menus: kind = "track" | "album" | "folder"; /// mode = "next" | "later" | "queue". #[qinvokable] @@ -317,6 +340,7 @@ pub struct QbzLocalRust { local_tracks_loading_more: bool, local_tracks_has_more: bool, local_tracks_sort: QString, + local_tracks_group: QString, local_tracks_json: QString, local_album_loading: bool, local_album_json: QString, @@ -355,6 +379,7 @@ impl Default for QbzLocalRust { local_tracks_loading_more: false, local_tracks_has_more: false, local_tracks_sort: QString::from("default"), + local_tracks_group: QString::from("off"), local_tracks_json: QString::from("[]"), local_album_loading: false, local_album_json: QString::from(""), @@ -395,8 +420,12 @@ impl qbz_local::QbzLocal { } // Seed the persisted toolbar choices so the first paint matches // what the user last picked (shared with the Slint frontend). + // `tracks_group` joined the seed with PARITY-DEBT #13: the key was + // read and preserved on disk but never reached the UI, so the Tracks + // tab silently reset to "No grouping" on every launch. let mode = lib::album_mode(); let sort = lib::tracks_sort(); + let group = lib::tracks_group(); crate::spawn(async move { let available = tokio::task::spawn_blocking(lib::has_library) .await @@ -407,6 +436,8 @@ impl qbz_local::QbzLocal { .set_local_album_mode(QString::from(mode.as_str())); b.as_mut() .set_local_tracks_sort(QString::from(sort.as_str())); + b.as_mut() + .set_local_tracks_group(QString::from(group.as_str())); }); }); publish_plex_state(); @@ -429,6 +460,18 @@ impl qbz_local::QbzLocal { }); // The grouping IS the query — reload both album surfaces. load_tab_impl("albums".to_string()); + // ...and the ARTISTS tab derives from that same album set: its album + // cache is keyed by the group key, so a folder-mode compilation + // cross-lists under every artist until the tab is revisited + // (PARITY-DEBT #9). The Slint drops the model and lets + // `ensure_artists_loaded` re-fetch on the next visit + // (`local_library.rs:727-738 invalidate_artists`); this port has no + // such guard — `loadTab` re-queries on every tab change — so the + // equivalent is to drop the cache AND re-run the load right here. + // That also covers the case the lazy version cannot: the mode is + // reachable from Settings, so the user can flip it while STANDING on + // the Artists tab. + invalidate_artists(); } pub fn tracks_search(self: Pin<&mut Self>, query: QString) { @@ -446,6 +489,18 @@ impl qbz_local::QbzLocal { load_tracks(true); } + pub fn tracks_set_group(self: Pin<&mut Self>, mode: QString) { + let mode = mode.to_string(); + lib::set_tracks_group(&mode); + ui(move |mut b| { + b.as_mut() + .set_local_tracks_group(QString::from(mode.as_str())); + }); + // NO reload: unlike the sort, grouping is a client-side visual + // reorder over the pages already loaded (the reference's + // `set_tracks_group` only persists + re-derives). + } + pub fn tracks_load_more(self: Pin<&mut Self>) { if !lib::tracks_has_more() { return; @@ -539,13 +594,18 @@ impl qbz_local::QbzLocal { }); } - pub fn play_track(self: Pin<&mut Self>, track_id: QString) { + pub fn play_tracks_visible( + self: Pin<&mut Self>, + visible_ids_json: QString, + track_id: QString, + ) { let Ok(row) = track_id.to_string().parse::() else { return; }; + let ids = visible_ids_json.to_string(); let runtime = crate::app(); crate::spawn(async move { - lib::play_tracks_from(&runtime, row).await; + lib::play_tracks_visible(&runtime, ids, row).await; }); } @@ -690,6 +750,10 @@ impl qbz_local::QbzLocal { crate::local_album_actions::open_artist_by_name(name.to_string()); } + pub fn artist_album_ids(&self, artist: QString) -> QString { + QString::from(crate::local_albums::artist_album_ids(&artist.to_string()).as_str()) + } + pub fn album_edit_tags(self: Pin<&mut Self>, id: QString) { crate::local_album_actions::edit_tags(id.to_string()); } diff --git a/crates/qbz-qt/src/local_bridge_ops.rs b/crates/qbz-qt/src/local_bridge_ops.rs index d588500ca..b2c4a576e 100644 --- a/crates/qbz-qt/src/local_bridge_ops.rs +++ b/crates/qbz-qt/src/local_bridge_ops.rs @@ -177,6 +177,21 @@ pub(crate) fn load_albums() { }); } +/// Album identity changed: the Artists tab's album cache is keyed by the group +/// key, so it is stale the moment the mode flips (PARITY-DEBT #9 — a +/// folder-mode compilation cross-lists under every artist). Drop the cached +/// document, then re-run the load so the tab is correct even when the user is +/// standing on it. Port of `local_library.rs:727-738 invalidate_artists` (the +/// reference can stop at the drop: its `ensure_artists_loaded` guard re-fetches +/// on the next visit, a guard this port does not have). +pub(crate) fn invalidate_artists() { + lib::state(|s| { + s.artists.clear(); + }); + ui(|mut b| b.as_mut().set_local_artists_json(QString::from("[]"))); + load_artists(); +} + pub(crate) fn load_artists() { ui(|mut b| b.as_mut().set_local_artists_loading(true)); crate::spawn(async move { diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index 6ace3a9db..4944782b4 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -198,16 +198,36 @@ async fn apply(rows: Vec, action: &str) -> bool { crate::myqbz_add_qt::open_items(crate::myqbz_add_qt::track_items_from_local(&rows)); false } - // GAP (not a half-path): this port has no playlist picker modal — the - // Slint routes this into `playlist_picker::open_multi`, which was not - // ported. Same gap the PlayerBar / NowPlayingBarSmall menus document. + // The app-wide picker in LOCAL MODE — the Slint's + // `playlist_picker::open_multi(&ids, local = true)`. The refs are + // SOURCE-AWARE (`local_picker_ref_for_track`: Plex rows as + // "plex:", everything else as its library row id, resolved + // at insert time), and they are carried as refs the whole way: the + // picker's `Payload::LocalRefs` is the type that keeps a library row + // id from ever reaching the Qobuz endpoint, where it would mean a + // different track. + // + // Returns FALSE (keep the selection) for the same reason + // `add-to-mixtape` does: the picker is still open and a failed write + // is retried from the same modal. "add-to-playlist" => { - log::warn!( - "[qbz-qt] local bulk {action}: no picker seam in this port ({} row(s) dropped)", - rows.len() - ); + if rows.is_empty() { + return false; + } + let refs: Vec = rows + .iter() + .map(crate::local_playlist_qt::local_picker_ref_for_track) + .collect(); + crate::playlist_picker_qt::open_for_local_refs(&crate::app(), refs); false } + // NO CALLER as of 2026-07-31: no bulk bar offers this action any more. + // The Local Library Tracks tab used to (LocalTracksTab.qml), and the + // owner removed it as a context error — that tab is the whole local + // library, not a favourites surface. The arm is kept because the + // vocabulary is shared with the surfaces that WILL want it once local + // hearts land; it must stay a no-op until then. + // // GAP: local hearts are not wired in this port at all — `map_track` // publishes `isFavorite: false` unconditionally, and the local // favorites store is keyed by FILE PATH behind a private handle in diff --git a/crates/qbz-qt/src/local_library_qt.rs b/crates/qbz-qt/src/local_library_qt.rs index 8a32bfa1c..eb9b0f0f8 100644 --- a/crates/qbz-qt/src/local_library_qt.rs +++ b/crates/qbz-qt/src/local_library_qt.rs @@ -59,8 +59,8 @@ pub use crate::local_rows::{ // --- state / prefs --------------------------------------------------------- pub use crate::local_state::{ - album_mode, counts, has_library, set_album_mode, set_tracks_query, set_tracks_sort, state, - tracks_has_more, tracks_sort, with_db, + album_mode, counts, has_library, set_album_mode, set_tracks_group, set_tracks_query, + set_tracks_sort, state, tracks_group, tracks_has_more, tracks_sort, with_db, }; // --- queries --------------------------------------------------------------- @@ -81,7 +81,7 @@ pub use crate::local_artwork::{fetch_plex_misses, resolve_window_blocking, Artwo // --- playback -------------------------------------------------------------- pub use crate::local_playback::{ enqueue, local_queue_track, play_album, play_current_if_local, play_folder, play_folder_track, - play_local_file, play_plex_track, play_tracks_from, + play_local_file, play_plex_track, play_tracks_visible, }; // --- plex ------------------------------------------------------------------ diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 54412898f..ff7953378 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -329,17 +329,62 @@ pub async fn play_folder_track(runtime: &Runtime, folder: String, row_id: i64) { play_rows(runtime, tracks, start, false).await; } -/// Tracks tab row click: the ALREADY-LOADED page set becomes the queue so -/// playback continues down the list. No DB re-query — the raw rows are kept -/// by the loader, which is also what makes a merged PLEX row playable (it has -/// no `local_tracks` row to re-query). -pub async fn play_tracks_from(runtime: &Runtime, row_id: i64) { +/// Reorder the loaded raw rows into the order the Tracks tab is RENDERING and +/// locate the clicked row inside it. +/// +/// `None` — and the caller falls back to the SQL-page order — when the clicked +/// id does not resolve inside the ordered list. Same guard, same reason as +/// `library_qt::order_by_visible` (the port of `playback.rs::order_by_visible`, +/// :3408-3428): better the old queue than a queue that starts on the wrong row. +/// +/// Ids arrive as strings because that is what the QML rows carry +/// (`local_rows::TrackRow.id`); unparseable ones are dropped. +pub(crate) fn order_by_visible( + rows: &[LocalTrack], + visible_ids: &[String], + clicked_id: i64, +) -> Option<(Vec, usize)> { + let by_id: std::collections::HashMap = + rows.iter().map(|t| (t.id, t)).collect(); + let ordered: Vec = visible_ids + .iter() + .filter_map(|id| id.parse::().ok()) + .filter_map(|id| by_id.get(&id).map(|t| (*t).clone())) + .collect(); + let idx = ordered.iter().position(|t| t.id == clicked_id)?; + Some((ordered, idx)) +} + +/// Tracks tab row click (PARITY-DEBT #14): the ALREADY-LOADED page set becomes +/// the queue so playback continues down the list, in the order ON SCREEN. No +/// DB re-query — the raw rows are kept by the loader, which is also what makes +/// a merged PLEX row playable (it has no `local_tracks` row to re-query). +/// +/// `visible_ids_json` is the JSON string array of the track ids the tab is +/// CURRENTLY rendering, in render order; `clicked_id` is the row that was hit. +/// +/// The Tracks tab's SORT is server-side (it defines the pagination order), but +/// its GROUP modes ("by album" / "by artist" / "by name") are a client-side +/// visual reorder on top of the loaded pages — the reference is explicit that +/// they "keep their client-side visual reorder on top" (commit `e379aa65`). +/// Queueing `tracks_raw` therefore played the SQL order while the user was +/// looking at the grouped one: click row 3 of the "Air" group and you heard +/// whatever happened to sit at SQL offset 3. The order can only come from the +/// view (it is derived in QML, like `LibraryView`'s), so it comes down as an +/// id array and the raw rows play the part of the authoritative cache. +pub async fn play_tracks_visible(runtime: &Runtime, visible_ids_json: String, clicked_id: i64) { let rows: Vec = state(|s| s.tracks_raw.clone()); if rows.is_empty() { return; } - let start = rows.iter().position(|t| t.id == row_id).unwrap_or(0); - play_rows(runtime, rows, start, false).await; + let ids: Vec = serde_json::from_str(&visible_ids_json).unwrap_or_default(); + match order_by_visible(&rows, &ids, clicked_id) { + Some((ordered, start)) => play_rows(runtime, ordered, start, false).await, + None => { + let start = rows.iter().position(|t| t.id == clicked_id).unwrap_or(0); + play_rows(runtime, rows, start, false).await; + } + } } /// Look up ONE raw row by id: the loaded Tracks page first, then the open @@ -409,7 +454,57 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String) #[cfg(test)] mod tests { - use super::plex_rating_key; + use super::{order_by_visible, plex_rating_key}; + use qbz_library::LocalTrack; + + fn track(id: i64, title: &str) -> LocalTrack { + LocalTrack { + id, + title: title.to_string(), + ..Default::default() + } + } + + fn ids(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + fn titles(rows: &[LocalTrack]) -> Vec<&str> { + rows.iter().map(|t| t.title.as_str()).collect() + } + + /// PARITY-DEBT #14: the queue follows the VISIBLE order (here: reversed by + /// a group mode), not the SQL page order the loader cached. + #[test] + fn queue_follows_the_visible_order() { + let rows = vec![track(1, "a"), track(2, "b"), track(3, "c")]; + let (ordered, start) = + order_by_visible(&rows, &ids(&["3", "1", "2"]), 1).expect("clicked row resolves"); + assert_eq!(titles(&ordered), vec!["c", "a", "b"]); + assert_eq!(start, 1); + } + + /// Ids the loaded page does not have (a row scrolled out of the raw cache, + /// a stale report) are dropped rather than poisoning the queue, and the + /// start index still points at the clicked row AFTER the drop. + #[test] + fn unknown_and_unparseable_ids_are_dropped() { + let rows = vec![track(1, "a"), track(2, "b")]; + let (ordered, start) = order_by_visible(&rows, &ids(&["99", "not-a-number", "2", "1"]), 1) + .expect("clicked row resolves"); + assert_eq!(titles(&ordered), vec!["b", "a"]); + assert_eq!(start, 1); + } + + /// The clicked row not being in the visible list is the one case the + /// caller must NOT start a queue from: `None` sends it to the SQL-order + /// fallback instead of playing the wrong track. + #[test] + fn clicked_row_outside_the_visible_list_is_none() { + let rows = vec![track(1, "a"), track(2, "b")]; + assert!(order_by_visible(&rows, &ids(&["2"]), 1).is_none()); + assert!(order_by_visible(&rows, &[], 1).is_none()); + } /// LocalLibrary path: the hint IS the raw rating key (`local_queue_track` /// stamps `file_path`, which for a Plex row is the server key). Unchanged diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs new file mode 100644 index 000000000..f3adc206a --- /dev/null +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -0,0 +1,978 @@ +//! First-class LOCAL playlists — port of `crates/qbz/src/local_playlist.rs`. +//! +//! # Nothing here is a new store +//! +//! The data lives where it already lives: `qbz_library::local_playlists`, in +//! the SAME per-user `library.db` the reference build writes, under the same +//! `local:` ids. `LibraryDatabase::open()` runs that module's +//! `init_schema` itself (`qbz-library/src/database.rs:53`) — all +//! `CREATE TABLE IF NOT EXISTS` plus pragma-guarded additive `ALTER`s — and +//! this port opens through the same call. So a user who already has local +//! playlists sees them here with no migration, and can keep using either +//! build against the same rows. +//! +//! That is the whole point: local playlists are the feature for people +//! running QBZ as a player WITHOUT Qobuz, so losing or forking their data in +//! the Slint -> Qt move is the one outcome that is not acceptable. +//! +//! # Scope of this file +//! +//! The repo layer + the write paths + the sidebar's cover resolution. The +//! detail view / playback / reorder half of the reference module is the next +//! step and lands beside this one. +//! +//! OWED PORTS (recorded, not silently dropped): +//! - `resolve_cover_urls`'s OFFLINE-CACHE arm: the reference fills leftover +//! collage slots from downloaded Qobuz covers. There is no offline cache in +//! this port (PARITY-DEBT #20), so a Qobuz-only local playlist resolves no +//! cover here and falls back to the glyph — same as the reference does when +//! nothing is downloaded. +//! - `add_drag_tracks_blocking`: the sidebar drop payload. The drag seam +//! exists in this port; wiring it is part of the sidebar step. + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; + +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use qbz_library::local_playlists as repo; +use qbz_models::QueueTrack; + +use crate::library_db_qt::with_db; +use crate::playlist_qt::{PlaylistDoc, PlaylistTrackRow}; + +type Runtime = Arc>; + +/// Type guard (D7): a playlist reference is EITHER a Qobuz `u64` id or a +/// `local:` string. Qobuz-bound calls take `u64` only, so a local ref is +/// unrepresentable there BY CONSTRUCTION — which is what stops a `local:` id +/// from ever reaching an endpoint that would read it as a catalog number. +#[derive(Debug, Clone)] +pub enum PlaylistRef { + Qobuz(u64), + Local(String), +} + +impl PlaylistRef { + pub fn parse(id: &str) -> Option { + if repo::is_local_playlist_id(id) { + Some(Self::Local(id.to_string())) + } else { + id.parse::().ok().map(Self::Qobuz) + } + } +} + +/// True when `id` names a local playlist. +pub fn is_local_id(id: &str) -> bool { + repo::is_local_playlist_id(id) +} + +// ──────────────────────── blocking repo wrappers ──────────────────────── +// Every one opens the per-user library.db on the CALLING thread — never call +// these from the Qt event loop; wrap them in `spawn_blocking`. +// +// Reads pass `create: false` so listing playlists on a fresh account cannot +// conjure a library.db as a side effect; writes pass `true` because "create +// your first local playlist" must work on an install that has never written +// a local flag before. + +pub fn list_blocking() -> Vec { + with_db(false, |db| Ok(db.with_connection(repo::list))) + .and_then(|r| r.ok()) + .unwrap_or_default() +} + +pub fn get_blocking(id: &str) -> Option { + with_db(false, |db| Ok(db.with_connection(|conn| repo::get(conn, id)))) + .and_then(|r| r.ok()) + .flatten() +} + +pub fn get_tracks_blocking(id: &str) -> Vec { + with_db(false, |db| { + Ok(db.with_connection(|conn| repo::get_tracks(conn, id))) + }) + .and_then(|r| r.ok()) + .unwrap_or_default() +} + +pub fn create_blocking(name: &str, description: Option<&str>, offline_only: bool) -> Option { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::create(conn, name, description, offline_only))) + }) + .and_then(|r| r.ok()) +} + +pub fn update_blocking( + id: &str, + name: &str, + description: Option<&str>, + offline_only: bool, +) -> bool { + with_db(true, |db| { + Ok(db.with_connection(|conn| { + repo::rename(conn, id, name)?; + repo::set_description(conn, id, description)?; + repo::set_offline_only(conn, id, offline_only) + })) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +pub fn delete_blocking(id: &str) -> bool { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::delete(conn, id))) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +/// B3: the manager's favorite flag for a local playlist. Locals keep their +/// own flag on the `local_playlists` row — they are not in `playlist_settings` +/// (that table's PK is a u64 Qobuz id, which a `local:` can never be). +pub fn set_favorite_blocking(id: &str, favorite: bool) -> bool { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::set_favorite(conn, id, favorite))) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +/// B3: hidden flag for a local playlist (hidden locals drop from the sidebar). +pub fn set_hidden_blocking(id: &str, hidden: bool) -> bool { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::set_hidden(conn, id, hidden))) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +/// Move a local playlist into `folder_id`, or to root when `None`. Folder +/// membership for locals rides the `local_playlists.folder_id` column, which +/// points at the SAME `playlist_folders` table the Qobuz playlists use — so +/// one folder holds both kinds. +pub fn move_to_folder_blocking(id: &str, folder_id: Option<&str>) { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::move_to_folder(conn, id, folder_id))) + }); +} + +// ──────────────────────────── track appends ──────────────────────────── + +/// Append Qobuz track ids. Returns the inserted count. +/// +/// Ids in the Plex synthetic namespace (>= 2^40, `local_plex::PLEX_TRACK_ID_FLOOR`) +/// are NOT catalog ids: storing one writes a row that can never resolve — the +/// field-garbage class the reference calls out. They are refused HERE, at the +/// last gate before the repo write, rather than at each caller. +pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize { + let entries: Vec = track_ids + .iter() + .filter(|&&tid| { + if tid >= crate::local_plex::PLEX_TRACK_ID_FLOOR { + log::warn!( + "[qbz-qt] local playlist add: refused non-catalog id {tid} as a Qobuz ref" + ); + false + } else { + true + } + }) + .map(|&tid| repo::LocalPlaylistTrackInput::Qobuz(tid)) + .collect(); + add_inputs_blocking(id, &entries) +} + +/// Resolve a `local_tracks` row to its playlist input, SOURCE-AWARE: an +/// offline copy (`qobuz_download`) becomes a Qobuz ref (it has a real catalog +/// id), a Plex row becomes a Plex ref (its rating key lives in `file_path`), +/// anything else becomes a local file path. Getting this wrong is how a row +/// ends up unresolvable forever. +fn local_row_input( + db: &qbz_library::LibraryDatabase, + rid: i64, +) -> Result, qbz_library::LibraryError> { + let Some(track) = db.get_track(rid)? else { + log::warn!("[qbz-qt] local playlist add: unknown local row {rid}"); + return Ok(None); + }; + Ok(Some(match track.source.as_deref() { + Some("qobuz_download") => match track.qobuz_track_id { + Some(qid) => repo::LocalPlaylistTrackInput::Qobuz(qid as u64), + None => repo::LocalPlaylistTrackInput::Local(track.file_path.clone()), + }, + Some("plex") => repo::LocalPlaylistTrackInput::Plex(track.file_path.clone()), + _ => repo::LocalPlaylistTrackInput::Local(track.file_path.clone()), + })) +} + +fn add_inputs_blocking(id: &str, entries: &[repo::LocalPlaylistTrackInput]) -> usize { + if entries.is_empty() { + return 0; + } + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::add_tracks(conn, id, entries))) + }) + .and_then(|r| r.ok()) + .unwrap_or(0) +} + +/// Append local-mode refs — `""` LocalLibrary row ids (resolved +/// source-aware through [`local_row_input`]) or `"plex:"` Plex +/// rows. Plex rows carry the KEY rather than a row id because their synthetic +/// ids never resolve through `get_track`. Returns the inserted count. +pub fn add_local_refs_blocking(id: &str, refs: &[String]) -> usize { + let entries: Vec = with_db(true, |db| { + let mut out = Vec::new(); + for r in refs { + if let Some(key) = r.strip_prefix("plex:") { + out.push(repo::LocalPlaylistTrackInput::Plex(key.to_string())); + } else if let Ok(rid) = r.parse::() { + if let Some(input) = local_row_input(db, rid)? { + out.push(input); + } + } else { + log::warn!("[qbz-qt] local playlist add: unrecognized ref {r}"); + } + } + Ok(out) + }) + .unwrap_or_default(); + add_inputs_blocking(id, &entries) +} + +// ──────────────────────────── custom artwork ──────────────────────────── + +/// Copy `src` into the artwork cache and store it as this playlist's custom +/// artwork. Returns the stored path. Blocking. +pub fn set_custom_artwork_blocking(id: &str, src: &str) -> Option { + let cache = qbz_library::get_artwork_cache_dir(); + std::fs::create_dir_all(&cache).ok()?; + let ext = std::path::Path::new(src) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("jpg"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let suffix = id.trim_start_matches(repo::LOCAL_PLAYLIST_PREFIX); + let dest = cache.join(format!("local_playlist_{suffix}_{ts}.{ext}")); + if let Err(e) = std::fs::copy(src, &dest) { + log::error!("[qbz-qt] copy local playlist artwork failed: {e}"); + return None; + } + let dest_str = dest.to_string_lossy().to_string(); + match with_db(true, |db| { + Ok(db.with_connection(|conn| repo::set_custom_artwork(conn, id, Some(&dest_str)))) + }) { + Some(Ok(())) => Some(dest_str), + Some(Err(e)) => { + log::error!("[qbz-qt] store local playlist artwork failed: {e}"); + None + } + None => None, + } +} + +/// Clear this playlist's custom artwork. Blocking. +pub fn clear_custom_artwork_blocking(id: &str) { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::set_custom_artwork(conn, id, None))) + }); +} + +// ──────────────────────────── cover resolution ──────────────────────────── + +/// Up to `limit` cover refs for a local playlist's tracks, in track order and +/// WITHOUT any network — the sidebar / manager micro-collage. +/// +/// Sources, all local: a Local track's `local_tracks.artwork_path`, and a Plex +/// track's cached thumb. Returns file paths and Plex thumb paths; the art +/// loader routes by shape. +/// +/// Blocking — call it from `spawn_blocking`. (The reference is async only +/// because its leftover-slot fill reaches into the offline cache behind an +/// async lock; with no offline cache here there is nothing to await.) +pub fn resolve_cover_urls_blocking(id: &str, limit: usize) -> Vec { + let mut covers: Vec = Vec::new(); + for t in get_tracks_blocking(id) { + if covers.len() >= limit { + break; + } + match t.source { + repo::LocalPlaylistTrackSource::Local => { + if let Some(path) = t.local_path { + if let Some(Some(track)) = with_db(false, |db| db.get_track_by_path(&path)) { + if let Some(art) = track.artwork_path { + if !covers.contains(&art) { + covers.push(art); + } + } + } + } + } + repo::LocalPlaylistTrackSource::Plex => { + if let Some(key) = t.plex_key { + if let Ok(list) = qbz_plex::plex_cache_get_cached_tracks_by_keys(&[key]) { + if let Some(pt) = list.into_iter().next() { + let lt = crate::local_plex::map_cached_to_local_track(pt); + if let Some(art) = lt.artwork_path { + if !covers.contains(&art) { + covers.push(art); + } + } + } + } + } + } + // Qobuz rows would fill their slot from the offline cache in the + // reference; this port has none (see the module header). + repo::LocalPlaylistTrackSource::Qobuz => {} + } + } + covers.truncate(limit); + covers +} + +// ──────────────────────── the detail view ──────────────────────── + +/// One resolved, renderable row. +/// +/// The reference has a sixth variant, `Cached` — Qobuz metadata read out of +/// the offline cache so a downloaded row still renders with no network. This +/// port has no offline cache (PARITY-DEBT #20), so that variant could only +/// ever be dead code; a Qobuz row that the batch does not return is hidden, +/// which is what the reference does too once its cache has nothing either. +pub enum RowItem { + /// Full catalog track (online fetch). + Qobuz(Box), + /// Local file resolved from library.db by path. + Local(Box), + /// A local file whose metadata lookup missed but whose file EXISTS on + /// disk — renders with a filename fallback rather than vanishing. Hiding + /// is for rows with no metadata source anywhere, not for a file that is + /// sitting right there. + LocalFile { path: String }, + /// Plex ref resolved from the Plex cache into the same `LocalTrack` shape + /// the Local Library merges, so render / queue / artwork all ride the + /// existing source-aware paths. + Plex(Box), + /// A ref that cannot resolve right now: a `plex_key` the cache does not + /// know (purged, never synced, or garbage written by an old mis-typed + /// add), or a `qobuz_track_id` outside the catalog range (the legacy + /// untyped-drag bug stored Plex synthetic 2^40 ids as Qobuz ids). + /// Rendered HONESTLY and still selectable, so the user can remove it. + Unresolved { + /// "plex" (a cache miss — may heal after a resync) or "qobuz" + /// (an out-of-range id — permanent garbage). + kind: &'static str, + /// The raw stored ref, shown so the user knows WHAT is broken. + reference: String, + }, +} + +pub struct LoadedRow { + pub position: i32, + pub item: RowItem, +} + +/// The open local detail's playable queue snapshot, aligned with the row ids, +/// plus the per-row repo positions used for removal. Mirrors what +/// `playlist_qt` keeps for Qobuz lists. +static CURRENT_QUEUE: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +/// (playlist id, offline_only) of the open local detail. +static CURRENT_META: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +/// Row display id -> repo `position`, for removal. +static ROW_POSITIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Clear the open-detail snapshot, so rows from a previously open playlist can +/// never resolve against a different one. +pub fn clear_open_snapshot() { + if let Ok(mut cur) = CURRENT_QUEUE.lock() { + cur.clear(); + } + if let Ok(mut meta) = CURRENT_META.lock() { + *meta = None; + } + if let Ok(mut pos) = ROW_POSITIONS.lock() { + pos.clear(); + } +} + +/// The id of the open local detail, if one is open. +pub fn open_id() -> Option { + CURRENT_META.lock().ok()?.as_ref().map(|(id, _)| id.clone()) +} + +/// The ready, SOURCE-AWARE QueueTrack of an open-detail row by display id. +/// `None` for unplayable rows and for any id while no local detail is open. +pub fn queue_track_for_row(id: &str) -> Option { + let queue = CURRENT_QUEUE.lock().ok()?; + queue.iter().find(|q| q.id.to_string() == id).cloned() +} + +/// The Plex rating key of an open-detail row by display id. Resolved Plex rows +/// carry a NUMERIC synthetic id in the model; the string key only lives in the +/// queue snapshot's `source_item_id_hint`, which is how the drag and picker +/// paths recover it instead of mis-typing the numeric id as a catalog id. +pub fn plex_key_for_row(id: &str) -> Option { + let queue = CURRENT_QUEUE.lock().ok()?; + queue + .iter() + .find(|q| q.id.to_string() == id) + .filter(|q| q.source.as_deref() == Some("plex")) + .and_then(|q| q.source_item_id_hint.clone()) +} + +/// Local-mode picker ref for an open-detail row: `"plex:"` for resolved +/// Plex rows, `""` for local file rows and for OFFLINE-CACHE +/// rows, `None` for Qobuz rows (those ride the catalog-id flow). +pub fn local_picker_ref_for_row(id: &str) -> Option { + let queue = CURRENT_QUEUE.lock().ok()?; + let q = queue.iter().find(|q| q.id.to_string() == id)?; + match q.source.as_deref() { + Some("plex") => q + .source_item_id_hint + .as_ref() + .map(|key| format!("plex:{key}")), + Some("local") => Some(q.id.to_string()), + // An OFFLINE COPY row (`local_playback::local_queue_track` tags these + // "qobuz_download"). It reaches this function because `row_to_display` + // publishes every non-Plex `RowItem::Local` with `source: "local"`, so + // TrackRow.qml offers the entry — returning `None` here would render it + // and no-op, which is the defect class this round is closing. + // + // Its `q.id` is NOT a library row id: `local_queue_track` sets the + // queue id to `qobuz_track_id` when the row has one. The library row id + // is carried in `source_item_id_hint` for exactly this reason, and it + // is what `local_row_input` needs to re-derive the right input kind + // (`Qobuz(qid)` when the copy has a catalog id, the file path when it + // does not). + Some("qobuz_download") => q.source_item_id_hint.clone(), + _ => None, + } +} + +/// Local-mode picker ref for a LOCAL LIBRARY row (`main.rs:4701` +/// `local_picker_ref`) — the sibling of [`local_picker_ref_for_row`], which +/// serves the open local-playlist DETAIL instead. +/// +/// Two different sources, two helpers, and they are not interchangeable: a +/// detail row only knows its display id and recovers the Plex key from the +/// open detail's queue snapshot, while a `LocalTrack` carries the key in +/// `file_path` (that is where the Plex merge stores it) and has a real +/// library row id. A Plex row must NEVER ride its numeric id — the synthetic +/// 2^40 ids do not resolve through `get_track`, and typing one as a Qobuz id +/// is the legacy bug that wrote permanently unresolvable rows. +pub fn local_picker_ref_for_track(track: &qbz_library::LocalTrack) -> String { + if track.source.as_deref() == Some("plex") { + format!("plex:{}", track.file_path) + } else { + track.id.to_string() + } +} + +fn total_duration_label(rows: &[LoadedRow]) -> String { + let secs: u64 = rows + .iter() + .map(|r| match &r.item { + RowItem::Qobuz(t) => t.duration as u64, + RowItem::Local(t) | RowItem::Plex(t) => t.duration_secs, + RowItem::LocalFile { .. } | RowItem::Unresolved { .. } => 0, + }) + .sum(); + let mins = secs / 60; + if mins >= 60 { + qbz_i18n::t_args( + "{} h {} min", + &[&(mins / 60).to_string(), &(mins % 60).to_string()], + ) + } else { + qbz_i18n::t_args("{} min", &[&mins.to_string()]) + } +} + +/// Build the display row + its queue track (when playable) for one resolved row. +fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option) { + match item { + RowItem::Qobuz(track) => { + let row = crate::playlist_qt::map_track(track); + let queue = crate::playlist_qt::row_to_queue_public(&row); + (row, Some(queue)) + } + RowItem::Local(t) | RowItem::Plex(t) => { + let queue = crate::local_playback::local_queue_track(t); + let source = if matches!(item, RowItem::Plex(_)) { + "plex" + } else { + "local" + }; + let row = PlaylistTrackRow { + id: queue.id.to_string(), + playlist_track_id: queue.id, + title: t.title.clone(), + artist: t.artist.clone(), + album: t.album.clone(), + duration: crate::playlist_qt::mmss(t.duration_secs as u32), + duration_secs: t.duration_secs, + // Through the LOCAL row helpers, not the Qobuz ones: a local + // track's `sample_rate` is in Hz (44100), the catalog's is in + // kHz (44.1), and `tier_of` also reads the FORMAT — which is + // how a lossless FLAC is told from an mp3 when the tags carry + // no bit depth. + quality_tier: crate::local_rows::tier_of(&t.format, t.bit_depth, t.sample_rate) + .to_string(), + quality_detail: crate::home_qt::quality_detail_from_parts( + t.bit_depth, + Some(t.sample_rate), + ), + art_url: t.artwork_path.clone().unwrap_or_default(), + // `TrackRow.qml` renders `artPath`, never `artUrl` — the Qobuz + // arm fills it from the download cache, and a local row's + // cover is ALREADY on disk (or is a Plex thumb path), which + // `cached_path` classifies and turns into the `file://` url + // QML can decode. Without this the rows rendered art-less + // while the sidebar collage for the same playlist did not. + art_path: crate::artwork_qt::cached_path( + t.artwork_path.as_deref().unwrap_or_default(), + ), + source: source.to_string(), + ..Default::default() + }; + (row, Some(queue)) + } + RowItem::LocalFile { path } => { + // No metadata anywhere, but the file is on disk: show the filename + // so the row is identifiable, and mark it unplayable until the + // library index knows it again. + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + ( + PlaylistTrackRow { + id: path.clone(), + title: name, + source: "local".to_string(), + unavailable: true, + unavailable_ref: path.clone(), + ..Default::default() + }, + None, + ) + } + RowItem::Unresolved { kind, reference } => ( + PlaylistTrackRow { + id: format!("{kind}:{reference}"), + title: qbz_i18n::t("Unavailable track"), + source: (*kind).to_string(), + unavailable: true, + unavailable_ref: reference.clone(), + ..Default::default() + }, + None, + ), + } +} + +/// Load + resolve a local playlist and publish it through the SHARED playlist +/// view. Qobuz rows resolve in one batch fetch; local rows from library.db by +/// path; Plex rows from the Plex cache in one bulk lookup. +/// +/// Runs off the Qt thread. Returns false when the playlist does not exist. +pub async fn load(runtime: &Runtime, playlist_id: &str) -> bool { + let id = playlist_id.to_string(); + let Ok((header, tracks)) = tokio::task::spawn_blocking({ + let id = id.clone(); + move || (get_blocking(&id), get_tracks_blocking(&id)) + }) + .await + else { + return false; + }; + let Some(header) = header else { + log::warn!("[qbz-qt] local playlist {id}: not found"); + return false; + }; + + // Qobuz rows: ONE batch fetch. Offline the fetch is skipped entirely — + // without an offline cache there is no other metadata source, so those + // rows hide (and a local-only playlist is unaffected, which is the point). + let offline = crate::offline_fwd::engine().is_offline(); + let qobuz_ids: Vec = tracks.iter().filter_map(|t| t.qobuz_track_id).collect(); + let mut fetched: HashMap = HashMap::new(); + if !offline && !qobuz_ids.is_empty() { + match runtime.core().get_tracks_batch(&qobuz_ids).await { + Ok(list) => { + for t in list { + fetched.insert(t.id, t); + } + } + Err(e) => log::warn!("[qbz-qt] local playlist {id}: qobuz batch failed: {e}"), + } + } + + // Plex rows: ONE bulk cache lookup by rating key. + let plex_keys: Vec = tracks + .iter() + .filter_map(|t| t.plex_key.clone()) + .filter(|k| !k.is_empty()) + .collect(); + let plex_resolved: HashMap = if plex_keys.is_empty() { + HashMap::new() + } else { + tokio::task::spawn_blocking(move || { + match qbz_plex::plex_cache_get_cached_tracks_by_keys(&plex_keys) { + Ok(rows) => rows + .into_iter() + .map(crate::local_plex::map_cached_to_local_track) + .map(|t| (t.file_path.clone(), t)) + .collect(), + Err(e) => { + log::warn!("[qbz-qt] local playlist: plex cache resolve failed: {e}"); + HashMap::new() + } + } + }) + .await + .unwrap_or_default() + }; + + // Local rows: resolve by path, and stat the misses on the same worker so + // an unindexed-but-present file still renders. + let local_paths: Vec = tracks.iter().filter_map(|t| t.local_path.clone()).collect(); + let (locals, on_disk): ( + HashMap, + std::collections::HashSet, + ) = if local_paths.is_empty() { + Default::default() + } else { + tokio::task::spawn_blocking(move || { + let resolved = with_db(false, |db| { + let mut out = HashMap::new(); + for path in &local_paths { + if let Some(track) = db.get_track_by_path(path)? { + out.insert(path.clone(), track); + } + } + Ok(out) + }) + .unwrap_or_default(); + let on_disk: std::collections::HashSet = local_paths + .iter() + .filter(|p| !resolved.contains_key(*p)) + .filter(|p| std::path::Path::new(p.as_str()).exists()) + .cloned() + .collect(); + (resolved, on_disk) + }) + .await + .unwrap_or_default() + }; + + let mut rows: Vec = Vec::new(); + let (mut hidden, mut missing_files, mut unresolved) = (0usize, 0usize, 0usize); + for t in tracks { + let item = match t.source { + repo::LocalPlaylistTrackSource::Qobuz => { + let Some(tid) = t.qobuz_track_id else { + hidden += 1; + continue; + }; + if tid >= crate::local_plex::PLEX_TRACK_ID_FLOOR { + unresolved += 1; + log::warn!( + "[qbz-qt] local playlist {id}: qobuz ref {tid} is outside the catalog \ + range (legacy mis-typed row) — rendered as unavailable" + ); + RowItem::Unresolved { + kind: "qobuz", + reference: tid.to_string(), + } + } else if let Some(track) = fetched.remove(&tid) { + RowItem::Qobuz(Box::new(track)) + } else { + hidden += 1; + continue; + } + } + repo::LocalPlaylistTrackSource::Local => match t.local_path.as_ref() { + Some(p) => { + if let Some(track) = locals.get(p) { + RowItem::Local(Box::new(track.clone())) + } else if on_disk.contains(p) { + RowItem::LocalFile { path: p.clone() } + } else { + missing_files += 1; + continue; + } + } + None => { + hidden += 1; + continue; + } + }, + repo::LocalPlaylistTrackSource::Plex => { + let key = t.plex_key.clone().unwrap_or_default(); + match plex_resolved.get(&key) { + Some(track) => RowItem::Plex(Box::new(track.clone())), + None => { + unresolved += 1; + log::warn!( + "[qbz-qt] local playlist {id}: plex key {key:?} not in the Plex \ + cache — rendered as unavailable" + ); + RowItem::Unresolved { + kind: "plex", + reference: key, + } + } + } + } + }; + rows.push(LoadedRow { + position: t.position, + item, + }); + } + if hidden > 0 { + log::info!("[qbz-qt] local playlist {id}: {hidden} row(s) unavailable, hidden"); + } + if missing_files > 0 { + log::info!( + "[qbz-qt] local playlist {id}: {missing_files} local file row(s) missing on disk, hidden" + ); + } + if unresolved > 0 { + log::info!( + "[qbz-qt] local playlist {id}: {unresolved} row(s) with unresolvable refs, rendered as unavailable" + ); + } + + // Build the display rows + the playable snapshot in ONE pass, so a row's + // display id and its queue entry can never disagree. + let mut display: Vec = Vec::with_capacity(rows.len()); + let mut queue: Vec = Vec::new(); + let mut positions: HashMap = HashMap::new(); + for row in &rows { + let (item, track) = row_to_display(&row.item); + positions.insert(item.id.clone(), row.position); + if let Some(t) = track { + queue.push(t); + } + display.push(item); + } + + let covers = tokio::task::spawn_blocking({ + let id = id.clone(); + move || resolve_cover_urls_blocking(&id, 4) + }) + .await + .unwrap_or_default(); + + let doc = PlaylistDoc { + id: header.id.clone(), + name: header.name.clone(), + description: header.description.clone().unwrap_or_default(), + cover_path: header + .custom_artwork_path + .clone() + .filter(|p| !p.is_empty()) + .unwrap_or_default(), + covers, + track_count: display.len() as i32, + total_duration: total_duration_label(&rows), + tracks: display, + // A local playlist is always the user's own; there is nobody to + // follow it from and nothing to copy it into. + is_owner: true, + is_local_playlist: true, + offline_only: header.offline_only, + ..Default::default() + }; + + if let Ok(mut cur) = CURRENT_QUEUE.lock() { + *cur = queue; + } + if let Ok(mut meta) = CURRENT_META.lock() { + *meta = Some((header.id.clone(), header.offline_only)); + } + if let Ok(mut pos) = ROW_POSITIONS.lock() { + *pos = positions; + } + crate::playlist_qt::adopt_doc(doc); + true +} + +// ──────────────────────────── playback ──────────────────────────── + +/// Play the open local detail from `start_row_id` ("" = from the top). +/// +/// D8: an offline-only playlist stamps the queue via +/// `set_queue_offline_only`, which is what keeps ANY of its tracks from being +/// pushed to Qobuz Connect. The flag is set BEFORE the queue is installed and +/// cleared for every non-offline-only playlist, or a stamp would leak into the +/// next thing the user plays. +pub async fn play(runtime: &Runtime, start_row_id: &str) { + let (queue, offline_only) = { + let Ok(q) = CURRENT_QUEUE.lock() else { return }; + let offline_only = CURRENT_META + .lock() + .ok() + .and_then(|m| m.as_ref().map(|(_, o)| *o)) + .unwrap_or(false); + (q.clone(), offline_only) + }; + if queue.is_empty() { + return; + } + let start = if start_row_id.is_empty() { + 0 + } else { + queue + .iter() + .position(|t| t.id.to_string() == start_row_id) + .unwrap_or(0) + }; + runtime.core().set_queue_offline_only(offline_only); + let first_id = queue[start].id; + crate::playback_qt::set_queue_stamped(runtime, queue, Some(start), None).await; + crate::playback_qt::play_queue_track_public(runtime, first_id).await; +} + +/// Remove the open detail's row by display id, then reload. +/// +/// The row menu's "Remove from playlist" on a LOCAL detail lands here through +/// `crate::playlist_remove_track` — the Qobuz arm (`playlist_qt::remove_track`) +/// cannot serve it: it parses the open document's id as a `u64` and a +/// `local:` never parses, so it returned without doing anything (the +/// renders-and-no-ops defect the owner reported). +/// +/// The reload is what makes the row disappear: `load` rebuilds the document +/// from the repo and republishes it through `playlist_qt::adopt_doc`, so the +/// view settles on the same path the initial open uses instead of on an +/// optimistic patch that would then have to be reconciled. It is OFFLINE-SAFE: +/// the repo write is a local SQLite write and the reload degrades exactly as +/// opening the playlist offline already does (Qobuz-sourced rows hide, local +/// and Plex rows stay). +pub async fn remove_row(runtime: &Runtime, row_id: &str) { + let Some((playlist_id, _)) = CURRENT_META.lock().ok().and_then(|m| m.clone()) else { + return; + }; + let Some(position) = ROW_POSITIONS + .lock() + .ok() + .and_then(|p| p.get(row_id).copied()) + else { + log::warn!("[qbz-qt] local playlist remove: unknown row {row_id}"); + return; + }; + let pid = playlist_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::remove_track(conn, &pid, position))) + }) + }) + .await; + load(runtime, &playlist_id).await; + // The sidebar row's cover collage is built from this playlist's MEMBER + // covers, so dropping a row can change it. `reload_sidebar_including_local` + // is the offline-safe verb — `reload_sidebar()` early-returns offline and + // would leave the collage stale for exactly the users local playlists + // exist for. + crate::reload_sidebar_including_local(); +} + +/// Reorder the open detail: move the row at `from` to `to` (repo positions), +/// then reload. +/// +/// `repo::reorder` is remove-then-insert over the stored `position` column, so +/// BOTH endpoints must name rows that exist; it no-ops otherwise. The two +/// public entry points below ([`move_row`], [`reorder_row`]) are what turn the +/// VISIBLE order the view speaks in into these positions. +pub async fn reorder(runtime: &Runtime, from: i32, to: i32) { + let Some((playlist_id, _)) = CURRENT_META.lock().ok().and_then(|m| m.clone()) else { + return; + }; + if from == to { + return; + } + let pid = playlist_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + with_db(true, |db| { + Ok(db.with_connection(|conn| repo::reorder(conn, &pid, from, to))) + }) + }) + .await; + load(runtime, &playlist_id).await; +} + +/// The repo positions of two rows named by display id, in one lock. +/// +/// Hidden rows (an unresolvable Qobuz ref while offline, a local file that is +/// gone) keep their own `position` in the DB but never reach the document, so +/// the visible order has GAPS in position space. Resolving both endpoints +/// through the map instead of doing index arithmetic is what makes the move +/// land exactly at the anchor's slot across those gaps — the reference states +/// the same rule (`local_playlist.rs:1845-1848`). +fn positions_of(a: &str, b: &str) -> Option<(i32, i32)> { + let map = ROW_POSITIONS.lock().ok()?; + Some((map.get(a).copied()?, map.get(b).copied()?)) +} + +/// Arrow reorder — move the row `row_id` one slot up (`delta < 0`) or down +/// (`delta > 0`) in the open LOCAL detail's natural (repo) order. +/// +/// The local branch of the detail view's reorder chevrons, the twin of +/// `playlist_qt::move_row` (which rebuilds the Qobuz custom-order sidecar). +/// There is no sidecar here: the repo `position` IS the order, which is also +/// why the reference hides the "Custom" sort option on a local playlist (B2). +pub async fn move_row(runtime: &Runtime, row_id: &str, delta: i32) { + if delta == 0 { + return; + } + let ids = crate::playlist_qt::row_ids(); + let Some(idx) = ids.iter().position(|id| id.as_str() == row_id) else { + log::warn!("[qbz-qt] local playlist move: row {row_id} is not on the open page"); + return; + }; + let neighbour = idx as i32 + delta.signum(); + if neighbour < 0 || neighbour as usize >= ids.len() { + return; // already first / last + } + let Some((from, to)) = positions_of(row_id, &ids[neighbour as usize]) else { + return; + }; + reorder(runtime, from, to).await; +} + +/// Drag reorder — move the visible row `from_row` to insertion slot `to_slot` +/// (0..=N, the gap the pointer was released over). +/// +/// The ANCHOR row is what turns a gap into a position: moving DOWN the dragged +/// row lands right after the row above the gap (`to_slot - 1`), moving UP it +/// lands right before the row at the gap (`to_slot`). Same resolution as the +/// reference (`local_playlist.rs:1816-1832`). +pub async fn reorder_row(runtime: &Runtime, from_row: usize, to_slot: usize) { + // The two slots that drop back onto the same gap. The view already skips + // them; the guard is kept because this is a public entry point. + if to_slot == from_row || to_slot == from_row + 1 { + return; + } + let ids = crate::playlist_qt::row_ids(); + if from_row >= ids.len() || to_slot > ids.len() { + return; + } + let anchor = if to_slot > from_row { to_slot - 1 } else { to_slot }; + if anchor == from_row { + return; + } + let Some((from, to)) = positions_of(&ids[from_row], &ids[anchor]) else { + return; + }; + reorder(runtime, from, to).await; +} diff --git a/crates/qbz-qt/src/local_state.rs b/crates/qbz-qt/src/local_state.rs index 53b8d980d..f9598d740 100644 --- a/crates/qbz-qt/src/local_state.rs +++ b/crates/qbz-qt/src/local_state.rs @@ -25,8 +25,15 @@ pub const TRACKS_PAGE: u64 = 500; // handle, so it never crosses an await point). // --------------------------------------------------------------------------- +/// `unwrap_or(0)` = the GUEST profile — kept identical to +/// `library_db_qt::db_path`, which carries the full reasoning. Short version: +/// `?`-ing out here made the whole local library invisible on a machine that +/// has never completed a Qobuz login, and `users/0/` is the profile +/// `activate_offline` and `adopt_guest_profile` already use for that user. +/// The two helpers open the SAME file and must never disagree about where it +/// is. pub fn db_path() -> Option { - let uid = UserDataPaths::load_last_user_id()?; + let uid = UserDataPaths::load_last_user_id().unwrap_or(0); Some( dirs::data_dir()? .join("qbz") @@ -81,6 +88,10 @@ pub struct LocalState { pub tracks_offset: u64, pub tracks_query: String, pub tracks_sort: String, + /// Tracks-tab grouping ("off" | "album" | "artist" | "name") — persisted + /// (locallibrary_ui `tracks_group`). Client-side only: the group modes + /// reorder the loaded pages, they never touch the SQL ORDER BY. + pub tracks_group: String, pub tracks_has_more: bool, /// The FULL flattened tree (visible derivation applies the rail search). pub tree: Vec, @@ -113,6 +124,7 @@ pub fn state(f: impl FnOnce(&mut LocalState) -> R) -> R { LocalState { album_mode: prefs.albums_id_mode, tracks_sort: prefs.tracks_sort, + tracks_group: prefs.tracks_group, ..LocalState::default() } }); @@ -276,6 +288,23 @@ pub fn tracks_sort() -> String { state(|s| s.tracks_sort.clone()) } +/// Tracks-tab grouping. Persisted through the SAME read-modify-write of +/// `locallibrary_ui.json` the sort and the album identity use — the key was +/// already read back on load and preserved on write, it was only ever missing +/// its setter and its seed (PARITY-DEBT #13). +pub fn set_tracks_group(mode: &str) { + let mode = match mode { + "album" | "artist" | "name" => mode, + _ => "off", + }; + state(|s| s.tracks_group = mode.to_string()); + update_prefs(|p| p.tracks_group = mode.to_string()); +} + +pub fn tracks_group() -> String { + state(|s| s.tracks_group.clone()) +} + pub fn set_tracks_query(q: &str) { state(|s| s.tracks_query = q.to_string()); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 44407afd0..f7073837e 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -41,11 +41,28 @@ mod myqbz_bridge; mod myqbz_add_bridge; mod disco_bridge; mod blacklist_bridge; +mod playlist_picker_bridge; +// Playlist Manager. THREE singletons, not one (contract D2): the manager +// document + folder list + organisation writes here, the folder modals on +// QbzFolderEdit and the shared playlist editor on QbzPlaylistEdit — so a +// folder save cannot perturb an open playlist editor. +mod playlist_manager_bridge; +mod folder_edit_bridge; +mod playlist_edit_bridge; +// Playlist Importer (public Spotify / Apple Music / Tidal / Deezer playlists). +// Its own singleton: a separate domain from both the playlist detail and the +// manager, opened from two shell surfaces that outlive each other, and its +// modal must survive the one that opened it (05 §5.8). +mod playlist_import_bridge; mod artwork_qt; mod atmosphere_qt; mod bridge; mod discover_config_qt; mod fav_cache_qt; +// The folder-modal controller. A plain module — it declares no +// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files. +mod folder_edit_qt; +mod folders_qt; mod foryou_qt; mod genre_filter_qt; mod home_qt; @@ -54,7 +71,11 @@ mod recently_qt; mod recommendations_qt; mod library_db_qt; mod library_qt; +mod library_bulk; +mod library_prefs; +mod library_sidepanel; mod local_library_qt; +mod local_artist_match; mod local_rows; mod local_state; mod local_plex; @@ -62,6 +83,7 @@ mod local_albums; mod local_tree; mod local_artwork; mod local_playback; +mod local_playlist_qt; mod local_bridge_ops; mod local_bulk; mod local_ephemeral; @@ -106,11 +128,30 @@ mod ambient_qt; mod artist_qt; mod lyrics_qt; mod playback_qt; +mod playlist_picker_qt; +// Playlist Importer controller (the `qbz-playlist-import` crate's frontend +// half). Plain module — it declares no #[cxx_qt::bridge], so it must NOT +// appear in build.rs's rust_files. +mod playlist_import_qt; +// Playlist Manager controller, split three ways up front (the reference is +// 1053 lines BEFORE the two state machines and two serializers this port +// adds): _qt = load / cache / toolbar / publish, _rows = the pure model +// functions, _ops = the optimistic mutations. Plain modules — they declare no +// #[cxx_qt::bridge], so they must NOT appear in build.rs's rust_files. +mod playlist_manager_ops; +mod playlist_manager_qt; +mod playlist_manager_rows; +// The SHARED playlist editor's controller (rename · description · +// offline-only · delete), driven from the manager's three delegates, the +// sidebar row menu and the playlist detail header. Plain module — it declares +// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files. +mod playlist_edit_qt; mod playlist_qt; mod queue_qt; mod search_qt; mod settings_qt; mod sidebar_qt; +mod sleep_timer_qt; mod theme_qt; mod integrations_qt; mod viz_qt; @@ -154,6 +195,28 @@ static HOME_LOADED: Mutex = Mutex::new(false); /// (`warm_favorites_once`). Reset on logout with the rest. static FAV_WARMED: Mutex = Mutex::new(false); +/// Whether the sidebar tree has been built this session (`load_sidebar_once`). +/// +/// MODULE-LEVEL on purpose. It used to be a function-local `static` inside +/// `load_sidebar_once`, which made it PROCESS-scoped with no way to reach it +/// from `do_logout` — so after logout -> login the latch was still set, the +/// tree was never rebuilt for the new session, and the sidebar stayed on the +/// `"[]"` the logout block publishes until the user found the Refresh row. +/// Owner-reproduced 2026-07-31 (qbz.log 20:33:32 login -> no `sidebar loaded` +/// until 20:33:47, which is the manual refresh). Reset with its siblings below. +static SIDEBAR_LOADED: Mutex = Mutex::new(false); + +/// Drop every "once per session" latch. Called at each session ENTRY +/// (`on_session_entered`) and again on logout, so neither a logout -> login nor +/// an offline -> login transition can leave a new session reading the previous +/// one's answer. Each latch's own doc explains what it gates. +fn reset_session_latches() { + *HOME_LOADED.lock().unwrap() = false; + *LIBRARY_LOADED.lock().unwrap() = false; + *FAV_WARMED.lock().unwrap() = false; + *SIDEBAR_LOADED.lock().unwrap() = false; +} + pub(crate) fn register_qt_thread(thread: CxxQtThread) { if QT_THREAD.set(thread).is_err() { log::warn!("[qbz-qt] Qt thread handle already registered"); @@ -243,9 +306,34 @@ pub(crate) fn on_boot() { }); } -/// Post-login UI state: session header, refresh has-previous-session, -/// clear login errors/phase, switch to the shell. -fn enter_shell(session: auth_qt::SessionInfo) { +/// Everything a SESSION ENTRY owes the shell, whatever the door was. +/// +/// Extracted from [`enter_shell`] because `start_offline` — the "Start offline" +/// button, the ONLY way into the app for a user with no network — did NOT run +/// any of it. It recorded the nav entry, pushed the now-playing model and +/// switched the screen; the sidebar tree, the 1 Hz playback pump, the +/// integrations runtime, the streaming-quality seed, the settings document and +/// the persisted volume were all skipped. On a warm session (login first, then +/// offline) most of that had already run in the same process and hid the hole; +/// on a COLD offline start the shell came up with an empty sidebar (no folders, +/// no LOCAL playlists — the entire library for an account-less user), no +/// playback pump and the volume at 100 % on an exclusive-mode DAC. +/// +/// Every step below is either idempotent or self-gates offline +/// (`load_home_once` / `warm_favorites_once` return early and, importantly, +/// leave their latch UNSET so a later online session still runs them), so the +/// two callers can share it verbatim. +fn on_session_entered() { + // The once-per-session latches, reset HERE and not only in `do_logout`: + // logging in FROM an offline session (the D2 recovery banner, or the login + // screen after "Start offline") never passes through logout — verified in + // the owner's 2026-07-31 log, where the OAuth exchange at 20:33:32 follows + // the offline entry at 20:32:38 with no `logged out` line between them. A + // latch reset only at logout would leave that transition with the OFFLINE + // sidebar (folders + locals, no Qobuz playlists) for the rest of the run. + // Every caller of this function is one genuine session entry, so this is + // the one place that sees them all. + reset_session_latches(); // Phase 2: the shell mounts on the (only) "home" view; seed the nav // history and push the current now-playing model onto the bar. nav_qt::record("home"); @@ -271,15 +359,6 @@ fn enter_shell(session: auth_qt::SessionInfo) { // Phase 10: seed the playback request tier from the persisted // streaming-quality pref (Settings > Audio writes it live after this). playback_qt::set_streaming_quality(&settings_qt::streaming_quality()); - session_bridge::ui(move |mut b| { - b.as_mut().set_session_user_name(QString::from(session.display_name.as_str())); - b.as_mut().set_session_subscription(QString::from(session.subscription.as_str())); - b.as_mut().set_has_previous_session(true); - b.as_mut().set_login_error(QString::from("")); - b.as_mut().set_restore_error(QString::from("")); - b.as_mut().set_login_phase(0); - b.as_mut().set_screen(QString::from("shell")); - }); // Seed the settings document ONCE at shell entry. It used to be published // only by `navigate_to("settings")` and by a language change, so until the // user opened Settings the shell read an EMPTY doc — and the now-playing @@ -300,6 +379,21 @@ fn enter_shell(session: auth_qt::SessionInfo) { spawn(async move { playback_qt::set_volume(&rt, restored).await }); } +/// Post-login UI state: the shared session entry, then the session header, +/// has-previous-session, cleared login errors/phase and the screen switch. +fn enter_shell(session: auth_qt::SessionInfo) { + on_session_entered(); + session_bridge::ui(move |mut b| { + b.as_mut().set_session_user_name(QString::from(session.display_name.as_str())); + b.as_mut().set_session_subscription(QString::from(session.subscription.as_str())); + b.as_mut().set_has_previous_session(true); + b.as_mut().set_login_error(QString::from("")); + b.as_mut().set_restore_error(QString::from("")); + b.as_mut().set_login_phase(0); + b.as_mut().set_screen(QString::from("shell")); + }); +} + /// Login screen primary button / recovery banner: the system-browser OAuth /// flow on a background task; phases and results hop back to Qt. pub(crate) fn start_login() { @@ -345,7 +439,12 @@ pub(crate) fn cancel_login() { session_bridge::ui(|mut b| b.as_mut().set_login_phase(0)); } -/// "Start offline": unauthenticated offline session -> shell placeholder. +/// "Start offline": unauthenticated offline session -> the shell. +/// +/// It runs the SAME [`on_session_entered`] sequence a login does. It used to +/// run two lines of it (nav + now-playing), which is why an offline session +/// came up with no sidebar at all — no folders, no local playlists — and the +/// Refresh row was the only way back. See `on_session_entered`'s header. pub(crate) fn start_offline() { let runtime = app(); spawn(async move { @@ -356,6 +455,9 @@ pub(crate) fn start_offline() { } else { format!("Offline (user {user_id})") }; + // The full session entry, in the same order the login path + // uses it — NOT the two-line subset this used to run. + on_session_entered(); session_bridge::ui(move |mut b| { b.as_mut().set_session_user_name(QString::from(name.as_str())); b.as_mut().set_session_subscription(QString::from("")); @@ -364,8 +466,6 @@ pub(crate) fn start_offline() { b.as_mut().set_login_phase(0); b.as_mut().set_screen(QString::from("shell")); }); - nav_qt::record("home"); - now_playing::publish_current(); } Err(e) => { log::error!("[qbz-qt] failed to enter offline mode: {e}"); @@ -397,9 +497,25 @@ pub(crate) fn do_logout() { // and re-warm the favourite-id cache that `auth_qt::logout` just // emptied — otherwise the next account's hearts stay blank until it // opens Library. - *HOME_LOADED.lock().unwrap() = false; - *LIBRARY_LOADED.lock().unwrap() = false; - *FAV_WARMED.lock().unwrap() = false; + // + // The SIDEBAR latch is in that set too, for the same reason plus a + // sharper one: the block below publishes `"[]"` into the tree, so a + // latched `load_sidebar_once` leaves the next session staring at an + // empty sidebar with no way back except the Refresh row + // (owner-reproduced 2026-07-31). It is only resettable at all because + // it now lives at module level — see SIDEBAR_LOADED. + // + // `on_session_entered` resets the same set: logout is NOT the only way + // a session ends (offline -> login never passes through here). + reset_session_latches(); + // The tree's own CACHE, not just the published document. It holds the + // outgoing user's playlists, folders, folder membership, hidden set and + // local rows, and NOTHING else clears it — every `publish_sidebar()` + // rebuilds straight from it (the Playlist Manager's optimistic + // move-to-folder does exactly that), so a leftover cache re-renders the + // previous account's tree for the next one. Same class as the + // `local_library_qt::reset()` above. + sidebar_qt::teardown(); session_bridge::ui(|mut b| { b.as_mut().set_session_user_name(QString::from("")); b.as_mut().set_session_subscription(QString::from("")); @@ -473,17 +589,26 @@ fn warm_favorites_once() { // ============================ Sidebar + pins (phase 7) ===================== /// Load + publish the sidebar tree (session entry; idempotent per session). +/// +/// It goes through [`reload_sidebar_including_local`], NOT [`reload_sidebar`], +/// and it has no offline early return of its own. A session that STARTS +/// offline used to get an entirely empty sidebar — no folders, no local +/// playlists — which is the whole library for a user with no Qobuz account. +/// `sidebar_qt::load` gates only its Qobuz fetch (preserving whatever the +/// cache already holds), so offline this reads folders + locals and publishes +/// them; online it is the same call it always was. +/// The latch is [`SIDEBAR_LOADED`], a MODULE-level static reset by `do_logout` +/// — see its declaration for the logout -> login bug a function-local one +/// caused. fn load_sidebar_once() { - static LOADED: Mutex = Mutex::new(false); - if *LOADED.lock().unwrap() { - return; + { + let mut guard = SIDEBAR_LOADED.lock().unwrap(); + if *guard { + return; + } + *guard = true; } - if offline_fwd::engine().status().is_offline() { - log::info!("[qbz-qt] sidebar load skipped (offline session)"); - return; - } - *LOADED.lock().unwrap() = true; - reload_sidebar(); + reload_sidebar_including_local(); } /// Fetch playlists + folders and publish the flattened entries. @@ -498,7 +623,32 @@ pub(crate) fn reload_sidebar() { }); } -fn publish_sidebar() { +/// Same rebuild, but it also runs OFFLINE. +/// +/// `reload_sidebar` bails while offline because its Qobuz fetch cannot +/// succeed. The sidebar's LOCAL playlists can still change in that state — the +/// picker's D8 branch creates one offline — and for a user with no Qobuz +/// account they are the entire sidebar, so a no-op there means the playlist +/// they just created never appears. `sidebar_qt::load` already reads the +/// locals independently of the Qobuz fetch and survives it failing or being +/// gate-refused, so there is nothing to guard against here beyond one wasted +/// (and already-handled) request. +pub(crate) fn reload_sidebar_including_local() { + let runtime = app(); + spawn(async move { + sidebar_qt::load(&runtime).await; + publish_sidebar(); + }); +} + +/// Republish the flattened entries from the sidebar CACHE — no fetch, no DB +/// read. +/// +/// `pub(crate)` since the Playlist Manager landed: its optimistic move-to-folder +/// patches `sidebar_qt::CACHE` in place and then republishes from it, which is +/// the only refresh that is correct offline AND does not cost a round trip +/// (contract D10). +pub(crate) fn publish_sidebar() { let entries = sidebar_qt::rebuild(); let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()); log::debug!("[qbz-qt] sidebar published: {} entries ({} bytes)", entries.len(), json.len()); @@ -525,6 +675,39 @@ pub(crate) fn sidebar_toggle_folder(id: &str) { publish_sidebar(); } +/// Mini-rail folder flyout: publish that folder's playlists (contract §4.7). +/// +/// This exists so the flyout does NOT have to force-expand the folder to see +/// its children. `sidebar_json` only carries a folder's rows while it is +/// EXPANDED, so the old path called `sidebar_toggle_folder` on the way in — a +/// persistent side effect (the folder stayed open once the sidebar was +/// re-opened) that the reference does not have. `sidebar_qt::folder_popup_rows` +/// answers from the session CACHE instead, which is also what makes this work +/// offline and on the Qt thread. +/// +/// `count` is `rows.len()`, deliberately: the reference takes it from the entry +/// row, which is computed AFTER the playlist search filter that +/// `load_folder_popup` does not apply, so its header count and its list can +/// legitimately disagree. `folderName` is a best-effort cache read — the flyout +/// takes the name it renders from the clicked entry, synchronously, because +/// this document lands a later event-loop turn. +pub(crate) fn sidebar_open_folder_popup(folder_id: &str) { + let rows = sidebar_qt::folder_popup_rows(folder_id); + let count = rows.len(); + let name = sidebar_qt::folder_name(folder_id).unwrap_or_default(); + let doc = serde_json::json!({ + "folderId": folder_id, + "folderName": name, + "count": count, + "rows": rows, + }); + let json = doc.to_string(); + log::debug!("[qbz-qt] sidebar folder popup: {folder_id} ({count} rows)"); + shell_bridge::ui(move |mut b| { + b.as_mut().set_sidebar_folder_popup_json(QString::from(json.as_str())); + }); +} + /// Sidebar cover dispatch: plain url list (the tree's collage is /// url-keyed). Disk hits emit immediately; misses download then emit. pub(crate) fn sidebar_artwork_window(urls_json: String) { @@ -648,7 +831,48 @@ pub(crate) fn load_release_section(artist_id: String, release_type: String, offs /// Sidebar "+" — create an empty playlist with the default name, then /// reload the tree (the Slint flow opens a naming modal — POC-NOTE). +/// +/// OFFLINE it creates a LOCAL playlist instead, which is the reference's D8 +/// rule collapsed onto this port's no-modal shortcut: `on_create_playlist` +/// (qbz/src/main.rs:21109) opens the create modal with `offline_only` set ON +/// **and LOCKED** while offline, so creation there always produces a local +/// playlist. Without this arm the "+" was a fully lit, pointer-cursor button +/// whose entire offline effect was `log::error!` — the dead-control class the +/// owner's standing rule forbids, on the ONE surface a user with no Qobuz +/// account owns. `offline_only = true` matches the picker's own offline create +/// (`playlist_picker_qt.rs:555-559`): the playlist is never offered for upload +/// and never reaches a QConnect push. pub(crate) fn create_playlist() { + if offline_fwd::engine().status().is_offline() { + // The SAME default name the online arm uses — one msgid, one label, + // whichever door the user came through. + let name = qbz_i18n::t("New Playlist"); + spawn(async move { + let created = tokio::task::spawn_blocking(move || { + local_playlist_qt::create_blocking(&name, None, true) + }) + .await + .ok() + .flatten(); + match created { + Some(new_id) => { + log::info!("[qbz-qt] local playlist created offline: {new_id}"); + // The offline-safe verb — `reload_sidebar` early-returns + // here, which is exactly this branch. + reload_sidebar_including_local(); + // Land on it, like the online arm does. `open_playlist` + // routes a `local:` id to the local loader and does NOT + // offline-gate it. + open_playlist(new_id); + } + None => { + log::error!("[qbz-qt] offline local playlist create failed"); + toast_qt::error(qbz_i18n::t("Couldn't create the playlist")); + } + } + }); + return; + } let runtime = app(); spawn(async move { match runtime @@ -795,6 +1019,31 @@ pub(crate) fn queue_toggle_favorite(kind: String, id: String) { spawn(async move { queue_qt::toggle_favorite(&runtime, &kind, &id).await }); } +pub(crate) fn queue_toggle_stop_after(id: String) { + let runtime = app(); + spawn(async move { queue_qt::toggle_stop_after(&runtime, &id).await }); +} + +pub(crate) fn queue_toggle_infinite_play() { + let runtime = app(); + spawn(async move { queue_qt::toggle_infinite_play(&runtime).await }); +} + +pub(crate) fn queue_save_as_playlist() { + let runtime = app(); + spawn(async move { queue_qt::save_as_playlist(&runtime).await }); +} + +pub(crate) fn queue_add_to_playlist(index: i32) { + let runtime = app(); + spawn(async move { queue_qt::add_to_playlist(&runtime, index.max(0) as usize).await }); +} + +pub(crate) fn queue_panel_opened() { + let runtime = app(); + spawn(async move { queue_qt::panel_opened(&runtime).await }); +} + /// AlbumView header Shuffle. pub(crate) fn play_album_shuffled(album_id: String) { let runtime = app(); @@ -872,6 +1121,21 @@ pub(crate) fn play_track(track_id: u64) { /// Open the playlist detail view (sidebar row / playlist card click). pub(crate) fn open_playlist(playlist_id: String) { + // LOCAL playlists (`local:`) route to their own loader and open + // REGARDLESS of connectivity. They are the whole feature for people using + // QBZ as a player without Qobuz — refusing them while offline would gate + // local files behind a network the user does not have. + if local_playlist_qt::is_local_id(&playlist_id) { + nav_qt::record("playlist"); + ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); + let runtime = app(); + spawn(async move { + if !local_playlist_qt::load(&runtime, &playlist_id).await { + log::warn!("[qbz-qt] local playlist {playlist_id} load failed"); + } + }); + return; + } if offline_fwd::engine().status().is_offline() { return; } @@ -883,6 +1147,9 @@ pub(crate) fn open_playlist(playlist_id: String) { // Clear the previous playlist before the fetch — same stale-render as the // album and artist views had. ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); + // A Qobuz detail must not inherit the previous LOCAL detail's snapshot, or + // its rows would resolve against the wrong playlist's queue. + local_playlist_qt::clear_open_snapshot(); let runtime = app(); spawn(async move { if let Err(e) = playlist_qt::load(&runtime, pid).await { @@ -914,6 +1181,13 @@ pub(crate) fn myqbz_open_detail(id: String) { pub(crate) fn playlist_play_all() { let runtime = app(); spawn(async move { + // A LOCAL detail plays from its OWN resolved snapshot: its rows are a + // mix of catalog, file and Plex tracks that the Qobuz play-all path + // cannot build, and an offline-only one has to stamp the queue. + if local_playlist_qt::open_id().is_some() { + local_playlist_qt::play(&runtime, "").await; + return; + } if let Err(e) = playlist_qt::play_all(&runtime).await { log::error!("[qbz-qt] playlist play-all failed: {e}"); } @@ -975,9 +1249,66 @@ pub(crate) fn playlist_enqueue_track(track_id: String, mode: String) { }); } -pub(crate) fn playlist_remove_track(playlist_track_id: u64) { +/// Row ⋯ "Remove from playlist", BY DISPLAY ROW ID. +/// +/// The id is a string because a LOCAL playlist's rows are not all catalog +/// tracks: a local-file row's id is a library row id, an unresolved one is +/// `"plex:"` or a bare path. The Qobuz arm parses it back to the +/// membership row id, which for a Qobuz playlist IS the catalog id +/// (`playlist_qt.rs:364` sets `playlist_track_id: track.id`). +/// +/// Routing by OPEN DETAIL, not by the id's shape: `local_playlist_qt::open_id` +/// is `Some` only while a `local:` detail is the open page (`open_playlist` +/// clears the snapshot before a Qobuz load), and it is the same test +/// `playlist_play_all` already routes on. +pub(crate) fn playlist_remove_track(row_id: String) { let runtime = app(); - spawn(async move { playlist_qt::remove_track(&runtime, playlist_track_id).await }); + spawn(async move { + if local_playlist_qt::open_id().is_some() { + local_playlist_qt::remove_row(&runtime, &row_id).await; + return; + } + let Ok(playlist_track_id) = row_id.parse::() else { + log::warn!("[qbz-qt] playlist remove: non-numeric row id {row_id}"); + return; + }; + playlist_qt::remove_track(&runtime, playlist_track_id).await + }); +} + +/// Drag-reorder drop: move the visible row `from` to insertion slot `slot`. +/// +/// Routes like the chevrons below: a LOCAL playlist writes the repo `position` +/// order directly (no sidecar — the repo order IS the order), a Qobuz one +/// rebuilds the custom-order sidecar. Same split as the reference +/// (`main.rs:20815` `on_reorder_track`). +pub(crate) fn playlist_reorder(from: i32, slot: i32) { + if from < 0 || slot < 0 || slot == from || slot == from + 1 { + return; + } + if local_playlist_qt::open_id().is_some() { + let runtime = app(); + spawn(async move { + local_playlist_qt::reorder_row(&runtime, from as usize, slot as usize).await; + }); + return; + } + playlist_qt::reorder_track(from as usize, slot as usize); +} + +/// Per-row reorder chevrons: -1 = up, +1 = down. Same routing as the drag. +pub(crate) fn playlist_move_row(row_id: String, delta: i32) { + if delta == 0 { + return; + } + if local_playlist_qt::open_id().is_some() { + let runtime = app(); + spawn(async move { + local_playlist_qt::move_row(&runtime, &row_id, delta).await; + }); + return; + } + playlist_qt::move_row(&row_id, delta); } // --- Drag & drop (DragState + DragActions, state.slint parity) ----------- diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index af1c7e6d2..a1fe2c432 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -1958,12 +1958,34 @@ pub(crate) fn bulk_action(id: String) { "remove-selected" => crate::myqbz_edit_qt::remove_selected(), "clear" => clear_selection(), // The Slint's seventh action resolves the selection to Qobuz track ids - // and opens the global playlist picker. This port has no playlist picker - // yet — the same gap LabelView.qml:18 and MixView.qml:32 already record - // for their own bars — so the entry stays inert rather than silently - // doing something else. + // and opens the global playlist picker (LIVE here since + // QbzPlaylistPicker landed). + // + // Only QOBUZ TRACK items resolve to a catalog id. An album or playlist + // item is a container whose members this bar never fetched, and a + // local / Plex item's `source_item_id` is a library path or a rating + // key — the picker's Qobuz arm would read either as a catalog id and + // add an unrelated track (the id-confusion class + // `playlist_picker_qt.rs`'s header exists to make unrepresentable). + // Those rows are SKIPPED and counted in the log rather than guessed at. "add-to-playlist" => { - log::warn!("[qbz-qt] myqbz_detail bulk_action: add-to-playlist has no picker in this port"); + let items = selected_full_items(); + let ids: Vec = items + .iter() + .filter(|it| it.item_type == ItemType::Track && it.source == AlbumSource::Qobuz) + .map(|it| it.source_item_id.clone()) + .collect(); + let skipped = items.len() - ids.len(); + if skipped > 0 { + log::info!( + "[qbz-qt] myqbz_detail add-to-playlist: skipped {skipped} non-Qobuz-track \ + item(s) (album / playlist containers and local / Plex refs have no catalog id)" + ); + } + if ids.is_empty() { + return; + } + crate::playlist_picker_qt::open_for_ids(&crate::app(), ids); } other => { log::warn!("[qbz-qt] myqbz_detail bulk_action: unknown id {other}"); diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index f0e08a724..fcbf6eb27 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -18,7 +18,8 @@ //! Ids in use: `home`, `library`, `local`, `localalbum`, `album`, `artist`, //! `settings`, `search`, `playlist`, `discoverbrowse`, `playlistbrowse`, //! `recentalbums`, `mostplayedalbums`, `label`, `labelreleases`, `mix`, -//! `mixtapes`, `collections`, `mixtapedetail`, `discobuilder`, `blacklist`. +//! `mixtapes`, `collections`, `mixtapedetail`, `discobuilder`, `blacklist`, +//! `playlistmanager`. use std::sync::Mutex; @@ -54,6 +55,19 @@ pub fn record(view: &str) { publish(can_back, can_forward, current); } +/// The id of the entry the user is standing on. +/// +/// Read-only, and the ONLY reader today is `playlist_qt::back_if_showing` +/// (contract §5.1): a delete may only navigate away when the deleted playlist +/// is the one whose detail page is open, or deleting a row from the Playlist +/// Manager throws the user off the manager. The bridge already publishes this +/// as `QbzShell.currentView`, but a Rust caller has no way to read a Qt +/// property back off the Qt thread — hence the accessor rather than a QML +/// round trip. +pub fn current_view() -> String { + with_history(|h| h.entries[h.index].clone()) +} + /// Move one entry back, if possible. pub fn back() { let (can_back, can_forward, current) = with_history(|h| { diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 74936a6c5..99a55e9bf 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -17,11 +17,15 @@ //! - Streaming quality: seeded from ui_prefs ("streaming_quality") and //! live-updated by Settings > Audio (settings_qt). The #638 device-cap clamp //! lives in the Slint glue and is NOT ported. -//! - Offline-cache tier (offline bytes), prefetch warming, stop-after, -//! infinite refill, QConnect branches. +//! - Offline-cache tier (offline bytes), prefetch warming, QConnect branches. //! //! DONE, previously listed here — kept as one line each so the list above //! only ever names REAL gaps: +//! - Stop-after-this-song (#35) — the end-of-track arm consumes the marker and +//! pauses AHEAD of repeat/shuffle, and the gapless pre-queue guard that was +//! already here now defends a marker the UI can actually place. +//! - Infinite refill (#33) — `try_infinite_refill` chains a smart artist radio +//! off the ended track when the queue runs out and the ∞ toggle is on. //! - Gapless prefetch: the engine raises `gapless_ready` ~10s out and this //! module now answers it with `play_next` (both the streaming and the //! local/DSD arms), so transitions are sample-accurate again. @@ -356,7 +360,11 @@ fn warn_dead_context(tracks: &[QueueTrack], site: &str) { /// Fetch the album, build the queue (playback.rs `make_queue_track` port), /// set it starting at track 1, and play through the core's resolved path. -async fn fetch_album_queue( +/// `pub(crate)` so `library_bulk` can build ONE concatenated queue out of a +/// multi-album selection and hand it to `enqueue_track_list_mode` — going +/// through `enqueue_album` per id would publish the queue N times and, for +/// "later", append instead of landing in the manual block's tail. +pub(crate) async fn fetch_album_queue( runtime: &Arc>, album_id: &str, ) -> Result, String> { @@ -776,6 +784,68 @@ pub async fn play_track_list_in( Ok(()) } +/// When the queue is exhausted and `InfiniteRadio` autoplay is on, build a +/// smart artist radio seeded by the just-finished track and start it, replacing +/// the spent queue. Returns `true` when a radio actually started. +/// +/// Port of `playback.rs::try_infinite_refill`, including the reason it does not +/// mirror Tauri literally: Tauri appends the radio and retries `next()`, which +/// cannot work here because `QueueManager::next()` has already nulled +/// `current_index` at the queue edge — the retry would replay the OLD queue +/// from index 1 instead of the radio. Starting the radio fresh reaches the +/// intended behaviour and chains: each radio's end re-seeds the next one, which +/// is what makes it infinite rather than one extra radio. +/// +/// `play_track_list` runs the queue through `filter_blacklisted_queue`, so a +/// radio made entirely of blocked artists cannot come back as playback. +async fn try_infinite_refill( + runtime: &Arc>, + seed_track_id: u64, +) -> bool { + if !crate::queue_qt::is_infinite_play() || seed_track_id == 0 { + return false; + } + let artist_id = match runtime.core().get_track(seed_track_id).await { + Ok(track) => match track.performer.as_ref().map(|p| p.id) { + Some(id) => id, + None => { + log::warn!( + "[qbz-qt] infinite radio: seed track {seed_track_id} has no performer" + ); + return false; + } + }, + Err(e) => { + log::warn!("[qbz-qt] infinite radio: get_track {seed_track_id} failed: {e}"); + return false; + } + }; + match runtime.core().create_smart_artist_radio(artist_id).await { + Ok(tracks) if !tracks.is_empty() => { + let queue: Vec = + tracks.iter().map(crate::foryou_qt::to_queue_track).collect(); + match play_track_list(runtime, queue, 0, false).await { + Ok(()) => { + log::info!( + "[qbz-qt] infinite radio: chained {} track(s) from artist {artist_id}", + tracks.len() + ); + true + } + Err(e) => { + log::warn!("[qbz-qt] infinite radio: play failed: {e}"); + false + } + } + } + Ok(_) => false, + Err(e) => { + log::warn!("[qbz-qt] infinite radio: build failed: {e}"); + false + } + } +} + /// Append a pre-built queue to the current one ("Add all to queue"). pub async fn enqueue_track_list( runtime: &Arc>, @@ -1543,12 +1613,11 @@ pub fn start_poll_loop(runtime: Arc>) { // the current one still plays, via `Player::play_next`, which // appends to the same rodio sink and is what makes the transition // sample-accurate. - // The stop-after guard is last and short-circuited. It is a NO-OP - // today — this port has no UI that marks a track (queue_qt.rs:10 - // records the cut) so the core always answers None — but it is - // kept 1:1 because the day the marker lands, its absence is a - // silent bug: the engine hands off seamlessly and the marker never - // fires, which is exactly what playback.rs:5372-5377 warns about. + // The stop-after guard is last and short-circuited. It is LIVE now + // (the queue row menu can place the marker): without it the engine + // would hand the next track's bytes over seamlessly and the marker + // would never fire, which is exactly what playback.rs:5372-5377 + // warns about. if event.gapless_ready && event.gapless_next_track_id == 0 && track_id != 0 @@ -1653,16 +1722,57 @@ pub fn start_poll_loop(runtime: Arc>) { && duration > 0 && seen_position + 2 >= duration; if track_ended { + // Seed for InfiniteRadio, read BEFORE anything moves the + // cursor: the track that just ended is still the current one. + let ended_track_id = runtime + .core() + .current_track() + .await + .map(|t| t.id) + .unwrap_or(0); + // Stop-after-this-song: if the track that just ended carries + // the marker, HALT here — do not advance, do not refill. The + // queue stays intact and the finished track stays parked in + // now-playing, so pressing play resumes from where the user + // left off. `consume_stop_after_if` is one-shot (it clears the + // marker), and this runs AHEAD of repeat/shuffle exactly as + // the reference does (playback.rs:5749-5772) — behind them the + // marker would lose to a repeat-one and never fire. + if ended_track_id != 0 + && runtime.core().consume_stop_after_if(ended_track_id).await + { + if let Err(e) = runtime.core().pause() { + log::warn!("[qbz-qt] stop-after: pause failed: {e}"); + } + last_track_id = 0; + seen_position = 0; + gapless_requested_for = 0; + crate::now_playing::set_playing(false); + // Republish so the row drops its CircleStop in the same + // frame the marker is consumed. + crate::queue_qt::publish(&runtime).await; + // The tail-of-loop bookkeeping, inlined because this arm + // returns to the top early and must NOT run + // `seen_position = position` (the reset above is the + // point). Dropping the Discord push here would leave the + // presence saying "playing" after the timer stopped us. + if is_playing != was_playing { + crate::integrations_qt::discord_push(&runtime); + } + was_playing = is_playing; + continue; + } last_track_id = 0; - // POC-NOTE: no stop-after / infinite-refill — the core queue's - // own next (repeat/shuffle aware). The skip-unavailable walk - // this note also used to claim is missing IS ported: a failed - // play inside `play_queue_track` hands off to - // `auto_skip_unavailable` (PARITY-DEBT #2). + // The skip-unavailable walk is ported: a failed play inside + // `play_queue_track` hands off to `auto_skip_unavailable` + // (PARITY-DEBT #2). if let Some(track) = runtime.core().next_track().await { let next_id = track.id; log::info!("[qbz-qt] poll: advancing to {next_id}"); play_queue_track(&runtime, next_id).await; + } else if try_infinite_refill(&runtime, ended_track_id).await { + // InfiniteRadio started a fresh smart radio; it replaced + // the queue and published on its way in. } else { log::info!("[qbz-qt] poll: queue finished"); crate::now_playing::set_playing(false); diff --git a/crates/qbz-qt/src/playlist_edit_bridge.rs b/crates/qbz-qt/src/playlist_edit_bridge.rs new file mode 100644 index 000000000..97472b0c0 --- /dev/null +++ b/crates/qbz-qt/src/playlist_edit_bridge.rs @@ -0,0 +1,192 @@ +//! QbzPlaylistEdit — the SHARED playlist editor's QML singleton (contract +//! §3.3, block 3). Port of `primitives/EditPlaylistModal.slint` plus the +//! `EditPlaylistActions` handlers in the reference's `main.rs`. +//! +//! The third of the three singletons the Playlist Manager surface owns (D2): +//! `QbzPlaylistManager` holds the manager document and the organisation +//! writes, `QbzFolderEdit` owns the two folder panels, and this one owns the +//! ONE rename / description / offline-only / delete modal — for BOTH kinds of +//! playlist (`local:` and a Qobuz `u64`) and from every surface that +//! offers "Edit playlist": +//! +//! * the manager's grid card, list row and tree row (block 4), +//! * the sidebar's row context menu (block 5), +//! * the playlist DETAIL header's pencil (block 7) — which is why +//! `qml/views/PlaylistView.qml`'s inline rename+delete popup is deleted: +//! it could express neither a description nor the offline-only flag, and +//! a second editor is exactly the fork the port rule forbids. +//! +//! It is a SEPARATE singleton from `QbzPlaylist` for the same reason +//! `QbzPlaylistPicker` is: its callers live outside the detail view, and in +//! most of those sessions no playlist detail is open at all — so nothing here +//! may read the open document, and a detail republish can never perturb an +//! open editor. +//! +//! # ONE document, and the id is NOT in QML's hands +//! +//! `editJson` (§4.6) carries the seeds plus `descLoaded`, `isLocal` and +//! `busy`. The id being edited lives in RUST state and is never echoed back +//! from QML: a republish can then never make the modal save under the wrong +//! id, which is the same rule `folder_edit_qt` holds the folder id under. +//! +//! # `descLoaded` is load-bearing, not cosmetic (§5.2) +//! +//! `false` means the real description could not be resolved. The modal then +//! does NOT render the description field at all and `save` passes `None`, +//! which `update_playlist` reads as "leave it alone". A field the user never +//! saw can never overwrite stored data. The reference seeds `""` and always +//! sends `Some(trimmed)`, so renaming a Qobuz playlist from its manager +//! DELETES its description; the local branch writes SQL NULL. Neither is +//! ported. +//! +//! # `busy` HOLDS THE MODAL OPEN (D22) +//! +//! Save and delete disarm the buttons and keep the panel up; it closes on +//! success only, and a failure stays open and toasts. `close()` is refused +//! while busy. Same shape as the folder panels. +//! +//! **No `#[qsignal]`** — outcomes are republishes and toasts +//! (`playlist_picker_bridge.rs:13`). +//! +//! `delete` is a C++ keyword, so the member is `delete_playlist` / +//! `deletePlaylist` (§3.3). The module is `qbz_playlist_edit_bridge` — a +//! bridge module is never named after a workspace crate (E0659). + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_playlist_edit_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // The ONE editor document (§4.6): open / id / name / description / + // descLoaded / isLocal / offlineOnly / busy. Parseable default so a + // binding reading `doc.open` on the pre-publish frame cannot throw. + #[qproperty(QString, edit_json)] + + type QbzPlaylistEdit = super::QbzPlaylistEditRust; + + /// Registers this object's Qt-thread hop. Without it every `ui()` + /// publish from Rust is dropped on the floor, silently and forever: + /// the modal mounts, no document ever arrives, `editJson` stays at + /// its default and NOTHING is logged on either side. + #[qinvokable] + fn boot(self: Pin<&mut QbzPlaylistEdit>); + + /// Open the editor on `id` — either a Qobuz `u64` spelled as a string + /// or a `local:`. The seeds (name, description, offline-only) + /// are resolved BEFORE the modal opens, so it never flashes an empty + /// name field; §5.2 gives the resolution order per kind. + /// + /// A `local:` id resolves through a pure DB read, so the editor works + /// offline and for an account-less user — the population local + /// playlists exist for. + #[qinvokable] + fn open(self: Pin<&mut QbzPlaylistEdit>, id: QString); + + /// Persist. Name / description / offline-only are QML-LOCAL drafts and + /// arrive as arguments (`MyQbzModals.qml`'s convention); the id and + /// `descLoaded` are Rust-owned. `offline_only` is ignored for a Qobuz + /// playlist — the flag is a local-playlist column. + #[qinvokable] + fn save( + self: Pin<&mut QbzPlaylistEdit>, + name: QString, + description: QString, + offline_only: bool, + ); + + /// Delete the open playlist. `delete` is a C++ keyword, hence the + /// name. A Qobuz playlist re-derives ownership first and a FOLLOWED + /// one is unsubscribed instead (§5.1); the back-navigation is + /// CONDITIONAL on the user actually standing on that playlist's + /// detail page. + #[qinvokable] + fn delete_playlist(self: Pin<&mut QbzPlaylistEdit>); + + /// Dismiss. Refused while a save or delete is in flight (D22). + #[qinvokable] + fn close(self: Pin<&mut QbzPlaylistEdit>); + } + + impl cxx_qt::Threading for QbzPlaylistEdit {} +} + +use qbz_playlist_edit_bridge::QbzPlaylistEdit; + +/// Rust side of the playlist-editor bridge (plain storage, phase-1 pattern). +pub struct QbzPlaylistEditRust { + edit_json: QString, +} + +impl Default for QbzPlaylistEditRust { + fn default() -> Self { + Self { + // Closed and parseable: `doc.open === true` on frame 1 is false, + // and no binding in the modal throws before the first publish. + edit_json: QString::from("{\"open\":false}"), + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue an editor mutation onto the Qt event loop (no-op before `boot()` +/// registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPlaylistEdit>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_playlist_edit_bridge::QbzPlaylistEdit { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] playlist edit Qt thread already registered"); + } + // Publish the closed document once, so the modal's parse sees the + // full shape rather than the terser Default literal. + crate::playlist_edit_qt::publish(); + } + + pub fn open(self: Pin<&mut Self>, id: QString) { + crate::playlist_edit_qt::open(&id.to_string()); + } + + pub fn save( + self: Pin<&mut Self>, + name: QString, + description: QString, + offline_only: bool, + ) { + crate::playlist_edit_qt::save( + &name.to_string(), + &description.to_string(), + offline_only, + ); + } + + pub fn delete_playlist(self: Pin<&mut Self>) { + crate::playlist_edit_qt::delete_playlist(); + } + + pub fn close(self: Pin<&mut Self>) { + crate::playlist_edit_qt::close(); + } +} diff --git a/crates/qbz-qt/src/playlist_edit_qt.rs b/crates/qbz-qt/src/playlist_edit_qt.rs new file mode 100644 index 000000000..ed7f1d126 --- /dev/null +++ b/crates/qbz-qt/src/playlist_edit_qt.rs @@ -0,0 +1,539 @@ +//! Shared playlist editor controller — the Rust half of `QbzPlaylistEdit` +//! (contract block 3). Port of `primitives/EditPlaylistModal.slint` + +//! the reference's `EditPlaylistActions` handlers. +//! +//! ONE editor for BOTH kinds of playlist and for every surface that offers +//! "Edit playlist": the manager's three delegates, the sidebar row menu and +//! the playlist detail header's pencil. +//! +//! # What is state here and what is state in QML +//! +//! Name, description and the offline-only checkbox are QML-LOCAL drafts; +//! `save(name, description, offlineOnly)` takes all three as arguments +//! (`MyQbzModals.qml`'s convention). What lives HERE is only what QML cannot +//! own: +//! * the id being edited — held in Rust so a republish can never make the +//! modal save under a different playlist; +//! * `descLoaded` — a resolution OUTCOME, and the gate that decides whether +//! a description write happens at all; +//! * `isLocal` — the kind, which decides every branch below; +//! * `busy` — it gates the writes, so the writer owns it. +//! +//! # The three rules a naive transcription gets wrong +//! +//! **1. A rename must CARRY the description (§5.2).** The reference seeds +//! `description: ""` from the manager and always sends `Some(trimmed)` into +//! `update_playlist`, so renaming a Qobuz playlist from the manager DELETES +//! its description; the local branch is the same story with SQL NULL +//! (`local_playlists.rs set_description(conn, id, None)`). Here the real +//! description is RESOLVED before the modal opens, and when it could not be +//! resolved (`desc_loaded == false`) the field is not rendered and `save` +//! passes `None` — which `update_playlist` reads as "leave it alone". A field +//! the user never saw can never overwrite stored data. +//! +//! **2. A Qobuz delete must RE-DERIVE ownership (§5.1).** `playlist/delete` +//! returns 200 and silently no-ops on a playlist you do not own — the +//! "deleted ok but it stays" bug. A FOLLOWED playlist goes through +//! `unsubscribe_playlist`. That branch lives in +//! `playlist_qt::delete_by_id`, which this module delegates to rather than +//! reimplementing. +//! +//! **3. The back-navigation after a delete is CONDITIONAL (§5.1).** Deleting +//! from the manager or from the sidebar must NOT navigate — an unconditional +//! `nav_qt::back()` throws the user off the Playlist Manager the moment they +//! delete a row. `playlist_qt::back_if_showing` fires only when the open +//! detail IS the deleted playlist. +//! +//! # Offline and account-less users are first-class here +//! +//! A `local:` id resolves, saves and deletes through pure `library.db` reads +//! and writes — no network, no Qobuz account. So none of that path may be +//! routed through `crate::reload_sidebar()`, which opens with an +//! `is_offline()` early return: every refresh here goes through +//! `crate::reload_sidebar_including_local()` (D10) plus +//! `playlist_manager_qt::reload_if_loaded()`, the ONE call allowed to +//! early-return on a cold manager cache (§5.3). +//! +//! # Thread rules (§5.19) +//! +//! `LibraryDatabase` wraps a `!Send` rusqlite connection, so every +//! `local_playlist_qt` call is `spawn_blocking`-only. An invokable mutates a +//! `Mutex`, publishes, and `crate::spawn`s the rest. + +use std::sync::{LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Default)] +struct EditState { + open: bool, + /// `""` while closed; otherwise `"12345678"` or `"local:"`. Kept as + /// a STRING all the way down — nothing may parse a `local:` id as an + /// integer (§3.1). + id: String, + /// SEEDs, consumed once by QML on the open transition. + name: String, + description: String, + /// `false` ⇒ the real description could not be resolved. The modal then + /// hides the field and `save` passes `None` (§5.2). + desc_loaded: bool, + is_local: bool, + offline_only: bool, + busy: bool, +} + +static EDIT: LazyLock> = LazyLock::new(|| Mutex::new(EditState::default())); + +fn state() -> std::sync::MutexGuard<'static, EditState> { + EDIT.lock().unwrap_or_else(|e| e.into_inner()) +} + +// --------------------------------------------------------------------------- +// Document (§4.6) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EditDoc { + open: bool, + id: String, + name: String, + description: String, + desc_loaded: bool, + is_local: bool, + offline_only: bool, + busy: bool, +} + +/// Serialize a snapshot. Split from [`edit_doc`] so the shape is testable +/// without touching the process-global `EDIT` — tests run in parallel threads +/// and a test that locked the global would race every other one. +fn edit_doc_of(st: EditState) -> String { + serde_json::to_string(&EditDoc { + open: st.open, + id: st.id, + name: st.name, + description: st.description, + desc_loaded: st.desc_loaded, + is_local: st.is_local, + offline_only: st.offline_only, + busy: st.busy, + }) + .unwrap_or_else(|_| "{\"open\":false}".to_string()) +} + +fn edit_doc() -> String { + edit_doc_of(state().clone()) +} + +/// Publish the document. Also called from `boot()` so the closed FULL shape is +/// on the QML side before the first open. +pub(crate) fn publish() { + let json = edit_doc(); + crate::playlist_edit_bridge::ui(move |mut b| { + b.as_mut().set_edit_json(QString::from(json.as_str())); + }); +} + +// --------------------------------------------------------------------------- +// The shared post-write refresh +// --------------------------------------------------------------------------- + +/// Everything a successful rename or delete has to touch. +/// +/// * `reload_if_loaded()` — the manager republishes, so a rename from the +/// manager updates the card instead of leaving the old name on it until the +/// view is re-entered (the reference never refreshes its own model here). +/// It is the ONE call allowed to early-return on a cold cache, so driving +/// the editor from the sidebar or the detail page costs nothing. +/// * `reload_sidebar_including_local()` — the OFFLINE-SAFE sidebar verb. +/// `reload_sidebar()` bails while offline, which would make renaming or +/// deleting a LOCAL playlist change nothing on screen for exactly the +/// population local playlists exist for. +fn after_write() { + crate::playlist_manager_qt::reload_if_loaded(); + crate::reload_sidebar_including_local(); +} + +// --------------------------------------------------------------------------- +// Open +// --------------------------------------------------------------------------- + +/// The resolved seeds for one playlist. +struct Seed { + name: String, + description: String, + desc_loaded: bool, + is_local: bool, + offline_only: bool, +} + +/// Open the editor on `id`. +/// +/// The seeds are resolved BEFORE `open` flips true (the `folder_edit_qt` +/// precedent), so the modal never renders an empty name field and then +/// repaints. §5.2's resolution order, per kind: +/// +/// * `local:` → `local_playlist_qt::get_blocking` on `spawn_blocking`. A pure +/// local read: it works offline and with no Qobuz account, and it always +/// answers, so `descLoaded` is ALWAYS true on this arm. +/// * Qobuz → the manager cache when it is warm (free, and it already holds +/// the description because the fetch that filled it returned one), else +/// `core().get_playlist(pid)`. +/// * Neither could answer → `descLoaded: false`, with whatever name the +/// sidebar cache can still supply so the modal is not anonymous. +pub(crate) fn open(id: &str) { + let id = id.trim().to_string(); + if id.is_empty() { + return; + } + if crate::local_playlist_qt::is_local_id(&id) { + open_local(id); + } else { + match id.parse::() { + Ok(pid) => open_qobuz(id, pid), + Err(_) => log::warn!("[qbz-qt] playlist editor: unusable id"), + } + } +} + +fn open_local(id: String) { + crate::spawn(async move { + let lookup = id.clone(); + let found = tokio::task::spawn_blocking(move || { + crate::local_playlist_qt::get_blocking(&lookup) + }) + .await + .ok() + .flatten(); + + let Some(p) = found else { + // Deleted underneath us, or the DB is unreadable. Opening an + // editor on a playlist that no longer exists would be worse than + // doing nothing (`folder_edit_qt::open_editor`'s precedent). + log::warn!("[qbz-qt] playlist editor: local playlist not found"); + return; + }; + seat( + id, + Seed { + name: p.name, + description: p.description.unwrap_or_default(), + // A local read either answered or we returned above. + desc_loaded: true, + is_local: true, + offline_only: p.offline_only, + }, + ); + }); +} + +fn open_qobuz(id: String, pid: u64) { + // Warm manager cache first: it is free, it is already merged, and it + // carries the description precisely so this lookup does not need a fetch. + if let Some((name, description)) = crate::playlist_manager_qt::cached_playlist_seed(pid) { + seat( + id, + Seed { + name, + description: description.unwrap_or_default(), + desc_loaded: true, + is_local: false, + offline_only: false, + }, + ); + return; + } + let runtime = crate::app(); + crate::spawn(async move { + let seed = match runtime.core().get_playlist(pid).await { + Ok(p) => Seed { + name: p.name, + description: p.description.unwrap_or_default(), + desc_loaded: true, + is_local: false, + offline_only: false, + }, + Err(e) => { + // Offline, or the playlist is gone. The editor still opens — + // deleting or unfollowing a playlist you cannot fetch is a + // legitimate thing to want — but with the description field + // suppressed, so an unknown description cannot be written + // back as "" (§5.2). + log::warn!("[qbz-qt] playlist editor: get_playlist {pid} failed: {e}"); + Seed { + name: crate::sidebar_qt::playlist_name(pid).unwrap_or_default(), + description: String::new(), + desc_loaded: false, + is_local: false, + offline_only: false, + } + } + }; + seat(id, seed); + }); +} + +/// Adopt a resolved seed as the open editor and publish it. +fn seat(id: String, seed: Seed) { + { + let mut st = state(); + *st = EditState { + open: true, + id, + name: seed.name, + description: seed.description, + desc_loaded: seed.desc_loaded, + is_local: seed.is_local, + offline_only: seed.offline_only, + busy: false, + }; + } + publish(); +} + +// --------------------------------------------------------------------------- +// Save +// --------------------------------------------------------------------------- + +/// Persist the drafts. +/// +/// Validation stays deliberately weak (§5.25): the ONE gate is a non-blank +/// name, and the modal's Save button reads the same gate so a whitespace-only +/// name shows as disabled instead of lying. +pub(crate) fn save(name: &str, description: &str, offline_only: bool) { + let trimmed = name.trim().to_string(); + let (id, is_local, desc_loaded) = { + let mut st = state(); + if !st.open || st.busy || st.id.is_empty() || trimmed.is_empty() { + return; + } + st.busy = true; + (st.id.clone(), st.is_local, st.desc_loaded) + }; + publish(); + + // `None` = leave the stored description alone. Only a description the user + // actually SAW may be asserted (§5.2). + let desc = if desc_loaded { + Some(description.trim().to_string()) + } else { + None + }; + + crate::spawn(async move { + let ok = if is_local { + save_local(&id, &trimmed, desc, offline_only).await + } else { + save_qobuz(&id, &trimmed, desc).await + }; + settle(ok, "Failed to rename playlist", "rename"); + }); +} + +async fn save_local( + id: &str, + name: &str, + desc: Option, + offline_only: bool, +) -> bool { + let id = id.to_string(); + let name = name.to_string(); + tokio::task::spawn_blocking(move || { + // `update_blocking` always writes the description column, and a `None` + // there is SQL NULL — so when the description was never resolved the + // STORED one is re-read and written back unchanged rather than wiped. + // (`descLoaded` is always true on the local arm today; this is the + // belt that keeps a future caller from silently deleting data.) + let description = match desc { + Some(d) => Some(d), + None => crate::local_playlist_qt::get_blocking(&id).and_then(|p| p.description), + }; + crate::local_playlist_qt::update_blocking( + &id, + &name, + description.as_deref(), + offline_only, + ) + }) + .await + .unwrap_or(false) +} + +async fn save_qobuz(id: &str, name: &str, desc: Option) -> bool { + let Ok(pid) = id.parse::() else { + return false; + }; + let runtime = crate::app(); + // `offline_only` is a `local_playlists` column and has no Qobuz analogue — + // the modal only renders that row when `isLocal`, so nothing is dropped. + match crate::playlist_qt::rename_by_id(&runtime, pid, name, desc.as_deref()).await { + Ok(()) => true, + Err(e) => { + log::error!("[qbz-qt] {e}"); + false + } + } +} + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +/// Delete the open playlist. +/// +/// There is no confirm sub-modal, matching the reference (`EditPlaylistModal` +/// calls `EditPlaylistActions.delete()` straight from its danger button) — +/// unlike the FOLDER editor, whose reference raises a native rfd message box +/// that this port replaced with `QbzConfirmModal`. +pub(crate) fn delete_playlist() { + let (id, is_local) = { + let mut st = state(); + if !st.open || st.busy || st.id.is_empty() { + return; + } + st.busy = true; + (st.id.clone(), st.is_local) + }; + publish(); + + crate::spawn(async move { + let ok = if is_local { + let target = id.clone(); + tokio::task::spawn_blocking(move || crate::local_playlist_qt::delete_blocking(&target)) + .await + .unwrap_or(false) + } else { + match id.parse::() { + Ok(pid) => { + let runtime = crate::app(); + // The ownership re-derivation and the unsubscribe branch + // live in there (§5.1); it NEVER navigates. + match crate::playlist_qt::delete_by_id(&runtime, pid).await { + Ok(()) => true, + Err(e) => { + log::error!("[qbz-qt] {e}"); + false + } + } + } + Err(_) => false, + } + }; + + if ok { + // CONDITIONAL (§5.1): only when the user is actually standing on + // that playlist's detail page. Deleting from the manager or the + // sidebar must leave them where they are. + crate::playlist_qt::back_if_showing(&id); + } + settle(ok, "Failed to delete playlist", "delete"); + }); +} + +// --------------------------------------------------------------------------- +// Close + the shared outcome +// --------------------------------------------------------------------------- + +/// Clear `busy`, close on success only (D22), publish, then refresh or toast. +fn settle(ok: bool, msgid: &'static str, verb: &'static str) { + { + let mut st = state(); + st.busy = false; + // Stay OPEN on failure, with everything the user typed intact. + st.open = !ok; + } + publish(); + + if ok { + after_write(); + } else { + log::warn!("[qbz-qt] playlist {verb} failed"); + crate::toast_qt::error(qbz_i18n::t(msgid)); + } +} + +/// Dismiss. Refused while a save or delete is in flight (D22) — the modal is +/// the only thing reporting that a write is running. +pub(crate) fn close() { + { + let mut st = state(); + if st.busy { + return; + } + st.open = false; + } + publish(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_closed_document_parses_and_carries_the_full_shape() { + // Frame-1 safety: every key the modal reads must be present, or a + // binding on the pre-publish frame reads `undefined`. + let json = edit_doc_of(EditState::default()); + let v: serde_json::Value = serde_json::from_str(&json).expect("edit doc parses"); + for key in [ + "open", + "id", + "name", + "description", + "descLoaded", + "isLocal", + "offlineOnly", + "busy", + ] { + assert!(v.get(key).is_some(), "missing key {key}"); + } + assert_eq!(v["open"], serde_json::Value::Bool(false)); + } + + #[test] + fn a_local_id_stays_a_string_and_camel_cases_the_flags() { + // Nothing in the pipeline may parse `local:` as an integer + // (§3.1), and QML reads `isLocal` / `offlineOnly` / `descLoaded` — + // a snake_case key here would read `undefined` and the offline-only + // row would silently never render. + let json = edit_doc_of(EditState { + open: true, + id: "local:abc-def".into(), + name: "Road trip".into(), + description: "sunny".into(), + desc_loaded: true, + is_local: true, + offline_only: true, + busy: false, + }); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["id"], serde_json::Value::String("local:abc-def".into())); + assert_eq!(v["isLocal"], serde_json::Value::Bool(true)); + assert_eq!(v["offlineOnly"], serde_json::Value::Bool(true)); + assert_eq!(v["descLoaded"], serde_json::Value::Bool(true)); + assert!(v.get("is_local").is_none()); + } + + #[test] + fn an_unresolved_description_is_published_as_not_loaded() { + // The modal keys the whole description field off this flag, and `save` + // keys `None` off it — the one thing standing between a failed fetch + // and a wiped description (§5.2). + let json = edit_doc_of(EditState { + open: true, + id: "42".into(), + name: "Jazz".into(), + desc_loaded: false, + ..EditState::default() + }); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["descLoaded"], serde_json::Value::Bool(false)); + assert_eq!(v["description"], serde_json::Value::String(String::new())); + } +} diff --git a/crates/qbz-qt/src/playlist_import_bridge.rs b/crates/qbz-qt/src/playlist_import_bridge.rs new file mode 100644 index 000000000..3bb10dea7 --- /dev/null +++ b/crates/qbz-qt/src/playlist_import_bridge.rs @@ -0,0 +1,153 @@ +//! QbzPlaylistImport — the "Import Playlist" modal's bridge, port of the +//! `PlaylistImportState` / `PlaylistImportActions` globals in the reference's +//! `ui/state.slint:5821-5871` and the handler arms in its `main.rs`. +//! +//! A SEPARATE singleton from `QbzPlaylist` and from `QbzPlaylistManager`: the +//! importer is its own domain (an external-provider scraper + a Qobuz matcher), +//! it is reachable from two shell surfaces that outlive each other (the sidebar +//! `...` menu and the closed-sidebar flyout), and its modal must survive the +//! surface that opened it — `05 §5.8.5` states that requirement explicitly. +//! +//! Props: ONE JSON document (`playlist_import_qt.rs ImportDoc`) covering all +//! of the modal's states — URL entry, provider detection, the preview / +//! customisation panel, the live progress panel with its append-only log, and +//! the summary block. No `#[qsignal]`: outcomes are toasts, a sidebar refresh +//! and a navigation, all raised by the controller itself (`05 §5.8.6`). +//! +//! The module is `qbz_playlist_import_bridge`, never `qbz_playlist_import` — +//! that is the workspace crate's name and would be an E0659 ambiguity +//! (`05 §5.8.1`; the `library_bridge.rs:1-6` precedent). The QML type name +//! comes from the QObject, so QML is unaffected. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_playlist_import_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // ONE JSON document (playlist_import_qt.rs ImportDoc). + #[qproperty(QString, import_json)] + + type QbzPlaylistImport = super::QbzPlaylistImportRust; + + /// Registers this object's Qt-thread hop (Main.qml boots EVERY domain + /// singleton; only QbzSession.boot also fires crate::on_boot). + #[qinvokable] + fn boot(self: Pin<&mut QbzPlaylistImport>); + + /// Open the modal, fully reset — NO ARGUMENTS (`05 §5.8.2`). The + /// folder dropdown is read from library.db by the controller; the + /// sidebar must not hand it in. + #[qinvokable] + fn open(self: Pin<&mut QbzPlaylistImport>); + + /// Hide the modal. Does NOT cancel an in-flight import (§1.8). + #[qinvokable] + fn close(self: Pin<&mut QbzPlaylistImport>); + + /// Every URL keystroke — Rust recomputes activeProvider / canFetch / + /// showPreview (and the post-completion rearm path). + #[qinvokable] + fn url_edited(self: Pin<&mut QbzPlaylistImport>, text: QString); + + /// Every rename keystroke — keeps the controller's mirror fresh. + #[qinvokable] + fn name_edited(self: Pin<&mut QbzPlaylistImport>, text: QString); + + /// Folder dropdown selection (index into `folderIds`). + #[qinvokable] + fn set_folder_index(self: Pin<&mut QbzPlaylistImport>, index: i32); + + /// Step A — fetch the provider preview. No session required. + #[qinvokable] + fn fetch(self: Pin<&mut QbzPlaylistImport>); + + /// Step B — run the import (rename + folder choice), with live + /// progress. + #[qinvokable] + fn execute(self: Pin<&mut QbzPlaylistImport>); + } + + impl cxx_qt::Threading for QbzPlaylistImport {} +} + +use qbz_playlist_import_bridge::QbzPlaylistImport; + +/// Rust side of the importer bridge (plain storage, phase-1 pattern). +pub struct QbzPlaylistImportRust { + import_json: QString, +} + +impl Default for QbzPlaylistImportRust { + fn default() -> Self { + Self { + // Parseable default so QML's JSON.parse never throws on frame 1. + import_json: QString::from("{}"), + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue an importer mutation onto the Qt event loop (no-op before boot +/// registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPlaylistImport>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_playlist_import_bridge::QbzPlaylistImport { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] playlist import Qt thread already registered"); + } + // Closed until a host opens it, and the closed document is already + // seeded in Default — nothing to publish here. + } + + pub fn open(self: Pin<&mut Self>) { + crate::playlist_import_qt::open(); + } + + pub fn close(self: Pin<&mut Self>) { + crate::playlist_import_qt::close(); + } + + pub fn url_edited(self: Pin<&mut Self>, text: QString) { + crate::playlist_import_qt::on_url_edited(&text.to_string()); + } + + pub fn name_edited(self: Pin<&mut Self>, text: QString) { + crate::playlist_import_qt::on_name_edited(&text.to_string()); + } + + pub fn set_folder_index(self: Pin<&mut Self>, index: i32) { + crate::playlist_import_qt::set_folder_index(index); + } + + pub fn fetch(self: Pin<&mut Self>) { + crate::playlist_import_qt::fetch(); + } + + pub fn execute(self: Pin<&mut Self>) { + crate::playlist_import_qt::execute(); + } +} diff --git a/crates/qbz-qt/src/playlist_import_qt.rs b/crates/qbz-qt/src/playlist_import_qt.rs new file mode 100644 index 000000000..6bb89152c --- /dev/null +++ b/crates/qbz-qt/src/playlist_import_qt.rs @@ -0,0 +1,960 @@ +//! Playlist Importer — port of `crates/qbz/src/playlist_import.rs` (the +//! controller) plus the `PlaylistImportActions` handler arms in the +//! reference's `main.rs:21867-22045` (the async plumbing, the folder +//! assignment, the toast, the sidebar reconcile and the navigation), driven by +//! the headless `qbz-playlist-import` crate. +//! +//! Two-step flow: URL entry -> provider auto-detect -> fetch preview -> rename +//! + optional folder -> import with a live progress bar, a status line and an +//! append-only log, then an in-panel Summary block. Every interpolated string +//! (log lines, status line, summary block) is formatted HERE and lands in the +//! document pre-formatted; the modal renders it verbatim, exactly as the +//! reference does. +//! +//! # Close-mid-import semantics (reference §1.8) — ported verbatim +//! +//! Closing the modal NEVER cancels the tokio import task. On completion the +//! toast + folder assignment + sidebar refresh still fire; navigation happens +//! only while the modal is still open AND the run's generation is current. +//! [`GENERATION`] is bumped on every [`open`] and [`execute`], so a stale run's +//! sink events and completion arms can never touch a reopened modal's fresh +//! state. +//! +//! # Where this port deviates, and why (recorded, not silent) +//! +//! 1. **The folder dropdown reads the DB itself.** The reference seeds it from +//! `SidebarState.folders` (`playlist_import.rs:88-101`). Doc 05 §5.8.2 +//! forbids that here: the sidebar must not become a data source for another +//! domain, so [`open`] spawns a `folders_qt::load_folders_full()` read and +//! republishes. Hidden folders are filtered out; index 0 is always +//! `No folder` with id `""`, the create-playlist builder shape. +//! +//! 2. **Progress publishes are coalesced at ~60 ms; the STATE still updates on +//! every event.** The reference sets four Slint properties per sink event +//! and pushes one row into a `VecModel` — both O(1). Here a publish +//! re-serialises the WHOLE document and QML re-parses it and rebuilds the +//! log `Repeater`, and matching emits one event PER TRACK (thousands on a +//! large playlist). So [`publish_progress`] rate-limits with a guaranteed +//! trailing flush, while every log append, phase change and terminal arm +//! publishes unconditionally. Nothing observable is dropped: a 6 px bar and +//! a status line cannot show more than ~16 fps of change, and the final +//! value always lands through the terminal publish. +//! +//! 3. **No cancel, still** — there is no cancel in the reference and none is +//! invented here. +//! +//! Everything else — the two-step gate, the locked provider, the +//! rearm-on-URL-edit path after a completed import, the 5 %-milestone matching +//! log, the per-chunk adding log, the summary block, the `parts_line` reuse and +//! the fixed en-US thousands grouping — is 1:1. + +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use serde::Serialize; + +use qbz_playlist_import::{ + detect_provider_key, ImportEvent, ImportPhase, ImportPlaylist, ImportProgressSink, + ImportProvider, ImportSummary, ProviderKey, +}; + +type Runtime = Arc>; + +// --------------------------------------------------------------------------- +// The document +// --------------------------------------------------------------------------- + +/// One append-only line of the conversion log (`ImportLogEntry` in the +/// reference's `state.slint:5814`). `status` is `"info" | "success" | "error"` +/// and drives the row colour. +#[derive(Clone, Serialize)] +pub struct LogEntry { + pub message: String, + pub status: String, +} + +/// `QbzPlaylistImport.importJson`. ONE document for the whole modal — the +/// picker/manager convention. +#[derive(Default, Serialize)] +struct ImportDoc { + open: bool, + /// Bumped by every [`open`]. QML resets its two local text mirrors when it + /// changes, which is what reproduces the reference's "Tauri remounts the + /// component on every open" behaviour (§5.8.3). + #[serde(rename = "resetSeq")] + reset_seq: i64, + /// Rust's mirror of the URL field — the re-seed source for the QML input + /// while it does NOT have focus, and `""` right after a reset. + url: String, + /// Same, for the rename field. Written by Rust on a successful preview + /// (prefilled with the source playlist name). + #[serde(rename = "customName")] + custom_name: String, + /// True during the preview fetch AND during the import execute. + loading: bool, + /// `""` = none; the red banner at the top of the body. + error: String, + /// `"" | "spotify" | "apple" | "tidal" | "deezer"` — the locked-or-detected + /// provider; its source logo renders at full opacity. + #[serde(rename = "activeProvider")] + active_provider: String, + /// Provider detected AND not offline. + #[serde(rename = "canFetch")] + can_fetch: bool, + /// A preview exists AND the URL has not been edited since the fetch — + /// flips the modal into step B (rename + Import). + #[serde(rename = "showPreview")] + show_preview: bool, + #[serde(rename = "importCompleted")] + import_completed: bool, + /// The "Conversion progress" panel is mounted. + #[serde(rename = "progressVisible")] + progress_visible: bool, + /// The bar + the status/current-track lines are visible. + #[serde(rename = "hasProgress")] + has_progress: bool, + /// 0..1. + progress: f32, + #[serde(rename = "statusLine")] + status_line: String, + #[serde(rename = "currentTrack")] + current_track: String, + log: Vec, + /// Index 0 is always `No folder` (id `""`), then the visible folders. + #[serde(rename = "folderOptions")] + folder_options: Vec, + #[serde(rename = "folderIds")] + folder_ids: Vec, + #[serde(rename = "folderIndex")] + folder_index: i32, + /// Summary block, pre-formatted lines (`""` = hidden). + #[serde(rename = "summaryPlaylist")] + summary_playlist: String, + #[serde(rename = "summaryMatched")] + summary_matched: String, + #[serde(rename = "summarySkipped")] + summary_skipped: String, + #[serde(rename = "summaryParts")] + summary_parts: String, +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +/// Everything the modal owns, published fields and session-only fields alike. +/// Reset wholesale on every [`open`] — the reference clears 14 Slint +/// properties plus its `Session` struct for the same reason +/// (`playlist_import.rs:66-104`). +#[derive(Default)] +struct State { + // ---- published ---- + open: bool, + url: String, + custom_name: String, + loading: bool, + error: String, + active_provider: String, + can_fetch: bool, + show_preview: bool, + import_completed: bool, + progress_visible: bool, + has_progress: bool, + progress: f32, + status_line: String, + current_track: String, + log: Vec, + folder_options: Vec, + folder_ids: Vec, + folder_index: i32, + summary_playlist: String, + summary_matched: String, + summary_skipped: String, + summary_parts: String, + + // ---- session-only (the reference's `Session`) ---- + /// The fetched preview, kept so `execute` can read the source name. + preview: Option, + /// Trimmed URL the preview was fetched for (Svelte `previewUrl`). + preview_url: String, + /// Provider locked at fetch time; survives URL edits until a reset path + /// clears it (Svelte `lockedProvider`). + locked_provider: Option, + /// Trimmed URL of the last completed import (Svelte `lastImportedUrl`). + last_imported_url: String, + /// 5 %-milestone tracker for the matching log lines (-1 = none yet). + last_logged_percent: i32, +} + +impl State { + /// The reset every [`open`] performs, folder list included — the folders + /// are re-read asynchronously right after. + fn reset(&mut self) { + let opts = vec![qbz_i18n::t("No folder")]; + *self = State { + folder_options: opts, + folder_ids: vec![String::new()], + last_logged_percent: -1, + ..State::default() + }; + } + + fn clear_summary(&mut self) { + self.summary_playlist.clear(); + self.summary_matched.clear(); + self.summary_skipped.clear(); + self.summary_parts.clear(); + } + + fn clear_progress_lines(&mut self) { + self.has_progress = false; + self.status_line.clear(); + self.current_track.clear(); + } +} + +static STATE: LazyLock> = LazyLock::new(|| Mutex::new(State::default())); + +/// Import generation (§1.8): bumped on every [`open`] and [`execute`]. Sink +/// events and task completions carry the generation they were spawned with; a +/// mismatch means the modal was reset for a fresh run, so the stale run may +/// only fire toast + folder assignment + sidebar refresh, never modal writes. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Bumped by every [`open`]; QML resets its local text mirrors on the change. +static RESET_SEQ: AtomicI64 = AtomicI64::new(0); + +pub fn current_generation() -> u64 { + GENERATION.load(Ordering::SeqCst) +} + +fn bump_generation() -> u64 { + GENERATION.fetch_add(1, Ordering::SeqCst) + 1 +} + +// --------------------------------------------------------------------------- +// Publishing +// --------------------------------------------------------------------------- + +/// Process start, for the coalescing clock. `Instant` has no const ctor, so it +/// is latched on first use. +static CLOCK: LazyLock = LazyLock::new(std::time::Instant::now); + +fn now_ms() -> u64 { + CLOCK.elapsed().as_millis() as u64 +} + +static LAST_PUBLISH_MS: AtomicU64 = AtomicU64::new(0); +static FLUSH_PENDING: AtomicBool = AtomicBool::new(false); + +/// Minimum gap between two PROGRESS-driven publishes (deviation 2 in the +/// module header). Log appends, phase changes and terminal arms bypass it. +const PROGRESS_PUBLISH_MS: u64 = 60; + +fn build_doc(st: &State) -> ImportDoc { + ImportDoc { + open: st.open, + reset_seq: RESET_SEQ.load(Ordering::SeqCst), + url: st.url.clone(), + custom_name: st.custom_name.clone(), + loading: st.loading, + error: st.error.clone(), + active_provider: st.active_provider.clone(), + can_fetch: st.can_fetch, + show_preview: st.show_preview, + import_completed: st.import_completed, + progress_visible: st.progress_visible, + has_progress: st.has_progress, + progress: st.progress, + status_line: st.status_line.clone(), + current_track: st.current_track.clone(), + log: st.log.clone(), + folder_options: st.folder_options.clone(), + folder_ids: st.folder_ids.clone(), + folder_index: st.folder_index, + summary_playlist: st.summary_playlist.clone(), + summary_matched: st.summary_matched.clone(), + summary_skipped: st.summary_skipped.clone(), + summary_parts: st.summary_parts.clone(), + } +} + +/// Serialise the live state and post it to Qt. Never call this while holding +/// [`STATE`] — it takes the lock itself. +fn publish() { + let doc = { + let st = STATE.lock().unwrap(); + build_doc(&st) + }; + LAST_PUBLISH_MS.store(now_ms(), Ordering::SeqCst); + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + crate::playlist_import_bridge::ui(move |mut b| { + b.as_mut().set_import_json(QString::from(json.as_str())); + }); +} + +/// Rate-limited publish for the high-frequency progress path, with a +/// GUARANTEED trailing flush so the last value of a burst is never the one +/// that got dropped. +fn publish_progress() { + let now = now_ms(); + let last = LAST_PUBLISH_MS.load(Ordering::SeqCst); + if now.saturating_sub(last) >= PROGRESS_PUBLISH_MS { + publish(); + return; + } + // One flush in flight at a time; it publishes whatever the state holds + // when it wakes, which is by definition the newest value. + if FLUSH_PENDING.swap(true, Ordering::SeqCst) { + return; + } + crate::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(PROGRESS_PUBLISH_MS)).await; + FLUSH_PENDING.store(false, Ordering::SeqCst); + publish(); + }); +} + +/// Append one pre-formatted line to the conversion log and publish +/// unconditionally (a log line is a discrete event, never coalesced). +fn push_log(message: String, status: &str) { + { + let mut st = STATE.lock().unwrap(); + st.log.push(LogEntry { + message, + status: status.to_string(), + }); + } + publish(); +} + +// --------------------------------------------------------------------------- +// Invokables +// --------------------------------------------------------------------------- + +/// Open the modal fully reset (§5.8.3). Qt thread — nothing here touches the +/// DB; the folder list arrives from the spawned read below. +pub fn open() { + // Invalidate any in-flight run's modal writes before resetting. + bump_generation(); + RESET_SEQ.fetch_add(1, Ordering::SeqCst); + { + let mut st = STATE.lock().unwrap(); + st.reset(); + st.open = true; + // The offline gate is recomputed here too, so a modal opened offline + // starts with its confirm button already disabled rather than waiting + // for the first keystroke. + st.can_fetch = false; + } + publish(); + refresh_folders(); +} + +/// Read the folder list off library.db and republish. `!is_hidden` only +/// (§5.8.2 / D11) — a hidden folder is not offered as an import target, the +/// same rule the manager's move-to-folder menus follow. +fn refresh_folders() { + crate::spawn(async move { + let folders = tokio::task::spawn_blocking(|| { + crate::folders_qt::load_folders_full() + .into_iter() + .filter(|f| !f.is_hidden) + .map(|f| (f.id, f.name)) + .collect::>() + }) + .await + .unwrap_or_default(); + { + let mut st = STATE.lock().unwrap(); + // A modal closed while the read was in flight must not be + // resurrected, and its reset already installed the "No folder" + // pair. + if !st.open { + return; + } + st.folder_options = std::iter::once(qbz_i18n::t("No folder")) + .chain(folders.iter().map(|(_, name)| name.clone())) + .collect(); + st.folder_ids = std::iter::once(String::new()) + .chain(folders.iter().map(|(id, _)| id.clone())) + .collect(); + st.folder_index = 0; + } + publish(); + }); +} + +/// Close. Deliberately does NOT cancel an in-flight import (§1.8) and does NOT +/// clear the state — a reopen is what resets, so a user who closes by accident +/// mid-import and reopens still sees... nothing of the old run, because +/// [`open`] resets. The state is kept only so the running task's completion +/// arms have somewhere to write. +pub fn close() { + { + let mut st = STATE.lock().unwrap(); + st.open = false; + } + publish(); +} + +/// Is the modal still on screen? Read by the completion arm before navigating. +fn is_open() -> bool { + STATE.lock().map(|st| st.open).unwrap_or(false) +} + +/// Recompute the URL-derived fields on every keystroke (the reference's +/// derived `detectedProvider` / `activeProvider` / `isValid` / `showPreview`), +/// plus the post-completion fresh-import rearm path. +pub fn on_url_edited(text: &str) { + let trimmed = text.trim().to_string(); + let detected = detect_provider_key(text); + let offline = crate::offline_fwd::engine().is_offline(); + { + let mut st = STATE.lock().unwrap(); + st.url = text.to_string(); + + // After a completed import, editing the URL away from the imported one + // rearms the modal for a fresh import without reopening. + if st.import_completed && trimmed != st.last_imported_url { + st.locked_provider = None; + st.import_completed = false; + st.error.clear(); + st.log.clear(); + st.progress_visible = false; + st.progress = 0.0; + st.clear_progress_lines(); + st.clear_summary(); + } + + let active = st.locked_provider.or(detected); + st.active_provider = active.map(|p| p.as_str()).unwrap_or("").to_string(); + st.can_fetch = detected.is_some() && !offline; + st.show_preview = st.preview.is_some() && trimmed == st.preview_url; + } + publish(); +} + +/// Keep the rename mirror fresh (read back by [`execute`]). Fired on every +/// keystroke of the name field. +pub fn on_name_edited(text: &str) { + { + let mut st = STATE.lock().unwrap(); + st.custom_name = text.to_string(); + } + publish(); +} + +/// Folder dropdown selection. Out-of-range indices are ignored rather than +/// stored: the id lookup at execute time indexes this same vector. +pub fn set_folder_index(index: i32) { + { + let mut st = STATE.lock().unwrap(); + if index < 0 || index as usize >= st.folder_ids.len() { + return; + } + st.folder_index = index; + } + publish(); +} + +// --------------------------------------------------------------------------- +// Step A — fetch the preview +// --------------------------------------------------------------------------- + +/// Step A gate + reset (the reference's `begin_fetch`). Returns the URL to +/// fetch, or `None` when the gate fails. +fn begin_fetch() -> Option { + let url = { + let mut st = STATE.lock().unwrap(); + if st.loading || !st.can_fetch { + return None; + } + let url = st.url.clone(); + let detected = detect_provider_key(&url)?; + st.preview = None; + st.preview_url.clear(); + st.locked_provider = Some(detected); + st.loading = true; + st.error.clear(); + st.show_preview = false; + st.import_completed = false; + st.active_provider = detected.as_str().to_string(); + st.progress = 0.0; + st.clear_progress_lines(); + st.clear_summary(); + st.log.clear(); + st.progress_visible = true; + url + }; + push_log(qbz_i18n::t("Checking playlist link..."), "info"); + Some(url) +} + +/// Step A. The preview needs no session (the reference notes this) — only the +/// execute does. +pub fn fetch() { + let Some(url) = begin_fetch() else { + return; + }; + // A reopen mid-fetch bumps the generation; the stale preview result must + // not land on the fresh modal (§1.8). + let generation = current_generation(); + crate::spawn(async move { + let res = qbz_playlist_import::preview_public_playlist(&url).await; + if generation != current_generation() { + return; + } + match res { + Ok(p) => apply_preview_ok(&url, p), + Err(e) => apply_preview_err(&e.to_string()), + } + }); +} + +/// Preview fetch succeeded. +fn apply_preview_ok(url: &str, preview: ImportPlaylist) { + let count = preview.tracks.len(); + let provider = provider_display_name(&preview.provider); + { + let mut st = STATE.lock().unwrap(); + st.custom_name = preview.name.clone(); + st.preview_url = url.trim().to_string(); + st.preview = Some(preview); + } + push_log( + qbz_i18n::t_args("Found {} tracks from {}.", &[&count.to_string(), provider]), + "success", + ); + { + let mut st = STATE.lock().unwrap(); + st.loading = false; + // The URL input is disabled during the fetch, so it still equals the + // fetched URL — step B (rename + Import) becomes visible. + st.show_preview = st.url.trim() == st.preview_url; + } + publish(); +} + +/// Preview fetch failed. +fn apply_preview_err(err: &str) { + { + let mut st = STATE.lock().unwrap(); + st.error = err.to_string(); + st.loading = false; + } + push_log(qbz_i18n::t_args("Import failed: {}", &[err]), "error"); +} + +// --------------------------------------------------------------------------- +// Step B — execute the import +// --------------------------------------------------------------------------- + +/// Everything the execute task needs, snapshotted before it spawns. +struct ExecuteArgs { + url: String, + name_override: Option, + /// Local folder id chosen in the dropdown (`""` = no folder). + folder_id: String, + /// The run's generation (§1.8), carried by the sink and the completion + /// arms. + generation: u64, +} + +/// Step B gate + reset (the reference's `begin_execute`). +fn begin_execute() -> Option { + let mut st = STATE.lock().unwrap(); + if st.loading || st.import_completed { + return None; + } + let source_name = st.preview.as_ref()?.name.clone(); + // The rename goes out only when it differs from the source name; an empty + // rename falls back to the source name (reference Appendix A). + let custom = st.custom_name.trim().to_string(); + let name_override = if custom != source_name && !custom.is_empty() { + Some(custom) + } else { + None + }; + st.last_logged_percent = -1; + let url = st.preview_url.clone(); + let folder_id = st + .folder_ids + .get(st.folder_index.max(0) as usize) + .cloned() + .unwrap_or_default(); + st.loading = true; + st.error.clear(); + st.progress_visible = true; + Some(ExecuteArgs { + url, + name_override, + folder_id, + generation: bump_generation(), + }) +} + +/// Step B — `import_public_playlist(...)` with live sink progress, then the +/// folder assignment, the toast, the sidebar reconcile and the navigation. +pub fn execute() { + let Some(args) = begin_execute() else { + return; + }; + publish(); + let runtime = crate::app(); + crate::spawn(async move { + // The reference's `RequiresUserSession` gate: execute needs a + // logged-in client (the preview does not). + let client = { + let lock = runtime.core().client(); + let guard = lock.read().await; + guard.as_ref().cloned() + }; + let Some(client) = client else { + if args.generation == current_generation() { + apply_execute_err(&qbz_i18n::t("Not logged in to Qobuz")); + } + crate::toast_qt::error(qbz_i18n::t("Playlist import failed")); + return; + }; + + let sink: Arc = Arc::new(QtSink { + generation: args.generation, + }); + let res = qbz_playlist_import::import_public_playlist( + &args.url, + &client, + args.name_override.as_deref(), + false, // is_public — the reference hardcodes false, no toggle + sink, + ) + .await; + + match res { + Ok(summary) => { + // reco: NOT logged, 1:1 with the reference — the importer is a + // bulk external import, not a per-track taste action. (This + // port has no reco store at all; see the picker's OWED PORT.) + // + // Assign every created part to the chosen folder (library.db) + // BEFORE the sidebar reload, so the reload already sees the + // membership. + if !args.folder_id.is_empty() { + for pid in &summary.qobuz_playlist_ids { + let (pid, fid) = (*pid, args.folder_id.clone()); + let _ = tokio::task::spawn_blocking(move || { + crate::folders_qt::move_playlist(pid, Some(fid.as_str())); + }) + .await; + } + } + // Toast + sidebar refresh fire even after a close mid-import + // (§1.8); the generation guard keeps a stale run's writes off a + // reopened modal's fresh state. + if args.generation == current_generation() { + apply_execute_ok(&summary); + } + if summary.matched_tracks > 0 { + crate::toast_qt::success(qbz_i18n::t("Playlist imported")); + } + reconcile_sidebar_after_import( + &runtime, + summary.qobuz_playlist_ids.clone(), + summary.playlist_name.clone(), + summary.matched_tracks, + ); + if let Some(first) = summary.qobuz_playlist_ids.first() { + // Navigate only while the modal is still open AND this run + // is current (§1.8). + if args.generation == current_generation() && is_open() { + crate::open_playlist(first.to_string()); + } + } + } + Err(e) => { + let msg = e.to_string(); + if args.generation == current_generation() { + apply_execute_err(&msg); + } + crate::toast_qt::error(qbz_i18n::t("Playlist import failed")); + } + } + }); +} + +/// Import finished: completion logs + the summary block + `importCompleted`. +fn apply_execute_ok(summary: &ImportSummary) { + { + let mut st = STATE.lock().unwrap(); + st.last_imported_url = st.preview_url.clone(); + st.import_completed = true; + } + push_log( + qbz_i18n::t_args( + "Imported {} of {} tracks into QBZ.", + &[ + &summary.matched_tracks.to_string(), + &summary.total_tracks.to_string(), + ], + ), + "success", + ); + if !summary.qobuz_playlist_ids.is_empty() { + if summary.parts_created > 1 { + push_log(parts_line(summary.parts_created), "success"); + } else { + push_log(qbz_i18n::t("Playlist created in Qobuz™."), "success"); + } + } else { + push_log(qbz_i18n::t("No matching tracks found."), "error"); + } + { + let mut st = STATE.lock().unwrap(); + // Summary block (pre-formatted; "" = hidden). `playlist_name` is the + // name the playlist was created under — rename included. + st.summary_playlist = qbz_i18n::t_args("Playlist: {}", &[&summary.playlist_name]); + st.summary_matched = qbz_i18n::t_args( + "Tracks matched: {} / {}", + &[ + &summary.matched_tracks.to_string(), + &summary.total_tracks.to_string(), + ], + ); + st.summary_skipped = + qbz_i18n::t_args("Skipped: {}", &[&summary.skipped_tracks.to_string()]); + st.summary_parts = if summary.parts_created > 1 { + parts_line(summary.parts_created) + } else { + String::new() + }; + // The bar/status hide with `loading`, as in the reference. + st.clear_progress_lines(); + st.loading = false; + } + publish(); +} + +/// Import failed. The error toast lives in the caller. +fn apply_execute_err(err: &str) { + { + let mut st = STATE.lock().unwrap(); + st.error = err.to_string(); + st.clear_progress_lines(); + st.loading = false; + } + push_log(qbz_i18n::t_args("Import failed: {}", &[err]), "error"); +} + +// --------------------------------------------------------------------------- +// The progress sink +// --------------------------------------------------------------------------- + +/// Streams crate events onto the document. Unlike the reference's `SlintSink` +/// there is no window handle to upgrade: `publish` hops through the bridge's +/// `ui()` on its own, so `emit` is callable from the import task directly. +struct QtSink { + generation: u64, +} + +impl ImportProgressSink for QtSink { + fn emit(&self, event: ImportEvent) { + // Stale generation = the modal was reset (reopened) while this run was + // in flight — its events must never touch the fresh state (§1.8). + if self.generation != current_generation() { + return; + } + apply_event(event); + } +} + +/// One sink event onto the document — the two Svelte event listeners. +fn apply_event(event: ImportEvent) { + match event { + ImportEvent::Phase(phase) => match phase { + ImportPhase::Matching => { + push_log(qbz_i18n::t("Searching Qobuz catalog..."), "info"); + } + // Creating / Adding re-fire once per created part — log each, as + // the reference does. + ImportPhase::Creating => { + push_log(qbz_i18n::t("Creating playlist..."), "success"); + } + ImportPhase::Adding => { + push_log(qbz_i18n::t("Adding tracks to playlist..."), "info"); + } + }, + ImportEvent::Progress(p) => { + // The bar + the status line update on EVERY event; only the + // PUBLISH is coalesced (module header, deviation 2). + let mut log_line: Option = None; + { + let mut st = STATE.lock().unwrap(); + st.has_progress = p.total > 0; + if p.total > 0 { + st.progress = p.current as f32 / p.total as f32; + } + if p.phase == "adding" { + // Status line per phase — the owner's deliberate deviation + // from the Tauri modal, which reused "Matching tracks…" + // here (see qbz_playlist_import::sink::ImportPhase). + let line = qbz_i18n::t_args( + "Adding tracks: {} / {}", + &[&p.current.to_string(), &p.total.to_string()], + ); + st.status_line = line.clone(); + // One log line per 50-track chunk event (chunk counts, not + // tracks) — the reference logs every adding event. + log_line = Some(line); + } else if p.total > 0 { + let line = qbz_i18n::t_args( + "Matching tracks: {} / {} ({} found)", + &[ + &group_thousands(p.current), + &group_thousands(p.total), + &group_thousands(p.matched_so_far), + ], + ); + st.status_line = line.clone(); + // Matching is high-frequency (one event per track): log + // only at 5 % milestones, exactly like the Svelte listener. + let pct = (p.current as u64 * 100 / p.total as u64) as i32; + if pct >= st.last_logged_percent + 5 { + st.last_logged_percent = pct; + log_line = Some(line); + } + } + st.current_track = p.current_track.unwrap_or_default(); + } + match log_line { + // `push_log` publishes unconditionally, which is what keeps a + // log append from being swallowed by the coalescing window. + Some(line) => push_log(line, "info"), + None => publish_progress(), + } + } + } +} + +// --------------------------------------------------------------------------- +// Sidebar reconcile (the reference's `main.rs::reconcile_sidebar_after_import`) +// --------------------------------------------------------------------------- + +/// Make the imported playlist appear in the sidebar despite the user-playlists +/// endpoint lagging the write: an OPTIMISTIC row now, then a bounded-retry +/// reload until the API lists it. +/// +/// `sidebar_qt::load` + `crate::publish_sidebar()` are called directly rather +/// than `crate::reload_sidebar()`: the latter early-returns while offline, and +/// the pair below is the refresh that is correct in every connectivity state +/// (the same reasoning D10 records for the manager's optimistic move). +fn reconcile_sidebar_after_import( + runtime: &Runtime, + ids: Vec, + name: String, + tracks_count: u32, +) { + const MAX_ATTEMPTS: u32 = 6; + if ids.is_empty() { + return; + } + let first = ids[0]; + let single = ids.len() == 1; + // Optimistic insert NOW (single-playlist imports only — multi-part imports + // get their per-part names from the API once it catches up). + if single { + crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count); + crate::publish_sidebar(); + } + let runtime = runtime.clone(); + crate::spawn(async move { + let mut attempt = 0u32; + loop { + attempt += 1; + crate::sidebar_qt::load(&runtime).await; + let present = ids + .iter() + .all(|id| crate::sidebar_qt::playlist_name(*id).is_some()); + // Put the optimistic row BACK after every load, not only at the + // terminal attempt. This port's `sidebar_qt::load` REPLACES the + // cache in place, where the reference's `sidebar::load` returns its + // data and only `sidebar::apply` writes — so there the optimistic + // row survives the whole retry window and here it would not. Any + // unrelated `publish_sidebar()` inside that window (a folder + // toggle, a sort or search change, a local-playlist mutation) would + // otherwise republish a cache the imported playlist had just been + // wiped out of, and the row would vanish for up to 15 s. Idempotent + // by id, and evaluated AFTER `present` so it cannot fake it. + if !present && single { + crate::sidebar_qt::insert_qobuz_entry(first, &name, tracks_count); + } + if present || attempt >= MAX_ATTEMPTS { + if !present { + log::warn!( + "[qbz-qt] playlist-import: sidebar list still missing the imported \ + playlist after {attempt} attempts; the optimistic row stays until \ + the next load" + ); + } + crate::publish_sidebar(); + break; + } + tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await; + } + }); +} + +// --------------------------------------------------------------------------- +// Formatting helpers (ported verbatim) +// --------------------------------------------------------------------------- + +/// "Split into {count} playlists (Qobuz 2000-track limit)" — used as both a log +/// line and the summary parts line, as in the reference. +fn parts_line(count: u32) -> String { + qbz_i18n::t_args( + "Split into {} playlists (Qobuz 2000-track limit)", + &[&count.to_string()], + ) +} + +/// Display names for the "Found N tracks from {provider}." log (Svelte +/// `formatProvider`). The enum is exhaustive, so Svelte's "Unknown" arm is +/// unreachable here. +fn provider_display_name(provider: &ImportProvider) -> &'static str { + match provider { + ImportProvider::Spotify => "Spotify", + ImportProvider::AppleMusic => "Apple Music", + ImportProvider::Tidal => "Tidal", + ImportProvider::Deezer => "Deezer", + } +} + +/// `toLocaleString()` twin for the matching log/status numbers ("12,345"). +/// Fixed en-US grouping is the deliberate choice, 1:1 with the reference. +fn group_thousands(n: u32) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::group_thousands; + + #[test] + fn group_thousands_matches_to_locale_string() { + assert_eq!(group_thousands(0), "0"); + assert_eq!(group_thousands(999), "999"); + assert_eq!(group_thousands(1000), "1,000"); + assert_eq!(group_thousands(12345), "12,345"); + assert_eq!(group_thousands(1234567), "1,234,567"); + } +} diff --git a/crates/qbz-qt/src/playlist_manager_bridge.rs b/crates/qbz-qt/src/playlist_manager_bridge.rs new file mode 100644 index 000000000..7e4c47aeb --- /dev/null +++ b/crates/qbz-qt/src/playlist_manager_bridge.rs @@ -0,0 +1,246 @@ +//! QbzPlaylistManager — the Playlist Manager's QML singleton. +//! +//! One of THREE singletons this surface owns (contract D2), not one god +//! object: this one holds the manager document, the canonical folder list and +//! the playlist-ORGANISATION writes (favourite, hidden, move-to-folder, +//! reorder). `QbzFolderEdit` owns the folder modals and `QbzPlaylistEdit` owns +//! the shared rename/description/delete modal, so a folder save can never +//! perturb an open playlist editor and neither of them couples to the +//! manager's own state. +//! +//! Delegates only — every body here is one line into `playlist_manager_qt` / +//! `playlist_manager_ops`. Two properties are read by surfaces OUTSIDE the +//! manager view: `foldersJson` feeds the sidebar's row context menu and both +//! move-to-folder menus, and it is kept fresh by +//! `playlist_manager_qt::refresh_folders()`, which never consults the manager +//! cache (D5). +//! +//! **No `#[qsignal]`** on any of the three bridges — outcomes are republishes +//! and toasts (the `playlist_picker_bridge.rs:13` convention). +//! +//! The module is `qbz_playlist_manager_bridge`, never `qbz_playlist_manager` +//! and never `qbz_playlist`: a bridge module named after a workspace crate is +//! E0659. The QML type name comes from the QObject, so QML is unaffected. +//! +//! Every id crossing this boundary is a **string**, and a `local:` must +//! survive the round trip untouched — nothing on either side may parse one as +//! an integer. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_playlist_manager_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // The manager document (playlist_manager_qt.rs ManagerDoc: toolbar + // state, the two counters, the filtered+sorted flat set and the + // flattened tree). + #[qproperty(QString, manager_json)] + // The CANONICAL folder array — every consumer reads this one property, + // including the two surfaces that exist before the manager document + // does (the sidebar's row menu and its move-to-folder list). Default + // "[]" so a pre-publish `JSON.parse` yields an empty array, not a throw. + #[qproperty(QString, folders_json)] + // Written by the LOAD path ONLY: navigate() / reload() set it true and + // the load's terminal rebuild clears it. The document publish never + // touches it — see playlist_manager_qt's header for what breaks when + // the two are conflated. + #[qproperty(bool, pm_loading)] + + type QbzPlaylistManager = super::QbzPlaylistManagerRust; + + /// Registers this object's Qt-thread hop. Until Main.qml calls it, + /// every `ui()` post is a SILENT no-op — the view mounts, every + /// publish is dropped, and nothing is logged on either side. + #[qinvokable] + fn boot(self: Pin<&mut QbzPlaylistManager>); + + /// Open the manager: record the route, raise the spinner, publish the + /// toolbar. Does NOT load — the view's `Component.onCompleted` does. + #[qinvokable] + fn navigate(self: Pin<&mut QbzPlaylistManager>); + + /// The single load (network + one blocking DB hop), from the view's + /// `Component.onCompleted`. That is also the back/forward re-entry + /// path, where `navigate()` never ran. + #[qinvokable] + fn reload(self: Pin<&mut QbzPlaylistManager>); + + /// Search-as-you-type. The field's text is QML-local; this stores the + /// query at once and coalesces the rebuild on a 150 ms debounce. + #[qinvokable] + fn search_changed(self: Pin<&mut QbzPlaylistManager>, query: QString); + + /// Visibility filter: `"all" | "visible" | "hidden"`. + #[qinvokable] + fn set_filter(self: Pin<&mut QbzPlaylistManager>, value: QString); + + /// Sort option. Re-selecting the ACTIVE one flips the direction (#657). + #[qinvokable] + fn set_sort(self: Pin<&mut QbzPlaylistManager>, value: QString); + + /// View mode: `"grid" | "list" | "tree"`. + #[qinvokable] + fn set_view_mode(self: Pin<&mut QbzPlaylistManager>, value: QString); + + /// Folder mode on/off; leaving it while in the tree falls back to grid. + #[qinvokable] + fn toggle_folder_mode(self: Pin<&mut QbzPlaylistManager>); + + /// Collapse / expand the FOLDERS section (the only collapsible one). + #[qinvokable] + fn toggle_folders_collapsed(self: Pin<&mut QbzPlaylistManager>); + + /// Expand / collapse one folder in the tree. + #[qinvokable] + fn toggle_tree_folder(self: Pin<&mut QbzPlaylistManager>, folder_id: QString); + + /// Flip the favourite flag. Both id kinds; no sidebar refresh. + #[qinvokable] + fn toggle_favorite(self: Pin<&mut QbzPlaylistManager>, id: QString); + + /// Flip the hidden flag. Both id kinds; refreshes the sidebar through + /// the verb that also runs offline. + #[qinvokable] + fn toggle_hidden(self: Pin<&mut QbzPlaylistManager>, id: QString); + + /// Move a playlist into a folder. `""` as `folder_id` means ROOT. + #[qinvokable] + fn move_to_folder( + self: Pin<&mut QbzPlaylistManager>, + playlist_id: QString, + folder_id: QString, + ); + + /// Custom-order arrow up (Qobuz rows only — see `reorder_step`). + #[qinvokable] + fn move_up(self: Pin<&mut QbzPlaylistManager>, id: QString); + + /// Custom-order arrow down. + #[qinvokable] + fn move_down(self: Pin<&mut QbzPlaylistManager>, id: QString); + } + + impl cxx_qt::Threading for QbzPlaylistManager {} +} + +use qbz_playlist_manager_bridge::QbzPlaylistManager; + +/// Rust side of the manager bridge (plain storage — the controller owns every +/// piece of real state). +pub struct QbzPlaylistManagerRust { + manager_json: QString, + folders_json: QString, + pm_loading: bool, +} + +impl Default for QbzPlaylistManagerRust { + fn default() -> Self { + Self { + // Parseable defaults so QML's guarded JSON.parse never throws on + // frame 1, and so `doc.playlists` is `undefined` rather than a + // crash before the first publish. + manager_json: QString::from("{}"), + folders_json: QString::from("[]"), + // FALSE: the spinner is raised by navigate()/reload(), not by the + // default. A default of true would strand the view spinning in any + // session where neither ran. + pm_loading: false, + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue a manager mutation onto the Qt event loop (no-op before `boot()` +/// registers the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPlaylistManager>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_playlist_manager_bridge::QbzPlaylistManager { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] playlist manager Qt thread already registered"); + } + crate::playlist_manager_qt::boot(); + } + + pub fn navigate(self: Pin<&mut Self>) { + crate::playlist_manager_qt::navigate(); + } + + pub fn reload(self: Pin<&mut Self>) { + crate::playlist_manager_qt::reload(); + } + + pub fn search_changed(self: Pin<&mut Self>, query: QString) { + crate::playlist_manager_qt::search_changed(&query.to_string()); + } + + pub fn set_filter(self: Pin<&mut Self>, value: QString) { + crate::playlist_manager_qt::set_filter(&value.to_string()); + } + + pub fn set_sort(self: Pin<&mut Self>, value: QString) { + crate::playlist_manager_qt::set_sort(&value.to_string()); + } + + pub fn set_view_mode(self: Pin<&mut Self>, value: QString) { + crate::playlist_manager_qt::set_view_mode(&value.to_string()); + } + + pub fn toggle_folder_mode(self: Pin<&mut Self>) { + crate::playlist_manager_qt::toggle_folder_mode(); + } + + pub fn toggle_folders_collapsed(self: Pin<&mut Self>) { + crate::playlist_manager_qt::toggle_folders_collapsed(); + } + + pub fn toggle_tree_folder(self: Pin<&mut Self>, folder_id: QString) { + crate::playlist_manager_qt::toggle_tree_folder(&folder_id.to_string()); + } + + pub fn toggle_favorite(self: Pin<&mut Self>, id: QString) { + crate::playlist_manager_ops::toggle_favorite(&id.to_string()); + } + + pub fn toggle_hidden(self: Pin<&mut Self>, id: QString) { + crate::playlist_manager_ops::toggle_hidden(&id.to_string()); + } + + pub fn move_to_folder(self: Pin<&mut Self>, playlist_id: QString, folder_id: QString) { + crate::playlist_manager_ops::move_to_folder( + &playlist_id.to_string(), + &folder_id.to_string(), + ); + } + + pub fn move_up(self: Pin<&mut Self>, id: QString) { + crate::playlist_manager_ops::move_up(&id.to_string()); + } + + pub fn move_down(self: Pin<&mut Self>, id: QString) { + crate::playlist_manager_ops::move_down(&id.to_string()); + } +} diff --git a/crates/qbz-qt/src/playlist_manager_ops.rs b/crates/qbz-qt/src/playlist_manager_ops.rs new file mode 100644 index 000000000..0d439c45e --- /dev/null +++ b/crates/qbz-qt/src/playlist_manager_ops.rs @@ -0,0 +1,285 @@ +//! Playlist Manager, controller part 3 — the optimistic mutations: favourite, +//! hidden, move-to-folder and the arrow reorder, plus their persistence and +//! their sidebar hop. +//! +//! Port of the `PlaylistManagerActions` per-card handlers in the reference's +//! `main.rs:5335-5535` (`on_toggle_favorite` / `on_toggle_hidden` / +//! `on_move_to_folder` / `on_move_up` / `on_move_down`) plus +//! `playlist_manager.rs`'s `*_local` cache patches and `reorder_step`. +//! +//! # The shape every mutation here follows (§5.3) — and why +//! +//! ```text +//! 1. patch the manager cache — ONLY when it is warm +//! 2. publish the manager document — ONLY when step 1 happened +//! 3. write the DB — ALWAYS +//! 4. hop the sidebar — ALWAYS (per the D10 table) +//! ``` +//! +//! Steps 3 and 4 must never sit behind a `CACHE.is_some()` test, and there +//! must never be an early return at the top of one of these functions. Blocks +//! 2, 3 and 5 mount these same verbs behind SIDEBAR surfaces, where the +//! manager view has typically never been opened and the cache is therefore +//! `None`. Mirroring `reload_if_loaded()`'s early return here turns "Hide from +//! sidebar" and "Move to folder" into silent no-ops for exactly the +//! account-less, offline population local playlists exist for. +//! +//! The corollary is that the new value cannot come from the cache alone: when +//! the cache is cold each toggle re-reads the CURRENT flag inside the same +//! `spawn_blocking` that writes it, and negates that. A toggle that cannot see +//! the old value is not a toggle. +//! +//! # Which sidebar verb, per mutation (D10) +//! +//! | mutation | sidebar | +//! |---|---| +//! | favourite | **none** — not a sidebar-visible property | +//! | hidden (either kind) | `crate::reload_sidebar_including_local()` | +//! | move to folder (either kind) | `sidebar_qt::move_playlist_optimistic` + `crate::publish_sidebar()` | +//! | reorder | **none** — the sidebar picks the positions up on its next load | +//! +//! `crate::reload_sidebar()` — the "refetch from Qobuz" verb — is NOT used by +//! any of them: its first statement is an offline bail, so it is a no-op for +//! precisely the users these writes are for. + +use crate::folders_qt; +use crate::local_playlist_qt; +use crate::playlist_manager_qt::{patch_cache, publish_document, visible_qobuz_ids}; + +// --------------------------------------------------------------------------- +// Favourite +// --------------------------------------------------------------------------- + +/// Flip the cached favourite flag. `Some(new_value)` when the cache was warm +/// AND held the row; `None` otherwise, which is not an error — it means the +/// DB write below has to derive the value itself. +fn patch_favorite(id: &str) -> Option { + let mut out = None; + patch_cache(|data| { + if local_playlist_qt::is_local_id(id) { + if let Some(p) = data.locals.iter_mut().find(|p| p.id == id) { + p.is_favorite = !p.is_favorite; + out = Some(p.is_favorite); + } + } else if let Ok(pid) = id.parse::() { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) { + p.is_favorite = !p.is_favorite; + out = Some(p.is_favorite); + } + } + }); + out +} + +/// Toggle a playlist's favourite flag. +/// +/// A LOCAL playlist keeps its flag on its own `local_playlists` row (B3): the +/// `playlist_settings` table's primary key is a `u64` Qobuz id, which a +/// `local:` can never be. No sidebar refresh — the heart is not a +/// sidebar-visible property. +pub(crate) fn toggle_favorite(id: &str) { + let optimistic = patch_favorite(id); + if optimistic.is_some() { + publish_document(); + } + let id = id.to_string(); + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + if local_playlist_qt::is_local_id(&id) { + let value = optimistic.unwrap_or_else(|| { + !local_playlist_qt::get_blocking(&id) + .map(|p| p.favorite) + .unwrap_or(false) + }); + local_playlist_qt::set_favorite_blocking(&id, value); + } else if let Ok(pid) = id.parse::() { + let value = optimistic.unwrap_or_else(|| { + !folders_qt::playlist_settings_map() + .get(&pid) + .map(|s| s.is_favorite) + .unwrap_or(false) + }); + folders_qt::set_favorite(pid, value); + } + }) + .await; + }); +} + +// --------------------------------------------------------------------------- +// Hidden +// --------------------------------------------------------------------------- + +fn patch_hidden(id: &str) -> Option { + let mut out = None; + patch_cache(|data| { + if local_playlist_qt::is_local_id(id) { + if let Some(p) = data.locals.iter_mut().find(|p| p.id == id) { + p.is_hidden = !p.is_hidden; + out = Some(p.is_hidden); + } + } else if let Ok(pid) = id.parse::() { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) { + p.is_hidden = !p.is_hidden; + out = Some(p.is_hidden); + } + } + }); + out +} + +/// Toggle a playlist's hidden flag, then refresh the sidebar — hidden +/// playlists drop out of the tree, so it must re-render. +/// +/// The refresh verb is `reload_sidebar_including_local()`, the one that has no +/// offline gate. Hiding is undoable ONLY from the manager's All/Visible/Hidden +/// filter, so this landing without that filter would make hiding irreversible +/// from inside QBZ — which is why block 5 (the sidebar row menu's Hide row) +/// depends on block 4. +pub(crate) fn toggle_hidden(id: &str) { + let optimistic = patch_hidden(id); + if optimistic.is_some() { + publish_document(); + } + let id = id.to_string(); + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + if local_playlist_qt::is_local_id(&id) { + let value = optimistic.unwrap_or_else(|| { + !local_playlist_qt::get_blocking(&id) + .map(|p| p.hidden) + .unwrap_or(false) + }); + local_playlist_qt::set_hidden_blocking(&id, value); + } else if let Ok(pid) = id.parse::() { + let value = optimistic.unwrap_or_else(|| { + !folders_qt::playlist_settings_map() + .get(&pid) + .map(|s| s.hidden) + .unwrap_or(false) + }); + folders_qt::set_hidden(pid, value); + } + }) + .await; + // AFTER the write settles, inside the same task — otherwise the + // rebuild reads the pre-write state (reference: main.rs:5386-5394). + crate::reload_sidebar_including_local(); + }); +} + +// --------------------------------------------------------------------------- +// Move to folder +// --------------------------------------------------------------------------- + +/// Patch the cached folder membership. `""` = root. Returns whether the cache +/// was warm and held the row. +fn patch_folder(id: &str, folder_id: &str) -> bool { + let mut patched = false; + let target = (!folder_id.is_empty()).then(|| folder_id.to_string()); + patch_cache(|data| { + if local_playlist_qt::is_local_id(id) { + if let Some(p) = data.locals.iter_mut().find(|p| p.id == id) { + p.folder_id = target; + patched = true; + } + } else if let Ok(pid) = id.parse::() { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) { + p.folder_id = target; + patched = true; + } + } + }); + patched +} + +/// Move a playlist into `folder_id`, or to root when it is `""`. +/// +/// Both id kinds are handled (D6.4): a `local:` routes to +/// `local_playlists.folder_id`, which points at the SAME `playlist_folders` +/// table the Qobuz playlists use, so one folder holds both kinds. +pub(crate) fn move_to_folder(playlist_id: &str, folder_id: &str) { + if patch_folder(playlist_id, folder_id) { + publish_document(); + } + let id = playlist_id.to_string(); + let fid = folder_id.to_string(); + crate::spawn(async move { + let write_id = id.clone(); + let write_fid = fid.clone(); + let _ = tokio::task::spawn_blocking(move || { + let target = (!write_fid.is_empty()).then_some(write_fid.as_str()); + if local_playlist_qt::is_local_id(&write_id) { + local_playlist_qt::move_to_folder_blocking(&write_id, target); + } else if let Ok(pid) = write_id.parse::() { + folders_qt::move_playlist(pid, target); + } + }) + .await; + // Patch + republish rather than reload: the offline-safe reload verb + // still re-reads the whole DB for a change we already know, and the + // network one is a no-op offline (D10). + crate::sidebar_qt::move_playlist_optimistic(&id, &fid); + crate::publish_sidebar(); + }); +} + +// --------------------------------------------------------------------------- +// Arrow reorder (custom sort) +// --------------------------------------------------------------------------- + +/// Move a playlist one slot up in the custom order. +pub(crate) fn move_up(id: &str) { + reorder_step(id, -1); +} + +/// Move a playlist one slot down in the custom order. +pub(crate) fn move_down(id: &str) { + reorder_step(id, 1); +} + +/// Swap a playlist with its neighbour in the CURRENTLY VISIBLE order and +/// renumber `position = 0..n` over that vector. +/// +/// Two things here look like bugs and are faithful ports (D9): +/// +/// * Positions are renumbered over the **visible subset only**, so rows the +/// filter removed keep their old positions and can collide. A collision +/// degrades to stable order, not corruption. Do not "fix" it. +/// * A LOCAL playlist is not reorderable at all — it sorts at `i64::MAX` and +/// `playlist_settings` has no row it could occupy — so its `parse::()` +/// fails here and the chevrons are not rendered on its card (D29). +/// +/// A no-op (id not visible, or already at the end) writes NOTHING — the same +/// rule as the reference's "empty order ⇒ skip the DB write". With a cold +/// cache the visible order is empty, so every call is that no-op; the arrows +/// are only ever drawn by the manager view, which cannot render without the +/// document that fills the cache. +fn reorder_step(id: &str, delta: i32) { + let Ok(pid) = id.parse::() else { + return; + }; + let mut ids = visible_qobuz_ids(); + let Some(pos) = ids.iter().position(|&x| x == pid) else { + return; + }; + let target = pos as i32 + delta; + if target < 0 || target as usize >= ids.len() { + return; + } + ids.swap(pos, target as usize); + + if patch_cache(|data| { + for (i, moved) in ids.iter().enumerate() { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == *moved) { + p.position = i as i32; + } + } + }) { + publish_document(); + } + + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || folders_qt::reorder_playlists(&ids)).await; + }); +} diff --git a/crates/qbz-qt/src/playlist_manager_qt.rs b/crates/qbz-qt/src/playlist_manager_qt.rs new file mode 100644 index 000000000..c23e8c644 --- /dev/null +++ b/crates/qbz-qt/src/playlist_manager_qt.rs @@ -0,0 +1,909 @@ +//! Playlist Manager, controller part 1 — the load, the cache, the toolbar +//! state and the two publish paths. +//! +//! Port of `crates/qbz/src/playlist_manager.rs`'s `load` / `apply` / `rebuild` +//! / `reset_session` / `navigate` plus the `PlaylistManagerActions` toolbar +//! handlers in the reference's `main.rs:5230-5330`. The pure model functions +//! live in `playlist_manager_rows`; the mutations live in +//! `playlist_manager_ops`. +//! +//! # The two publish paths are NOT the same function (§5.4) +//! +//! | path | writes | writes `pmLoading`? | +//! |---|---|---| +//! | [`publish_document`] | `managerJson` + `foldersJson` | **NO** | +//! | [`publish_loaded`] (the load's terminal rebuild) | both | yes — `false` | +//! +//! `navigate()` and `reload()` each set `pmLoading = true`; only the load +//! clears it. Conflating them is not a style question: the reference's +//! `rebuild()` ends with an unconditional `set_loading(false)` and gets away +//! with it because its `navigate` publishes NOTHING. Copy "publish always +//! clears loading" while also publishing from `navigate()`, and the `false` +//! lands in the same event-loop turn as the `true` — the spinner never renders, +//! the whole network+DB load runs with it off, and the empty-state branch +//! (`!loading && playlists.length === 0`) flashes "No playlists found." on +//! every single entry. +//! +//! # `foldersJson` is cache-INDEPENDENT (D5 / §5.3) +//! +//! The sidebar's row context menu reads `foldersJson` in sessions where the +//! manager view has never been opened, so the folder list cannot be a function +//! of the manager cache. [`refresh_folders`] reads the DB directly and never +//! consults `CACHE`; [`FOLDERS_CACHE`] holds the result, and a warm manager +//! cache refreshes it in memory (its counts then track the optimistic patches). +//! `managerJson.folderCount` is derived from the SAME vector in the SAME `ui()` +//! closure, so the counter row and the folder array can never disagree. +//! +//! # Everything reachable from an invokable is Qt-thread-safe +//! +//! `LibraryDatabase` wraps a `!Send` rusqlite connection and every `folders_qt` +//! / `local_playlist_qt` call opens the DB, so none of them may run on the Qt +//! thread. An invokable here only ever mutates a `Mutex`, publishes, and +//! `crate::spawn`s the rest (§5.19). [`publish_document`] in particular NEVER +//! touches the DB. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use serde::Serialize; + +use crate::folders_qt; +use crate::playlist_manager_rows as rows; +use crate::playlist_manager_rows::{ + FolderRow, PlaylistRow, PmData, PmLocalPlaylist, PmPlaylist, TreeRow, +}; + +type Runtime = Arc>; + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +/// Last-loaded data. `None` means **never loaded this session** — the one +/// condition `reload_if_loaded()` is allowed to test, and the one every +/// mutation is FORBIDDEN to test before its DB write (§5.3). +static CACHE: Mutex> = Mutex::new(None); + +/// The canonical published folder array. Filled by [`refresh_folders`] from +/// the DB and by [`build_document`] from a warm cache; it is what +/// `foldersJson` always carries, so a cold-cache publish can never clobber it +/// with `[]`. +static FOLDERS_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +/// Session tree-expand state. Never reset — only the latch below is, so a +/// re-entry re-expands every folder on top of whatever the user collapsed. +/// That is the reference behaviour (§5.5); keep it. +static EXPANDED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + +/// True once the tree has auto-expanded folders since the last `navigate()`. +static TREE_INIT: Mutex = Mutex::new(false); + +/// Debounce generation for the search field (D8). A publish only happens for +/// the newest keystroke. +static SEARCH_GEN: AtomicU64 = AtomicU64::new(0); + +/// Search debounce, milliseconds. Every OTHER toolbar setter publishes +/// immediately — only typing is coalesced, because each publish re-serialises +/// the whole document and tears down every visible card and collage. +const SEARCH_DEBOUNCE_MS: u64 = 150; + +/// Session-scoped toolbar state. It SURVIVES leaving and re-entering the view +/// (§5.5) and resets only on restart, so `navigate()` must not touch it. +#[derive(Clone, Debug)] +struct Toolbar { + /// SEED ONLY for the QML field — the text itself is QML-local (D8). + search: String, + /// `"all" | "visible" | "hidden"`. + filter: String, + /// `"name" | "recent" | "playcount" | "tracks" | "custom"`. + sort: String, + sort_asc: bool, + /// `"grid" | "list" | "tree"`. + view_mode: String, + folder_mode: bool, + folders_collapsed: bool, +} + +impl Default for Toolbar { + fn default() -> Self { + Self { + search: String::new(), + filter: "all".into(), + sort: "name".into(), + sort_asc: true, + view_mode: "grid".into(), + folder_mode: true, + folders_collapsed: false, + } + } +} + +static TOOLBAR: LazyLock> = LazyLock::new(|| Mutex::new(Toolbar::default())); + +// --------------------------------------------------------------------------- +// The wire document (§4.2) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ManagerDoc { + search: String, + filter: String, + sort: String, + sort_asc: bool, + view_mode: String, + folder_mode: bool, + folders_collapsed: bool, + can_reorder: bool, + /// Always equal to `foldersJson.length` — same source, same closure (D5). + folder_count: i32, + /// POST-FILTER visible rows, locals included. + playlist_count: i32, + playlists: Vec, + /// EMPTY unless `folderMode && viewMode == "tree"`. + tree: Vec, +} + +// --------------------------------------------------------------------------- +// Publishing +// --------------------------------------------------------------------------- + +/// First-open auto-expand: the tree expands every folder the first time it is +/// opened after a `navigate()`. `reset_session` clears the latch, never +/// `EXPANDED` itself. +fn ensure_tree_init(data: &PmData) { + let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner()); + if *init { + return; + } + if let Ok(mut exp) = EXPANDED.lock() { + for f in &data.folders { + exp.insert(f.id.clone()); + } + } + *init = true; +} + +/// Serialise `(managerJson, foldersJson)` from the live cache + toolbar. +/// +/// Pure with respect to I/O: no DB, no network. The only state it writes is +/// [`FOLDERS_CACHE`] (refreshed in memory from a warm manager cache) and the +/// tree auto-expand latch. +fn build_document() -> (String, String) { + let cached = CACHE.lock().unwrap().clone(); + let warm = cached.is_some(); + let data = cached.unwrap_or_default(); + let tb = TOOLBAR.lock().unwrap().clone(); + let query = tb.search.trim().to_lowercase(); + let offline = crate::offline_fwd::engine().status().is_offline(); + + // Warm cache: recompute so the counts track optimistic moves. Cold cache: + // whatever `refresh_folders()` last read, NEVER an empty array — the + // sidebar's move-to-folder list reads this in sessions with no manager. + let folders = if warm { + let fresh = rows::folder_rows(&data); + *FOLDERS_CACHE.lock().unwrap() = fresh.clone(); + fresh + } else { + FOLDERS_CACHE.lock().unwrap().clone() + }; + + let playlists = rows::visible_playlist_rows( + &data, + &query, + &tb.filter, + &tb.sort, + tb.sort_asc, + tb.folder_mode, + &tb.view_mode, + offline, + ); + + let tree = if tb.folder_mode && tb.view_mode == "tree" { + ensure_tree_init(&data); + let expanded = EXPANDED.lock().unwrap().clone(); + rows::build_tree( + &data, + &query, + &tb.filter, + &tb.sort, + tb.sort_asc, + offline, + &expanded, + ) + } else { + Vec::new() + }; + + let doc = ManagerDoc { + search: tb.search.clone(), + filter: tb.filter.clone(), + sort: tb.sort.clone(), + sort_asc: tb.sort_asc, + view_mode: tb.view_mode.clone(), + folder_mode: tb.folder_mode, + folders_collapsed: tb.folders_collapsed, + // D9: the DIRECTION is part of the gate. The reference omits it, and + // under custom+descending its arrows renumber `position = 0..n` over + // the REVERSED order — one press silently rewrites the user's whole + // persisted custom order. + can_reorder: tb.sort == "custom" && tb.sort_asc && query.is_empty(), + folder_count: folders.len() as i32, + playlist_count: playlists.len() as i32, + playlists, + tree, + }; + + ( + serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()), + serde_json::to_string(&folders).unwrap_or_else(|_| "[]".into()), + ) +} + +/// Publish the document. **Never touches `pmLoading`** (§5.4). +pub(crate) fn publish_document() { + let (manager, folders) = build_document(); + crate::playlist_manager_bridge::ui(move |mut b| { + b.as_mut().set_manager_json(QString::from(manager.as_str())); + b.as_mut().set_folders_json(QString::from(folders.as_str())); + }); +} + +/// The load's TERMINAL rebuild — the one and only place `pmLoading` is +/// cleared. +fn publish_loaded() { + let (manager, folders) = build_document(); + crate::playlist_manager_bridge::ui(move |mut b| { + b.as_mut().set_manager_json(QString::from(manager.as_str())); + b.as_mut().set_folders_json(QString::from(folders.as_str())); + b.as_mut().set_pm_loading(false); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// Seed the closed/default document and kick the cache-independent folder +/// read, so the sidebar's row menu has a folder list before the manager has +/// ever been opened (§5.19). +pub(crate) fn boot() { + publish_document(); + refresh_folders(); +} + +/// Open the manager: record the route, clear the tree latch, raise the +/// spinner, publish the TOOLBAR so the view mounts with chrome instead of an +/// empty document. **No load** — the view's `Component.onCompleted` calls +/// [`reload`], which is also what serves the back/forward re-entry case +/// (`nav_qt::back()` republishes `currentView` and runs no per-view load, so +/// `navigate()` never ran). Loading from both would run `get_user_playlists()` +/// twice over the network on every entry (§5.11). +pub(crate) fn navigate() { + crate::nav_qt::record("playlistmanager"); + reset_session(); + // Raised here, cleared ONLY by publish_loaded(). Written inline rather + // than through a shared helper so the invariant is greppable: three + // `set_pm_loading` call sites in the whole crate — this one, reload()'s, + // and the single `false` at the end of the load. + crate::playlist_manager_bridge::ui(|mut b| { + b.as_mut().set_pm_loading(true); + }); + publish_document(); +} + +/// Reset ONLY the tree-expand latch (§5.5). Search / filter / sort / +/// direction / view mode / folder mode / folders-collapsed all persist for the +/// session — do not reset them here. +fn reset_session() { + *TREE_INIT.lock().unwrap_or_else(|e| e.into_inner()) = false; +} + +/// The single load. Raises the spinner at entry too, because a Back-triggered +/// `Component.onCompleted` reaches here without `navigate()` having run. +pub(crate) fn reload() { + crate::playlist_manager_bridge::ui(|mut b| { + b.as_mut().set_pm_loading(true); + }); + let runtime = crate::app(); + crate::spawn(async move { + let data = load(&runtime).await; + *CACHE.lock().unwrap() = Some(data); + publish_loaded(); + }); +} + +/// Refresh the manager after a write made ELSEWHERE (the folder editor, the +/// playlist editor, the sidebar row menu). +/// +/// This early return is the ONLY thing in this controller allowed to be +/// conditional on the cache: when the manager has never been opened there is +/// no reader for the document, so the refetch would be pure cost. Generalising +/// it to the mutations turns "Hide from sidebar" and "Move to folder" into +/// silent no-ops for the account-less, offline population the whole feature +/// exists for (§5.3). +/// +/// Callers: `folder_edit_qt::after_write` (block 2) and +/// `playlist_edit_qt::after_write` (block 3), after every successful write. It +/// lands here because the cross-singleton refresh seam is this controller's to +/// define, not theirs. +pub fn reload_if_loaded() { + if CACHE.lock().unwrap().is_none() { + return; + } + reload(); +} + +/// Name + description of a QOBUZ playlist out of the WARM manager cache. +/// +/// Block 3's editor seeds from here before it falls back to +/// `core().get_playlist(pid)` (§5.2): the loader already fetched the +/// description and `PmPlaylist` carries it precisely so opening the editor +/// from the manager costs no request and works with the network down. +/// `None` means "the cache cannot answer" — never "no description"; a +/// playlist that genuinely has none answers `Some((name, None))`. +/// +/// Deliberately NOT extended to `local:` ids: the local arm reads +/// `library.db` directly, which is always authoritative and always available. +pub(crate) fn cached_playlist_seed(id: u64) -> Option<(String, Option)> { + let guard = CACHE.lock().ok()?; + let data = guard.as_ref()?; + data.playlists + .iter() + .find(|p| p.id == id) + .map(|p| (p.name.clone(), p.description.clone())) +} + +/// Re-read `foldersJson` straight from the DB and republish. +/// +/// Deliberately NOT gated on the manager cache (D5): a folder created from the +/// sidebar must reach `foldersJson` in a session where the manager view was +/// never opened. Counts come from `CACHE` when it is warm and from the DB when +/// it is not, so the number is never a placeholder 0 in either state. +pub fn refresh_folders() { + crate::spawn(async move { + let rows = tokio::task::spawn_blocking(folder_rows_blocking) + .await + .unwrap_or_default(); + *FOLDERS_CACHE.lock().unwrap() = rows; + publish_document(); + }); +} + +/// The DB half of [`refresh_folders`]. Blocking — `spawn_blocking` only. +fn folder_rows_blocking() -> Vec { + let folders = folders_qt::load_folders_full(); + // Warm cache: its counts already fold in the optimistic patches, so reuse + // them rather than re-reading a DB that may lag an in-flight write. + let cached = CACHE.lock().unwrap().clone(); + if let Some(data) = cached.as_ref() { + let counts = rows::folder_counts(data); + return folders + .iter() + .map(|f| rows::folder_item(f, counts.get(&f.id).copied().unwrap_or(0))) + .collect(); + } + // Cold cache: count both kinds from the DB, or a folder holding only local + // playlists reads "0 playlists" on its card (D6.3). + let settings = folders_qt::playlist_settings_map(); + let locals = crate::local_playlist_qt::list_blocking(); + let live: HashSet<&String> = folders.iter().map(|f| &f.id).collect(); + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for s in settings.values() { + if let Some(fid) = s.folder_id.as_ref().filter(|f| live.contains(f)) { + *counts.entry(fid.clone()).or_insert(0) += 1; + } + } + for p in &locals { + if let Some(fid) = p.folder_id.as_ref().filter(|f| live.contains(f)) { + *counts.entry(fid.clone()).or_insert(0) += 1; + } + } + folders + .iter() + .map(|f| rows::folder_item(f, counts.get(&f.id).copied().unwrap_or(0))) + .collect() +} + +// --------------------------------------------------------------------------- +// Toolbar +// --------------------------------------------------------------------------- + +/// Search-as-you-type. The query is stored IMMEDIATELY (so a publish triggered +/// by anything else already reflects it) and the rebuild is coalesced on a +/// 150 ms debounce (D8). +pub(crate) fn search_changed(query: &str) { + TOOLBAR.lock().unwrap().search = query.to_string(); + let generation = SEARCH_GEN.fetch_add(1, Ordering::SeqCst) + 1; + crate::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS)).await; + if SEARCH_GEN.load(Ordering::SeqCst) == generation { + publish_document(); + } + }); +} + +pub(crate) fn set_filter(value: &str) { + let valid = matches!(value, "all" | "visible" | "hidden"); + TOOLBAR.lock().unwrap().filter = if valid { value.to_string() } else { "all".into() }; + publish_document(); +} + +/// #657 direction toggle: re-selecting the ACTIVE option flips the direction; +/// picking a DIFFERENT one sets it and resets to its natural direction. Same +/// shape as `sidebar_qt::set_sort` — mirrored, not reinvented. +pub(crate) fn set_sort(value: &str) { + let option = match value { + "name" | "recent" | "playcount" | "tracks" | "custom" => value, + _ => "name", + }; + { + let mut tb = TOOLBAR.lock().unwrap(); + if tb.sort == option { + tb.sort_asc = !tb.sort_asc; + } else { + tb.sort = option.to_string(); + tb.sort_asc = true; + } + } + publish_document(); +} + +pub(crate) fn set_view_mode(value: &str) { + let mode = match value { + "grid" | "list" | "tree" => value, + _ => "grid", + }; + TOOLBAR.lock().unwrap().view_mode = mode.to_string(); + publish_document(); +} + +/// Leaving folder mode while in the tree falls back to grid — the tree IS the +/// folder surface, so `!folderMode && viewMode == "tree"` is unreachable. +pub(crate) fn toggle_folder_mode() { + { + let mut tb = TOOLBAR.lock().unwrap(); + tb.folder_mode = !tb.folder_mode; + if !tb.folder_mode && tb.view_mode == "tree" { + tb.view_mode = "grid".into(); + } + } + publish_document(); +} + +/// Collapse / expand the FOLDERS section. +/// +/// The reference deliberately does NOT rebuild here (`main.rs:5313-5322`) — +/// the flag is a pure view property there. In this port the flag rides +/// `managerJson`, so it must be published; the rebuild that comes with it is +/// from the cache and hits neither the network nor the DB. +pub(crate) fn toggle_folders_collapsed() { + { + let mut tb = TOOLBAR.lock().unwrap(); + tb.folders_collapsed = !tb.folders_collapsed; + } + publish_document(); +} + +pub(crate) fn toggle_tree_folder(folder_id: &str) { + { + let mut expanded = EXPANDED.lock().unwrap(); + if !expanded.remove(folder_id) { + expanded.insert(folder_id.to_string()); + } + } + publish_document(); +} + +// --------------------------------------------------------------------------- +// Cache access for `playlist_manager_ops` +// --------------------------------------------------------------------------- + +/// Run `f` against the cache when it is WARM; `false` when it is cold. +/// +/// The mutations use the return value to decide whether to republish — never +/// whether to write to the DB (§5.3). +pub(crate) fn patch_cache(f: impl FnOnce(&mut PmData)) -> bool { + let mut guard = CACHE.lock().unwrap(); + match guard.as_mut() { + Some(data) => { + f(data); + true + } + None => false, + } +} + +/// The ids of the currently visible QOBUZ rows, in the published order — the +/// vector `reorder_step` swaps in and renumbers. +/// +/// Locals drop out here exactly as they do in the reference (which parses the +/// model's ids as `u64`): custom positions live in `playlist_settings`, whose +/// primary key is a Qobuz id, so a local has no slot to occupy (D29). +pub(crate) fn visible_qobuz_ids() -> Vec { + let Some(data) = CACHE.lock().unwrap().clone() else { + return Vec::new(); + }; + let tb = TOOLBAR.lock().unwrap().clone(); + let query = tb.search.trim().to_lowercase(); + let offline = crate::offline_fwd::engine().status().is_offline(); + rows::visible_playlist_rows( + &data, + &query, + &tb.filter, + &tb.sort, + tb.sort_asc, + tb.folder_mode, + &tb.view_mode, + offline, + ) + .iter() + .filter_map(|r| r.id.parse::().ok()) + .collect() +} + +// --------------------------------------------------------------------------- +// The load +// --------------------------------------------------------------------------- + +/// Fetch playlists (Qobuz) + folders + settings + stats + local counts + local +/// playlists, and merge them into the Send `PmData`. +/// +/// **ONE `spawn_blocking`**, returning every DB read as a tuple; a join error +/// degrades the whole tuple to empties rather than panicking. Do not split it +/// into six hops — the DB is opened per call, and the point of the single hop +/// is to open it the minimum number of times per load. +/// +/// There is no error state anywhere in this path: the fetch swallows its +/// failure into an empty vector and the join swallows its own, so a network +/// outage renders as "No playlists found." That is the reference (§5.13). +async fn load(runtime: &Runtime) -> PmData { + let offline = crate::offline_fwd::engine().status().is_offline(); + let remote: Vec = if offline { + Vec::new() + } else { + runtime.core().get_user_playlists().await.unwrap_or_else(|e| { + log::warn!("[qbz-qt] playlist-manager playlists load failed: {e}"); + Vec::new() + }) + }; + + let (folders, settings, play_counts, local_counts, locals, fav_ids) = + tokio::task::spawn_blocking(|| { + // §5.6: locals get covers. Ask the sidebar cache FIRST — the work + // is already done there — and only fall back to the repo for the + // ones it cannot answer, because `resolve_cover_urls_blocking` + // opens the DB per call. + let locals: Vec = crate::local_playlist_qt::list_blocking() + .into_iter() + .map(|p| PmLocalPlaylist { + cover_urls: crate::sidebar_qt::local_covers(&p.id).unwrap_or_else(|| { + crate::local_playlist_qt::resolve_cover_urls_blocking(&p.id, 4) + }), + id: p.id, + name: p.name, + description: p.description, + offline_only: p.offline_only, + track_count: p.track_count, + is_favorite: p.favorite, + is_hidden: p.hidden, + folder_id: p.folder_id, + }) + .collect(); + ( + folders_qt::load_folders_full(), + folders_qt::playlist_settings_map(), + folders_qt::playlist_play_counts(), + folders_qt::playlist_local_counts(), + locals, + crate::library_db_qt::favorite_playlist_ids(), + ) + }) + .await + .unwrap_or_else(|_| { + log::warn!("[qbz-qt] playlist-manager DB read join failed"); + Default::default() + }); + + let folder_ids: HashSet<&String> = folders.iter().map(|f| &f.id).collect(); + + let mut playlists: Vec = remote + .iter() + .map(|p| { + let s = settings.get(&p.id).cloned().unwrap_or_default(); + PmPlaylist { + id: p.id, + name: p.name.clone(), + description: p.description.clone(), + tracks_count: p.tracks_count, + duration: p.duration, + local_count: local_counts.get(&p.id).copied().unwrap_or(0), + play_count: play_counts.get(&p.id).copied().unwrap_or(0), + is_favorite: s.is_favorite, + is_hidden: s.hidden, + // A folder that no longer exists falls back to root — the same + // guard the sidebar applies (§5.18). + folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)), + // No settings row = PlaylistSettingsLite::default(): visible, + // unfavourited, root, position 0 — so an unpositioned set is + // one tie block that keeps API order under a stable sort. + position: s.position, + cover_urls: cover_urls(p), + } + }) + .collect(); + + // INTERNAL favourites top-up (§5.18): a hearted playlist that is neither + // owned nor subscribed never comes back from `get_user_playlists`, so + // without this it is invisible here while Favorites > Playlists shows it. + // `is_favorite: true` is the ONLY hardcoded field; everything else still + // comes from the settings + count maps. Online only. + if !offline { + let known: HashSet = playlists.iter().map(|p| p.id).collect(); + for fid in fav_ids { + if known.contains(&fid) { + continue; + } + if let Ok(p) = runtime.core().get_playlist(fid).await { + let s = settings.get(&fid).cloned().unwrap_or_default(); + playlists.push(PmPlaylist { + id: fid, + name: p.name.clone(), + description: p.description.clone(), + tracks_count: p.tracks_count, + duration: p.duration, + local_count: local_counts.get(&fid).copied().unwrap_or(0), + play_count: play_counts.get(&fid).copied().unwrap_or(0), + is_favorite: true, + is_hidden: s.hidden, + folder_id: s.folder_id.filter(|f| folder_ids.contains(f)), + position: s.position, + cover_urls: cover_urls(&p), + }); + } + } + } + + // OFFLINE synthesis: the Qobuz fetch was skipped, so the reachable Qobuz + // playlists are the MIXED ones — those with >= 1 local sidecar row. Named + // from the sidebar's session cache (loaded while online) else the + // "Playlist (N local)" fallback. The persisted snapshot subsystem that + // would supply better names offline is absent from this port (D30), which + // is why there is no third source here. + if offline { + let known: HashSet = playlists.iter().map(|p| p.id).collect(); + let mut ids: Vec = local_counts + .iter() + .filter(|&(_, &count)| count > 0) + .map(|(&id, _)| id) + .filter(|id| !known.contains(id)) + .collect(); + // A HashMap iterates in an arbitrary order; sort so the synthesised + // block is stable across rebuilds (it is a tie block under `recent`). + ids.sort_unstable(); + for id in ids { + let count = local_counts.get(&id).copied().unwrap_or(0); + let s = settings.get(&id).cloned().unwrap_or_default(); + playlists.push(PmPlaylist { + id, + name: crate::sidebar_qt::playlist_name(id).unwrap_or_else(|| { + qbz_i18n::t_args("Playlist ({} local)", &[&count.to_string()]) + }), + description: None, + tracks_count: 0, + duration: 0, + local_count: count, + play_count: play_counts.get(&id).copied().unwrap_or(0), + is_favorite: s.is_favorite, + is_hidden: s.hidden, + folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)), + position: s.position, + cover_urls: Vec::new(), + }); + } + } + + // Locals get the SAME dangling-folder guard as the Qobuz rows, so + // `build_tree`'s "only unfiled locals go in the root run" can be a plain + // `folder_id.is_none()` test. + let mut locals = locals; + for p in &mut locals { + if p.folder_id.as_ref().is_some_and(|f| !folder_ids.contains(f)) { + p.folder_id = None; + } + } + + log::info!( + "[qbz-qt] playlist-manager loaded: {} playlists, {} folders, {} local", + playlists.len(), + folders.len(), + locals.len() + ); + PmData { + playlists, + folders, + locals, + } +} + +/// Up to four de-duplicated cover urls. +/// +/// `images300` → `images150` → `images`, FIRST NON-EMPTY LIST WINS, then skip +/// empties, skip duplicates, cap at 4. The reference's doc-comment claims +/// "images150 > images300" and is wrong about its own code; this matches the +/// code, and `sidebar_qt.rs:186-188`. +fn cover_urls(p: &qbz_models::Playlist) -> Vec { + let source = [&p.images300, &p.images150, &p.images] + .into_iter() + .flatten() + .find(|v| !v.is_empty()); + let mut out: Vec = Vec::new(); + if let Some(list) = source { + for url in list { + if !url.is_empty() && !out.contains(url) { + out.push(url.clone()); + } + if out.len() == 4 { + break; + } + } + } + out +} + +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// The toolbar statics are process-global; these tests drive them through + /// the public setters and re-derive expectations from the state they just + /// set, so ordering between them cannot matter. The bridge publish hop is + /// a no-op off the Qt thread (QT_THREAD unset). + static LOCK: Mutex<()> = Mutex::new(()); + + fn reset_toolbar() { + *TOOLBAR.lock().unwrap() = Toolbar::default(); + } + + #[test] + fn set_sort_flips_direction_on_reselect_and_resets_it_on_a_new_option() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + assert_eq!(TOOLBAR.lock().unwrap().sort, "name"); + assert!(TOOLBAR.lock().unwrap().sort_asc); + + set_sort("name"); + assert!(!TOOLBAR.lock().unwrap().sort_asc, "re-select flips"); + set_sort("name"); + assert!(TOOLBAR.lock().unwrap().sort_asc, "and flips back"); + + set_sort("name"); + set_sort("tracks"); + let tb = TOOLBAR.lock().unwrap().clone(); + assert_eq!(tb.sort, "tracks"); + assert!(tb.sort_asc, "a NEW option resets to its natural direction"); + + // Unknown values fall back to "name" rather than poisoning the state. + set_sort("nonsense"); + assert_eq!(TOOLBAR.lock().unwrap().sort, "name"); + reset_toolbar(); + } + + #[test] + fn leaving_folder_mode_from_the_tree_falls_back_to_grid() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + set_view_mode("tree"); + toggle_folder_mode(); + let tb = TOOLBAR.lock().unwrap().clone(); + assert!(!tb.folder_mode); + assert_eq!(tb.view_mode, "grid"); + // Re-entering folder mode does NOT restore the tree. + toggle_folder_mode(); + assert_eq!(TOOLBAR.lock().unwrap().view_mode, "grid"); + reset_toolbar(); + } + + #[test] + fn setters_reject_unknown_values() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + set_filter("hidden"); + assert_eq!(TOOLBAR.lock().unwrap().filter, "hidden"); + set_filter("bogus"); + assert_eq!(TOOLBAR.lock().unwrap().filter, "all"); + set_view_mode("bogus"); + assert_eq!(TOOLBAR.lock().unwrap().view_mode, "grid"); + reset_toolbar(); + } + + #[test] + fn can_reorder_needs_custom_ascending_and_an_empty_query() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + let can_reorder = || -> bool { + let (manager, _) = build_document(); + let v: serde_json::Value = serde_json::from_str(&manager).unwrap(); + v["canReorder"].as_bool().unwrap() + }; + assert!(!can_reorder(), "default sort is name"); + set_sort("custom"); + assert!(can_reorder()); + // D9: descending custom would renumber a REVERSED order. + set_sort("custom"); + assert!(!can_reorder(), "custom + descending must not offer arrows"); + set_sort("custom"); + assert!(can_reorder()); + TOOLBAR.lock().unwrap().search = " jazz ".into(); + assert!(!can_reorder(), "an active query must not offer arrows"); + TOOLBAR.lock().unwrap().search = " ".into(); + assert!(can_reorder(), "a whitespace-only query is no query"); + reset_toolbar(); + } + + #[test] + fn the_cold_document_is_parseable_and_folder_count_tracks_the_folder_array() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + *CACHE.lock().unwrap() = None; + *FOLDERS_CACHE.lock().unwrap() = vec![FolderRow { + id: "f1".into(), + name: "Jazz".into(), + count: 4, + ..Default::default() + }]; + let (manager, folders) = build_document(); + let m: serde_json::Value = serde_json::from_str(&manager).unwrap(); + let f: serde_json::Value = serde_json::from_str(&folders).unwrap(); + assert_eq!(f.as_array().unwrap().len(), 1); + assert_eq!( + m["folderCount"].as_i64().unwrap(), + 1, + "folderCount must equal foldersJson.length (D5)" + ); + assert_eq!(m["playlistCount"].as_i64().unwrap(), 0); + assert!(m["playlists"].as_array().unwrap().is_empty()); + assert!(m["tree"].as_array().unwrap().is_empty()); + // The defaults QML reads with `!== false`. + assert_eq!(m["sortAsc"], serde_json::json!(true)); + assert_eq!(m["folderMode"], serde_json::json!(true)); + assert_eq!(m["viewMode"], serde_json::json!("grid")); + *FOLDERS_CACHE.lock().unwrap() = Vec::new(); + reset_toolbar(); + } + + #[test] + fn the_tree_is_published_only_in_folder_mode_tree_view() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_toolbar(); + *CACHE.lock().unwrap() = Some(PmData { + playlists: vec![PmPlaylist { + id: 1, + name: "Alpha".into(), + ..Default::default() + }], + folders: vec![], + locals: vec![], + }); + let tree_len = || -> usize { + let (manager, _) = build_document(); + let v: serde_json::Value = serde_json::from_str(&manager).unwrap(); + v["tree"].as_array().unwrap().len() + }; + assert_eq!(tree_len(), 0, "grid mode publishes no tree"); + set_view_mode("tree"); + assert_eq!(tree_len(), 1); + toggle_folder_mode(); + assert_eq!(tree_len(), 0, "leaving folder mode empties the tree"); + *CACHE.lock().unwrap() = None; + reset_toolbar(); + } +} diff --git a/crates/qbz-qt/src/playlist_manager_rows.rs b/crates/qbz-qt/src/playlist_manager_rows.rs new file mode 100644 index 000000000..38ceb50bc --- /dev/null +++ b/crates/qbz-qt/src/playlist_manager_rows.rs @@ -0,0 +1,1042 @@ +//! Playlist Manager, controller part 2 — the data model and every PURE +//! function over it: filters, comparators, the Qobuz+local merge, the row +//! serializers and the tree flattener. +//! +//! Port of the model half of `crates/qbz/src/playlist_manager.rs` +//! (`passes` / `sort_playlists` / `sort_entries` / `local_entries` / +//! `folder_item` / `playlist_item` / `local_playlist_item` / `build_tree` / +//! `format_duration` / `parse_color`). +//! +//! Nothing here touches the DB, the network, a global or the Qt thread — it is +//! all `(&PmData, toolbar) -> rows`, which is what makes it the only part of +//! this controller that can be unit-tested without a build. `playlist_manager_qt` +//! owns the cache, the toolbar statics and the publish hop; +//! `playlist_manager_ops` owns the mutations. +//! +//! # Two deliberate divergences from the reference, both from contract D6 +//! +//! 1. **Locals live in folders.** The Qt sidebar already files local playlists +//! inside folders (`sidebar_qt.rs:352-379`), so the reference's "locals are +//! always root" would ship a contradiction the user can see, with no way to +//! undo it. So: the flat grid/list EXCLUDES a filed local in folder mode +//! exactly as it excludes a filed Qobuz playlist, a folder's tree members +//! are *(Qobuz members) ∪ (local members)*, and both count fields fold +//! locals in. A folder holding three locals and zero Qobuz rows must not +//! read "0 playlists". +//! 2. **`sort_playlists` (the Qobuz-only comparator) is not ported.** Its one +//! caller in the reference is `build_tree`'s folder-member sort, and here +//! that member list is a merged Qobuz+local set, so it goes through +//! [`sort_entries`] like every other list. Keeping a second comparator that +//! nothing calls would be two definitions of one order — and running it as a +//! pre-pass is not harmless: for `"recent"` it contributes only the +//! `reverse()`, which `sort_entries` would then apply a second time. +//! +//! # `parse_color` is a GATE, not a converter (§5.20) +//! +//! Slint needed a real `Color`; QML's `Qt.color()` parses `#rgb` and `#rrggbb` +//! natively. What still has to happen in Rust is the VALIDITY test, so a stored +//! gradient (`linear-gradient(...)`) or CSS var (`var(--accent-primary)`) never +//! reaches QML as a colour. The published pair is `iconColor` (the string, or +//! `""`) plus `hasColor`; `""` means "use the theme accent". + +use std::collections::{HashMap, HashSet}; + +use serde::Serialize; + +use crate::folders_qt::FolderFull; + +// --------------------------------------------------------------------------- +// The Send data model (the loader fills it, the cache holds it) +// --------------------------------------------------------------------------- + +/// One Qobuz playlist merged with its local settings + stats. +#[derive(Clone, Debug, Default)] +pub(crate) struct PmPlaylist { + pub id: u64, + pub name: String, + /// The REAL description, carried so a rename cannot wipe it (§5.2). The + /// fetch already returned it, so it is free. Read by block 3's playlist + /// editor through `playlist_manager_qt::cached_playlist_seed`, which seeds + /// from this cache before falling back to a `get_playlist` request. + pub description: Option, + /// Remote (Qobuz) track count. + pub tracks_count: u32, + /// Total playlist duration in seconds. + pub duration: u32, + /// Local (non-Qobuz) sidecar track count. + pub local_count: u32, + pub play_count: u32, + pub is_favorite: bool, + pub is_hidden: bool, + /// Already filtered through the LIVE folder id set by the loader — a + /// playlist pointing at a deleted folder arrives here as `None`. + pub folder_id: Option, + pub position: i32, + /// Up to four de-duplicated cover urls (same scheme as the sidebar). + pub cover_urls: Vec, +} + +impl PmPlaylist { + pub fn total_count(&self) -> u32 { + self.tracks_count + self.local_count + } +} + +/// One LOCAL playlist (library.db entity, id `local:`). +#[derive(Clone, Debug, Default)] +pub(crate) struct PmLocalPlaylist { + pub id: String, + pub name: String, + /// From `repo::LocalPlaylist.description` (§5.2). Same story as the Qobuz + /// row's: written here, read by block 3's editor. + #[allow(dead_code)] + pub description: Option, + pub offline_only: bool, + pub track_count: u32, + pub is_favorite: bool, + pub is_hidden: bool, + /// D6: locals DO carry folder membership here, via + /// `local_playlists.folder_id`, pointing at the same folder table the + /// Qobuz playlists use. Loader-normalised against the live folder ids. + pub folder_id: Option, + /// §5.6: filled, unlike the reference's hardcoded `cover_count: 0`. + pub cover_urls: Vec, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct PmData { + pub playlists: Vec, + pub folders: Vec, + pub locals: Vec, +} + +// --------------------------------------------------------------------------- +// The wire documents (§4) +// --------------------------------------------------------------------------- + +/// One entry of `QbzPlaylistManager.foldersJson` (§4.1). +/// +/// `iconType` is deliberately absent: `hasCustomImage` answers the only +/// question the view asks, and it answers it correctly (D25). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FolderRow { + pub id: String, + pub name: String, + /// Meaning depends on the surface, ON PURPOSE (§5.7): in `foldersJson` it + /// is TOTAL membership (Qobuz + local, ignoring search and the visibility + /// filter); in `managerJson.tree[].folder` it is the POST-FILTER member + /// count. Do not unify them. + pub count: i32, + pub icon_preset: String, + /// `""` means "use the theme accent". + pub icon_color: String, + pub has_color: bool, + /// Published, NEVER filtered on here — the manager shows hidden folders at + /// 0.6 opacity and the sidebar derives its own `visibleFolders` (D11). + pub is_hidden: bool, + pub has_custom_image: bool, + /// `file://…` percent-encoded, `""` whenever `hasCustomImage` is false. + pub custom_image_path: String, +} + +/// One playlist row — what `PmGridCard` / `PmListRow` / `PmTreePlaylistRow` +/// read (§4.3). Every count line is pluralised in RUST; QML never pluralises. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlaylistRow { + /// Qobuz id AS A STRING, or `local:`. Nothing in QML may parse one. + pub id: String, + pub name: String, + pub tracks_line: String, + /// `""` when the duration is 0; always `""` for locals. + pub duration_line: String, + /// `""` when `localCount == 0`; always `""` for locals. + pub local_line: String, + pub local_count: i32, + pub total_count: i32, + pub play_count: i32, + /// `"no" | "some_local" | "all_local"`; `""` for locals. + pub local_status: String, + pub is_favorite: bool, + pub is_hidden: bool, + pub is_local: bool, + pub offline_only: bool, + /// `""` = root. Carries the LOCAL's real folder id too (D6). + pub folder_id: String, + /// 0..4 urls, de-duplicated, in order. `PlaylistCollage` resolves paths. + pub covers: Vec, +} + +/// One flattened tree row (§4.2). The absent half is omitted from the JSON, so +/// QML reads `modelData.folder` / `.playlist` guarded by `kind`. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TreeRow { + /// Exactly `"folder"` or `"playlist"`. + pub kind: String, + pub expanded: bool, + pub indent: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub folder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub playlist: Option, +} + +// --------------------------------------------------------------------------- +// Colour + duration +// --------------------------------------------------------------------------- + +/// Validity gate for a stored folder colour: `Some(hex)` only for solid `#rgb` +/// / `#rrggbb`. Gradients, CSS vars, `#rrggbbaa` and empty all return `None` +/// and the tile falls back to the accent. +pub(crate) fn parse_color(s: &str) -> Option { + let hex = s.strip_prefix('#')?; + if !matches!(hex.len(), 3 | 6) { + return None; + } + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + Some(s.to_string()) +} + +/// Total-playtime label, e.g. `"1 h 43 min"` or `"12 min"`. Empty at zero. +pub(crate) fn format_duration(seconds: u32) -> String { + if seconds == 0 { + return String::new(); + } + let hours = seconds / 3600; + let mins = (seconds % 3600) / 60; + if hours > 0 { + qbz_i18n::t_args("{} h {} min", &[&hours.to_string(), &mins.to_string()]) + } else { + qbz_i18n::t_args("{} min", &[&mins.to_string()]) + } +} + +// --------------------------------------------------------------------------- +// Row builders +// --------------------------------------------------------------------------- + +pub(crate) fn folder_item(f: &FolderFull, count: usize) -> FolderRow { + let color = parse_color(&f.icon_color); + // BOTH halves, like the reference: an `icon_type` of "custom" with no + // stored path is a half-written row, and rendering it as "has an image" + // paints a bare coloured square. + let has_custom_image = f.icon_type == "custom" && f.custom_image_path.is_some(); + FolderRow { + id: f.id.clone(), + name: f.name.clone(), + count: count as i32, + icon_preset: f.icon_preset.clone(), + icon_color: color.clone().unwrap_or_default(), + has_color: color.is_some(), + is_hidden: f.is_hidden, + has_custom_image, + custom_image_path: if has_custom_image { + f.custom_image_path + .as_deref() + .map(crate::artwork_qt::file_url) + .unwrap_or_default() + } else { + String::new() + }, + } +} + +pub(crate) fn playlist_item(p: &PmPlaylist) -> PlaylistRow { + let local_status = if p.local_count == 0 { + "no" + } else if p.tracks_count == 0 { + "all_local" + } else { + "some_local" + }; + let total = p.total_count(); + PlaylistRow { + id: p.id.to_string(), + name: p.name.clone(), + tracks_line: qbz_i18n::tf( + "{} track", + "{} tracks", + total as i64, + &[&total.to_string()], + ), + duration_line: format_duration(p.duration), + local_line: if p.local_count > 0 { + qbz_i18n::t_args("({} local)", &[&p.local_count.to_string()]) + } else { + String::new() + }, + local_count: p.local_count as i32, + total_count: total as i32, + play_count: p.play_count as i32, + local_status: local_status.to_string(), + is_favorite: p.is_favorite, + is_hidden: p.is_hidden, + is_local: false, + offline_only: false, + folder_id: p.folder_id.clone().unwrap_or_default(), + covers: p.cover_urls.clone(), + } +} + +pub(crate) fn local_playlist_item(p: &PmLocalPlaylist) -> PlaylistRow { + PlaylistRow { + id: p.id.clone(), + name: p.name.clone(), + tracks_line: qbz_i18n::tf( + "{} track", + "{} tracks", + p.track_count as i64, + &[&p.track_count.to_string()], + ), + // A local playlist has no Qobuz duration and no sidecar split, so both + // secondary lines are empty by construction — not "not implemented". + duration_line: String::new(), + local_line: String::new(), + local_count: 0, + total_count: p.track_count as i32, + play_count: 0, + local_status: String::new(), + is_favorite: p.is_favorite, + is_hidden: p.is_hidden, + is_local: true, + offline_only: p.offline_only, + // D6: the local's REAL folder id, not "". + folder_id: p.folder_id.clone().unwrap_or_default(), + covers: p.cover_urls.clone(), + } +} + +// --------------------------------------------------------------------------- +// Filters +// --------------------------------------------------------------------------- + +/// Whether a Qobuz playlist passes search + folder + visibility. +/// +/// `query` must already be trimmed and lowercased. +pub(crate) fn passes( + p: &PmPlaylist, + query: &str, + filter: &str, + folder_mode: bool, + view_mode: &str, +) -> bool { + if !query.is_empty() && !p.name.to_lowercase().contains(query) { + return false; + } + // In folder mode (non-tree) the grid/list shows ONLY root playlists — + // folders own their members, and entering one is the tree's job. + if folder_mode && view_mode != "tree" && p.folder_id.is_some() { + return false; + } + match filter { + "visible" => !p.is_hidden, + "hidden" => p.is_hidden, + _ => true, + } +} + +/// The LOCAL playlists that pass the same three filters, name-sorted. +/// +/// D6.1: the folder clause is the PARALLEL of `passes`'s, applied to the local +/// vector — never one filter over an already-merged set, which would let the +/// offline filter (Qobuz-only) reach the locals. +pub(crate) fn local_entries<'a>( + data: &'a PmData, + query: &str, + filter: &str, + folder_mode: bool, + view_mode: &str, +) -> Vec<&'a PmLocalPlaylist> { + let mut locals: Vec<&PmLocalPlaylist> = data + .locals + .iter() + .filter(|p| query.is_empty() || p.name.to_lowercase().contains(query)) + .filter(|p| !(folder_mode && view_mode != "tree" && p.folder_id.is_some())) + .filter(|p| match filter { + "visible" => !p.is_hidden, + "hidden" => p.is_hidden, + _ => true, + }) + .collect(); + locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + locals +} + +// --------------------------------------------------------------------------- +// The merged display set +// --------------------------------------------------------------------------- + +/// Display-stage union of a Qobuz playlist and a LOCAL one, so locals +/// INTERLEAVE into the active sort instead of being appended after the Qobuz +/// block. +/// +/// Sort keys a local does not have, and what it sorts as (all reference +/// behaviour, §5.17): +/// * `playcount` — 0, i.e. LAST under the descending play-count sort; +/// * `custom` — `i64::MAX`, i.e. after the whole positioned set; +/// * `recent` — no comparator runs at all, so it keeps its position after the +/// API-ordered Qobuz block. +/// +/// Ties keep pre-sort order (the sorts are stable): API order among Qobuz rows, +/// name order among locals ([`local_entries`] name-sorts before the union). +pub(crate) enum PmEntry<'a> { + Qobuz(&'a PmPlaylist), + Local(&'a PmLocalPlaylist), +} + +impl PmEntry<'_> { + fn name_lower(&self) -> String { + match self { + Self::Qobuz(p) => p.name.to_lowercase(), + Self::Local(p) => p.name.to_lowercase(), + } + } + + fn total_count(&self) -> u32 { + match self { + Self::Qobuz(p) => p.total_count(), + Self::Local(p) => p.track_count, + } + } + + fn play_count(&self) -> u32 { + match self { + Self::Qobuz(p) => p.play_count, + Self::Local(_) => 0, + } + } + + fn position(&self) -> i64 { + match self { + Self::Qobuz(p) => p.position as i64, + Self::Local(_) => i64::MAX, + } + } + + pub(crate) fn item(&self) -> PlaylistRow { + match self { + Self::Qobuz(p) => playlist_item(p), + Self::Local(p) => local_playlist_item(p), + } + } +} + +/// Order the merged display set. +/// +/// The direction is NOT folded into the comparator — it is a full `reverse()` +/// of the sorted vector, which is what makes descending `"recent"` mean +/// oldest-first (that option runs no comparator at all: the API order IS +/// newest-first). Looks like a bug, is not (§5.17). +pub(crate) fn sort_entries(list: &mut [PmEntry], sort: &str, asc: bool) { + match sort { + "name" => list.sort_by_key(|e| e.name_lower()), + "playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())), + "tracks" => list.sort_by(|a, b| b.total_count().cmp(&a.total_count())), + "custom" => list.sort_by_key(|e| e.position()), + // "recent" — Qobuz keeps API order, locals stay after it. + _ => {} + } + if !asc { + list.reverse(); + } +} + +// --------------------------------------------------------------------------- +// The two published collections +// --------------------------------------------------------------------------- + +/// TOTAL membership per folder — Qobuz members plus LOCAL members — ignoring +/// search and the visibility filter. This is what `foldersJson[].count` shows +/// on the cards and chips (§5.7 / D6.3). +pub(crate) fn folder_counts(data: &PmData) -> HashMap { + let mut counts: HashMap = HashMap::new(); + for p in &data.playlists { + if let Some(fid) = &p.folder_id { + *counts.entry(fid.clone()).or_insert(0) += 1; + } + } + for p in &data.locals { + if let Some(fid) = &p.folder_id { + *counts.entry(fid.clone()).or_insert(0) += 1; + } + } + counts +} + +/// `foldersJson` from a WARM cache: every folder, hidden ones included, with +/// total-membership counts. +pub(crate) fn folder_rows(data: &PmData) -> Vec { + let counts = folder_counts(data); + data.folders + .iter() + .map(|f| folder_item(f, counts.get(&f.id).copied().unwrap_or(0))) + .collect() +} + +/// The filtered + sorted flat set that feeds the grid and list bodies — and, +/// because it IS the visible order, the vector `reorder_step` renumbers. +/// +/// Published unconditionally, tree mode included: in tree mode `passes` skips +/// its folder clause, so this holds every matching row (folder members +/// included) and a view-mode switch needs no rebuild. +/// +/// `offline` reduces the Qobuz side to the MIXED playlists (>= 1 local sidecar +/// row). The offline SNAPSHOT subsystem is absent from this port (D30), so +/// `offline_available` is not a term here. Locals are never touched by it — +/// they are local. +#[allow(clippy::too_many_arguments)] +pub(crate) fn visible_playlist_rows( + data: &PmData, + query: &str, + filter: &str, + sort: &str, + asc: bool, + folder_mode: bool, + view_mode: &str, + offline: bool, +) -> Vec { + let filtered: Vec<&PmPlaylist> = data + .playlists + .iter() + .filter(|p| !offline || p.local_count > 0) + .filter(|p| passes(p, query, filter, folder_mode, view_mode)) + .collect(); + let mut entries: Vec = filtered.into_iter().map(PmEntry::Qobuz).collect(); + entries.extend( + local_entries(data, query, filter, folder_mode, view_mode) + .into_iter() + .map(PmEntry::Local), + ); + sort_entries(&mut entries, sort, asc); + entries.iter().map(|e| e.item()).collect() +} + +/// Flatten folders + their (expanded) members + the root run into the tree. +/// +/// `expanded` is passed IN rather than read from a global so this stays pure: +/// the first-open auto-expand latch lives in `playlist_manager_qt`, which +/// primes the set before calling here. +/// +/// While searching — and offline, where the mixed-only filter can empty a +/// folder — a folder with nothing matching in EITHER set is dropped entirely, +/// and every surviving folder is force-expanded so the matches inside are +/// visible (§5.8). That is the opposite of the grid/list, where the folder +/// cards never react to the query. +pub(crate) fn build_tree( + data: &PmData, + query: &str, + filter: &str, + sort: &str, + asc: bool, + offline: bool, + expanded: &HashSet, +) -> Vec { + let searching = !query.is_empty(); + + let matches = |p: &PmPlaylist| -> bool { + if offline && p.local_count == 0 { + return false; + } + if searching && !p.name.to_lowercase().contains(query) { + return false; + } + match filter { + "visible" => !p.is_hidden, + "hidden" => p.is_hidden, + _ => true, + } + }; + let local_matches = |p: &PmLocalPlaylist| -> bool { + if searching && !p.name.to_lowercase().contains(query) { + return false; + } + match filter { + "visible" => !p.is_hidden, + "hidden" => p.is_hidden, + _ => true, + } + }; + + let mut rows: Vec = Vec::new(); + for f in &data.folders { + // D6.2: a folder's members are (Qobuz members) ∪ (local members). + let mut members: Vec = data + .playlists + .iter() + .filter(|p| p.folder_id.as_deref() == Some(f.id.as_str())) + .filter(|p| matches(p)) + .map(PmEntry::Qobuz) + .collect(); + let mut local_members: Vec<&PmLocalPlaylist> = data + .locals + .iter() + .filter(|p| p.folder_id.as_deref() == Some(f.id.as_str())) + .filter(|p| local_matches(p)) + .collect(); + local_members.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + members.extend(local_members.into_iter().map(PmEntry::Local)); + + if (searching || offline) && members.is_empty() { + continue; + } + sort_entries(&mut members, sort, asc); + let is_exp = searching || expanded.contains(&f.id); + rows.push(TreeRow { + kind: "folder".into(), + expanded: is_exp, + indent: false, + // POST-FILTER member count here, unlike `foldersJson` (§5.7). + folder: Some(folder_item(f, members.len())), + playlist: None, + }); + if is_exp { + for e in &members { + rows.push(TreeRow { + kind: "playlist".into(), + expanded: false, + indent: true, + folder: None, + playlist: Some(e.item()), + }); + } + } + } + + // The root run: unfiled Qobuz playlists and unfiled LOCALS, interleaved + // into the same sort. A filed local is NOT here — it is under its folder. + let mut entries: Vec = data + .playlists + .iter() + .filter(|p| p.folder_id.is_none()) + .filter(|p| matches(p)) + .map(PmEntry::Qobuz) + .collect(); + let mut root_locals: Vec<&PmLocalPlaylist> = data + .locals + .iter() + .filter(|p| p.folder_id.is_none()) + .filter(|p| local_matches(p)) + .collect(); + root_locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + entries.extend(root_locals.into_iter().map(PmEntry::Local)); + sort_entries(&mut entries, sort, asc); + for e in &entries { + rows.push(TreeRow { + kind: "playlist".into(), + expanded: false, + indent: false, + folder: None, + playlist: Some(e.item()), + }); + } + rows +} + +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(id: &str, name: &str) -> FolderFull { + FolderFull { + id: id.into(), + name: name.into(), + icon_type: "preset".into(), + icon_preset: "folder".into(), + icon_color: String::new(), + custom_image_path: None, + is_hidden: false, + } + } + + fn pl(id: u64, name: &str) -> PmPlaylist { + PmPlaylist { + id, + name: name.into(), + tracks_count: 10, + ..Default::default() + } + } + + fn local(id: &str, name: &str) -> PmLocalPlaylist { + PmLocalPlaylist { + id: format!("local:{id}"), + name: name.into(), + track_count: 5, + ..Default::default() + } + } + + fn ids(rows: &[PlaylistRow]) -> Vec { + rows.iter().map(|r| r.id.clone()).collect() + } + + // --- parse_color: the validity gate --------------------------------- + + #[test] + fn parse_color_accepts_only_solid_hex() { + assert_eq!(parse_color("#3b82f6").as_deref(), Some("#3b82f6")); + assert_eq!(parse_color("#abc").as_deref(), Some("#abc")); + // Everything the reference rejects, so the tile falls back to accent. + assert!(parse_color("").is_none()); + assert!(parse_color("#3b82f6ff").is_none()); + assert!(parse_color("var(--accent-primary)").is_none()); + assert!(parse_color("linear-gradient(90deg, #fff, #000)").is_none()); + assert!(parse_color("#zzz").is_none()); + assert!(parse_color("3b82f6").is_none()); + } + + #[test] + fn folder_item_publishes_the_colour_pair_and_suppresses_a_half_written_image() { + let mut f = folder("f1", "Jazz"); + f.icon_color = "linear-gradient(#fff,#000)".into(); + let row = folder_item(&f, 3); + assert_eq!(row.icon_color, ""); + assert!(!row.has_color); + assert_eq!(row.count, 3); + + // icon_type says custom but no path was stored — NOT an image. + f.icon_type = "custom".into(); + f.custom_image_path = None; + assert!(!folder_item(&f, 0).has_custom_image); + assert_eq!(folder_item(&f, 0).custom_image_path, ""); + + f.custom_image_path = Some("/tmp/a b#1.png".into()); + let row = folder_item(&f, 0); + assert!(row.has_custom_image); + assert_eq!(row.custom_image_path, "file:///tmp/a b%231.png"); + } + + // --- duration -------------------------------------------------------- + + #[test] + fn format_duration_is_empty_at_zero_and_drops_hours_under_one() { + assert_eq!(format_duration(0), ""); + assert_eq!(format_duration(12 * 60), "12 min"); + assert_eq!(format_duration(3600 + 43 * 60), "1 h 43 min"); + } + + // --- comparators ----------------------------------------------------- + + #[test] + fn sort_entries_interleaves_locals_by_name() { + let data = PmData { + playlists: vec![pl(1, "Beta"), pl(2, "Delta")], + locals: vec![local("a", "Alpha"), local("c", "Charlie")], + folders: vec![], + }; + let rows = visible_playlist_rows(&data, "", "all", "name", true, false, "grid", false); + assert_eq!( + ids(&rows), + vec!["local:a", "1", "local:c", "2"], + "locals must interleave, not be appended" + ); + } + + #[test] + fn descending_is_a_reverse_not_a_flipped_comparator() { + let data = PmData { + playlists: vec![pl(1, "Beta"), pl(2, "Alpha")], + locals: vec![], + folders: vec![], + }; + let asc = visible_playlist_rows(&data, "", "all", "name", true, false, "grid", false); + let desc = visible_playlist_rows(&data, "", "all", "name", false, false, "grid", false); + assert_eq!(ids(&asc), vec!["2", "1"]); + assert_eq!(ids(&desc), vec!["1", "2"]); + } + + #[test] + fn recent_runs_no_comparator_so_descending_means_oldest_first() { + // API order IS newest-first; "recent" must preserve it verbatim. + let data = PmData { + playlists: vec![pl(3, "Zulu"), pl(1, "Alpha"), pl(2, "Mike")], + locals: vec![], + folders: vec![], + }; + let asc = visible_playlist_rows(&data, "", "all", "recent", true, false, "grid", false); + assert_eq!(ids(&asc), vec!["3", "1", "2"]); + let desc = visible_playlist_rows(&data, "", "all", "recent", false, false, "grid", false); + assert_eq!(ids(&desc), vec!["2", "1", "3"]); + } + + #[test] + fn locals_sort_last_under_playcount_and_custom() { + let mut a = pl(1, "Aaa"); + a.play_count = 7; + a.position = 3; + let mut b = pl(2, "Bbb"); + b.play_count = 0; + b.position = 1; + let data = PmData { + playlists: vec![a, b], + locals: vec![local("l", "Zzz")], + folders: vec![], + }; + let by_play = + visible_playlist_rows(&data, "", "all", "playcount", true, false, "grid", false); + assert_eq!(by_play[0].id, "1"); + assert_eq!( + by_play.last().unwrap().id, + "local:l", + "a local has no play stat, so it sorts as 0 = last" + ); + let by_custom = + visible_playlist_rows(&data, "", "all", "custom", true, false, "grid", false); + assert_eq!(ids(&by_custom), vec!["2", "1", "local:l"]); + } + + // --- filters --------------------------------------------------------- + + #[test] + fn folder_mode_hides_filed_rows_of_both_kinds_from_the_flat_body() { + let mut filed = pl(1, "Filed"); + filed.folder_id = Some("f1".into()); + let mut filed_local = local("l1", "FiledLocal"); + filed_local.folder_id = Some("f1".into()); + let data = PmData { + playlists: vec![filed, pl(2, "Root")], + locals: vec![filed_local, local("l2", "RootLocal")], + folders: vec![folder("f1", "Jazz")], + }; + let folder_mode = + visible_playlist_rows(&data, "", "all", "name", true, true, "grid", false); + assert_eq!( + ids(&folder_mode), + vec!["2", "local:l2"], + "D6.1: a filed LOCAL is excluded exactly like a filed Qobuz row" + ); + // Flat mode shows everything, both kinds. + let flat = visible_playlist_rows(&data, "", "all", "name", true, false, "grid", false); + assert_eq!(ids(&flat).len(), 4); + // Tree mode publishes the flat set unconditionally too (§4.2). + let tree_mode = + visible_playlist_rows(&data, "", "all", "name", true, true, "tree", false); + assert_eq!(ids(&tree_mode).len(), 4); + } + + #[test] + fn visibility_filter_applies_to_locals_own_hidden_flag() { + let mut hidden_q = pl(1, "HiddenQ"); + hidden_q.is_hidden = true; + let mut hidden_l = local("l1", "HiddenL"); + hidden_l.is_hidden = true; + let data = PmData { + playlists: vec![hidden_q, pl(2, "VisibleQ")], + locals: vec![hidden_l, local("l2", "VisibleL")], + folders: vec![], + }; + // Sorted by NAME ascending, so the locals interleave rather than + // trailing the Qobuz rows: "VisibleL" < "VisibleQ" and + // "HiddenL" < "HiddenQ" (D6 — see + // `sort_entries_interleaves_locals_by_name`). + let visible = + visible_playlist_rows(&data, "", "visible", "name", true, false, "grid", false); + assert_eq!(ids(&visible), vec!["local:l2", "2"]); + let hidden = + visible_playlist_rows(&data, "", "hidden", "name", true, false, "grid", false); + assert_eq!(ids(&hidden), vec!["local:l1", "1"]); + } + + #[test] + fn offline_keeps_mixed_qobuz_rows_and_every_local() { + let mut mixed = pl(1, "Mixed"); + mixed.local_count = 3; + let data = PmData { + playlists: vec![mixed, pl(2, "PureRemote")], + locals: vec![local("l1", "Alpha")], + folders: vec![], + }; + let rows = visible_playlist_rows(&data, "", "all", "name", true, false, "grid", true); + assert_eq!( + ids(&rows), + vec!["local:l1", "1"], + "the offline filter is Qobuz-only; a local is local" + ); + } + + #[test] + fn search_is_case_insensitive_over_both_kinds() { + let data = PmData { + playlists: vec![pl(1, "Late Night"), pl(2, "Morning")], + locals: vec![local("l1", "night owl")], + folders: vec![], + }; + let rows = visible_playlist_rows(&data, "night", "all", "name", true, false, "grid", false); + assert_eq!(ids(&rows), vec!["1", "local:l1"]); + } + + // --- counts ---------------------------------------------------------- + + #[test] + fn folder_counts_fold_locals_in_so_a_locals_only_folder_is_not_zero() { + let mut filed_local = local("l1", "One"); + filed_local.folder_id = Some("f1".into()); + let mut other = local("l2", "Two"); + other.folder_id = Some("f1".into()); + let mut hidden_q = pl(1, "HiddenButCounted"); + hidden_q.folder_id = Some("f2".into()); + hidden_q.is_hidden = true; + let data = PmData { + playlists: vec![hidden_q], + locals: vec![filed_local, other], + folders: vec![folder("f1", "LocalsOnly"), folder("f2", "Mixed")], + }; + let rows = folder_rows(&data); + assert_eq!(rows[0].count, 2, "D6.3: a locals-only folder is not empty"); + assert_eq!( + rows[1].count, 1, + "total membership ignores the visibility filter" + ); + } + + // --- the tree -------------------------------------------------------- + + #[test] + fn build_tree_files_locals_under_their_folder_and_leaves_only_unfiled_at_root() { + let mut filed_q = pl(1, "QobuzMember"); + filed_q.folder_id = Some("f1".into()); + let mut filed_l = local("l1", "LocalMember"); + filed_l.folder_id = Some("f1".into()); + // Root names chosen so the merged name sort is unambiguous: + // "Alpha Root" (Qobuz) < "Zulu Root" (local). + let data = PmData { + playlists: vec![filed_q, pl(2, "Alpha Root")], + locals: vec![filed_l, local("l2", "Zulu Root")], + folders: vec![folder("f1", "Jazz")], + }; + let expanded: HashSet = ["f1".to_string()].into_iter().collect(); + let rows = build_tree(&data, "", "all", "name", true, false, &expanded); + let shape: Vec<(&str, String)> = rows + .iter() + .map(|r| { + ( + r.kind.as_str(), + r.folder + .as_ref() + .map(|f| f.id.clone()) + .or_else(|| r.playlist.as_ref().map(|p| p.id.clone())) + .unwrap_or_default(), + ) + }) + .collect(); + assert_eq!( + shape, + vec![ + ("folder", "f1".to_string()), + ("playlist", "local:l1".to_string()), + ("playlist", "1".to_string()), + ("playlist", "2".to_string()), + ("playlist", "local:l2".to_string()), + ] + ); + // The folder row's count is the POST-FILTER member count (§5.7). + assert_eq!(rows[0].folder.as_ref().unwrap().count, 2); + // Indent marks membership. + assert!(rows[1].indent && rows[2].indent); + assert!(!rows[3].indent && !rows[4].indent); + } + + #[test] + fn collapsed_folder_emits_its_header_only() { + let mut filed = pl(1, "Member"); + filed.folder_id = Some("f1".into()); + let data = PmData { + playlists: vec![filed], + locals: vec![], + folders: vec![folder("f1", "Jazz")], + }; + let rows = build_tree(&data, "", "all", "name", true, false, &HashSet::new()); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "folder"); + assert!(!rows[0].expanded); + } + + #[test] + fn searching_prunes_empty_folders_and_force_expands_the_survivors() { + let mut hit = pl(1, "Night Jazz"); + hit.folder_id = Some("f1".into()); + let mut miss = pl(2, "Morning"); + miss.folder_id = Some("f2".into()); + let data = PmData { + playlists: vec![hit, miss], + locals: vec![], + folders: vec![folder("f1", "Jazz"), folder("f2", "Rock")], + }; + // Nothing expanded — search must expand anyway. + let rows = build_tree(&data, "night", "all", "name", true, false, &HashSet::new()); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].folder.as_ref().unwrap().id, "f1"); + assert!(rows[0].expanded); + assert_eq!(rows[1].playlist.as_ref().unwrap().id, "1"); + } + + #[test] + fn a_folder_holding_only_matching_locals_survives_the_search_prune() { + let mut filed_l = local("l1", "Night Owl"); + filed_l.folder_id = Some("f1".into()); + let data = PmData { + playlists: vec![], + locals: vec![filed_l], + folders: vec![folder("f1", "Jazz")], + }; + let rows = build_tree(&data, "night", "all", "name", true, false, &HashSet::new()); + assert_eq!( + rows.len(), + 2, + "D6.3: without locals in the member set this folder is pruned away" + ); + } + + // --- serialisation --------------------------------------------------- + + #[test] + fn tree_row_omits_the_absent_half_and_camel_cases_its_keys() { + let row = TreeRow { + kind: "playlist".into(), + expanded: false, + indent: true, + folder: None, + playlist: Some(local_playlist_item(&local("l1", "Alpha"))), + }; + let json = serde_json::to_string(&row).unwrap(); + assert!(!json.contains("\"folder\"")); + assert!(json.contains("\"isLocal\":true")); + assert!(json.contains("\"tracksLine\"")); + assert!(json.contains("\"offlineOnly\"")); + } + + #[test] + fn local_rows_carry_covers_the_folder_id_and_no_qobuz_only_lines() { + let mut p = local("l1", "Alpha"); + p.folder_id = Some("f1".into()); + p.cover_urls = vec!["https://a/1.jpg".into()]; + p.offline_only = true; + let row = local_playlist_item(&p); + assert_eq!(row.folder_id, "f1", "D6: the local's REAL folder id"); + assert_eq!(row.covers.len(), 1, "§5.6: locals get covers"); + assert_eq!(row.duration_line, ""); + assert_eq!(row.local_line, ""); + assert_eq!(row.local_status, ""); + assert_eq!(row.play_count, 0); + assert!(row.offline_only); + assert_eq!(row.total_count, 5); + } + + #[test] + fn qobuz_row_local_status_has_three_arms() { + let mut p = pl(1, "A"); + assert_eq!(playlist_item(&p).local_status, "no"); + p.local_count = 3; + assert_eq!(playlist_item(&p).local_status, "some_local"); + assert_eq!(playlist_item(&p).total_count, 13); + p.tracks_count = 0; + assert_eq!(playlist_item(&p).local_status, "all_local"); + assert_eq!(playlist_item(&p).local_line, "(3 local)"); + } +} diff --git a/crates/qbz-qt/src/playlist_picker_bridge.rs b/crates/qbz-qt/src/playlist_picker_bridge.rs new file mode 100644 index 000000000..ac38d7659 --- /dev/null +++ b/crates/qbz-qt/src/playlist_picker_bridge.rs @@ -0,0 +1,222 @@ +//! QbzPlaylistPicker — the app-wide "Add to playlist" picker, port of +//! `crates/qbz/src/playlist_picker.rs` + `primitives/PlaylistPickerModal.slint`. +//! +//! A SEPARATE singleton from `QbzPlaylist` for the same reason `QbzMyQbzAdd` is +//! separate from `QbzMyQbz`: this is the surface every OTHER domain's QML +//! touches (the queue footer and row menu, the multi-select bars, the unified +//! per-track menu on every catalog surface, the Local Library rows, the local +//! playlist detail and both now-playing bars), so none of them couples to +//! playlist-detail state, and a detail republish can never perturb an open +//! picker. +//! +//! Three QML entry points, one per PAYLOAD KIND, because the kind is what the +//! whole module is built to keep unmistakable: `open_for_track` / +//! `open_for_tracks` take Qobuz catalog ids, `open_for_local_row` takes a +//! local-playlist DETAIL row and resolves its ref in Rust. Local LIBRARY rows +//! have their own resolver already and go through +//! `QbzLocal.bulkAction("track", …, "add-to-playlist")` (`local_bulk.rs`), +//! which is the only place that can turn a Tracks-table row id into a +//! source-aware ref. +//! +//! Props: one JSON document (`playlist_picker_qt.rs PickerDoc`), covering the +//! picker itself, the inline create panel and the duplicate-confirm sub-modal. +//! No `#[qsignal]` — outcomes are toasts and state changes are republishes. +//! +//! The module is `qbz_playlist_picker_bridge`, never `qbz_playlist`: a bridge +//! module is never named after a workspace crate (E0659; `library_bridge.rs:1-6` +//! precedent). The QML type name comes from the QObject, so QML is unaffected. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_playlist_picker_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // ONE JSON document (playlist_picker_qt.rs PickerDoc: open/loading/ + // creating flags, the playlist rows, the inline create panel and the + // duplicate-confirm sub-modal). + #[qproperty(QString, picker_json)] + + type QbzPlaylistPicker = super::QbzPlaylistPickerRust; + + /// Registers this object's Qt-thread hop (Main.qml boots EVERY domain + /// singleton; only QbzSession.boot also fires crate::on_boot). + #[qinvokable] + fn boot(self: Pin<&mut QbzPlaylistPicker>); + + /// Open the picker carrying ONE Qobuz catalog track id — the per-row + /// "Add to playlist" entry of the unified track menu + /// (`primitives/TrackContextMenu.slint:145`). The caller must have + /// established that the id IS a catalog id; a LocalLibrary row id or a + /// Plex synthetic id means a different track once it reaches Qobuz + /// (see this file's sibling `open_for_local_row`, and the four-case + /// matrix in `playlist_picker_qt.rs`'s header). + #[qinvokable] + fn open_for_track(self: Pin<&mut QbzPlaylistPicker>, track_id: QString); + + /// Same, for a JSON string ARRAY of Qobuz catalog track ids (a + /// section-level "Add all to playlist"). Unparseable entries are + /// dropped by `playlist_picker_qt::open_for_ids`. + #[qinvokable] + fn open_for_tracks(self: Pin<&mut QbzPlaylistPicker>, ids_json: QString); + + /// Open the picker in LOCAL MODE for ONE row of the currently open + /// LOCAL playlist detail. The ref is built by + /// `local_playlist_qt::local_picker_ref_for_row` — never by QML, which + /// only knows the row's display id and cannot recover a Plex rating + /// key from it. A Qobuz row of a local playlist returns `None` here and + /// must ride `open_for_track` instead. + #[qinvokable] + fn open_for_local_row(self: Pin<&mut QbzPlaylistPicker>, row_id: QString); + + /// Row click — add the carried tracks to that playlist. Runs duplicate + /// detection first; if the target already holds any of them the + /// confirm sub-modal opens instead of adding. + #[qinvokable] + fn pick(self: Pin<&mut QbzPlaylistPicker>, playlist_id: QString); + + /// Name substring filter — Rust re-filters its row cache and + /// republishes (the `QbzMyQbzAdd.search` convention). + #[qinvokable] + fn search(self: Pin<&mut QbzPlaylistPicker>, query: QString); + + /// Reveal / hide the inline "Create new playlist" name field. + #[qinvokable] + fn show_create(self: Pin<&mut QbzPlaylistPicker>, open: bool); + + /// Create the playlist, add the carried tracks, close, toast. + #[qinvokable] + fn create_and_add(self: Pin<&mut QbzPlaylistPicker>, name: QString); + + /// Duplicate-confirm sub-modal: add every carried track, or only the + /// ones the target does not already hold. + #[qinvokable] + fn confirm_duplicates(self: Pin<&mut QbzPlaylistPicker>, add_all: bool); + + /// Dismiss the duplicate-confirm sub-modal without adding anything. + #[qinvokable] + fn cancel_duplicates(self: Pin<&mut QbzPlaylistPicker>); + + /// Close and clear the carried tracks. + #[qinvokable] + fn close(self: Pin<&mut QbzPlaylistPicker>); + } + + impl cxx_qt::Threading for QbzPlaylistPicker {} +} + +use qbz_playlist_picker_bridge::QbzPlaylistPicker; + +/// Rust side of the picker bridge (plain storage, phase-1 pattern). +pub struct QbzPlaylistPickerRust { + picker_json: QString, +} + +impl Default for QbzPlaylistPickerRust { + fn default() -> Self { + Self { + // Parseable default so QML's JSON.parse never throws on frame 1. + picker_json: QString::from("{}"), + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop (bridges/mod.rs pattern) +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue a picker mutation onto the Qt event loop (no-op before boot registers +/// the thread). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPlaylistPicker>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_playlist_picker_bridge::QbzPlaylistPicker { + pub fn boot(self: Pin<&mut Self>) { + if QT_THREAD.set(self.qt_thread()).is_err() { + log::warn!("[qbz-qt] playlist picker Qt thread already registered"); + } + // Closed until a host opens it, and the closed document is already + // seeded in Default — nothing to publish here. + } + + pub fn open_for_track(self: Pin<&mut Self>, track_id: QString) { + let id = track_id.to_string(); + if id.is_empty() { + return; + } + crate::playlist_picker_qt::open_for_ids(&crate::app(), vec![id]); + } + + pub fn open_for_tracks(self: Pin<&mut Self>, ids_json: QString) { + let ids: Vec = serde_json::from_str(&ids_json.to_string()).unwrap_or_default(); + if ids.is_empty() { + log::debug!("[qbz-qt] playlist picker: openForTracks with an empty id list, ignored"); + return; + } + crate::playlist_picker_qt::open_for_ids(&crate::app(), ids); + } + + pub fn open_for_local_row(self: Pin<&mut Self>, row_id: QString) { + let id = row_id.to_string(); + // `local_picker_ref_for_row` reads the OPEN local detail's queue + // snapshot; `None` = no local detail open, an unresolvable row, or a + // Qobuz row (which belongs on `open_for_track`). Refusing beats + // guessing: the numeric display id of a Plex row parses as a catalog + // id perfectly happily and would store a different track. + match crate::local_playlist_qt::local_picker_ref_for_row(&id) { + Some(reference) => { + crate::playlist_picker_qt::open_for_local_refs(&crate::app(), vec![reference]) + } + None => log::warn!( + "[qbz-qt] playlist picker: no local-mode ref for detail row {id} — \ + not a local/Plex row of an open local playlist, refusing to add" + ), + } + } + + pub fn pick(self: Pin<&mut Self>, playlist_id: QString) { + crate::playlist_picker_qt::pick(&playlist_id.to_string()); + } + + pub fn search(self: Pin<&mut Self>, query: QString) { + crate::playlist_picker_qt::search(&query.to_string()); + } + + pub fn show_create(self: Pin<&mut Self>, open: bool) { + crate::playlist_picker_qt::show_create(open); + } + + pub fn create_and_add(self: Pin<&mut Self>, name: QString) { + crate::playlist_picker_qt::create_and_add(&name.to_string()); + } + + pub fn confirm_duplicates(self: Pin<&mut Self>, add_all: bool) { + crate::playlist_picker_qt::confirm_duplicates(add_all); + } + + pub fn cancel_duplicates(self: Pin<&mut Self>) { + crate::playlist_picker_qt::cancel_duplicates(); + } + + pub fn close(self: Pin<&mut Self>) { + crate::playlist_picker_qt::close(); + } +} diff --git a/crates/qbz-qt/src/playlist_picker_qt.rs b/crates/qbz-qt/src/playlist_picker_qt.rs new file mode 100644 index 000000000..5d64712ed --- /dev/null +++ b/crates/qbz-qt/src/playlist_picker_qt.rs @@ -0,0 +1,720 @@ +//! "Add to playlist" picker — port of `crates/qbz/src/playlist_picker.rs` and +//! the `PlaylistPickerActions` / `DuplicateConfirmActions` handlers in the +//! reference's `main.rs:20070-20615`. +//! +//! One modal, three states in one document: the playlist list (filterable), the +//! inline "Create new playlist" panel, and the duplicate-confirm sub-modal that +//! opens when the chosen target already holds some of the carried tracks. +//! +//! The picker CARRIES a set of tracks and adds them wherever the user points. +//! That is what makes the queue's "save as playlist" work without a save-as +//! endpoint: it seeds the picker with the whole queue and the inline create row +//! turns it into a named playlist — the reference does exactly this +//! (`queue.rs::save_as_playlist`), and every other host (track menus, album / +//! artist / label pages, multi-select bars) is the same call with a different +//! id list. +//! +//! # The list: locals ALWAYS, Qobuz conditionally +//! +//! [`load`] lists the LOCAL playlists (library.db) first and unconditionally, +//! then appends the Qobuz set — unless offline, where the Qobuz half is hidden +//! entirely (decisions D3/D11: a Qobuz playlist cannot be written to offline, +//! so offering it would be a dead target). For a user running QBZ with no Qobuz +//! account, the local half is the WHOLE list. +//! +//! # The four-case matrix (target kind × payload kind) +//! +//! | target | payload | route | +//! |---|---|---| +//! | `local:` | local refs | `local_playlist_qt::add_local_refs_blocking` | +//! | `local:` | Qobuz ids | `local_playlist_qt::add_qobuz_tracks_blocking` | +//! | Qobuz `u64` | local refs | the library.db playlist SIDECAR ([`write_sidecar_refs`]) | +//! | Qobuz `u64` | Qobuz ids | `add_tracks_to_playlist` + the duplicate check | +//! +//! The payload kind rides [`Payload`], a two-variant enum rather than a +//! `Vec` plus an `is_local` flag. With the flag, "refs carried as +//! catalog ids" stays a REPRESENTABLE state, and it is not a state a reviewer +//! can spot by eye: a LocalLibrary row id like `42` parses as a `u64` perfectly +//! happily — it simply means a different track once it reaches Qobuz. With the +//! enum the Qobuz endpoints can only ever be handed a `Payload::Qobuz`, so the +//! confusion is unrepresentable instead of merely avoided. (Same idiom, and the +//! same reason, as `local_playlist_qt::PlaylistRef`.) +//! +//! The duplicate check runs on the Qobuz-target + Qobuz-ids case ONLY — it is +//! the only target whose membership the API can be asked about. +//! +//! # OWED PORT (recorded, not silently dropped) +//! +//! Reco event logging. The reference logs one `PlaylistAdd` per Qobuz id +//! (`reco::log_playlist_add`, with `None` as the playlist id when the target is +//! a local or brand-new playlist) and deliberately does NOT log local refs, +//! whose ids do not resolve against the Qobuz catalog. This port has no reco +//! store at all: `auth_qt.rs`'s header lists `reco` among the per-user stores +//! its session activation does not bind, so there is nothing to log into and +//! even the pre-existing Qobuz→Qobuz add logs nothing. The SOURCE GATE is +//! nevertheless honoured structurally — the local-ref arms below never hold a +//! Qobuz id, so when the store does land there is no path for a non-catalog id +//! to leak into it. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use cxx_qt_lib::QString; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use serde::Serialize; + +type Runtime = Arc>; + +/// What the picker is carrying. Two variants, never a `Vec` + flag — +/// see the module header. +#[derive(Clone)] +pub enum Payload { + /// Decimal Qobuz catalog track ids. + Qobuz(Vec), + /// LocalLibrary local-mode refs: `""` library row ids (resolved + /// source-aware at insert time to a local path / an offline-copy Qobuz id) + /// or `"plex:"` Plex rows. + LocalRefs(Vec), +} + +impl Default for Payload { + fn default() -> Self { + Self::Qobuz(Vec::new()) + } +} + +impl Payload { + fn len(&self) -> usize { + match self { + Self::Qobuz(v) => v.len(), + Self::LocalRefs(v) => v.len(), + } + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Drives the counter's msgid ("Adding {} local track(s)"). + fn is_local(&self) -> bool { + matches!(self, Self::LocalRefs(_)) + } + + fn clear(&mut self) { + *self = Self::Qobuz(Vec::new()); + } +} + +#[derive(Clone, Default, Serialize)] +pub struct PickerRow { + pub id: String, + pub name: String, + /// Pre-formatted "N tracks" line (plural resolved in Rust, like the + /// reference's `tracks_line`); empty for an empty playlist. + #[serde(rename = "tracksLine")] + pub tracks_line: String, + /// LOCAL playlist (library.db, id `local:`) — the row draws a + /// hard-drive glyph and the write goes to the repo, not the API. + #[serde(rename = "isLocal")] + pub is_local: bool, +} + +#[derive(Default, Serialize)] +pub struct DupDoc { + pub open: bool, + #[serde(rename = "duplicateCount")] + pub duplicate_count: usize, + #[serde(rename = "totalCount")] + pub total_count: usize, + #[serde(rename = "playlistName")] + pub playlist_name: String, + pub busy: bool, +} + +#[derive(Default, Serialize)] +pub struct PickerDoc { + pub open: bool, + pub loading: bool, + /// Number of tracks the picker is carrying — the modal's subtitle. + #[serde(rename = "trackCount")] + pub track_count: usize, + /// The carried payload is LOCAL refs — only changes the counter's wording + /// ("Adding {} local track(s)"); the row list is the same either way. + #[serde(rename = "localMode")] + pub local_mode: bool, + /// Rows AFTER the name filter. + pub playlists: Vec, + /// Total rows before the filter, so the modal can tell "you have no + /// playlists" apart from "none matched". + pub total: usize, + pub filter: String, + /// Inline create panel: revealed, and busy while the create runs. + #[serde(rename = "creatingOpen")] + pub creating_open: bool, + pub creating: bool, + pub dup: DupDoc, +} + +/// Everything the modal owns between opens. One mutex: every field is written +/// from bridge invokables (Qt thread) and from spawned tasks alike. +#[derive(Default)] +struct PickerState { + open: bool, + loading: bool, + /// The carried tracks, in the order the host supplied them. + payload: Payload, + /// The unfiltered row cache — `search` filters this rather than re-fetching. + all_rows: Vec, + filter: String, + creating_open: bool, + creating: bool, + /// Duplicate-confirm context, parked between the check and the user's + /// answer (the reference's `DUP_CONFIRM_STASH`). `None` = sub-modal closed. + dup: Option, + dup_busy: bool, +} + +struct DupContext { + playlist_id: u64, + playlist_name: String, + /// Every carried id (add-all uses this whole list). + all_ids: Vec, + /// The subset the target already holds (add-new-only subtracts these). + duplicate_ids: HashSet, + total_tracks: usize, +} + +static STATE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(PickerState::default())); + +/// Case-insensitive substring over the playlist name — the reference's +/// `filter-changed` rank pass reduced to a filter, because QML can drop rows +/// directly and does not need Slint's rank-and-reorder workaround. +fn matches(name: &str, query: &str) -> bool { + query.is_empty() || name.to_lowercase().contains(query) +} + +/// Rebuild the document from the live state and post it to Qt. +fn publish() { + let doc = { + let st = STATE.lock().unwrap(); + let query = st.filter.trim().to_lowercase(); + let playlists: Vec = st + .all_rows + .iter() + .filter(|r| matches(&r.name, &query)) + .cloned() + .collect(); + PickerDoc { + open: st.open, + loading: st.loading, + track_count: st.payload.len(), + local_mode: st.payload.is_local(), + playlists, + total: st.all_rows.len(), + filter: st.filter.clone(), + creating_open: st.creating_open, + creating: st.creating, + dup: match st.dup.as_ref() { + Some(ctx) => DupDoc { + open: true, + duplicate_count: ctx.duplicate_ids.len(), + total_count: ctx.total_tracks, + playlist_name: ctx.playlist_name.clone(), + busy: st.dup_busy, + }, + None => DupDoc::default(), + }, + } + }; + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + crate::playlist_picker_bridge::ui(move |mut b| { + b.as_mut().set_picker_json(QString::from(json.as_str())); + }); +} + +/// Open the picker seeded with decimal Qobuz track ids — the reference's +/// `open_for_ids(.., local = false)`. Unparseable entries are dropped here, at +/// the ONE place that is allowed to read a carried string as a catalog id. +/// +/// Callable from any thread: everything it touches is the mutex plus a Qt hop. +pub fn open_for_ids(runtime: &Runtime, ids: Vec) { + let parsed: Vec = ids.iter().filter_map(|s| s.parse::().ok()).collect(); + open_payload(runtime, Payload::Qobuz(parsed)); +} + +/// Open the picker seeded with LocalLibrary local-mode refs — the reference's +/// `open_for_ids(.., local = true)`. The refs are carried VERBATIM: nothing on +/// this path may parse them as numbers (see the module header). +/// +/// Build the refs with `local_playlist_qt::local_picker_ref_for_track` (a +/// LocalLibrary row) or `local_picker_ref_for_row` (an open local-playlist +/// detail row) — never by hand. +pub fn open_for_local_refs(runtime: &Runtime, refs: Vec) { + open_payload(runtime, Payload::LocalRefs(refs)); +} + +fn open_payload(runtime: &Runtime, payload: Payload) { + if payload.is_empty() { + return; + } + { + let mut st = STATE.lock().unwrap(); + st.open = true; + st.loading = true; + st.payload = payload; + // A stale row list would flash the PREVIOUS open's playlists under the + // new track count for as long as the fetch takes. + st.all_rows.clear(); + st.filter.clear(); + st.creating_open = false; + st.creating = false; + st.dup = None; + st.dup_busy = false; + } + publish(); + + let runtime = runtime.clone(); + crate::spawn(async move { + let rows = load(&runtime).await; + { + let mut st = STATE.lock().unwrap(); + // The user may have closed the picker while the fetch was in + // flight; landing rows into a closed picker would re-open it. + if !st.open { + return; + } + st.all_rows = rows; + st.loading = false; + } + publish(); + }); +} + +/// The picker rows: the LOCAL playlists first and ALWAYS, then the Qobuz set — +/// skipped entirely while offline (D3/D11; a Qobuz playlist cannot be written +/// to offline, so listing one would only offer a dead target). +/// +/// The local half is a library.db read, so it runs on a blocking worker. +async fn load(runtime: &Runtime) -> Vec { + let mut out: Vec = tokio::task::spawn_blocking(|| { + crate::local_playlist_qt::list_blocking() + .into_iter() + .map(|p| PickerRow { + id: p.id, + name: p.name, + tracks_line: tracks_line(p.track_count), + is_local: true, + }) + .collect::>() + }) + .await + .unwrap_or_default(); + + if crate::offline_fwd::engine().is_offline() { + return out; + } + match runtime.core().get_user_playlists().await { + Ok(playlists) => out.extend(playlists.into_iter().map(|p| PickerRow { + id: p.id.to_string(), + name: p.name, + tracks_line: tracks_line(p.tracks_count), + is_local: false, + })), + Err(e) => log::warn!("[qbz-qt] playlist picker load failed: {e}"), + } + out +} + +/// The row's "N tracks" line; empty for an empty playlist (the reference's +/// `tracks_line`). +fn tracks_line(count: u32) -> String { + if count > 0 { + qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]) + } else { + String::new() + } +} + +pub fn search(query: &str) { + STATE.lock().unwrap().filter = query.to_string(); + publish(); +} + +pub fn show_create(open: bool) { + { + let mut st = STATE.lock().unwrap(); + st.creating_open = open; + } + publish(); +} + +/// Close and clear the carried tracks, resetting the inline-create and filter +/// affordances so the next open starts clean (`on_close` in the reference). +pub fn close() { + { + let mut st = STATE.lock().unwrap(); + st.open = false; + st.loading = false; + st.payload.clear(); + st.all_rows.clear(); + st.filter.clear(); + st.creating_open = false; + st.creating = false; + st.dup = None; + st.dup_busy = false; + } + publish(); +} + +/// Row click — the four-case matrix (module header). The target kind comes +/// from the row id (`local:` vs a decimal Qobuz id), the payload kind +/// from [`Payload`]; the two are independent, which is why this is a matrix +/// and not a branch. +pub fn pick(playlist_id: &str) { + let (payload, name) = { + let st = STATE.lock().unwrap(); + let name = st + .all_rows + .iter() + .find(|r| r.id == playlist_id) + .map(|r| r.name.clone()) + .unwrap_or_default(); + (st.payload.clone(), name) + }; + if payload.is_empty() { + return; + } + let runtime = crate::app(); + + // Cases 1 and 2 — LOCAL playlist target: the write goes to the library.db + // repo, which works offline and without a Qobuz account (D7 routing). + if crate::local_playlist_qt::is_local_id(playlist_id) { + let target = playlist_id.to_string(); + let open_target = target.clone(); + // The target name is already resolved above; closing now matches the + // reference, which drops the modal before the write starts. + close(); + crate::spawn(async move { + let added = tokio::task::spawn_blocking(move || match payload { + // Case 1 — local refs: resolved source-aware at insert + // (offline copy -> Qobuz ref, Plex key -> Plex ref, else the + // file path). NOT logged to reco: not catalog ids. + Payload::LocalRefs(refs) => { + crate::local_playlist_qt::add_local_refs_blocking(&target, &refs) + } + // Case 2 — Qobuz ids onto a local playlist. The reference logs + // these to reco with `None` as the playlist id (a local + // playlist has no Qobuz id); this port has no reco store — + // see the module header's OWED PORT. + Payload::Qobuz(ids) => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) + } + }) + .await + .unwrap_or(0); + toast_added(added, &name); + refresh_local_target(&runtime, &open_target).await; + }); + return; + } + + let Ok(pid) = playlist_id.parse::() else { + return; + }; + + match payload { + // Case 3 — local refs onto a QOBUZ playlist. They attach through the + // library.db sidecar tables (the same tables the merged detail + // renders), never through the API: a library row id posted to Qobuz + // would silently mean a different track. + Payload::LocalRefs(refs) => { + close(); + // Base = the Qobuz block size from the sidebar's session cache. + let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); + crate::spawn(async move { + let written = write_sidecar_refs(pid, qobuz_count, refs).await; + toast_added(written, &name); + // E12: the open detail re-merges so the rows show up now. + crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; + }); + } + // Case 4 — Qobuz ids onto a Qobuz playlist: the API, and the ONLY case + // with a duplicate check (the only target whose membership can be + // asked about). Duplicates found => park the context and open the + // confirm sub-modal; none => add directly and toast. + // + // A FAILED dup check falls through to a direct add rather than + // blocking the action: a transient API error must not cost the user + // their click. + Payload::Qobuz(ids) => { + crate::spawn(async move { + match runtime.core().check_playlist_duplicates(pid, &ids).await { + Ok(dup) if dup.duplicate_count > 0 => { + { + let mut st = STATE.lock().unwrap(); + st.dup = Some(DupContext { + playlist_id: pid, + playlist_name: name, + all_ids: ids, + duplicate_ids: dup.duplicate_track_ids, + total_tracks: dup.total_tracks, + }); + st.dup_busy = false; + } + publish(); + } + Ok(_) => { + close(); + add_and_toast(&runtime, pid, ids, name).await; + } + Err(e) => { + log::warn!( + "[qbz-qt] playlist picker: dup check failed, adding directly: {e}" + ); + close(); + add_and_toast(&runtime, pid, ids, name).await; + } + } + }); + } + } +} + +/// Duplicate-confirm answer. `add_all` adds every carried id; otherwise only +/// the ones the target does not already hold (`all \ duplicates`) — and when +/// that difference is empty there is nothing to do but close. +pub fn confirm_duplicates(add_all: bool) { + let Some(ctx) = STATE.lock().unwrap().dup.take() else { + return; + }; + let to_add: Vec = if add_all { + ctx.all_ids + } else { + ctx.all_ids + .into_iter() + .filter(|id| !ctx.duplicate_ids.contains(id)) + .collect() + }; + if to_add.is_empty() { + close(); + return; + } + { + let mut st = STATE.lock().unwrap(); + st.dup_busy = true; + } + publish(); + + let pid = ctx.playlist_id; + let name = ctx.playlist_name; + let runtime = crate::app(); + crate::spawn(async move { + add_and_toast(&runtime, pid, to_add, name).await; + close(); + }); +} + +pub fn cancel_duplicates() { + { + let mut st = STATE.lock().unwrap(); + st.dup = None; + st.dup_busy = false; + } + publish(); +} + +/// Inline "Create new playlist" -> create, add the carried tracks, close, toast. +/// Re-entrancy is guarded by `creating`, which the confirm button also reads: +/// a double click would otherwise create two playlists with the same name. +/// +/// D8: OFFLINE the new playlist is a LOCAL one (library.db). A Qobuz create +/// cannot succeed without a session, and the local playlist is a first-class +/// target for a user who has no Qobuz account at all. +pub fn create_and_add(name: &str) { + let name = name.trim().to_string(); + if name.is_empty() { + return; + } + let payload = { + let mut st = STATE.lock().unwrap(); + if st.creating { + return; + } + st.creating = true; + st.payload.clone() + }; + publish(); + + let runtime = crate::app(); + if crate::offline_fwd::engine().is_offline() { + crate::spawn(async move { + let created = tokio::task::spawn_blocking({ + let name = name.clone(); + // offline_only = true, mirroring the reference's offline + // create: the playlist is never offered for upload and never + // reaches a QConnect push. + move || crate::local_playlist_qt::create_blocking(&name, None, true) + }) + .await + .ok() + .flatten(); + let Some(new_id) = created else { + log::error!("[qbz-qt] playlist picker: offline create-and-add failed"); + { + let mut st = STATE.lock().unwrap(); + st.creating = false; + } + publish(); + crate::toast_qt::error(qbz_i18n::t("Couldn't create the playlist")); + return; + }; + let added = tokio::task::spawn_blocking(move || match payload { + Payload::LocalRefs(refs) => { + crate::local_playlist_qt::add_local_refs_blocking(&new_id, &refs) + } + Payload::Qobuz(ids) => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &ids) + } + }) + .await + .unwrap_or(0); + close(); + toast_added(added, &name); + // The new playlist has to appear in the sidebar, or the user's + // only evidence it exists is the toast — and `reload_sidebar` + // is a no-op offline, which is exactly this branch. + crate::reload_sidebar_including_local(); + }); + return; + } + + crate::spawn(async move { + match runtime.core().create_playlist(&name, None, false).await { + Ok(playlist) => { + let pid = playlist.id; + match payload { + Payload::Qobuz(ids) if !ids.is_empty() => { + add_and_toast(&runtime, pid, ids, name).await; + } + // Local refs onto the freshly created QOBUZ playlist ride + // the SAME sidecar as picking an existing one — a new + // playlist's Qobuz block is empty, so the base is 0. + // + // DIVERGENCE, recorded: the reference re-parses its carried + // ref strings as `u64` here and posts them to the API, so a + // library row id `42` would be added as catalog track 42. + // That is precisely the confusion `Payload` exists to make + // unrepresentable, and it is not reproduced. + Payload::LocalRefs(refs) if !refs.is_empty() => { + let written = write_sidecar_refs(pid, 0, refs).await; + toast_added(written, &name); + crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; + } + _ => {} + } + close(); + crate::reload_sidebar_including_local(); + } + Err(e) => { + log::error!("[qbz-qt] playlist picker: create-and-add failed: {e}"); + { + let mut st = STATE.lock().unwrap(); + st.creating = false; + } + publish(); + crate::toast_qt::error(qbz_i18n::t("Couldn't create the Qobuz playlist")); + } + } + }); +} + +/// Seam C — attach local-mode refs to a QOBUZ playlist through the library.db +/// sidecar tables. Returns how many refs were written. +/// +/// The position comes from `next_playlist_sidecar_position(pid, qobuz_count)` +/// — `max(qobuz_count + local_count + plex_count, MAX(position) + 1)` — taken +/// ONCE and incremented per row, so the batch lands after the whole merged list +/// AND past every stored position. It must NEVER be derived from the ref list's +/// own index: the reference records that its earlier 0-based `enumerate` write +/// collided slots in the absolute-slot interleave and SILENTLY LOST rows. +/// +/// Re-adding an existing ref MOVES it to the append slot rather than +/// duplicating it (the writes are `INSERT OR REPLACE`; edge E4). +async fn write_sidecar_refs(pid: u64, qobuz_count: u32, refs: Vec) -> usize { + tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(true, |db| { + let mut next = db.next_playlist_sidecar_position(pid, qobuz_count)?; + let mut written = 0usize; + for r in &refs { + if let Some(key) = r.strip_prefix("plex:") { + db.add_plex_track_to_playlist(pid, key, next)?; + } else if let Ok(lid) = r.parse::() { + db.add_local_track_to_playlist(pid, lid, next)?; + } else { + log::warn!("[qbz-qt] playlist picker: unrecognized local ref {r}"); + continue; + } + next += 1; + written += 1; + } + Ok(written) + }) + .unwrap_or(0) + }) + .await + .unwrap_or(0) +} + +/// The Qobuz write path: add `ids` to `pid`, refresh, then toast the count. +/// Every Qobuz-target caller funnels here so the success/failure handling +/// cannot drift between the direct add, the two duplicate answers and +/// create-and-add. +async fn add_and_toast(runtime: &Runtime, pid: u64, ids: Vec, name: String) { + let n = ids.len(); + if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &ids).await { + log::error!("[qbz-qt] playlist picker: add to playlist {pid} failed: {e}"); + crate::toast_qt::error(qbz_i18n::t("Failed to add tracks")); + return; + } + // The sidebar's per-playlist track count is now stale, and a playlist page + // open on this id must re-merge (the reference's E12 refresh). + crate::playlist_qt::refresh_after_membership_change(runtime, pid).await; + toast_added(n, &name); +} + +/// The shared success toast (the reference's `toast_added_tracks`). Every arm +/// of the matrix ends here, so the wording cannot drift between them. A zero +/// count is silent: the repo layer already logged WHY nothing landed. +fn toast_added(n: usize, name: &str) { + if n == 0 { + return; + } + let msg = if name.is_empty() { + qbz_i18n::tf("Added {} track", "Added {} tracks", n as i64, &[&n.to_string()]) + } else { + qbz_i18n::tf( + "Added {} track to {}", + "Added {} tracks to {}", + n as i64, + &[&n.to_string(), name], + ) + }; + crate::toast_qt::success(msg); +} + +/// Re-render the open LOCAL detail after an add that targeted it. +/// +/// DIVERGENCE, recorded: the reference toasts and stops. Its local-mode picker +/// is opened from the Local Library, so "the target playlist is the page you +/// are looking at" does not arise there; in this port the same picker is +/// reachable with that playlist open, and a user with no Qobuz account — the +/// people local playlists exist for — would watch the add do visibly nothing. +/// The sidebar is deliberately NOT reloaded (1:1 with the reference): a local +/// row shows a cover collage, not a count, and a reload costs a Qobuz fetch. +async fn refresh_local_target(runtime: &Runtime, target: &str) { + if crate::local_playlist_qt::open_id().as_deref() == Some(target) { + let _ = crate::local_playlist_qt::load(runtime, target).await; + } +} diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index 22de922d5..a1cd487bb 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -19,10 +19,21 @@ //! - drag reorder (issue #589): visible-index move + persistence of the //! custom order. //! +//! LOCAL playlists are NOT out of scope any more (that POC-NOTE was stale and +//! it had already misled one call site into stamping `"source": "qobuz"` on +//! every row): `local_playlist_qt::load` resolves a `local:` detail and +//! publishes it THROUGH THIS DOCUMENT via [`adopt_doc`]. Consequences for +//! anyone reading a row here: +//! - `PlaylistDoc.is_local_playlist` says which kind is on screen. +//! - `PlaylistTrackRow.source` is `""` (Qobuz) | `"local"` | `"plex"`, and +//! `id` lives in a DIFFERENT id space per source. Never read a row's `id` +//! as a catalog id without checking `source` first — that is the +//! id-confusion class `playlist_picker_qt.rs`'s header exists to prevent. +//! - `unavailable` rows carry a ref that cannot resolve at all; they render +//! honestly and every "copy this track somewhere" affordance must be absent +//! on them. +//! //! POC-NOTEs: -//! - LOCAL playlists (library.db entities) are OUT OF SCOPE everywhere: -//! the view is Qobuz-only (the sidebar's local rows keep their -//! mark-active behavior from phase 7). //! - The custom drag order persists to a per-user `playlist_orders.json` //! in the user dir (the Slint uses `playlist_orders.db` with //! `(u64, bool, i32)` rows — same behavior, simpler backend for the POC). @@ -75,6 +86,21 @@ pub struct PlaylistTrackRow { pub art_path: String, #[serde(rename = "isFavorite")] pub is_favorite: bool, + /// Row provenance for a LOCAL playlist's mixed rows: "" (Qobuz — the + /// default for every Qobuz playlist row) | "local" | "plex". + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source: String, + /// A ref that cannot resolve right now — a Plex key the cache does not + /// know, or a stored "Qobuz" id outside the catalog range (the legacy + /// mis-typed-drag class). The row renders HONESTLY and stays selectable so + /// the user can remove it, instead of vanishing: hiding is for genuinely + /// offline Qobuz rows, not for refs that can never heal on their own. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unavailable: bool, + /// The raw stored ref behind `unavailable`, shown so the user can see WHAT + /// is broken rather than just that something is. + #[serde(default, rename = "unavailableRef", skip_serializing_if = "String::is_empty")] + pub unavailable_ref: String, } #[derive(Clone, Default, Serialize)] @@ -126,6 +152,16 @@ pub struct PlaylistDoc { #[serde(rename = "sortAsc")] pub sort_asc: bool, pub search: String, + /// This detail is a LOCAL playlist (`local:`), not a Qobuz one. The + /// view drops every Qobuz-only affordance on it (follow, copy, share, the + /// Qobuz heart) and offers the local ones instead. + #[serde(default, rename = "isLocalPlaylist", skip_serializing_if = "std::ops::Not::not")] + pub is_local_playlist: bool, + /// D8: an OFFLINE-ONLY local playlist. Nothing from one may ever reach + /// Qobuz — the queue it builds is stamped so the QConnect push site skips + /// the cloud entirely. + #[serde(default, rename = "offlineOnly", skip_serializing_if = "std::ops::Not::not")] + pub offline_only: bool, } // --------------------------------------------------------------------------- @@ -146,6 +182,22 @@ pub fn set_user_id(id: u64) { USER_ID.store(id, std::sync::atomic::Ordering::SeqCst); } +/// The signed-in user's id, or `None` when no session has been activated. +/// +/// The Qt twin of the reference's `library_db::current_user_id()` +/// (`main.rs:21024`) — same job, read out of the session snapshot this crate +/// already stashes instead of re-opening the DB. `0` is the never-set +/// sentinel and must NOT be compared against a real `owner.id`, hence the +/// `Option`: `delete_by_id`'s ownership test would otherwise read +/// "unauthenticated" as "owns nothing", which is right, and "owns playlist 0", +/// which is not. +fn current_user_id() -> Option { + match USER_ID.load(std::sync::atomic::Ordering::SeqCst) { + 0 => None, + id => Some(id), + } +} + // --------------------------------------------------------------------------- // Ownership / follow authority for playlist CARDS // --------------------------------------------------------------------------- @@ -251,6 +303,19 @@ fn publish(doc: &PlaylistDoc) { }); } +/// Publish a document built ELSEWHERE and adopt it as the open page. +/// +/// The LOCAL playlist detail renders through this same view — the reference +/// does exactly that (its local detail drives `PlaylistState` and the shared +/// row machinery rather than growing a second view), so search / sort / +/// multi-select / artwork all come for free and cannot drift between the two +/// kinds of playlist. `PAGE` is set too, so `with_doc` readers (the sort and +/// search handlers, the membership refresh) see the local page like any other. +pub(crate) fn adopt_doc(doc: PlaylistDoc) { + publish(&doc); + *PAGE.lock().unwrap() = Some(PageState { doc }); +} + fn with_doc(f: impl FnOnce(&mut PlaylistDoc) -> R) -> Option { let mut guard = PAGE.lock().unwrap(); guard.as_mut().map(|page| f(&mut page.doc)) @@ -260,11 +325,11 @@ fn with_doc(f: impl FnOnce(&mut PlaylistDoc) -> R) -> Option { // Mapping (playlist.rs to_item + header) // --------------------------------------------------------------------------- -fn mmss(secs: u32) -> String { +pub(crate) fn mmss(secs: u32) -> String { format!("{}:{:02}", secs / 60, secs % 60) } -fn tier(bit_depth: Option) -> &'static str { +pub(crate) fn tier(bit_depth: Option) -> &'static str { match bit_depth { Some(d) if d >= 24 => "hires", Some(_) => "cd", @@ -272,7 +337,7 @@ fn tier(bit_depth: Option) -> &'static str { } } -fn quality_label(bit_depth: Option, sample_rate: Option) -> String { +pub(crate) fn quality_label(bit_depth: Option, sample_rate: Option) -> String { match bit_depth { None => String::new(), Some(depth) => { @@ -288,7 +353,7 @@ fn quality_label(bit_depth: Option, sample_rate: Option) -> String { } } -fn map_track(track: &Track) -> PlaylistTrackRow { +pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow { let mut title = track.title.clone(); if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) { title = format!("{title} ({v})"); @@ -329,6 +394,12 @@ fn map_track(track: &Track) -> PlaylistTrackRow { } } +/// `row_to_queue` for the LOCAL playlist detail, which builds the same rows +/// through the same mapper and needs the same queue entries. +pub(crate) fn row_to_queue_public(row: &PlaylistTrackRow) -> QueueTrack { + row_to_queue(row) +} + fn row_to_queue(row: &PlaylistTrackRow) -> QueueTrack { QueueTrack { id: row.id.parse().unwrap_or(0), @@ -708,11 +779,31 @@ pub fn set_sort(field: &str) { !matches!(field, "added") }; apply_sort(d, field, asc); - d.clone() + (d.clone(), d.is_local_playlist) }); - if let Some(doc) = doc { + let Some((doc, is_local)) = doc else { return }; + // "default" is a NO-OP branch in `apply_sort` — it keeps whatever order + // the document is already in. On a Qobuz list that is right (the API's + // insertion order is the loaded order, and re-sorting is a reload away). + // On a LOCAL one it is not: "default" IS the editable repo order, the + // surface the reorder chevrons write to, so coming back to it after a + // Title sort has to RESTORE that order rather than leave the rows + // alphabetical under a label that says otherwise — the view offers the + // arrows on exactly this field. The repo is the cheap authority here (no + // network, works offline), so the reload rebuilds it. + if is_local && field == "default" { + let Some(id) = crate::local_playlist_qt::open_id() else { + publish(&doc); + return; + }; publish(&doc); + let runtime = crate::app(); + crate::spawn(async move { + crate::local_playlist_qt::load(&runtime, &id).await; + }); + return; } + publish(&doc); } pub fn set_search(query: &str) { @@ -880,33 +971,150 @@ pub async fn copy_playlist(runtime: &Arc>) { crate::reload_sidebar(); } -/// Rename (update_playlist, name only — the edit modal's save). -pub async fn rename(runtime: &Arc>, name: &str) -> Result<(), String> { +/// The id of the playlist whose detail page is currently loaded, if any. +/// `PAGE` holds the LOCAL detail too (`adopt_doc`), so this answers for both +/// kinds — which is what makes [`back_if_showing`] correct for a `local:` id. +pub fn open_doc_id() -> Option { + with_doc(|d| d.id.clone()) +} + +/// Navigate back ONLY when the user is standing on `deleted_id`'s detail page +/// (contract §5.1). +/// +/// The reference gates its two back-navigations on +/// `NavState.view == ContentView::Playlist && PlaylistState.id == deleted_id` +/// (`main.rs:20996`, `:21054`). Dropping that gate — which +/// `delete_playlist()` did — means deleting a row from the Playlist Manager or +/// from the sidebar throws the user off the surface they were working on. +pub(crate) fn back_if_showing(deleted_id: &str) { + if crate::nav_qt::current_view() != "playlist" { + return; + } + if open_doc_id().as_deref() != Some(deleted_id) { + return; + } + crate::nav_qt::back(); +} + +/// Rename + optional description write, BY ID — the seam the detail view's own +/// rename and the shared playlist editor (`playlist_edit_qt`) both go through. +/// +/// `description`: +/// * `None` — leave the stored description ALONE. `update_playlist` treats an +/// omitted field as unchanged, and this is what a caller that does not +/// KNOW the description must pass (§5.2). +/// * `Some(d)` — assert it. Only the editor passes this, and only when it +/// actually resolved the real description. The reference seeds `""` and +/// always sends `Some(trimmed)`, so every rename from its manager DELETES +/// the description; that bug is not ported. +/// +/// It NEVER navigates and NEVER reloads the sidebar — both are the caller's +/// decision (§5.1). It does patch the OPEN detail document, but only when the +/// renamed playlist happens to be the one on screen: the editor is reachable +/// from the manager and the sidebar, where the detail behind it is somebody +/// else's. +pub async fn rename_by_id( + runtime: &Arc>, + pid: u64, + name: &str, + description: Option<&str>, +) -> Result<(), String> { let name = name.trim().to_string(); if name.is_empty() { return Err("empty playlist name".to_string()); } + runtime + .core() + .update_playlist(pid, Some(&name), description, None) + .await + .map_err(|e| format!("rename playlist {pid} failed: {e}"))?; + + let target = pid.to_string(); + let patched = with_doc(|d| { + if d.id != target { + return None; + } + d.name = name.clone(); + if let Some(desc) = description { + // The doc's `description` is the HTML-STRIPPED display copy (see + // the loader at :545) and `description_short` is derived from it, + // so both are re-derived here rather than dropping the raw string + // in and leaving the header showing a stale short. + let stripped = qbz_text_utils::strip_html::strip_html(desc); + d.description_short = truncate_words(&stripped, 160); + d.description = stripped; + } + Some(d.clone()) + }); + if let Some(Some(doc)) = patched { + publish(&doc); + } + Ok(()) +} + +/// Delete BY ID, with the ownership re-derivation the API requires (§5.1). +/// +/// Qobuz's `playlist/delete` returns 200 and **no-ops** on a playlist you do +/// not own — the "deleted ok but it stays" bug. A FOLLOWED playlist has to go +/// through `unsubscribe_playlist` instead, and ownership is re-derived here +/// rather than trusted from a card, because the caller may be the sidebar or +/// the manager, where no `owner` was ever fetched. +/// +/// A failed ownership check falls back to NOT-OWNED, matching the reference: +/// unsubscribing something you own is a no-op, where deleting something you +/// follow silently does nothing and looks like a broken button. +/// +/// **NEVER navigates.** [`back_if_showing`] is the caller's to call. +pub async fn delete_by_id( + runtime: &Arc>, + pid: u64, +) -> Result<(), String> { + let me = current_user_id(); + let owned = match runtime.core().get_playlist(pid).await { + Ok(p) => me.is_some_and(|uid| uid == p.owner.id), + Err(e) => { + log::warn!("[qbz-qt] delete playlist {pid}: ownership check failed: {e}"); + false + } + }; + let res = if owned { + runtime.core().delete_playlist(pid).await + } else { + runtime.core().unsubscribe_playlist(pid).await + }; + res.map_err(|e| { + let verb = if owned { "delete" } else { "unsubscribe" }; + format!("{verb} playlist {pid} failed: {e}") + })?; + log::info!( + "[qbz-qt] playlist {pid} {}", + if owned { "deleted" } else { "unsubscribed" } + ); + Ok(()) +} + +/// Rename the OPEN playlist (`QbzBridge.playlistRename`). +/// +/// Passes `None` for the description on purpose: this caller never showed the +/// user a description field, so it has nothing to assert and must leave the +/// stored one untouched (§5.2). +pub async fn rename(runtime: &Arc>, name: &str) -> Result<(), String> { let Some(pid) = with_doc(|d| d.id.parse::().ok()) else { return Err("no playlist open".to_string()); }; let Some(pid) = pid else { return Err("invalid playlist id".to_string()); }; - runtime - .core() - .update_playlist(pid, Some(&name), None, None) - .await - .map_err(|e| format!("rename playlist {pid} failed: {e}"))?; - with_doc(|d| { - d.name = name; - let doc = d.clone(); - publish(&doc); - }); + rename_by_id(runtime, pid, name, None).await?; crate::reload_sidebar(); Ok(()) } -/// Delete the open playlist (edit modal's danger action) and navigate back. +/// Delete the OPEN playlist (`QbzBridge.playlistDelete`) and navigate back. +/// +/// The back-nav is still here — this caller is by definition standing on that +/// playlist's page — but it is now GATED, so it cannot fire against a page the +/// user navigated to in the meantime. pub async fn delete_playlist(runtime: &Arc>) -> Result<(), String> { let Some(pid) = with_doc(|d| d.id.parse::().ok()) else { return Err("no playlist open".to_string()); @@ -914,14 +1122,9 @@ pub async fn delete_playlist(runtime: &Arc>) -> Resul let Some(pid) = pid else { return Err("invalid playlist id".to_string()); }; - runtime - .core() - .delete_playlist(pid) - .await - .map_err(|e| format!("delete playlist {pid} failed: {e}"))?; - log::info!("[qbz-qt] playlist {pid} deleted"); + delete_by_id(runtime, pid).await?; crate::reload_sidebar(); - crate::nav_qt::back(); + back_if_showing(&pid.to_string()); Ok(()) } @@ -961,9 +1164,46 @@ pub async fn remove_track(runtime: &Arc>, playlist_tr } } +/// The open document's row ids, IN THE ORDER THE VIEW RENDERS THEM. +/// +/// The Qt twin of the reference's `playlist::full_item_ids()` +/// (`local_playlist.rs:1835` calls it for exactly this). `local_playlist_qt`'s +/// reorder arms speak in visible indices and have to reach the repo positions +/// behind them; `PAGE` holds the LOCAL detail too (`adopt_doc`), so this +/// answers for both kinds of playlist. +pub(crate) fn row_ids() -> Vec { + with_doc(|d| d.tracks.iter().map(|t| t.id.clone()).collect()).unwrap_or_default() +} + +/// Persist the document's CURRENT row order into the custom-order sidecar and +/// stamp the sort as "custom" — the shared tail of the two Qobuz reorder arms +/// (drag drop and the chevrons), so the two can never drift on which of the +/// three things (write, field, direction) they do. +fn stamp_custom_order(d: &mut PlaylistDoc) { + let ids: Vec = d + .tracks + .iter() + .filter_map(|t| t.id.parse::().ok()) + .collect(); + if let Ok(pid) = d.id.parse::() { + save_custom_orders(pid, &ids); + } + d.sort_field = "custom".to_string(); + d.sort_asc = true; +} + /// Drag reorder (issue #589): move the row at visible index `from` to /// insertion slot `slot` (0..N), persist the custom order, and switch the /// sort to "custom" (the Slint's reorder rides the custom sort). +/// +/// The QOBUZ arm only — `crate::playlist_reorder` routes a `local:` detail to +/// `local_playlist_qt::reorder_row`, which writes repo positions instead of a +/// sidecar. +/// +/// `from`/`slot` are indices into `d.tracks`, which is the UNFILTERED +/// document. The view therefore only offers a reorder while the in-playlist +/// search is empty (the same rule `playlist_manager_qt.rs:236` applies to the +/// manager's arrows), so a filtered index can never be read as a document one. pub fn reorder_track(from: usize, slot: usize) { let doc = with_doc(|d| { if !d.is_owner || from >= d.tracks.len() { @@ -976,18 +1216,35 @@ pub fn reorder_track(from: usize, slot: usize) { let row = d.tracks.remove(from); let insert_at = if slot > from { slot - 1 } else { slot }; d.tracks.insert(insert_at.min(d.tracks.len()), row); - // Persist the full id order in the new arrangement and flip the - // sort to custom so the order survives reloads. - let ids: Vec = d - .tracks - .iter() - .filter_map(|t| t.id.parse::().ok()) - .collect(); - if let Ok(pid) = d.id.parse::() { - save_custom_orders(pid, &ids); + stamp_custom_order(d); + Some(d.clone()) + }); + if let Some(doc) = doc.flatten() { + publish(&doc); + } +} + +/// Arrow reorder — move the row `row_id` one slot up (`delta < 0`) or down +/// (`delta > 0`) in the open QOBUZ playlist's custom order. +/// +/// The keyboard/mouse-accessible twin of the drag: the same persisted sidecar, +/// the same "custom" stamp, no gesture. `crate::playlist_move_row` routes a +/// `local:` detail to `local_playlist_qt::move_row` instead. +pub fn move_row(row_id: &str, delta: i32) { + if delta == 0 { + return; + } + let doc = with_doc(|d| { + if !d.is_owner { + return None; } - d.sort_field = "custom".to_string(); - d.sort_asc = true; + let idx = d.tracks.iter().position(|t| t.id == row_id)?; + let target = idx as i32 + delta.signum(); + if target < 0 || target as usize >= d.tracks.len() { + return None; // already first / last + } + d.tracks.swap(idx, target as usize); + stamp_custom_order(d); Some(d.clone()) }); if let Some(doc) = doc.flatten() { @@ -1103,8 +1360,21 @@ pub async fn add_tracks(runtime: &Arc>, playlist_id: return; } } + refresh_after_membership_change(runtime, playlist_id).await; +} + +/// The refresh every membership WRITE owes: the sidebar's per-playlist track +/// count is stale, and a playlist page open on this id has to re-merge or the +/// rows just added stay invisible until the user navigates away and back +/// (the reference's E12 refresh). +/// +/// Shared with the Add-to-Playlist picker, which writes to a playlist the user +/// may well be looking at. +pub(crate) async fn refresh_after_membership_change( + runtime: &Arc>, + playlist_id: u64, +) { crate::reload_sidebar(); - // E12: re-merge the open detail after a membership write to it. let open_id = with_doc(|d| d.id.parse::().ok()).flatten(); if open_id == Some(playlist_id) { let _ = load(runtime, playlist_id).await; diff --git a/crates/qbz-qt/src/queue_bridge.rs b/crates/qbz-qt/src/queue_bridge.rs index 975061d24..a5690b237 100644 --- a/crates/qbz-qt/src/queue_bridge.rs +++ b/crates/qbz-qt/src/queue_bridge.rs @@ -38,6 +38,16 @@ pub mod qbz_queue { // + pagination + #442 section markers). Supersedes `queueModel`. #[qproperty(QString, queue_json)] + // --- Sleep timer (queue footer) ------------------------------------ + // Kept OUT of `queue_json`: the countdown reformats every second and + // the document is re-parsed by three QML files on every change, so + // folding it in would re-parse the whole queue once a second for a + // string nothing else reads. Two scalar properties instead — the same + // split the reference makes with its own `SleepTimerState` global. + // `sleep_remaining` is pre-formatted in Rust (single source of truth). + #[qproperty(bool, sleep_active)] + #[qproperty(QString, sleep_remaining)] + type QbzQueue = super::QbzQueueRust; /// Registers this object's Qt-thread hop (Main.qml boots EVERY @@ -63,9 +73,32 @@ pub mod qbz_queue { fn queue_play_history(self: Pin<&mut QbzQueue>, index: i32); #[qinvokable] fn queue_toggle_favorite(self: Pin<&mut QbzQueue>, kind: QString, id: QString); + /// Row menu: "Stop after this" / "Cancel stop after this" (idempotent). + #[qinvokable] + fn queue_toggle_stop_after(self: Pin<&mut QbzQueue>, id: QString); + /// Row menu: seed the Add-to-Playlist picker with this one row. + #[qinvokable] + fn queue_add_to_playlist(self: Pin<&mut QbzQueue>, index: i32); /// Footer: Clear queue. #[qinvokable] fn queue_clear(self: Pin<&mut QbzQueue>); + /// Footer: save the whole queue as a playlist (seeds the picker). + #[qinvokable] + fn queue_save_as_playlist(self: Pin<&mut QbzQueue>); + /// Footer: toggle infinite play (InfiniteRadio autoplay). + #[qinvokable] + fn queue_toggle_infinite_play(self: Pin<&mut QbzQueue>); + /// The panel became visible — pull a fresh snapshot. The queue can + /// change while the panel is closed (a session restore, a play from + /// any view), and the publish dedup means the panel would otherwise + /// mount against whatever document happened to be last posted. + #[qinvokable] + fn queue_panel_opened(self: Pin<&mut QbzQueue>); + /// Footer sleep timer: arm for `minutes` / cancel. + #[qinvokable] + fn sleep_timer_set(self: Pin<&mut QbzQueue>, minutes: i32); + #[qinvokable] + fn sleep_timer_cancel(self: Pin<&mut QbzQueue>); } impl cxx_qt::Threading for QbzQueue {} @@ -80,6 +113,8 @@ type QListQVariant = QList; pub struct QbzQueueRust { queue_model: QListQVariant, queue_json: QString, + sleep_active: bool, + sleep_remaining: QString, } impl Default for QbzQueueRust { @@ -87,6 +122,8 @@ impl Default for QbzQueueRust { Self { queue_model: QListQVariant::default(), queue_json: QString::from("{}"), + sleep_active: false, + sleep_remaining: QString::default(), } } } @@ -144,7 +181,35 @@ impl qbz_queue::QbzQueue { crate::queue_toggle_favorite(kind.to_string(), id.to_string()); } + pub fn queue_toggle_stop_after(self: Pin<&mut Self>, id: QString) { + crate::queue_toggle_stop_after(id.to_string()); + } + + pub fn queue_add_to_playlist(self: Pin<&mut Self>, index: i32) { + crate::queue_add_to_playlist(index); + } + pub fn queue_clear(self: Pin<&mut Self>) { crate::queue_clear(); } + + pub fn queue_save_as_playlist(self: Pin<&mut Self>) { + crate::queue_save_as_playlist(); + } + + pub fn queue_toggle_infinite_play(self: Pin<&mut Self>) { + crate::queue_toggle_infinite_play(); + } + + pub fn queue_panel_opened(self: Pin<&mut Self>) { + crate::queue_panel_opened(); + } + + pub fn sleep_timer_set(self: Pin<&mut Self>, minutes: i32) { + crate::sleep_timer_qt::set(crate::app(), minutes); + } + + pub fn sleep_timer_cancel(self: Pin<&mut Self>) { + crate::sleep_timer_qt::cancel(); + } } diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index f66907340..30dcb8006 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -5,12 +5,18 @@ //! History tab. Mutations go straight to the core queue API; every one //! ends with a republish. //! -//! POC-NOTEs: -//! - QConnect remote reorder, the playlist picker (save-as-playlist opens -//! a modal picker upstream), infinite-play engine, sleep timer, stop-after -//! marker, ephemeral rows, coverflow: out of scope. -//! - Row favorite seeds from the phase-5 library feed (Slint uses -//! fav_cache — same truth). +//! OWED PORTS (what the reference has and this file still does not): +//! - QConnect remote reorder — `reorder_upcoming_if_remote` is offered the +//! move FIRST in the reference (the cloud is authoritative for queue order +//! while connected). There is no QConnect service in this port yet, so the +//! local core path is the only one; when the service lands, `move_track` +//! grows that arm BEFORE `core().move_track`. +//! - Coverflow: the reference also feeds the immersive coverflow's flat model +//! from this same refresh. The immersive view does not exist here +//! (PARITY-DEBT #31), so there is nothing to feed. +//! +//! Row favorite seeds from the phase-5 library feed (Slint uses fav_cache — +//! same truth). use std::collections::HashSet; use std::sync::Mutex; @@ -51,6 +57,12 @@ pub struct QueueRow { /// #442 section header drawn ABOVE this row: "" | "next-in-queue" | /// "next-up" (only on the unfiltered list). pub section: String, + /// Ephemeral row (a one-shot file play that leaves no trace in QBZ). + /// The panel drops every PERSISTENCE affordance on these — the heart, + /// "Add to playlist", "Track info" — exactly as `QueueSidebar.slint` + /// gates on `item.is-ephemeral`. + #[serde(rename = "isEphemeral")] + pub is_ephemeral: bool, } #[derive(Default, Serialize)] @@ -74,6 +86,18 @@ pub struct QueueDoc { pub shuffle: bool, #[serde(rename = "repeatMode")] pub repeat_mode: i32, + /// "Stop after this song" marker — the decimal-string id of the marked + /// track, or "" for none. Each row compares its own id to this to draw + /// the CircleStop in place of its track number (`QueueState.stop-after-id`). + #[serde(rename = "stopAfterId")] + pub stop_after_id: String, + /// Whether InfiniteRadio autoplay is on (the ∞ footer button's accent). + #[serde(rename = "infinitePlay")] + pub infinite_play: bool, + /// The live search query, echoed back so the panel can tell "the queue is + /// empty" apart from "nothing matched" (`QueueState.search-query`). + #[serde(rename = "searchQuery")] + pub search_query: String, } /// Panel view state (search + page), session-scope like the Slint view. @@ -172,6 +196,11 @@ fn row_from(track: &QueueTrack, favorites: &HashSet) -> QueueRow { is_favorite, is_local: track.is_local, section: String::new(), + // Both halves of the reference's test (`queue.rs:148`): the queue + // track's own source tag, and the id-range check for a row that + // reached the queue without one. + is_ephemeral: track.source.as_deref() == Some("ephemeral") + || crate::local_ephemeral::is_ephemeral_id(track.id as i64), } } @@ -321,6 +350,15 @@ pub async fn publish(runtime: &Arc>) { .collect(); let current = state.current_track.as_ref().map(|t| row_from(t, &favorites)); + // One core read for the marker (the reference reads it in the same pass, + // `queue.rs:396`), so a row can compare its id without a second round trip. + let stop_after_id = runtime + .core() + .get_stop_after() + .await + .map(|id| id.to_string()) + .unwrap_or_default(); + let doc = QueueDoc { has_current: current.is_some(), current, @@ -338,6 +376,12 @@ pub async fn publish(runtime: &Arc>) { qbz_models::RepeatMode::All => 1, qbz_models::RepeatMode::One => 2, }, + stop_after_id, + infinite_play: is_infinite_play(), + // The RAW typed text, not the trimmed/lowercased `query` — this is + // what the reference's `QueueState.search-query` holds, and the + // "nothing matched" state gates on it being non-empty. + search_query: search, }; let rows = doc.upcoming.len() + doc.history.len() + usize::from(doc.has_current); *LOCAL_ROW_IDS.lock().unwrap() = doc @@ -476,15 +520,151 @@ pub async fn play_history(runtime: &Arc>, index: usiz crate::playback_qt::play_queue_track_public(runtime, track.id).await; } +/// Empty the queue — `queue.rs::clear` verbatim. +/// +/// `keep_current` is the LIVE `is_playing` flag, not a constant: the reference +/// keeps the now-playing slot only while it is audible, so clearing the queue +/// mid-track empties Up Next and lets the current track finish, and clearing it +/// while paused/stopped wipes the card too. This port used to pass `false` and +/// then call `stop()` on top, which killed playback on every Clear — the one +/// footer button that WAS wired was also the one that diverged. +/// +/// The view state resets with it (page 0, search cleared), or the panel would +/// come back showing "page 3 of 1" filtered by a query with nothing left to +/// match. pub async fn clear_queue(runtime: &Arc>) { - // queue.rs clear_queue: drops everything INCLUDING the current track - // (keep_current: false) and stops playback. - runtime.core().clear_queue(false).await; - let _ = runtime.core().stop(); + let playing = runtime.core().get_playback_state().is_playing; + runtime.core().clear_queue(playing).await; + { + let mut view = VIEW.lock().unwrap(); + view.page = 0; + view.search.clear(); + } publish(runtime).await; crate::playback_qt::refresh_now_playing(runtime).await; } +/// Toggle the "stop after this song" marker on the queue track with `id` (a +/// decimal string, matching `QueueRow.id`). Idempotent — tapping the same track +/// clears it. The core auto-clears the marker on queue mutation, and `publish` +/// reflects whatever it currently holds. `queue.rs::toggle_stop_after` 1:1. +pub async fn toggle_stop_after(runtime: &Arc>, id: &str) { + let Ok(track_id) = id.parse::() else { + return; + }; + if runtime.core().get_stop_after().await == Some(track_id) { + runtime.core().clear_stop_after().await; + } else { + runtime.core().set_stop_after(track_id).await; + } + publish(runtime).await; +} + +/// Whether `InfiniteRadio` autoplay is on (the queue auto-refills with a smart +/// radio when it runs out). Read by the footer's ∞ tint AND by the refill +/// engine in `playback_qt`'s end-of-track arm — same single truth as the +/// reference's `QueueController::is_infinite_play`. +pub fn is_infinite_play() -> bool { + crate::settings_qt::is_infinite_play() +} + +/// Toggle infinite play: flips the persisted autoplay mode between +/// `InfiniteRadio` and `ContinueWithinSource` (`queue.rs::toggle_infinite_play`). +/// +/// Note the asymmetry with Settings > Playback's "Continue playback" switch, +/// which flips `ContinueWithinSource` <-> `PlayTrackOnly`: the three modes share +/// ONE preference, so turning infinite play off here lands on +/// `ContinueWithinSource` and that switch reads as ON. That is the reference's +/// behaviour, not a rounding of it. +pub async fn toggle_infinite_play(runtime: &Arc>) { + crate::settings_qt::set_infinite_play(!is_infinite_play()); + publish(runtime).await; +} + +/// The panel became visible — re-pull the queue (`QueueState.panel-opened`). +/// +/// The bridge property still holds the last document, so the panel does not +/// mount blank without this; what it buys is the case the reference names — +/// the queue changed while the panel was closed (session restore, a play from +/// a view that does not republish) — and it is nearly free, because `publish` +/// drops a byte-identical document before it reaches Qt. +pub async fn panel_opened(runtime: &Arc>) { + publish(runtime).await; +} + +/// Open the Add-to-Playlist picker seeded with the queue's tracks — current +/// first, then upcoming, de-duplicated, in PLAY order (`queue.rs::save_as_playlist`). +/// +/// The picker's inline "Create new playlist" row is what turns this into "save +/// the queue as a playlist"; picking an existing one appends. There is no +/// separate save-as-playlist endpoint in the reference either — same modal. +/// +/// LOCAL/Plex and ephemeral rows are skipped: their `id` is a library.db row id +/// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an +/// unrelated catalog track under that number. +/// +/// The local-mode ARM now exists (`playlist_picker_qt::open_for_local_refs`), +/// so the remaining blocker is narrower than the old note claimed: what this +/// module lacks is a source-aware row -> ref RESOLVER. A `QueueTrack`'s +/// `source_item_id_hint` is overloaded (`local_playback` documents it as the +/// album id on the non-mixtape enqueue paths), so it is not a safe Plex rating +/// key outside the local queue builder's own rows. PARITY-DEBT, recorded +/// rather than silently mixing the id spaces. +pub async fn save_as_playlist(runtime: &Arc>) { + let state = runtime.core().get_queue_state_full().await; + let mut ids: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut skipped = 0usize; + for track in state.current_track.iter().chain(state.upcoming.iter()) { + if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { + skipped += 1; + continue; + } + if seen.insert(track.id) { + ids.push(track.id.to_string()); + } + } + if skipped > 0 { + log::info!( + "[qbz-qt] queue save-as-playlist: skipped {skipped} local/ephemeral row(s) — \ + no local-mode picker in this port" + ); + } + if ids.is_empty() { + return; + } + crate::playlist_picker_qt::open_for_ids(runtime, ids); +} + +/// Open the picker seeded with ONE upcoming row (page-local `page_index`) — +/// the per-track "Add to playlist" entry in the row menu +/// (`queue.rs::add_to_playlist`). Resolves through the same page-local -> +/// queue-wide mapping as every other row action, so it is correct under a +/// search filter and on any page. +pub async fn add_to_playlist(runtime: &Arc>, page_index: usize) { + let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else { + log::warn!("[qbz-qt] queue: add_to_playlist {page_index} out of range"); + return; + }; + let state = runtime.core().get_queue_state_full().await; + let Some(track) = state.upcoming.get(upcoming_index) else { + log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track"); + return; + }; + if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { + // Defensive: QueuePanel.qml drops the entry for `isLocal` and + // `isEphemeral` rows, so this is only reachable from a stale page + // index. Not "no local-mode picker" (that arm exists) — no + // source-aware row -> ref resolver here; see `save_as_playlist`. + log::warn!( + "[qbz-qt] queue: add_to_playlist {page_index} is a local/ephemeral row — \ + no source-aware ref resolver on the queue" + ); + return; + } + crate::playlist_picker_qt::open_for_ids(runtime, vec![track.id.to_string()]); +} + /// Toggle a heart for a queue row (Qobuz favorite), then republish. /// /// GUARD: a LOCAL/Plex row's `id` is a library.db row id which is NUMERIC, diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index cdd534ea4..8bb40696f 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -82,6 +82,31 @@ pub fn audio_settings() -> qbz_audio::settings::AudioSettings { with_audio(|s| s.get_settings()).unwrap_or_default() } +/// Whether `InfiniteRadio` autoplay is on — the queue footer's ∞ state and the +/// gate on the end-of-track refill engine. Exposed here (rather than reopening +/// the store from `queue_qt`) so both readers see the SAME process-wide handle: +/// two stores over one SQLite file would have the footer and the engine +/// disagreeing after a toggle until one of them re-read. +pub fn is_infinite_play() -> bool { + with_playback(|s| s.get_preferences()) + .map(|p| p.autoplay_mode == AutoplayMode::InfiniteRadio) + .unwrap_or(false) +} + +/// Turn infinite play on/off. Off lands on `ContinueWithinSource` — the +/// reference's `toggle_infinite_play`, and the reason Settings' "Continue +/// playback" switch reads ON after infinite play is turned off here. +pub fn set_infinite_play(enabled: bool) { + let mode = if enabled { + AutoplayMode::InfiniteRadio + } else { + AutoplayMode::ContinueWithinSource + }; + if let Err(e) = with_playback(|s| s.set_autoplay_mode(mode)) { + log::error!("[qbz-qt] queue: set autoplay mode failed: {e}"); + } +} + fn with_playback( f: impl FnOnce(&PlaybackPreferencesStore) -> Result, ) -> Result { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 06c6edf4a..3e2c4331c 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -95,6 +95,14 @@ pub mod qbz_shell { #[qproperty(QString, sidebar_json)] #[qproperty(QString, sidebar_sort_by)] #[qproperty(bool, sidebar_sort_asc)] + // The MINI-RAIL folder flyout's own document (contract §4.7): + // `{folderId, folderName, count, rows:[{id,name,isLocal}]}`. It is a + // separate document from `sidebar_json` because a COLLAPSED folder's + // children are absent from the flattened entries — listing them used + // to require force-expanding the folder, a persistent side effect the + // reference does not have. The default is the FULL SHAPE, never "{}": + // `JSON.parse("{}").rows.length` throws in the pre-publish frame. + #[qproperty(QString, sidebar_folder_popup_json)] // --- Window chrome (phase 12) -------------------------------------- // The APPLIED titlebar mode (the ui_prefs `use_system_title_bar` @@ -268,8 +276,17 @@ pub mod qbz_shell { #[qinvokable] fn toggle_ambient_background(self: Pin<&mut QbzShell>); - /// Sidebar tree: rebuild + republish after load / sort / search / - /// folder toggle. + /// Sidebar tree: rebuild + republish (the `…` menu's Refresh row). + /// + /// Routes to `crate::reload_sidebar_including_local()`, NOT + /// `crate::reload_sidebar()`. The latter early-returns while offline, + /// which made the ONE recovery affordance for a broken tree unusable in + /// exactly the state where the user needs it — and an account-less user + /// has nothing else. The local-safe verb is not a no-op offline: it + /// re-reads folders, folder membership, the hidden set and the LOCAL + /// playlists from library.db and republishes, while `sidebar_qt::load` + /// preserves the cached Qobuz set rather than wiping it (see its + /// header). Online the two verbs do the same thing. #[qinvokable] fn reload_sidebar(self: Pin<&mut QbzShell>); #[qinvokable] @@ -278,6 +295,12 @@ pub mod qbz_shell { fn sidebar_search(self: Pin<&mut QbzShell>, query: QString); #[qinvokable] fn sidebar_toggle_folder(self: Pin<&mut QbzShell>, id: QString); + /// Mini-rail folder click: publish that folder's playlists into + /// `sidebar_folder_popup_json`. Reads the sidebar CACHE only — no DB, + /// no network, no expand side effect — so it behaves identically + /// offline (contract §4.7 / block 6). + #[qinvokable] + fn sidebar_open_folder_popup(self: Pin<&mut QbzShell>, folder_id: QString); /// Sidebar "+" — create an empty playlist (single core call), then /// reload the tree. #[qinvokable] @@ -329,6 +352,7 @@ pub struct QbzShellRust { sidebar_json: QString, sidebar_sort_by: QString, sidebar_sort_asc: bool, + sidebar_folder_popup_json: QString, system_title_bar: bool, system_title_bar_pref: bool, window_width: f32, @@ -384,6 +408,10 @@ impl Default for QbzShellRust { sidebar_json: QString::from("[]"), sidebar_sort_by: QString::from("name"), sidebar_sort_asc: true, + // FULL SHAPE, not "{}" — see the qproperty comment. + sidebar_folder_popup_json: QString::from( + r#"{"folderId":"","folderName":"","count":0,"rows":[]}"#, + ), system_title_bar: crate::settings_qt::use_system_title_bar(), system_title_bar_pref: crate::settings_qt::use_system_title_bar(), window_width, @@ -543,7 +571,8 @@ impl qbz_shell::QbzShell { } pub fn reload_sidebar(self: Pin<&mut Self>) { - crate::reload_sidebar(); + // The OFFLINE-SAFE verb — see the declaration's doc comment. + crate::reload_sidebar_including_local(); } pub fn sidebar_set_sort(self: Pin<&mut Self>, option: QString) { @@ -558,6 +587,10 @@ impl qbz_shell::QbzShell { crate::sidebar_toggle_folder(&id.to_string()); } + pub fn sidebar_open_folder_popup(self: Pin<&mut Self>, folder_id: QString) { + crate::sidebar_open_folder_popup(&folder_id.to_string()); + } + pub fn create_playlist(self: Pin<&mut Self>) { crate::create_playlist(); } diff --git a/crates/qbz-qt/src/sidebar_qt.rs b/crates/qbz-qt/src/sidebar_qt.rs index 7454357b3..d85630486 100644 --- a/crates/qbz-qt/src/sidebar_qt.rs +++ b/crates/qbz-qt/src/sidebar_qt.rs @@ -107,6 +107,26 @@ pub struct SidebarEntry { pub folder_id: String, /// Up to 4 de-duplicated cover urls for the 2x2 micro-collage. pub covers: Vec, + /// A first-class LOCAL playlist (`local:`), not a Qobuz one. The row + /// carries a hard-drive mark and routes to the local detail; its id is a + /// string, so nothing may parse it as a catalog number. + #[serde(default, rename = "isLocal", skip_serializing_if = "std::ops::Not::not")] + pub is_local: bool, +} + +/// One LOCAL playlist listed in the sidebar (library.db, ids `local:`). +/// +/// Present online AND offline — that is the whole point of the feature for +/// people running QBZ without Qobuz. Folder membership rides +/// `local_playlists.folder_id`, which points at the SAME folders the Qobuz +/// playlists use, so one folder holds both kinds. +#[derive(Clone)] +struct SidebarLocal { + id: String, + name: String, + folder_id: Option, + hidden: bool, + covers: Vec, } #[derive(Clone)] @@ -126,6 +146,8 @@ struct SidebarData { /// playlist id -> folder id. folder_map: HashMap, hidden_playlists: HashSet, + /// First-class local playlists, listed alongside the Qobuz set. + locals: Vec, } static CACHE: Mutex> = Mutex::new(None); @@ -138,72 +160,139 @@ static SORT: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(("name".to_string(), true))); static SEARCH: Mutex = Mutex::new(String::new()); +/// Logout: drop the outgoing user's tree and the session-scoped view state. +/// +/// `do_logout` publishes `"[]"` into the QML property, but that is only the +/// rendered document — this CACHE is what every `crate::publish_sidebar()` +/// rebuilds from (the Playlist Manager's optimistic move-to-folder patches it +/// in place and republishes without any fetch), so leaving it populated makes +/// the previous account's playlists, folders, folder membership, hidden set and +/// local rows re-appear for the next one. Same reasoning as `fav_cache_qt` / +/// `myqbz_*` / `artist_blacklist` teardowns in `auth_qt::logout`. +/// +/// The expand set and the search box are session state and go with it. SORT +/// stays: it is a view preference, holds no user data, and the reference keeps +/// it for the process too. +pub fn teardown() { + *CACHE.lock().unwrap() = None; + EXPANDED.lock().unwrap().clear(); + SEARCH.lock().unwrap().clear(); + log::info!("[qbz-qt] sidebar cache cleared (logout)"); +} + /// Fetch playlists (Qobuz) + folders + membership + hidden set (library.db). +/// +/// The Qobuz half is GATED on connectivity and, when refused, KEEPS whatever +/// the cache already holds. That distinction is load-bearing: this function is +/// also the body of `crate::reload_sidebar_including_local()`, the refresh verb +/// every local mutation uses, and it runs offline by design. Attempting the +/// fetch there would yield `Vec::new()` (the gate answers with an error), so +/// hiding a local playlist after going offline mid-session would WIPE the +/// Qobuz playlists out of the tree. The folders + locals reads below are +/// unconditional — they are the whole sidebar for a user with no Qobuz account. pub async fn load(runtime: &Arc>) { - log::debug!("[qbz-qt] sidebar load: fetching user playlists"); - let playlists: Vec = match runtime.core().get_user_playlists().await { - Ok(pls) => { - // Same response, second consumer: the playlist ownership / - // follow snapshot every PlaylistCard's tri-state overlay reads - // (`playlist_qt::set_user_playlists`). This is the earliest point - // in the session where the user's own playlist list exists, and - // it costs no extra request — the alternative was every card - // rendering the "follow a foreign playlist" arm until the Library - // view had been opened at least once. - let pairs: Vec<(u64, u64)> = pls.iter().map(|p| (p.id, p.owner.id)).collect(); - crate::playlist_qt::set_user_playlists(&pairs); - pls + let offline = crate::offline_fwd::engine().status().is_offline(); + let playlists: Vec = if offline { + // Preserve, never substitute: an empty vector here is a WIPE, not a + // refresh. Empty on a cold offline start, which is correct — there is + // nothing to preserve yet. + let cached = CACHE + .lock() + .unwrap() + .as_ref() + .map(|d| d.playlists.clone()) + .unwrap_or_default(); + log::info!( + "[qbz-qt] sidebar load: offline — Qobuz fetch skipped, {} cached playlist(s) kept", + cached.len() + ); + cached + } else { + log::debug!("[qbz-qt] sidebar load: fetching user playlists"); + match runtime.core().get_user_playlists().await { + Ok(pls) => { + // Same response, second consumer: the playlist ownership / + // follow snapshot every PlaylistCard's tri-state overlay reads + // (`playlist_qt::set_user_playlists`). This is the earliest point + // in the session where the user's own playlist list exists, and + // it costs no extra request — the alternative was every card + // rendering the "follow a foreign playlist" arm until the Library + // view had been opened at least once. + let pairs: Vec<(u64, u64)> = pls.iter().map(|p| (p.id, p.owner.id)).collect(); + crate::playlist_qt::set_user_playlists(&pairs); + pls + } + Err(e) => { + log::warn!("[qbz-qt] sidebar playlists load failed: {e}"); + Vec::new() + } } - Err(e) => { - log::warn!("[qbz-qt] sidebar playlists load failed: {e}"); - Vec::new() - } - } - .into_iter() - .map(|p| { - let cover_urls = { - let source = [&p.images300, &p.images150, &p.images] - .into_iter() - .flatten() - .find(|v| !v.is_empty()); - let mut out: Vec = Vec::new(); - if let Some(list) = source { - for url in list { - if !url.is_empty() && !out.contains(url) { - out.push(url.clone()); - } - if out.len() == 4 { - break; + .into_iter() + .map(|p| { + let cover_urls = { + let source = [&p.images300, &p.images150, &p.images] + .into_iter() + .flatten() + .find(|v| !v.is_empty()); + let mut out: Vec = Vec::new(); + if let Some(list) = source { + for url in list { + if !url.is_empty() && !out.contains(url) { + out.push(url.clone()); + } + if out.len() == 4 { + break; + } } } + out + }; + SidebarPlaylist { + id: p.id, + name: p.name, + tracks_count: p.tracks_count, + cover_urls, + position: 0, } - out - }; - SidebarPlaylist { - id: p.id, - name: p.name, - tracks_count: p.tracks_count, - cover_urls, - position: 0, - } - }) - .collect(); + }) + .collect() + }; log::debug!("[qbz-qt] sidebar load: playlists fetch settled, reading folders"); let (folders, folder_map, positions, hidden_playlists) = folders_blocking(); log::debug!("[qbz-qt] sidebar load: folders read"); let mut playlists = playlists; for p in &mut playlists { - if let Some(pos) = positions.get(&p.id) { - p.position = *pos; - } + // Assign, don't merge: a preserved cached row carries its previous + // position, and a settings row that has since been deleted must reset + // it to 0 rather than leave the stale value in place. + p.position = positions.get(&p.id).copied().unwrap_or(0); } + // First-class LOCAL playlists (D7). Read AFTER the Qobuz fetch settles but + // independently of it: the fetch can fail, or be gate-refused offline, and + // the local set must still list — for a user without Qobuz it is the ONLY + // set. Hidden locals drop here the way hidden Qobuz playlists do (B3). + // Covers resolve with no network, from the playlist's own tracks. + let locals: Vec = crate::local_playlist_qt::list_blocking() + .into_iter() + .filter(|p| !p.hidden) + .map(|p| SidebarLocal { + covers: crate::local_playlist_qt::resolve_cover_urls_blocking(&p.id, 4), + id: p.id, + name: p.name, + folder_id: p.folder_id, + hidden: p.hidden, + }) + .collect(); + log::debug!("[qbz-qt] sidebar load: {} local playlist(s)", locals.len()); + let (n_playlists, n_folders) = (playlists.len(), folders.len()); *CACHE.lock().unwrap() = Some(SidebarData { playlists, folders, folder_map, hidden_playlists, + locals, }); log::info!("[qbz-qt] sidebar loaded: {n_playlists} playlists, {n_folders} folders"); } @@ -250,6 +339,25 @@ fn folders_blocking() -> (Vec<(String, String)>, HashMap, HashMap Option { + CACHE + .lock() + .ok()? + .as_ref()? + .playlists + .iter() + .find(|p| p.id == id) + .map(|p| p.tracks_count) +} + /// SidebarPlaylist order for the active sort (sidebar.rs comparators: /// `recent`/`playcount` keep API order = newest-first; asc==true is always /// the natural direction). @@ -292,7 +400,30 @@ pub fn rebuild() -> Vec { indent, folder_id: folder_id.to_string(), covers: p.cover_urls.clone(), + is_local: false, }; + let local_entry_for = |p: &SidebarLocal, indent: bool, folder_id: &str| SidebarEntry { + kind: "playlist".into(), + id: p.id.clone(), + name: p.name.clone(), + expanded: false, + count: 0, + indent, + folder_id: folder_id.to_string(), + covers: p.covers.clone(), + is_local: true, + }; + let local_matches = + |p: &SidebarLocal| !searching || p.name.to_lowercase().contains(query.as_str()); + /// Locals sort among THEMSELVES by name, always — they have no + /// track-count or custom-position stat to honour the toolbar's other + /// sorts with, and inventing one would order them arbitrarily. + fn sorted_locals<'a>( + mut list: Vec<&'a SidebarLocal>, + ) -> Vec<&'a SidebarLocal> { + list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + list + } let mut entries: Vec = Vec::new(); for (fid, fname) in &data.folders { @@ -302,8 +433,17 @@ pub fn rebuild() -> Vec { .filter(|p| matches(p)) .filter(|p| !data.hidden_playlists.contains(&p.id)) .collect(); - // While searching, skip folders with no matching playlists. - if searching && members.is_empty() { + // A folder holds BOTH kinds: local membership rides + // `local_playlists.folder_id`, pointing at this same folder. + let local_members: Vec<&SidebarLocal> = sorted_locals( + data.locals + .iter() + .filter(|p| p.folder_id.as_deref() == Some(fid.as_str())) + .filter(|p| local_matches(p)) + .collect(), + ); + // While searching, skip folders with nothing matching in EITHER set. + if searching && members.is_empty() && local_members.is_empty() { continue; } // When searching, force-expand so matches inside are visible. @@ -313,15 +453,19 @@ pub fn rebuild() -> Vec { id: fid.clone(), name: fname.clone(), expanded: is_exp, - count: members.len() as i32, + count: (members.len() + local_members.len()) as i32, indent: false, folder_id: String::new(), covers: Vec::new(), + is_local: false, }); if is_exp { for p in members { entries.push(entry_for(p, true, fid)); } + for p in local_members { + entries.push(local_entry_for(p, true, fid)); + } } } // Root playlists — no folder, or a folder that no longer exists. @@ -335,6 +479,24 @@ pub fn rebuild() -> Vec { entries.push(entry_for(p, false, "")); } } + // LOCAL playlists not in a folder (or in one that no longer exists) — + // root rows AFTER the Qobuz set, name-sorted, honouring the same search. + // Always present, online or offline. + for p in sorted_locals( + data.locals + .iter() + .filter(|p| { + let in_folder = p + .folder_id + .as_ref() + .map(|f| folder_ids.contains(f)) + .unwrap_or(false); + !in_folder && local_matches(p) + }) + .collect(), + ) { + entries.push(local_entry_for(p, false, "")); + } entries } @@ -373,3 +535,184 @@ pub fn toggle_folder(id: &str) -> bool { true } } + +// --------------------------------------------------------------------------- +// Cache accessors for the Playlist Manager seam (contract D10 / §5.2 / §5.6) +// +// All four answer from the SESSION CACHE only — no DB, no network — so they +// are callable from the Qt thread and they behave identically offline. Each +// returns `None` / an empty vector when the sidebar has never loaded, and each +// caller has a documented fallback for that state rather than treating it as +// an error. +// --------------------------------------------------------------------------- + +/// One row of the mini-rail folder flyout (`QbzShell.sidebarFolderPopupJson`). +#[derive(Clone, Serialize)] +pub struct FolderPopupRow { + pub id: String, + pub name: String, + #[serde(rename = "isLocal")] + pub is_local: bool, +} + +/// The playlists inside `folder_id`, in the order the expanded tree would show +/// them: Qobuz members under the active sort with hidden ones dropped, then +/// that folder's locals appended name-sorted (mirrors `sidebar.rs:517-546`). +/// +/// The search filter is deliberately NOT applied — the flyout is opened from a +/// rail with no search field, and the reference's own header count comes from +/// the entry row, which is computed post-search. Here `count = rows.len()`, so +/// the header and the list can never disagree. +/// +/// Consumed by `crate::sidebar_open_folder_popup` (block 6), which serialises +/// the rows into `QbzShell.sidebarFolderPopupJson`. +pub fn folder_popup_rows(folder_id: &str) -> Vec { + let Some(data) = CACHE.lock().unwrap().clone() else { + return Vec::new(); + }; + let mut rows: Vec = sort_playlists(&data.playlists) + .into_iter() + .filter(|p| data.folder_map.get(&p.id).map(|f| f == folder_id).unwrap_or(false)) + .filter(|p| !data.hidden_playlists.contains(&p.id)) + .map(|p| FolderPopupRow { + id: p.id.to_string(), + name: p.name, + is_local: false, + }) + .collect(); + let mut locals: Vec<&SidebarLocal> = data + .locals + .iter() + .filter(|p| p.folder_id.as_deref() == Some(folder_id)) + .collect(); + locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + rows.extend(locals.into_iter().map(|p| FolderPopupRow { + id: p.id.clone(), + name: p.name.clone(), + is_local: true, + })); + rows +} + +/// A folder's display name from the session cache (`None` when the sidebar has +/// never loaded, or when the id names a HIDDEN folder — `SidebarData.folders` +/// is the already-filtered visible set, D11). +/// +/// Only `crate::sidebar_open_folder_popup` calls it, to fill the `folderName` +/// key of the §4.7 document. The FLYOUT does not read that key — it takes the +/// name synchronously from the clicked entry, because the document lands a +/// later event-loop turn — so a `None` here degrades to `""` in a field nobody +/// renders rather than to a blank header. +pub fn folder_name(folder_id: &str) -> Option { + CACHE + .lock() + .ok()? + .as_ref()? + .folders + .iter() + .find(|(id, _)| id == folder_id) + .map(|(_, name)| name.clone()) +} + +/// Insert an OPTIMISTIC Qobuz playlist row at the top of the sidebar cache — +/// the port of `crates/qbz/src/sidebar.rs:437-454`. +/// +/// The playlist importer creates playlists through the API, and the +/// user-playlists endpoint lags the write by seconds: without this the newly +/// imported playlist is simply absent from the tree until the bounded-retry +/// reload catches up, and the user's only evidence it exists is a toast. The +/// row carries no covers (the collage fills on the next real load) and is a +/// no-op when the id is already cached, so the retry loop can call it again +/// after a failed reload without duplicating. +/// +/// Cache-only, like every accessor in this block: the caller publishes +/// (`crate::publish_sidebar()`), which keeps this correct offline and free of a +/// round trip. +pub fn insert_qobuz_entry(id: u64, name: &str, tracks_count: u32) { + let mut guard = CACHE.lock().unwrap(); + // `None` = the sidebar has never loaded this session. The reference's cache + // is not an Option and always has somewhere to insert; synthesising an + // empty one here is the same behaviour — there is nothing yet for the + // single row to hide, and the retry loop's `load()` replaces it wholesale. + let data = guard.get_or_insert_with(SidebarData::default); + if data.playlists.iter().any(|p| p.id == id) { + return; + } + data.playlists.insert( + 0, + SidebarPlaylist { + id, + name: name.to_string(), + tracks_count, + cover_urls: Vec::new(), + position: 0, + }, + ); +} + +/// Patch a playlist's folder membership in the sidebar cache — `""` = root. +/// +/// The Playlist Manager's move-to-folder calls this and then +/// `crate::publish_sidebar()`, instead of a reload: the reload verb that works +/// offline still re-reads the whole DB, and the network one is a no-op offline +/// (D10). Both id kinds are handled — a Qobuz id patches `folder_map`, a +/// `local:` id patches the local row's own `folder_id` column mirror. +pub fn move_playlist_optimistic(id: &str, folder_id: &str) { + let mut guard = CACHE.lock().unwrap(); + let Some(data) = guard.as_mut() else { + return; + }; + let target = (!folder_id.is_empty()).then(|| folder_id.to_string()); + if crate::local_playlist_qt::is_local_id(id) { + if let Some(p) = data.locals.iter_mut().find(|p| p.id == id) { + p.folder_id = target; + } + } else if let Ok(pid) = id.parse::() { + match target { + Some(fid) => { + data.folder_map.insert(pid, fid); + } + None => { + data.folder_map.remove(&pid); + } + } + } +} + +/// A Qobuz playlist's name from the session cache. +/// +/// The offline-synthesis fallback name for the Playlist Manager (§5.2): a +/// playlist that only exists offline as "some id with local sidecar rows" is +/// named from whatever the sidebar loaded while online, else from +/// `"Playlist ({} local)"`. +/// +/// It CANNOT supply a description — `SidebarPlaylist` has no such field, unlike +/// the reference's `sidebar::playlist_name_desc` — so the playlist editor +/// resolves descriptions elsewhere and never through this. +pub fn playlist_name(id: u64) -> Option { + CACHE + .lock() + .ok()? + .as_ref()? + .playlists + .iter() + .find(|p| p.id == id) + .map(|p| p.name.clone()) +} + +/// A LOCAL playlist's already-resolved cover urls from the session cache. +/// +/// `resolve_cover_urls_blocking` opens the per-user DB per call, so the manager +/// loader asks here FIRST and only falls back to the repo for the locals the +/// cache cannot answer (§5.6). `None` means "not in the cache" — an empty +/// `Some(vec![])` means "cached, and it genuinely has no covers". +pub fn local_covers(id: &str) -> Option> { + CACHE + .lock() + .ok()? + .as_ref()? + .locals + .iter() + .find(|p| p.id == id) + .map(|p| p.covers.clone()) +} diff --git a/crates/qbz-qt/src/sleep_timer_qt.rs b/crates/qbz-qt/src/sleep_timer_qt.rs new file mode 100644 index 000000000..0d642ac74 --- /dev/null +++ b/crates/qbz-qt/src/sleep_timer_qt.rs @@ -0,0 +1,90 @@ +//! Sleep timer (queue footer) — port of `crates/qbz/src/sleep_timer.rs`. +//! +//! A single armed deadline that PAUSES playback when it elapses, plus a 1 Hz +//! countdown pushed onto the queue bridge. The deadline is a monotonic +//! `Instant`, not a wall-clock timestamp, so a laptop suspend or a clock jump +//! cannot fire it early or strand it. +//! +//! Nothing is persisted: an armed timer dies with the process. That is the +//! reference's deliberate choice — a timer that survived a restart would stop +//! playback long after the user forgot about it. +//! +//! A process-wide generation counter invalidates an in-flight task on cancel or +//! re-arm, so a superseded task can never pause playback behind the live one's +//! back. `set` bumps it and keeps its own value; the task exits the moment the +//! two stop matching. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use cxx_qt_lib::QString; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; + +/// Monotonically increasing token. Each `set`/`cancel` bumps it; a running task +/// keeps the value it was spawned with and exits as soon as it differs. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +const MIN_MINUTES: i32 = 1; +const MAX_MINUTES: i32 = 24 * 60; // 1440 + +fn push_state(active: bool, remaining: String) { + crate::queue_bridge::ui(move |mut b| { + b.as_mut().set_sleep_active(active); + b.as_mut().set_sleep_remaining(QString::from(remaining.as_str())); + }); +} + +/// Arm (or re-arm) the sleep timer for `minutes`, clamped to [1, 1440]. +/// Replaces any running timer. At expiry it pauses playback (only if something +/// is playing) and returns to idle. +pub fn set(runtime: Arc>, minutes: i32) { + if minutes <= 0 { + return; + } + let minutes = minutes.clamp(MIN_MINUTES, MAX_MINUTES); + // Bump first: this task owns `my_gen` until the next set/cancel. + let my_gen = GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1); + + crate::spawn(async move { + let deadline = Instant::now() + Duration::from_secs((minutes as u64) * 60); + // Immediate feedback: armed + the initial countdown, before the first + // tick lands. + push_state( + true, + qbz_text_utils::sleep::format_sleep_remaining((minutes as i64) * 60), + ); + + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + loop { + ticker.tick().await; // fires immediately, then every 1s + if GENERATION.load(Ordering::SeqCst) != my_gen { + return; // cancelled or superseded + } + let now = Instant::now(); + if now >= deadline { + if runtime.core().get_playback_state().is_playing { + if let Err(e) = runtime.core().pause() { + log::warn!("[qbz-qt] sleep-timer: pause on expiry failed: {e}"); + } + // The transport flag the bar reads is pushed by the poll + // loop's own play/pause edge; nothing to mirror here. + } + push_state(false, String::new()); + return; + } + let remaining = (deadline - now).as_secs() as i64; + push_state( + true, + qbz_text_utils::sleep::format_sleep_remaining(remaining), + ); + } + }); +} + +/// Cancel any armed timer and return to idle. +pub fn cancel() { + GENERATION.fetch_add(1, Ordering::SeqCst); // invalidate the running task + push_state(false, String::new()); +} diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 8a1deb870..d3973e40f 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -3880,6 +3880,16 @@ msgstr "Playlist konnte nicht kopiert werden" msgid "Playlist has no tracks to copy" msgstr "Die Playlist enthält keine Titel zum Kopieren" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} Titel hinzugefügt" +msgstr[1] "{} Titel hinzugefügt" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} Titel zu {} hinzugefügt" +msgstr[1] "{} Titel zu {} hinzugefügt" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "{} Titel zur Warteschlange hinzugefügt" @@ -4889,3 +4899,44 @@ msgid "Removed {} album from blacklist" msgid_plural "Removed {} albums from blacklist" msgstr[0] "{} Album aus der Sperrliste entfernt" msgstr[1] "{} Alben aus der Sperrliste entfernt" + +msgid "Playlist imported" +msgstr "Playlist importiert" + +msgid "Playlist import failed" +msgstr "Playlist-Import fehlgeschlagen" + +msgid "Not logged in to Qobuz" +msgstr "Nicht bei Qobuz angemeldet" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Ordner löschen" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Ordner „{}“ löschen? Seine Playlists werden wieder in die oberste Ebene verschoben." + +msgid "Choose a folder image" +msgstr "Wählen Sie ein Ordnerbild" + +msgid "Images" +msgstr "Bilder" + +msgid "Failed to create folder" +msgstr "Ordner konnte nicht erstellt werden" + +msgid "Failed to save folder" +msgstr "Ordner konnte nicht gespeichert werden" + +msgid "Failed to delete folder" +msgstr "Ordner konnte nicht gelöscht werden" + +msgid "Failed to rename playlist" +msgstr "Playlist konnte nicht umbenannt werden" + +msgid "Failed to delete playlist" +msgstr "Playlist konnte nicht gelöscht werden" + +msgid "Couldn't create the playlist" +msgstr "Playlist konnte nicht erstellt werden" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index 1fd1270a4..356b47715 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -53,6 +53,16 @@ msgstr "Failed to copy playlist" msgid "Playlist has no tracks to copy" msgstr "Playlist has no tracks to copy" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Added {} track" +msgstr[1] "Added {} tracks" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Added {} track to {}" +msgstr[1] "Added {} tracks to {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "Added {} track to queue" @@ -685,3 +695,44 @@ msgid "Removed {} album from blacklist" msgid_plural "Removed {} albums from blacklist" msgstr[0] "Removed {} album from blacklist" msgstr[1] "Removed {} albums from blacklist" + +msgid "Playlist imported" +msgstr "Playlist imported" + +msgid "Playlist import failed" +msgstr "Playlist import failed" + +msgid "Not logged in to Qobuz" +msgstr "Not logged in to Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Delete folder" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Delete the folder “{}”? Its playlists move back to the root." + +msgid "Choose a folder image" +msgstr "Choose a folder image" + +msgid "Images" +msgstr "Images" + +msgid "Failed to create folder" +msgstr "Failed to create folder" + +msgid "Failed to save folder" +msgstr "Failed to save folder" + +msgid "Failed to delete folder" +msgstr "Failed to delete folder" + +msgid "Failed to rename playlist" +msgstr "Failed to rename playlist" + +msgid "Failed to delete playlist" +msgstr "Failed to delete playlist" + +msgid "Couldn't create the playlist" +msgstr "Couldn't create the playlist" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index d551b7f17..89f22861d 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -3880,6 +3880,16 @@ msgstr "No se pudo copiar la lista de reproducción" msgid "Playlist has no tracks to copy" msgstr "La lista de reproducción no tiene pistas para copiar" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Se agregó {} pista" +msgstr[1] "Se agregaron {} pistas" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Se agregó {} pista a {}" +msgstr[1] "Se agregaron {} pistas a {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "Se agregó {} pista a la cola" @@ -4897,3 +4907,44 @@ msgstr "Se quitaron {} tracks de favoritos" msgid "Removing {} tracks from favorites…" msgstr "Quitando {} tracks de favoritos…" + +msgid "Playlist imported" +msgstr "Lista de reproducción importada" + +msgid "Playlist import failed" +msgstr "No se pudo importar la lista de reproducción" + +msgid "Not logged in to Qobuz" +msgstr "No has iniciado sesión en Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Eliminar carpeta" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "¿Eliminar la carpeta “{}”? Sus playlists vuelven a la raíz." + +msgid "Choose a folder image" +msgstr "Elige una imagen para la carpeta" + +msgid "Images" +msgstr "Imágenes" + +msgid "Failed to create folder" +msgstr "No se pudo crear la carpeta" + +msgid "Failed to save folder" +msgstr "No se pudo guardar la carpeta" + +msgid "Failed to delete folder" +msgstr "No se pudo eliminar la carpeta" + +msgid "Failed to rename playlist" +msgstr "No se pudo renombrar la playlist" + +msgid "Failed to delete playlist" +msgstr "No se pudo eliminar la playlist" + +msgid "Couldn't create the playlist" +msgstr "No se pudo crear la playlist" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index 7f2644584..696dbe61a 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -3880,6 +3880,16 @@ msgstr "Échec de la copie de la playlist" msgid "Playlist has no tracks to copy" msgstr "La playlist n'a aucun titre à copier" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} titre ajouté" +msgstr[1] "{} titres ajoutés" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} titre ajouté à {}" +msgstr[1] "{} titres ajoutés à {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "{} titre ajouté à la file d'attente" @@ -4889,3 +4899,44 @@ msgid "Removed {} album from blacklist" msgid_plural "Removed {} albums from blacklist" msgstr[0] "{} album retiré de la liste noire" msgstr[1] "{} albums retirés de la liste noire" + +msgid "Playlist imported" +msgstr "Playlist importée" + +msgid "Playlist import failed" +msgstr "Échec de l'importation de la playlist" + +msgid "Not logged in to Qobuz" +msgstr "Vous n'êtes pas connecté à Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Supprimer le dossier" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Supprimer le dossier « {} » ? Ses playlists retournent à la racine." + +msgid "Choose a folder image" +msgstr "Choisissez une image de dossier" + +msgid "Images" +msgstr "Images" + +msgid "Failed to create folder" +msgstr "Échec de la création du dossier" + +msgid "Failed to save folder" +msgstr "Échec de l’enregistrement du dossier" + +msgid "Failed to delete folder" +msgstr "Échec de la suppression du dossier" + +msgid "Failed to rename playlist" +msgstr "Échec du renommage de la playlist" + +msgid "Failed to delete playlist" +msgstr "Échec de la suppression de la playlist" + +msgid "Couldn't create the playlist" +msgstr "Impossible de créer la playlist" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 30c966650..4d4a79f27 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -3721,6 +3721,14 @@ msgstr "プレイリストのコピーに失敗しました" msgid "Playlist has no tracks to copy" msgstr "プレイリストにコピーするトラックがありません" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{}トラックを追加しました" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{}トラックを{}に追加しました" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "{}トラックをキューに追加しました" @@ -4876,3 +4884,44 @@ msgstr "アルバムのブラックリストのクリアに失敗しました" msgid "Removed {} album from blacklist" msgid_plural "Removed {} albums from blacklist" msgstr[0] "ブラックリストから{}枚のアルバムを削除しました" + +msgid "Playlist imported" +msgstr "プレイリストをインポートしました" + +msgid "Playlist import failed" +msgstr "プレイリストのインポートに失敗しました" + +msgid "Not logged in to Qobuz" +msgstr "Qobuzにログインしていません" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "フォルダを削除" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "フォルダ「{}」を削除しますか?プレイリストはルートに戻ります。" + +msgid "Choose a folder image" +msgstr "フォルダ画像を選択" + +msgid "Images" +msgstr "画像" + +msgid "Failed to create folder" +msgstr "フォルダの作成に失敗しました" + +msgid "Failed to save folder" +msgstr "フォルダの保存に失敗しました" + +msgid "Failed to delete folder" +msgstr "フォルダの削除に失敗しました" + +msgid "Failed to rename playlist" +msgstr "プレイリストの名前変更に失敗しました" + +msgid "Failed to delete playlist" +msgstr "プレイリストの削除に失敗しました" + +msgid "Couldn't create the playlist" +msgstr "プレイリストを作成できませんでした" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index e412a71b8..06c655a22 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -3869,6 +3869,16 @@ msgstr "Kopiëren van afspeellijst mislukt" msgid "Playlist has no tracks to copy" msgstr "Afspeellijst heeft geen nummers om te kopiëren" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} nummer toegevoegd" +msgstr[1] "{} nummers toegevoegd" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} nummer toegevoegd aan {}" +msgstr[1] "{} nummers toegevoegd aan {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "{} nummer aan wachtrij toegevoegd" @@ -4879,3 +4889,44 @@ msgstr "{} nummers verwijderd uit favorieten" msgid "Removing {} tracks from favorites…" msgstr "{} nummers worden verwijderd uit favorieten…" + +msgid "Playlist imported" +msgstr "Afspeellijst geïmporteerd" + +msgid "Playlist import failed" +msgstr "Importeren van afspeellijst mislukt" + +msgid "Not logged in to Qobuz" +msgstr "Niet ingelogd bij Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Map verwijderen" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Map “{}” verwijderen? De afspeellijsten gaan terug naar het hoofdniveau." + +msgid "Choose a folder image" +msgstr "Kies een mapafbeelding" + +msgid "Images" +msgstr "Afbeeldingen" + +msgid "Failed to create folder" +msgstr "Aanmaken van map mislukt" + +msgid "Failed to save folder" +msgstr "Opslaan van map mislukt" + +msgid "Failed to delete folder" +msgstr "Verwijderen van map mislukt" + +msgid "Failed to rename playlist" +msgstr "Hernoemen van afspeellijst mislukt" + +msgid "Failed to delete playlist" +msgstr "Verwijderen van afspeellijst mislukt" + +msgid "Couldn't create the playlist" +msgstr "Kon de afspeellijst niet aanmaken" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 6d08b2f1c..2e16359d2 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -3880,6 +3880,16 @@ msgstr "Falha ao copiar a playlist" msgid "Playlist has no tracks to copy" msgstr "A playlist não tem faixas para copiar" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "{} faixa adicionada" +msgstr[1] "{} faixas adicionadas" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "{} faixa adicionada a {}" +msgstr[1] "{} faixas adicionadas a {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "{} faixa adicionada à fila" @@ -4889,3 +4899,44 @@ msgid "Removed {} album from blacklist" msgid_plural "Removed {} albums from blacklist" msgstr[0] "{} álbum removido da lista negra" msgstr[1] "{} álbuns removidos da lista negra" + +msgid "Playlist imported" +msgstr "Playlist importada" + +msgid "Playlist import failed" +msgstr "Falha ao importar a playlist" + +msgid "Not logged in to Qobuz" +msgstr "Você não está conectado ao Qobuz" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Excluir pasta" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Excluir a pasta “{}”? Suas playlists voltam para a raiz." + +msgid "Choose a folder image" +msgstr "Escolha uma imagem para a pasta" + +msgid "Images" +msgstr "Imagens" + +msgid "Failed to create folder" +msgstr "Falha ao criar a pasta" + +msgid "Failed to save folder" +msgstr "Falha ao salvar a pasta" + +msgid "Failed to delete folder" +msgstr "Falha ao excluir a pasta" + +msgid "Failed to rename playlist" +msgstr "Falha ao renomear a playlist" + +msgid "Failed to delete playlist" +msgstr "Falha ao excluir a playlist" + +msgid "Couldn't create the playlist" +msgstr "Não foi possível criar a playlist" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index 0816084e6..9394e3b2a 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -3745,6 +3745,18 @@ msgstr "Не удалось скопировать плейлист" msgid "Playlist has no tracks to copy" msgstr "В плейлисте нет треков для копирования" +msgid "Added {} track" +msgid_plural "Added {} tracks" +msgstr[0] "Добавлен {} трек" +msgstr[1] "Добавлено {} трека" +msgstr[2] "Добавлено {} треков" + +msgid "Added {} track to {}" +msgid_plural "Added {} tracks to {}" +msgstr[0] "Добавлен {} трек в {}" +msgstr[1] "Добавлено {} трека в {}" +msgstr[2] "Добавлено {} треков в {}" + msgid "Added {} track to queue" msgid_plural "Added {} tracks to queue" msgstr[0] "Добавлен {} трек в очередь" @@ -4904,3 +4916,44 @@ msgid_plural "Removed {} albums from blacklist" msgstr[0] "Удалён {} альбом из чёрного списка" msgstr[1] "Удалено {} альбома из чёрного списка" msgstr[2] "Удалено {} альбомов из чёрного списка" + +msgid "Playlist imported" +msgstr "Плейлист импортирован" + +msgid "Playlist import failed" +msgstr "Не удалось импортировать плейлист" + +msgid "Not logged in to Qobuz" +msgstr "Вход в Qobuz не выполнен" + +# Qt playlist manager — folder modals (contract block 2) + +msgid "Delete folder" +msgstr "Удалить папку" + +msgid "Delete the folder “{}”? Its playlists move back to the root." +msgstr "Удалить папку «{}»? Её плейлисты вернутся в корень." + +msgid "Choose a folder image" +msgstr "Выберите изображение папки" + +msgid "Images" +msgstr "Изображения" + +msgid "Failed to create folder" +msgstr "Не удалось создать папку" + +msgid "Failed to save folder" +msgstr "Не удалось сохранить папку" + +msgid "Failed to delete folder" +msgstr "Не удалось удалить папку" + +msgid "Failed to rename playlist" +msgstr "Не удалось переименовать плейлист" + +msgid "Failed to delete playlist" +msgstr "Не удалось удалить плейлист" + +msgid "Couldn't create the playlist" +msgstr "Не удалось создать плейлист"