feat(qt): offline downloads + multi-select on the album page

Two AlbumView parity features sharing the row machinery:

OFFLINE CACHE (the menu rows and the per-track state the report was
missing):
- offline_qt.rs activates the per-user OfflineCacheState on login /
  session-restore / offline-session (one fan-out point in
  bind_per_user_stores) and tears it down on logout — same on-disk
  store as the other frontends, so copies made in Slint/Tauri show here
  and play here.
- playback_qt's play funnel now passes the active cache + the row sink
  to play_track_resolved (it passed None, None: the offline tier was
  unreachable even for downloaded tracks).
- offline_cache_qt.rs ports the action set: cache_track / cache_tracks /
  cache_album / redownload_album / remove_cached / refresh_cached, with
  toasts and a bridge-signal row sink (trackCacheStatusChanged).
- TrackRow's download column is live: cloud / spinner / accent check /
  red alert per status (0/1/2/3/4), click map ready->remove else
  download, and the context menu's offline block (Make available
  offline / Refresh / Remove). Status is seeded per row from the
  session cached-id set (album/artist/label/for-you docs) and pushed
  live over the signal.
- Album ⋯ menu: "Make available offline" swaps to "Refresh offline
  copy" on the live all-cached aggregate.

MULTI-SELECT: the toolbar toggle is live; selection state stays in QML
(LibraryView precedent), rows check off in select mode, the bulk bar
carries the reference's full inventory — select-all, play-next,
play-later, queue, add-to-playlist, add-to-mixtape/collection, Add to
Library, make-offline, clear — and the Ctrl+A/Escape router duck-types
the view's selectAll/exitMultiSelectMode like the other tabbed views.

Still out (documented in place): the Offline Manager view, the row
padlock on unlock, per-disc actions, playlist-level cache button.

km
This commit is contained in:
vicrodh committed 2026-08-11 01:38:18 -06:00
1 parent 704fd7e60a
commit 83b8bd8890
14 files changed
+980 -53

No files matched your search

