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:
14 files changed
+980
-53
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in new issue
Block a user