+77 -9
View File
@@ -141,7 +141,26 @@ Rectangle {
// offlineCache no per-track cache bridge (the download cell is a stub)
readonly property bool hasRadioSeam: false
readonly property bool hasSonglinkSeam: false
readonly property bool hasOfflineCacheSeam: false
// Live since the offline port: catalog (Qobuz) rows only — local/Plex
// rows have no offline tier to download into (TrackContextMenu.slint
// gates on the same qobuz-actions rule).
readonly property bool hasOfflineCacheSeam: root.catalogRow && !root.localSourceRow
// --- Offline cache status (TrackRow.slint:639-702) --------------------
// 0 none · 1 queued · 2 downloading · 3 ready · 4 failed. Seeded from
// the document row; live updates arrive on QbzShell's
// trackCacheStatusChanged and are adopted only for THIS row's id.
property int cacheStatus: root.item.cacheStatus !== undefined ? root.item.cacheStatus : 0
function setCacheStatus(status) {
root.cacheStatus = status
}
Connections {
target: QbzShell
enabled: root.hasOfflineCacheSeam
function onTrackCacheStatusChanged(trackId, status, progress) {
if (trackId === (root.item.id || "")) root.cacheStatus = status
}
}
// --- Add to playlist -------------------------------------------------
// LIVE since QbzPlaylistPicker landed. It is NOT a flat constant, because
@@ -715,21 +734,58 @@ Rectangle {
onClicked: root.toggleFavorite()
}
}
// Download slot (showDownload arm): inert glyph stub or reserved
// spacer (the offline-cache column — not ported, POC-NOTE; the
// Slint reserves the slot so the grid stays aligned).
// Download slot (showDownload arm) — the offline-cache column,
// TrackRow.slint:639-702: cloud when absent, spinner while queued /
// downloading, accent check when ready, red alert on failure. Click
// map: ready -> remove, anything else -> (re)download. The slot is
// reserved wherever showDownload is on so the grid stays aligned;
// the glyph itself also shows when a surface that hides the column
// has a row with a live copy (`cacheStatus > 0`).
Item {
visible: root.showDownload
visible: root.showDownload || (root.hasOfflineCacheSeam && root.cacheStatus > 0)
width: cols.colDownload
height: cols.colDownload
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
visible: root.downloadGlyph
visible: root.cacheStatus === 0
anchors.centerIn: parent
name: "cloud-download"
width: 16
height: 16
tintName: "muted"
tintName: dlArea.containsMouse ? root.playGlyphTint : "muted"
}
QbzSpinner {
visible: root.cacheStatus === 1 || root.cacheStatus === 2
anchors.centerIn: parent
size: 16
}
QbzIcon {
visible: root.cacheStatus === 3
anchors.centerIn: parent
name: "circle-check-big"
width: 16
height: 16
tintName: "accent"
}
QbzIcon {
visible: root.cacheStatus === 4
anchors.centerIn: parent
name: "triangle-alert"
width: 16
height: 16
tintName: "favorite"
}
MouseArea {
id: dlArea
anchors.fill: parent
enabled: root.hasOfflineCacheSeam && !root.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.cacheStatus === 3) QbzPlayer.uncacheTrack(root.item.id)
else QbzPlayer.cacheTrack(root.item.id)
}
}
}
// ⋯ menu (showMenu arm).
@@ -796,8 +852,17 @@ Rectangle {
m.push({ "label": t("Share Qobuz link", r), "icon": "link", "action": "share-qobuz" })
if (root.hasSonglinkSeam)
m.push({ "label": t("Share Song.link", r), "icon": "link", "action": "share-songlink" })
if (root.hasOfflineCacheSeam)
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
if (root.hasOfflineCacheSeam) {
// TrackContextMenu.slint:174-199 — the offline block tracks the
// row's live status: not-cached rows get the download arm, ready
// rows get refresh + remove.
if (root.cacheStatus === 3) {
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
} else {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
}
}
if (root.menuShowGoTo && root.item.albumId)
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
if (root.menuShowGoTo && root.item.artistId)
@@ -834,6 +899,9 @@ Rectangle {
QbzPlaylistPicker.openForTrack(root.item.id)
}
else if (a === "remove") root.removeRequested()
else if (a === "cache") QbzPlayer.cacheTrack(root.item.id)
else if (a === "uncache") QbzPlayer.uncacheTrack(root.item.id)
else if (a === "recache") QbzPlayer.recacheTrack(root.item.id)
else if (a === "share-qobuz") root.copyToClipboard(
"https://open.qobuz.com/track/" + (root.item.id || ""))
else if (a === "track-info") root.openTrackInfo()
+155 -13
View File
@@ -7,8 +7,9 @@
// heart), label/awards sidebar, and the two bottom carousels ("From the
// same artist", "Listening suggestions").
//
// POC-NOTEs: multi-select + bulk bar and the offline download column —
// out of scope (visible stubs are inert).
// POC-NOTEs: the offline download column (TrackRow status glyphs + ⋯ menu
// rows) and multi-select + bulk bar are LIVE. Still out: DiscHeaderMenu
// per-disc actions.
//
// Header atmosphere (AlbumPageView.slint:161-189, 221-257): the
// artwork-tinted band IS wired now, through the shared
@@ -201,6 +202,103 @@ Rectangle {
loadedAlbumId = id
localToggles = ({})
dispatchedCovers = ({})
// A new album carries no selection over (Slint resets multi-select
// on every album load/reset, album.rs:692-694).
setMultiSelect(false)
// Re-seed the live offline map from the new document (track rows
// carry their own seeded cacheStatus; this map tracks the LIVE one).
var seed = ({})
var ts = album.tracks || []
for (var i = 0; i < ts.length; i++)
seed[ts[i].id] = ts[i].cacheStatus || 0
cacheStates = seed
}
// --- Multi-select (AlbumPageView.slint:736-807) -------------------------
// The selection lives here in QML (LibraryView precedent): select-all and
// clear never reach Rust, everything else goes down as a JSON id array
// through QbzAlbum.albumBulkAction.
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
}
/// Selected ids in VISIBLE order — never Object.keys (integer-like keys
/// iterate in NUMERIC order, not the user's).
function selectedIdsInOrder() {
var rows = root.visibleTracks
var out = []
for (var i = 0; i < rows.length; i++)
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
return out
}
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.visibleTracks
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzAlbum.albumBulkAction(header.id, JSON.stringify(ids), action)
// The Slint keeps the selection while a picker is still open (a
// failed write is retried from the same modal) and clears after
// everything else.
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({})
}
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
if (root.multiSelect) root.setMultiSelect(false)
}
// --- Offline cache live state ------------------------------------------
// Row glyphs own their live status (TrackRow's Connections). This map
// exists for ONE consumer: the ⋯ menu's "Make available offline" /
// "Refresh offline copy" swap, which needs the all-rows-ready aggregate
// (Slint recomputes album-fully-cached on every row flip,
// main.rs:2426-2438).
property var cacheStates: ({})
readonly property bool fullyCachedLive: {
var ts = album.tracks || []
if (ts.length === 0)
return false
var m = cacheStates
for (var i = 0; i < ts.length; i++) {
var st = m[ts[i].id]
if (st === undefined) st = ts[i].cacheStatus || 0
if (st !== 3)
return false
}
return true
}
Connections {
target: QbzShell
function onTrackCacheStatusChanged(trackId, status, progress) {
if (!(trackId in root.cacheStates))
return
var m = root.cacheStates
m[trackId] = status
root.cacheStates = Object.assign({}, m)
}
}
// Already-requested artwork keys. The document is now published in FOUR
@@ -856,24 +954,30 @@ Rectangle {
}
}
}
// Multi-select toggle — no seam on this bridge, so it
// is DIMMED and inert-by-declaration instead of
// rendering as a live button that swallows the click.
// Multi-select toggle (AlbumPageView.slint:752-787):
// accent border + tint while active; leaving the mode
// clears the selection.
Rectangle {
width: 30
height: 30
radius: 6
opacity: 0.4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
color: selectToggleArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
QbzIcon {
name: "square-check-big"
width: 15
height: 15
anchors.centerIn: parent
tintName: "secondary"
tintName: root.multiSelect ? "accent" : "secondary"
}
MouseArea {
id: selectToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.setMultiSelect(!root.multiSelect)
}
}
}
@@ -983,6 +1087,9 @@ Rectangle {
qualityStyle: "text"
showDownload: true
downloadGlyph: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
menuShowLater: false
menuShowGoTo: false
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
@@ -1169,6 +1276,34 @@ Rectangle {
}
}
// Multi-select bulk bar (primitives/MultiSelectBar.slint port is
// controls/QbzMultiSelectBar.qml). Declared AFTER the scrolling body:
// declaration order IS z-order, and this bar overlays the content top.
QbzMultiSelectBar {
id: bulkBar
visible: root.multiSelect && !root.primaryLoading
anchors.top: parent.top
anchors.topMargin: 10
anchors.left: parent.left
anchors.leftMargin: 32
anchors.right: parent.right
anchors.rightMargin: 32
selectedCount: root.selectedCount
// The reference's full AlbumView inventory (AlbumPageView.slint
// :792-807), make-offline included.
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-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
// Thin auto-hiding scrollbar (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
@@ -1259,10 +1394,10 @@ Rectangle {
// Popup reparents to the window overlay).
AlbumInfoModal { id: albumInfo }
// Album ⋯ menu (AlbumContextMenu subset — the offline rows stay out:
// Qt has no album offline-cache seam. "Play later" as a distinct
// block-tail insert has no mode in playback_qt::enqueue_album either
// (next / append only), so "Add to queue" covers the append arm).
// Album ⋯ menu (AlbumContextMenu subset — "Play later" as a distinct
// block-tail insert has no mode in playback_qt::enqueue_album (next /
// append only), so "Add to queue" covers the append arm. The offline
// row swaps on the live all-cached aggregate).
QbzContextMenu {
id: albumMenu
menuWidth: 196
@@ -1277,6 +1412,9 @@ Rectangle {
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
{ "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" },
{ "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" },
// AlbumContextMenu.slint:137-152 — the offline row swaps
// on the all-cached aggregate (live from row flips).
{ "label": root.fullyCachedLive ? QbzSession.tr("Refresh offline copy", QbzSession.trRev) : QbzSession.tr("Make available offline", QbzSession.trRev), "icon": root.fullyCachedLive ? "refresh-cw" : "cloud-download", "action": root.fullyCachedLive ? "recache-album" : "cache-album" },
]
delegate: Rectangle {
required property var modelData
@@ -1331,6 +1469,10 @@ Rectangle {
QbzAlbum.shareQobuzLink(header.id)
} else if (a === "share-albumlink") {
QbzAlbum.shareAlbumLink(header.id)
} else if (a === "cache-album") {
QbzAlbum.albumCacheOffline(header.id)
} else if (a === "recache-album") {
QbzAlbum.albumRefreshOffline(header.id)
}
}
}
+21
View File
@@ -89,6 +89,16 @@ pub mod qbz_album_bridge {
/// review) document (album_info_qt.rs).
#[qinvokable]
fn open_album_info(self: Pin<&mut QbzAlbum>, album_id: QString);
/// ⋯ menu offline rows (offline_cache_qt.rs): download the whole
/// album / re-download its copies.
#[qinvokable]
fn album_cache_offline(self: Pin<&mut QbzAlbum>, album_id: QString);
#[qinvokable]
fn album_refresh_offline(self: Pin<&mut QbzAlbum>, album_id: QString);
/// Multi-select bulk bar (album_qt::bulk_action): ids in visible
/// order, action id from the bar's vocabulary.
#[qinvokable]
fn album_bulk_action(self: Pin<&mut QbzAlbum>, album_id: QString, ids_json: QString, action: QString);
}
impl cxx_qt::Threading for QbzAlbum {}
@@ -176,4 +186,15 @@ impl qbz_album_bridge::QbzAlbum {
pub fn open_album_info(self: Pin<&mut Self>, album_id: QString) {
crate::album_info_qt::open(album_id.to_string());
}
pub fn album_cache_offline(self: Pin<&mut Self>, album_id: QString) {
crate::offline_cache_qt::cache_album(album_id.to_string());
}
pub fn album_refresh_offline(self: Pin<&mut Self>, album_id: QString) {
// The menu's "Refresh offline copy" re-downloads the WHOLE album,
// not just the failed rows (Slint main.rs:12204).
crate::offline_cache_qt::redownload_album(album_id.to_string(), false);
}
pub fn album_bulk_action(self: Pin<&mut Self>, album_id: QString, ids_json: QString, action: QString) {
crate::album_qt::bulk_action(album_id.to_string(), ids_json.to_string(), action.to_string());
}
}
+150
View File
@@ -96,6 +96,11 @@ pub struct TrackRow {
/// drew empty — including on tracks the user had favourited.
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// Offline-cache status at build time (0 none / 3 ready — in-flight
/// states only ever arrive live, via `trackCacheStatusChanged`). Seeded
/// from the session cached-id set, Slint `album.rs:583`.
#[serde(rename = "cacheStatus")]
pub cache_status: i32,
}
/// `Deserialize` rides along with `Serialize` so a card can be read BACK out of
@@ -181,6 +186,12 @@ pub struct AlbumHeader {
/// by `load_album` for `download_booklet`.
#[serde(rename = "hasBooklet")]
pub has_booklet: bool,
/// Every track has a ready offline copy (Slint `album-fully-cached`:
/// `album.rs:677-679` — non-empty track list, all status 3). Swaps the
/// ⋯ menu's offline row between "Make available offline" and "Refresh
/// offline copy"; the view keeps it live from row-status signals.
#[serde(rename = "fullyCached")]
pub fully_cached: bool,
/// A user-picked cover override exists (Slint `custom_artwork` store,
/// shared file). The view swaps the header image to `customCoverPath`
/// and flips the cover menu's Add/Change/Remove rows.
@@ -512,6 +523,11 @@ fn map_track(track: &Track) -> TrackRow {
work_composer_name,
work_composer_id,
artwork_url: String::new(),
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
3
} else {
0
},
}
}
@@ -648,6 +664,8 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
.unwrap_or_default(),
awards,
has_booklet: !booklet_url.is_empty(),
// album.rs:677-679 — the whole album is cached when every track is.
fully_cached: !tracks.is_empty() && tracks.iter().all(|t| t.cache_status == 3),
has_custom_cover: !custom_cover.is_empty(),
custom_cover_path: custom_cover,
};
@@ -658,6 +676,138 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
})
}
// ==================== Multi-select bulk bar (AlbumView) ====================
/// The AlbumView bulk bar (Slint `AlbumActions.bulk-action`, main.rs
/// 14783-14884). The selection lives in QML; select-all/clear never reach
/// Rust. Ids arrive in VISIBLE order and are resolved against a fresh
/// `/album/get` (the port keeps no raw-track stash; the Slint resolves
/// against one). Tracks the response no longer carries are dropped.
pub fn bulk_action(album_id: String, ids_json: String, action: String) {
let ids: Vec<String> = serde_json::from_str(&ids_json).unwrap_or_default();
if ids.is_empty() {
log::debug!("[qbz-qt] album bulk {action}: empty selection, ignored");
return;
}
match action.as_str() {
"queue" | "play-next" | "play-later" => {
let runtime = crate::app();
crate::spawn(async move {
let queue = match crate::playback_qt::fetch_album_queue(&runtime, &album_id).await {
Ok(q) => q,
Err(e) => {
log::warn!("[qbz-qt] album bulk {action}: {e}");
return;
}
};
let wanted: std::collections::HashSet<String> =
ids.iter().map(|s| s.clone()).collect();
let mut picked: Vec<_> = queue
.into_iter()
.filter(|t| wanted.contains(&t.id.to_string()))
.collect();
if picked.is_empty() {
return;
}
let mode = match action.as_str() {
"play-next" => "next",
"play-later" => "later",
_ => "queue",
};
// "next" inserts at the cursor — feed REVERSED so the block
// keeps its album order (playback_qt::enqueue_album's rule).
if mode == "next" {
picked.reverse();
}
let picked = crate::playback_qt::stamped(
picked,
crate::playback_qt::PlayContext::album(&album_id),
);
if let Err(e) =
crate::playback_qt::enqueue_track_list_mode(&runtime, picked, mode).await
{
log::error!("[qbz-qt] album bulk {mode} failed: {e}");
}
});
}
"add-to-playlist" => {
// Same contract as the reference's playlist_picker::open_multi:
// the picker takes decimal catalog ids and filters the rest.
crate::playlist_picker_qt::open_for_ids(&crate::app(), ids);
}
"add-to-mixtape" => {
let runtime = crate::app();
crate::spawn(async move {
let album = match runtime.core().get_album(&album_id).await {
Ok(a) => a,
Err(e) => {
log::warn!("[qbz-qt] album bulk add-to-mixtape: {e}");
return;
}
};
let wanted: std::collections::HashSet<String> =
ids.iter().map(|s| s.clone()).collect();
let art = album.image.thumbnail.clone().or(album.image.small.clone());
let album_artist = album.artist.name.clone();
let items: Vec<crate::myqbz_add_qt::AddItem> = album
.tracks
.map(|c| c.items)
.unwrap_or_default()
.into_iter()
.filter(|t| wanted.contains(&t.id.to_string()))
.map(|t| crate::myqbz_add_qt::AddItem {
item_type: "track".into(),
source: "qobuz".into(),
source_item_id: t.id.to_string(),
title: t.title.clone(),
subtitle: Some(
t.performer
.as_ref()
.map(|p| p.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| album_artist.clone()),
),
artwork_url: art.clone(),
year: None,
track_count: None,
})
.collect();
if !items.is_empty() {
crate::myqbz_add_qt::open_items(items);
}
});
}
"add-to-favorites" => {
// Slint: per-id add + fav-cache set, then clear (main.rs:14855+).
let runtime = crate::app();
crate::spawn(async move {
for id in &ids {
if let Err(e) = runtime.core().add_favorite("track", id).await {
log::error!("[qbz-qt] album bulk favorite {id} failed: {e}");
}
crate::fav_cache_qt::set("track", id, true);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
crate::toast_qt::success(qbz_i18n::t("Added to Library"));
});
}
"make-offline" => {
let runtime = crate::app();
crate::spawn(async move {
let raw: Vec<u64> = ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
match runtime.core().get_tracks_batch(&raw).await {
Ok(tracks) if !tracks.is_empty() => {
crate::offline_cache_qt::cache_tracks(tracks)
}
Ok(_) => {}
Err(e) => log::warn!("[qbz-qt] album bulk make-offline: {e}"),
}
});
}
other => log::warn!("[qbz-qt] album bulk: unknown action {other}"),
}
}
// ==================== Booklet (Slint booklet.rs port) ======================
/// Booklet download timeout (matches the Slint client, `booklet.rs:19`).
+1
View File
@@ -428,6 +428,7 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
TrackRow {
is_favorite: crate::fav_cache_qt::contains_track(track.id),
id: track.id.to_string(),
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) { 3 } else { 0 },
number: (index + 1).to_string(),
title,
artist,
+16 -10
View File
@@ -15,12 +15,12 @@
//! artist/album blacklist, and the Recommendations dismissal set.
//!
//! Still omitted (no view reads them yet; each is a mechanical
//! `init_for_user(&dir)` when its view lands): `crate::offline::activate`,
//! `offline_cache::load_cached_ids`, `reco` (+train_async), `external_reco`,
//! `qbz_reco::ArtistVectorStore`, `discover_prefs`, `session_persist`,
//! `lyrics`. Also omitted: the subscription purge consumer
//! (`spawn_subscription_purge_check`) — it needs the offline cache, which
//! this phase does not bring up; TODO when the offline cache is wired.
//! `init_for_user(&dir)` when its view lands): `reco` (+train_async),
//! `external_reco`, `qbz_reco::ArtistVectorStore`, `discover_prefs`,
//! `session_persist`, `lyrics`. Also omitted: the subscription purge
//! consumer (`spawn_subscription_purge_check`) — it needs the offline
//! cache, which IS wired now (`offline_qt::activate` runs in
//! `bind_per_user_stores`); the purge consumer is its own TODO.
//!
//! KEPT (the offline guardrails this phase is about):
//! `ensure_api_initialized` (scoped gate lift), `login_with_oauth_code`,
@@ -170,7 +170,7 @@ where
crate::search_cache_qt::init();
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
bind_per_user_stores(&dir, user_id);
bind_per_user_stores(&dir, user_id).await;
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -200,7 +200,7 @@ where
/// `myqbz_qt::init_for_user` also runs the mixtape migrations (best-effort,
/// logs on failure, never blocks shell entry) — one entry point, so the caller
/// cannot bind the uid and forget the schema.
fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
// MyQBZ: branding (label + icon, read by the sidebar/nav flyout on frame
// 1) and the grids + mixtape schema.
crate::myqbz_prefs_qt::init_for_user(dir);
@@ -211,6 +211,9 @@ fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
crate::artist_blacklist::init_for_user(dir);
// "Not interested" dismissals on Recommendations cards.
crate::reco_dismiss_qt::init_for_user(dir);
// Offline DOWNLOAD cache (index.db + library.db + cached-id seed) —
// awaited so the ready-set is seeded before any view can build rows.
crate::offline_qt::activate(user_id).await;
}
/// Make sure the Qobuz client holds bundle tokens before a sign-in call.
@@ -305,7 +308,7 @@ where
crate::search_cache_qt::init();
// Phase 17: playlist is_owner math.
crate::playlist_qt::set_user_id(user_id);
bind_per_user_stores(&dir, user_id);
bind_per_user_stores(&dir, user_id).await;
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
@@ -384,7 +387,7 @@ where
crate::playlist_qt::set_user_id(user_id);
// NOT optional on the offline path — see the helper's doc comment for
// the leak this closes.
bind_per_user_stores(&dir, user_id);
bind_per_user_stores(&dir, user_id).await;
}
crate::offline_fwd::engine().set_offline_session(true);
Ok(user_id)
@@ -443,6 +446,9 @@ where
crate::artist_blacklist::teardown();
crate::reco_dismiss_qt::teardown();
crate::offline_fwd::teardown();
// The offline download cache (index.db/library connections + the cached
// id set) — torn down so the next account never reads this one's rows.
crate::offline_qt::deactivate().await;
runtime.deactivate().await?;
log::info!("[qbz-qt] logged out");
Ok(())
+1
View File
@@ -997,6 +997,7 @@ fn to_row(track: &Track) -> TrackRow {
.and_then(|a| a.image.best().cloned())
.unwrap_or_default(),
is_favorite: crate::fav_cache_qt::is_favorite("track", &track.id.to_string()),
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) { 3 } else { 0 },
..TrackRow::default()
}
}
+1
View File
@@ -1276,6 +1276,7 @@ fn parse_top_track(index: usize, raw: &Value) -> TrackRow {
.or_else(|| raw.get("maximum_sampling_rate").and_then(|v| v.as_f64()));
TrackRow {
is_favorite: id.parse::<u64>().map(crate::fav_cache_qt::contains_track).unwrap_or(false),
cache_status: if crate::offline_qt::is_cached(&id) { 3 } else { 0 },
id,
number: (index + 1).to_string(),
title,
+5
View File
@@ -182,6 +182,11 @@ mod now_playing;
mod output_labels;
mod quality_state;
mod offline_fwd;
// Offline cache (downloads tier): state activation on login + the action
// set. offline_fwd.rs is the offline MODE (connectivity); these two are the
// user-managed download cache (see AGENTS.md's caching-model note).
mod offline_qt;
mod offline_cache_qt;
mod album_qt;
mod track_info_qt;
// Album Info (Credits/Review) modal controller — info_modals.rs port.
+330
View File
@@ -0,0 +1,330 @@
//! Offline-cache actions for the Qt frontend — port of the Slint
//! `crates/qbz/src/offline_cache.rs` action set (cache_track /
//! cache_tracks / cache_album / redownload_album / remove_cached /
//! refresh_cached) with the bridge-signal row sink instead of the Slint
//! event-loop hop.
//!
//! All download machinery is the shared `qbz_offline_cache` crate (CMAF-first
//! downloader, 3-permit semaphore, retry backoff, vault); this module is the
//! frontend glue: pre-flight the limit, insert the queued row, push row
//! status, spawn, toast. Offline copies are always fetched at the top
//! quality tier (reference rule, `offline_cache.rs:138-156`).
//!
//! Row status vocabulary (Slint TrackRow.slint:639-702):
//! 0 = none · 1 = queued · 2 = downloading · 3 = ready · 4 = failed
//! Status 3 rows surface `trackCacheStatusChanged` so any listening view can
//! flip its glyph live; views also seed from `offline_qt::is_cached` at
//! document build time.
use std::sync::Arc;
use qbz_offline_cache::{CacheEvent, CacheEventSink, TrackCacheInfo};
use qbz_offline_cache::OfflineCacheState;
use crate::offline_qt;
use crate::shell_bridge;
use cxx_qt_lib::QString;
/// Push a row's cache status onto every listening view (the Qt fan-out of
/// Slint's `set_row_cache_status`). QML Connections handlers patch the row
/// inside their own document copy.
fn push_status(track_id: u64, status: i32, progress: f64) {
let id = track_id.to_string();
shell_bridge::ui(move |mut b| {
b.as_mut().track_cache_status_changed(
QString::from(id.as_str()),
status,
progress,
);
});
}
/// Build a sink that reflects cache + unlock events onto visible rows and
/// surfaces terminal toasts (Slint `row_sink`). Shared by the cache triggers
/// AND the play path (UnlockStart/End → the row padlock in Slint; the Qt
/// v1 does not draw the padlock — the events are still sunk here so the
/// status model stays truthful).
pub fn row_sink() -> CacheEventSink {
Arc::new(move |ev: CacheEvent| match ev {
CacheEvent::Started { track_id } => {
push_status(track_id, 2, 0.0);
}
CacheEvent::Progress {
track_id,
progress_percent,
..
} => {
let p = (progress_percent as f64 / 100.0).clamp(0.0, 1.0);
push_status(track_id, 2, p);
}
CacheEvent::Completed { track_id, .. } => {
offline_qt::mark_cached(track_id, true);
push_status(track_id, 3, 1.0);
crate::toast_qt::success(qbz_i18n::t("Cached for offline"));
}
CacheEvent::Processed { .. } => {
// Post-processing done; status already 'ready' from Completed.
}
CacheEvent::Failed { track_id, error } => {
log::warn!("[qbz-qt] offline cache failed for {track_id}: {error}");
push_status(track_id, 4, 0.0);
crate::toast_qt::error(qbz_i18n::t("Offline caching failed"));
}
CacheEvent::UnlockStart { .. } | CacheEvent::UnlockEnd { .. } => {
// The padlock row state is not drawn in the Qt port yet.
}
})
}
/// Build the DB row metadata from a catalog track (Slint `track_cache_info`).
fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo {
TrackCacheInfo {
track_id: track.id,
title: track.title.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: track.album.as_ref().map(|a| a.title.clone()),
album_id: track.album.as_ref().map(|a| a.id.clone()),
duration_secs: track.duration as u64,
quality: "UltraHiRes".to_string(),
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
}
}
fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
let file_path = off.track_file_path(track_id, "flac");
qbz_offline_cache::spawn_track_cache_download(
track_id,
file_path,
crate::app().core().client(),
off.fetcher.clone(),
off.db.clone(),
off.get_cache_path(),
off.library_db.clone(),
row_sink(),
off.cache_semaphore.clone(),
);
}
/// Cache a single track for offline playback (Slint `cache_track`).
pub fn cache_track(id: u64) {
crate::spawn(async move {
let Some(off) = offline_qt::get().await else {
crate::toast_qt::error(qbz_i18n::t("Log in to cache tracks offline"));
return;
};
let runtime = crate::app();
let track = match runtime.core().get_track(id).await {
Ok(t) => t,
Err(e) => {
log::error!("[qbz-qt] cache: get_track {id} failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Couldn't load that track"));
return;
}
};
let info = track_cache_info(&track);
let file_path = off.track_file_path(id, "flac");
let file_path_str = file_path.to_string_lossy().to_string();
// Pre-flight the cache limit, then insert the queued row.
{
let limit = *off.limit_bytes.lock().await;
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
let root = std::path::PathBuf::from(off.get_cache_path());
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
log::warn!("[qbz-qt] cache limit reached: {e}");
crate::toast_qt::error(
qbz_i18n::t("Offline cache is full — free space or raise the limit"),
);
return;
}
if let Err(e) = db.insert_track(&info, &file_path_str) {
log::error!("[qbz-qt] cache insert {id} failed: {e}");
return;
}
}
push_status(id, 1, 0.0);
spawn_download(&off, id);
});
}
/// Cache a batch of already-fetched catalog tracks (album flow, multi-select
/// bulk "Make available offline"; Slint `cache_tracks`).
pub fn cache_tracks(tracks: Vec<qbz_models::Track>) {
if tracks.is_empty() {
return;
}
crate::spawn(async move {
let Some(off) = offline_qt::get().await else {
crate::toast_qt::error(qbz_i18n::t("Log in to cache tracks offline"));
return;
};
// Pre-flight once for the whole batch (mirrors the reference).
{
let limit = *off.limit_bytes.lock().await;
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
let root = std::path::PathBuf::from(off.get_cache_path());
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
log::warn!("[qbz-qt] batch cache limit reached: {e}");
crate::toast_qt::error(
qbz_i18n::t("Offline cache is full — free space or raise the limit"),
);
return;
}
}
let count = tracks.len();
for track in &tracks {
let id = track.id;
let info = track_cache_info(track);
let file_path = off.track_file_path(id, "flac");
let file_path_str = file_path.to_string_lossy().to_string();
{
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
if db.insert_track(&info, &file_path_str).is_err() {
continue;
}
}
push_status(id, 1, 0.0);
spawn_download(&off, id);
}
crate::toast_qt::success(qbz_i18n::tf(
"Caching {} track offline…",
"Caching {} tracks offline…",
count as i64,
&[&count.to_string()],
));
});
}
/// Cache a whole album for offline playback (Slint `cache_album`): fetch its
/// tracks, then batch them.
pub fn cache_album(album_id: String) {
crate::spawn(async move {
let runtime = crate::app();
let album = match runtime.core().get_album(&album_id).await {
Ok(a) => a,
Err(e) => {
log::error!("[qbz-qt] cache: get_album {album_id} failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Couldn't load that album"));
return;
}
};
let tracks: Vec<qbz_models::Track> = album
.tracks
.map(|c| c.items)
.unwrap_or_default();
if tracks.is_empty() {
crate::toast_qt::error(qbz_i18n::t("This album has no playable tracks"));
return;
}
cache_tracks(tracks);
});
}
/// Re-download an album's offline copies (Slint `redownload_album`,
/// `failed_only = false` from the album menu's "Refresh offline copy").
pub fn redownload_album(album_id: String, failed_only: bool) {
crate::spawn(async move {
let Some(off) = offline_qt::get().await else {
return;
};
let targets: Vec<u64> = {
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
match db.get_album_tracks(&album_id) {
Ok(tracks) => {
let picked = qbz_offline_cache::maintenance::select_redownload_targets(
&tracks,
failed_only,
);
let ids: Vec<u64> = picked.iter().map(|t| t.track_id).collect();
for id in &ids {
let _ = db.reset_track_for_redownload(*id);
}
ids
}
Err(_) => Vec::new(),
}
};
for id in targets {
push_status(id, 1, 0.0);
spawn_download(&off, id);
}
});
}
/// Remove a track's offline copy (Slint `remove_cached`).
pub fn remove_cached(id: u64) {
crate::spawn(async move {
remove_cached_inner(id, true).await;
});
}
/// Refresh a track's offline copy (Slint `refresh_cached`): delete + re-queue
/// sequenced in ONE task — `insert_track` is not an upsert, so the delete
/// must land first.
pub fn refresh_cached(id: u64) {
crate::spawn(async move {
remove_cached_inner(id, false).await;
cache_track(id);
});
}
/// The removal body shared by `remove_cached` and `refresh_cached` (Slint
/// `remove_cached_inner`): DB row + on-disk bundle/file + library row.
async fn remove_cached_inner(id: u64, toast: bool) {
let Some(off) = offline_qt::get().await else {
return;
};
let removed_path = {
let guard = off.db.lock().await;
match guard.as_ref() {
Some(db) => db.delete_track(id).ok().flatten(),
None => return,
}
};
if let Some(p) = removed_path {
let path = std::path::Path::new(&p);
// v2 bundles live in `tracks-cmaf/<id>/` — remove the whole dir.
let looks_v2 = path
.parent()
.and_then(|pp| pp.parent())
.and_then(|r| r.file_name())
.and_then(|n| n.to_str())
== Some("tracks-cmaf");
if looks_v2 {
if let Some(dir) = path.parent() {
let _ = std::fs::remove_dir_all(dir);
}
} else {
let _ = std::fs::remove_file(path);
}
}
{
let guard = off.library_db.lock().await;
if let Some(db) = guard.as_ref() {
let _ = db.remove_qobuz_cached_track(id);
}
}
offline_qt::mark_cached(id, false);
push_status(id, 0, 0.0);
if toast {
crate::toast_qt::success(qbz_i18n::t("Removed from offline"));
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Offline-cache state for the Qt frontend — port of `crates/qbz/src/
//! offline.rs` plus the cached-id session set from `offline_cache.rs`.
//!
//! Same on-disk layout as the other frontends (`<cache>/qbz/users/<id>/audio/
//! index.db` + `<data>/qbz/users/<id>/library.db`), so the offline store is
//! SHARED with Slint/Tauri: a track downloaded there plays from the offline
//! tier here and vice versa. Activated on login/session-restore from
//! `auth_qt`, torn down on logout.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use tokio::sync::Mutex;
use qbz_offline_cache::OfflineCacheState;
static OFFLINE: OnceLock<Mutex<Option<Arc<OfflineCacheState>>>> = OnceLock::new();
fn slot() -> &'static Mutex<Option<Arc<OfflineCacheState>>> {
OFFLINE.get_or_init(|| Mutex::new(None))
}
fn user_cache_dir(user_id: u64) -> Option<PathBuf> {
Some(
dirs::cache_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
fn user_data_dir(user_id: u64) -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
/// The on-disk limit file (next to index.db, in the per-user audio dir).
fn limit_file(audio_dir: &str) -> PathBuf {
std::path::Path::new(audio_dir).join("offline_limit")
}
fn read_persisted_limit(audio_dir: &str) -> Option<u64> {
std::fs::read_to_string(limit_file(audio_dir))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
}
/// Construct + initialize the offline cache for `user_id`, replacing any
/// prior instance (Slint `offline::activate`).
pub async fn activate(user_id: u64) {
let Some(cache_dir) = user_cache_dir(user_id) else {
log::error!("[qbz-qt] offline: could not resolve cache dir");
return;
};
let Some(data_dir) = user_data_dir(user_id) else {
log::error!("[qbz-qt] offline: could not resolve data dir");
return;
};
let state = OfflineCacheState::new_empty();
if let Err(e) = state.init_at(&cache_dir).await {
log::error!("[qbz-qt] offline: init_at failed: {e}");
return;
}
// Restore the persisted cache size limit (written by the manager's
// edit-limit). Falls back to the 5 GB default when absent.
if let Some(bytes) = read_persisted_limit(&state.get_cache_path()) {
state.apply_persisted_limit(Some(bytes)).await;
}
if let Err(e) = state.init_library_connection(&data_dir).await {
// Non-fatal: cached playback still resolves from index.db; only the
// library-row sync (show-in-library) needs this connection.
log::warn!("[qbz-qt] offline: library connection failed: {e}");
}
*slot().lock().await = Some(Arc::new(state));
log::info!("[qbz-qt] offline cache activated for user {user_id}");
load_cached_ids().await;
}
/// Tear down the offline cache on logout.
pub async fn deactivate() {
if let Some(state) = slot().lock().await.take() {
state.teardown().await;
log::info!("[qbz-qt] offline cache deactivated");
}
if let Ok(mut s) = cached_ids().lock() {
s.clear();
}
}
/// The active offline cache state, or `None` before login / after logout.
pub async fn get() -> Option<Arc<OfflineCacheState>> {
slot().lock().await.clone()
}
// ---------------------------------------------------------------------------
// The session-wide ready set (Slint offline_cache.rs CACHED_IDS)
// ---------------------------------------------------------------------------
static CACHED_IDS: OnceLock<StdMutex<HashSet<u64>>> = OnceLock::new();
fn cached_ids() -> &'static StdMutex<HashSet<u64>> {
CACHED_IDS.get_or_init(|| StdMutex::new(HashSet::new()))
}
/// True if `track_id` (string form, as the rows carry it) has a ready offline
/// copy. Used to seed each track row's cache status at document build time.
pub fn is_cached(track_id: &str) -> bool {
let Ok(id) = track_id.parse::<u64>() else {
return false;
};
cached_ids().lock().map(|s| s.contains(&id)).unwrap_or(false)
}
pub fn mark_cached(track_id: u64, cached: bool) {
if let Ok(mut set) = cached_ids().lock() {
if cached {
set.insert(track_id);
} else {
set.remove(&track_id);
}
}
}
/// Seed the ready-set from the active cache's index.db (Slint
/// `load_cached_ids`). Called by `activate`.
async fn load_cached_ids() {
let Some(off) = get().await else {
return;
};
let ids: Vec<u64> = {
let guard = off.db.lock().await;
match guard.as_ref() {
Some(db) => db
.get_all_tracks()
.map(|tracks| {
tracks
.into_iter()
.filter(|t| matches!(t.status, qbz_offline_cache::OfflineCacheStatus::Ready))
.map(|t| t.track_id)
.collect()
})
.unwrap_or_default(),
None => Vec::new(),
}
};
if let Ok(mut set) = cached_ids().lock() {
*set = ids.into_iter().collect();
log::info!("[qbz-qt] offline: seeded {} cached track ids", set.len());
}
}
+32 -21
View File
@@ -414,6 +414,31 @@ pub(crate) async fn route_track_to_peer(track: &QueueTrack, mode: &str) -> bool
}
}
/// The one funnel every local play goes through. Hands the play path the
/// ACTIVE offline cache + the row-status sink (Slint passes both through
/// `play_track_resolved`; this port used to pass `None, None`, which made
/// the offline tier unreachable — downloaded tracks always streamed).
/// With no active cache this degrades to exactly the old behaviour.
pub(crate) async fn play_resolved_offline_aware(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
quality: qbz_models::Quality,
start_position_secs: u64,
) -> Result<(), String> {
let off = crate::offline_qt::get().await;
let sink = off.as_ref().map(|_| crate::offline_cache_qt::row_sink());
runtime
.core()
.play_track_resolved(
track_id,
quality,
off.as_deref(),
sink.as_ref(),
start_position_secs,
)
.await
}
/// QConnect CONTROLLER-mode play routing (contract §7's decided arm position —
/// Slint playback.rs:638-651): when a PEER renderer owns playback, route the
/// play to the peer instead of running the local audible step. Call AFTER
@@ -625,9 +650,7 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(first_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, first_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -676,9 +699,7 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(first_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, first_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -783,9 +804,7 @@ pub async fn play_album_from(
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(first_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, first_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
log::info!("[qbz-qt] play_album: play_track_resolved started for {first_id}");
@@ -821,9 +840,7 @@ pub async fn play_album_from_track(
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(first_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, first_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -942,9 +959,7 @@ pub async fn play_track_list_in(
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(first_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, first_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -1341,9 +1356,7 @@ pub async fn play_single_track(
if route_play_to_peer(runtime, track_id).await {
return Ok(());
}
runtime
.core()
.play_track_resolved(track_id, current_quality(), None, None, 0)
play_resolved_offline_aware(runtime, track_id, current_quality(), 0)
.await
.map_err(|e| format!("play_track {track_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -1485,9 +1498,7 @@ async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u
publish_queue(runtime).await;
return;
}
if let Err(e) = runtime
.core()
.play_track_resolved(track_id, current_quality(), None, None, 0)
if let Err(e) = play_resolved_offline_aware(runtime, track_id, current_quality(), 0)
.await
{
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
+24
View File
@@ -170,6 +170,14 @@ pub mod qbz_player {
/// AlbumCard ⋯ menu: Play next ("next") / Add to queue ("later").
#[qinvokable]
fn enqueue_album(self: Pin<&mut QbzPlayer>, album_id: QString, mode: QString);
/// Track-row offline cache actions (offline_cache_qt.rs): download /
/// remove / refresh the track's offline copy.
#[qinvokable]
fn cache_track(self: Pin<&mut QbzPlayer>, track_id: QString);
#[qinvokable]
fn uncache_track(self: Pin<&mut QbzPlayer>, track_id: QString);
#[qinvokable]
fn recache_track(self: Pin<&mut QbzPlayer>, track_id: QString);
/// ArtistView Popular Tracks row play (whole list as the queue).
#[qinvokable]
fn play_artist_track(self: Pin<&mut QbzPlayer>, track_id: QString);
@@ -388,6 +396,22 @@ impl qbz_player::QbzPlayer {
crate::enqueue_album(album_id.to_string(), mode.to_string());
}
pub fn cache_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(id) = track_id.to_string().parse::<u64>() {
crate::offline_cache_qt::cache_track(id);
}
}
pub fn uncache_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(id) = track_id.to_string().parse::<u64>() {
crate::offline_cache_qt::remove_cached(id);
}
}
pub fn recache_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(id) = track_id.to_string().parse::<u64>() {
crate::offline_cache_qt::refresh_cached(id);
}
}
pub fn play_artist_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(tid) = track_id.to_string().parse::<u64>() {
crate::play_artist_track(tid);
+10
View File
@@ -514,6 +514,16 @@ pub mod qbz_shell {
fn select_all_requested(self: Pin<&mut QbzShell>);
#[qsignal]
fn exit_multi_select_requested(self: Pin<&mut QbzShell>);
/// Offline-cache row status fan-out (offline_cache_qt::row_sink):
/// 0 none · 1 queued · 2 downloading · 3 ready · 4 failed. Views
/// patch the matching track row inside their own document copy.
#[qsignal]
fn track_cache_status_changed(
self: Pin<&mut QbzShell>,
track_id: QString,
status: i32,
progress: f64,
);
}
// The custom WRITE targets of the §4.6 seam properties. NOT qinvokables —