feat(navigation): add cascading local media menu
cx
This commit is contained in:
17 files changed
+549
-53
No files matched your search
@@ -5683,3 +5683,15 @@ msgstr "Du nutzt QBZ ohne Qobuz-Konto. Deine lokale Bibliothek und Downloads fun
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Kontinuierliche Wiedergabe"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Ordneransicht"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Medium öffnen"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Ordner öffnen…"
|
||||
@@ -1207,3 +1207,15 @@ msgstr "No track matches that."
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Continuous playback"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Folder view"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Open media"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Open folder…"
|
||||
@@ -5689,3 +5689,15 @@ msgstr "Estás usando QBZ sin cuenta de Qobuz. Tu biblioteca local y tus descarg
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Reproducción continua"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Vista por carpetas"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Abrir medios"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Abrir carpeta…"
|
||||
@@ -5681,3 +5681,15 @@ msgstr "Vous utilisez QBZ sans compte Qobuz. Votre bibliothèque locale et vos t
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Lecture continue"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Vue par dossiers"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Ouvrir un média"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Ouvrir un dossier…"
|
||||
@@ -5666,3 +5666,15 @@ msgstr "Qobuz アカウントなしで QBZ を使っています。ローカル
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "連続再生"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "開く"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "フォルダー表示"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "メディアを開く"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "フォルダーを開く…"
|
||||
@@ -5671,3 +5671,15 @@ msgstr "Je gebruikt QBZ zonder Qobuz-account. Je lokale bibliotheek en downloads
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Doorlopend afspelen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Openen"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Mapweergave"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Media openen"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Map openen…"
|
||||
@@ -5681,3 +5681,15 @@ msgstr "Você está usando o QBZ sem conta Qobuz. Sua biblioteca local e seus do
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Reprodução contínua"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Vista de pastas"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Abrir mídia"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Abrir pasta…"
|
||||
@@ -5700,3 +5700,15 @@ msgstr "Вы используете QBZ без аккаунта Qobuz. Лока
|
||||
|
||||
msgid "Continuous playback"
|
||||
msgstr "Непрерывное воспроизведение"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
msgid "Folder view"
|
||||
msgstr "Просмотр папок"
|
||||
|
||||
msgid "Open media"
|
||||
msgstr "Открыть медиафайл"
|
||||
|
||||
msgid "Open folder…"
|
||||
msgstr "Открыть папку…"
|
||||
@@ -125,8 +125,8 @@ Item {
|
||||
// Discover — glyph-less rows (Slint has-glyph: false).
|
||||
var discover = [
|
||||
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
|
||||
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true },
|
||||
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true }
|
||||
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true },
|
||||
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true }
|
||||
]
|
||||
if (reco) {
|
||||
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
|
||||
@@ -161,19 +161,18 @@ Item {
|
||||
"label": nav.trs("Local Library"),
|
||||
"qobuz": false,
|
||||
"enabled": true,
|
||||
// The `Open` entry is APPENDED, not marked enabled:false —
|
||||
// a disabled row still renders (NavFlyout.qml:329,364), and a
|
||||
// permanently greyed "Open" would advertise a tab that mostly
|
||||
// does not exist. `localEphemeralActive` is the same flag the
|
||||
// tab bar and the pane read.
|
||||
"entries": [
|
||||
{ "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
|
||||
{ "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
|
||||
{ "label": nav.trs("Folders"), "icon": "folder", "view": "local", "tab": "folders", "enabled": true },
|
||||
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
|
||||
].concat(QbzLocal.localEphemeralActive
|
||||
? [{ "label": QbzLocal.localEphemeralLabel, "icon": "folder-open", "view": "local", "tab": "ephemeral", "enabled": true }]
|
||||
: [])
|
||||
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true },
|
||||
{ "label": QbzSession.offline ? nav.trs("Folder view") : nav.trs("Folders"),
|
||||
"icon": "folder", "view": "local", "tab": "folders", "enabled": true },
|
||||
{ "label": QbzLocal.localEphemeralActive
|
||||
? QbzLocal.localEphemeralLabel
|
||||
: (QbzSession.offline ? nav.trs("Open media") : nav.trs("Open")),
|
||||
"icon": "folder-open", "view": "", "tab": "", "enabled": true,
|
||||
"submenu": nav.openMediaEntries() }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "myqbz",
|
||||
@@ -207,6 +206,32 @@ Item {
|
||||
]
|
||||
}
|
||||
|
||||
/// Second-level catalog for Local Library > Open. When a medium already
|
||||
/// exists its own row returns to it and Close is appended; all three open
|
||||
/// verbs remain available so replacing one medium never needs an extra
|
||||
/// navigation round trip.
|
||||
function openMediaEntries() {
|
||||
var entries = []
|
||||
if (QbzLocal.localEphemeralActive) {
|
||||
entries.push({ "label": QbzLocal.localEphemeralLabel,
|
||||
"icon": "folder-open", "view": "local",
|
||||
"tab": "ephemeral", "enabled": true })
|
||||
}
|
||||
entries.push(
|
||||
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
|
||||
"view": "", "tab": "", "action": "openFolder", "enabled": true },
|
||||
{ "label": nav.trs("Open audio CD"), "icon": "disc",
|
||||
"view": "", "tab": "", "action": "openCd", "enabled": true },
|
||||
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
|
||||
"view": "", "tab": "", "action": "openSacd", "enabled": true })
|
||||
if (QbzLocal.localEphemeralActive) {
|
||||
entries.push({ "label": nav.trs("Close"), "icon": "x",
|
||||
"view": "", "tab": "", "action": "closeMedia",
|
||||
"enabled": true })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// Which section owns the highlight, derived from the LIVE view (both hosts
|
||||
// used to carry this identical binding). Slint highlights off
|
||||
// HeaderMenuState.open-index; the hosts OR that in via `openId`.
|
||||
@@ -252,12 +277,13 @@ Item {
|
||||
panel.show(section, withTitle === true, trigger.mapToItem(nav, 0, 0).x, nav.dropdownY)
|
||||
}
|
||||
|
||||
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top,
|
||||
// always headed by the section name (Sidebar.slint:590).
|
||||
function openBeside(trigger, section) {
|
||||
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top.
|
||||
// The expanded row already names the section, while the compact icon does
|
||||
// not; callers pass that visible-text fact explicitly.
|
||||
function openBeside(trigger, section, withTitle) {
|
||||
if (!section || section.enabled === false) return
|
||||
var p = trigger.mapToItem(nav, trigger.width + 6, 0)
|
||||
panel.show(section, true, p.x, p.y)
|
||||
panel.show(section, withTitle === true, p.x, p.y)
|
||||
}
|
||||
|
||||
function closeMenu() { panel.close() }
|
||||
@@ -272,8 +298,26 @@ Item {
|
||||
// per route, crates/qbz/src/main.rs:18026-18046). Entries without one
|
||||
// (Collections, Mixtapes) are a plain section navigation.
|
||||
function activate(entry) {
|
||||
if (entry && entry.submenu) return
|
||||
panel.close()
|
||||
if (!entry || entry.enabled === false || entry.view === "") return
|
||||
if (!entry || entry.enabled === false) return
|
||||
if (entry.action === "openFolder") {
|
||||
QbzLocal.ephemeralOpen()
|
||||
return
|
||||
}
|
||||
if (entry.action === "openCd") {
|
||||
QbzLocal.ephemeralOpenCd()
|
||||
return
|
||||
}
|
||||
if (entry.action === "openSacd") {
|
||||
QbzLocal.ephemeralOpenSacd()
|
||||
return
|
||||
}
|
||||
if (entry.action === "closeMedia") {
|
||||
QbzLocal.ephemeralClear()
|
||||
return
|
||||
}
|
||||
if (entry.view === "") return
|
||||
if (entry.tab && entry.tab !== "")
|
||||
QbzShell.navigateToTab(entry.view, entry.tab)
|
||||
else
|
||||
@@ -356,13 +400,23 @@ Item {
|
||||
}
|
||||
Text {
|
||||
height: parent.height
|
||||
width: parent.width - (row.entry && row.entry.icon !== "" ? 23 : 0)
|
||||
width: parent.width
|
||||
- (row.entry && row.entry.icon !== "" ? 23 : 0)
|
||||
- (row.entry && row.entry.submenu ? 19 : 0)
|
||||
text: row.entry ? row.entry.label : ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzIcon {
|
||||
visible: row.entry && row.entry.submenu
|
||||
width: visible ? 11 : 0
|
||||
height: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "chevron-right"
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
@@ -371,13 +425,27 @@ Item {
|
||||
enabled: row.rowEnabled
|
||||
hoverEnabled: row.rowEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: nav.activate(row.entry)
|
||||
onClicked: {
|
||||
if (row.entry && row.entry.submenu)
|
||||
panel.openCascade(row, row.entry)
|
||||
else
|
||||
nav.activate(row.entry)
|
||||
}
|
||||
// Per-row hover keeps the idle timer paused while the cursor RESTS
|
||||
// on a row (the panel-body tracker below never sees it — the row
|
||||
// is on top of it).
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) panel.hoveredRow = row.rowIndex
|
||||
else if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
|
||||
if (containsMouse) {
|
||||
panel.hoveredRow = row.rowIndex
|
||||
if (row.entry && row.entry.submenu)
|
||||
panel.openCascade(row, row.entry)
|
||||
else
|
||||
panel.scheduleCascadeClose()
|
||||
} else {
|
||||
if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
|
||||
if (panel.cascadeAnchorRow === row.rowIndex)
|
||||
panel.scheduleCascadeClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,15 +468,38 @@ Item {
|
||||
property int hoveredRow: -1
|
||||
// True while the pointer rests on the panel BODY (padding/gaps).
|
||||
property bool bodyHovered: false
|
||||
// The cascade remains live while the pointer crosses the panel edge.
|
||||
// 280ms is long enough for a diagonal move but short enough that an
|
||||
// abandoned submenu does not become perennial.
|
||||
property int cascadeAnchorRow: -1
|
||||
property bool cascadeGrace: false
|
||||
// Pointer anywhere on the menu, or on the trigger that owns it — the
|
||||
// idle countdown is paused while this holds.
|
||||
readonly property bool menuHovered:
|
||||
bodyHovered || hoveredRow >= 0 || nav.triggerHovered
|
||||
|| cascade.menuHovered || cascadeGrace
|
||||
|
||||
function openCascade(row, entry) {
|
||||
cascadeClose.stop()
|
||||
panel.cascadeGrace = false
|
||||
panel.cascadeAnchorRow = row.rowIndex
|
||||
cascade.entries = entry.submenu || []
|
||||
cascade.x = panel.width - 2
|
||||
cascade.y = row.mapToItem(panel.contentItem, 0, 0).y
|
||||
if (!cascade.visible) cascade.open()
|
||||
}
|
||||
|
||||
function scheduleCascadeClose() {
|
||||
if (!cascade.visible) return
|
||||
panel.cascadeGrace = true
|
||||
cascadeClose.restart()
|
||||
}
|
||||
|
||||
function show(section, withTitle, px, py) {
|
||||
// Switching sections: drop stale hover state (the new rows have
|
||||
// not reported yet), exactly like the Slint `changed open-index`.
|
||||
if (panel.sectionId !== section.id) panel.hoveredRow = -1
|
||||
if (panel.sectionId !== section.id) cascade.close()
|
||||
panel.sectionId = section.id
|
||||
panel.title = withTitle ? section.label : ""
|
||||
panel.entries = section.entries
|
||||
@@ -421,6 +512,9 @@ Item {
|
||||
panel.sectionId = ""
|
||||
panel.hoveredRow = -1
|
||||
panel.bodyHovered = false
|
||||
panel.cascadeAnchorRow = -1
|
||||
panel.cascadeGrace = false
|
||||
cascade.close()
|
||||
nav.triggerHovered = false
|
||||
}
|
||||
|
||||
@@ -479,5 +573,119 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: cascadeClose
|
||||
interval: 280
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
panel.cascadeGrace = false
|
||||
if (panel.cascadeAnchorRow !== panel.hoveredRow && !cascade.menuHovered)
|
||||
cascade.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Child Popup, not a sibling: presses inside it count as part of the
|
||||
// menu family for CloseOnPressOutside, and closing the parent tears it
|
||||
// down automatically. The panels overlap by 2px so there is no dead
|
||||
// seam; the timer above tolerates diagonal pointer travel.
|
||||
Popup {
|
||||
id: cascade
|
||||
width: 224
|
||||
padding: 0
|
||||
topPadding: 6
|
||||
bottomPadding: 6
|
||||
height: 12 + cascade.entries.length * 32
|
||||
closePolicy: Popup.NoAutoClose
|
||||
|
||||
property var entries: []
|
||||
property int hoveredRow: -1
|
||||
property bool bodyHovered: false
|
||||
readonly property bool menuHovered: bodyHovered || hoveredRow >= 0
|
||||
|
||||
onClosed: {
|
||||
cascade.entries = []
|
||||
cascade.hoveredRow = -1
|
||||
cascade.bodyHovered = false
|
||||
panel.cascadeAnchorRow = -1
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onContainsMouseChanged: {
|
||||
cascade.bodyHovered = containsMouse
|
||||
if (containsMouse) {
|
||||
cascadeClose.stop()
|
||||
panel.cascadeGrace = false
|
||||
} else {
|
||||
panel.scheduleCascadeClose()
|
||||
}
|
||||
}
|
||||
onClicked: {}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
Repeater {
|
||||
model: cascade.entries
|
||||
delegate: Rectangle {
|
||||
id: cascadeRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
height: 32
|
||||
color: cascadeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 14
|
||||
spacing: 9
|
||||
QbzIcon {
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: cascadeRow.modelData.icon || ""
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 23
|
||||
height: parent.height
|
||||
text: cascadeRow.modelData.label || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: cascadeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: nav.activate(cascadeRow.modelData)
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
cascade.hoveredRow = cascadeRow.index
|
||||
cascadeClose.stop()
|
||||
panel.cascadeGrace = false
|
||||
} else {
|
||||
if (cascade.hoveredRow === cascadeRow.index)
|
||||
cascade.hoveredRow = -1
|
||||
panel.scheduleCascadeClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +413,7 @@ Rectangle {
|
||||
hoverEnabled: navRow.isEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
navFlyout.openBeside(navRow, navRow.section)
|
||||
navFlyout.openBeside(navRow, navRow.section, root.mini)
|
||||
// Opt-in "click lands on the first tab" — a no-op while the
|
||||
// Appearance toggle is off, which is the default.
|
||||
navFlyout.sectionClicked(navRow.section)
|
||||
@@ -423,7 +423,7 @@ Rectangle {
|
||||
// Hover-to-open; hovering another row overwrites the open
|
||||
// menu (instant switch, single-open by construction).
|
||||
navFlyout.triggerHovered = true
|
||||
navFlyout.openBeside(navRow, navRow.section)
|
||||
navFlyout.openBeside(navRow, navRow.section, root.mini)
|
||||
} else if (navRow.section && navFlyout.openId === navRow.section.id) {
|
||||
// Only the row owning the open menu clears the flag.
|
||||
navFlyout.triggerHovered = false
|
||||
|
||||
@@ -290,6 +290,7 @@ mod playlist_import_qt;
|
||||
// #[cxx_qt::bridge], so they must NOT appear in build.rs's rust_files.
|
||||
mod playlist_manager_ops;
|
||||
mod playlist_manager_qt;
|
||||
mod playlist_snapshot_qt;
|
||||
mod playlist_manager_rows;
|
||||
// The SHARED playlist editor's controller (rename · description ·
|
||||
// offline-only · delete), driven from the manager's three delegates, the
|
||||
|
||||
@@ -169,6 +169,22 @@ fn now_unix_secs() -> i64 {
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// Whether full offline-cache tracks may be served inside the subscription
|
||||
/// grace window. Missing state fails open, matching the shared engine and the
|
||||
/// reference frontend: only an explicit expired verdict may hide playback.
|
||||
pub fn offline_playback_allowed() -> bool {
|
||||
let now = now_unix_secs();
|
||||
SUBSCRIPTION
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| {
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|store| store.offline_playback_allowed(now).unwrap_or(true))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// D4 producer: a successful login verdict. Clears any running grace clock.
|
||||
pub fn subscription_mark_valid() {
|
||||
let now = now_unix_secs();
|
||||
|
||||
@@ -122,6 +122,13 @@ fn cached_ids() -> &'static StdMutex<HashSet<u64>> {
|
||||
CACHED_IDS.get_or_init(|| StdMutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Snapshot of the ready offline-cache ids. Playlist availability intersects
|
||||
/// this set with persisted playlist membership without holding the mutex over
|
||||
/// a database read.
|
||||
pub(crate) fn cached_ids_set() -> HashSet<u64> {
|
||||
cached_ids().lock().map(|ids| ids.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// True if `track_id` has a ready offline copy. Used to seed each track row's
|
||||
/// cache status at document build time — the rows carry the id as a STRING, so
|
||||
/// this is the string-keyed front door onto [`is_cached_id`].
|
||||
|
||||
@@ -573,6 +573,17 @@ async fn load(runtime: &Runtime) -> PmData {
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
crate::playlist_snapshot_qt::record_names_detached(
|
||||
remote
|
||||
.iter()
|
||||
.map(|playlist| crate::playlist_snapshot_qt::SnapshotNameEntry {
|
||||
qobuz_playlist_id: playlist.id,
|
||||
name: playlist.name.clone(),
|
||||
owner: Some(playlist.owner.name.clone()).filter(|owner| !owner.is_empty()),
|
||||
track_count: Some(playlist.tracks_count),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let (folders, settings, play_counts, local_counts, locals, fav_ids) =
|
||||
tokio::task::spawn_blocking(|| {
|
||||
|
||||
@@ -983,6 +983,12 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
// `take` (not clone): the header helpers below still need `&pl`, and the
|
||||
// track list is the heavy half of the response.
|
||||
let tracks = pl.tracks.take().map(|c| c.items).unwrap_or_default();
|
||||
crate::playlist_snapshot_qt::record_detail_detached(
|
||||
playlist_id,
|
||||
pl.name.clone(),
|
||||
pl.owner.name.clone(),
|
||||
tracks.iter().map(|track| track.id).collect(),
|
||||
);
|
||||
// Header artwork, two arms — 1:1 with the cards (PlaylistCard.qml):
|
||||
// the playlist's OWN graphic, else the member-cover mosaic. `images[0]`
|
||||
// (and the first track's sleeve) is a MEMBER-ALBUM cover: binding it
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Persisted Qobuz playlist names and membership for Qt offline navigation.
|
||||
//!
|
||||
//! The schema and queries live in `qbz-library`; this file only joins those
|
||||
//! frontend-neutral records to Qt's per-user database and ready-cache set.
|
||||
//! Producers are detached because they consume data the online list/detail
|
||||
//! loads already fetched and must never delay a render.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use qbz_library::qobuz_playlist_snapshot as repo;
|
||||
|
||||
pub use repo::SnapshotNameEntry;
|
||||
|
||||
pub fn record_names_detached(entries: Vec<SnapshotNameEntry>) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let write = move || {
|
||||
let result = crate::library_db_qt::with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| repo::upsert_names(conn, &entries)))
|
||||
});
|
||||
if let Some(Err(error)) = result {
|
||||
log::warn!("[qbz-qt] playlist snapshot names write failed: {error}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::spawn_blocking(write);
|
||||
} else {
|
||||
std::thread::spawn(write);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_detail_detached(playlist_id: u64, name: String, owner: String, track_ids: Vec<u64>) {
|
||||
let write = move || {
|
||||
let owner = Some(owner.as_str()).filter(|value| !value.is_empty());
|
||||
let result = crate::library_db_qt::with_db(true, |db| {
|
||||
Ok(db.with_connection(|conn| {
|
||||
repo::replace_tracks(conn, playlist_id, &name, owner, &track_ids)
|
||||
}))
|
||||
});
|
||||
match result {
|
||||
Some(Ok(true)) => {}
|
||||
Some(Ok(false)) => log::debug!(
|
||||
"[qbz-qt] playlist snapshot: {playlist_id} is not in the user list; membership skipped"
|
||||
),
|
||||
Some(Err(error)) => {
|
||||
log::warn!("[qbz-qt] playlist snapshot membership write failed: {error}")
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::spawn_blocking(write);
|
||||
} else {
|
||||
std::thread::spawn(write);
|
||||
}
|
||||
}
|
||||
|
||||
/// playlist id -> (persisted name, point-in-time Qobuz track count).
|
||||
pub fn headers_blocking() -> HashMap<u64, (String, Option<u32>)> {
|
||||
crate::library_db_qt::with_db(false, |db| Ok(db.with_connection(repo::all_headers)))
|
||||
.and_then(Result::ok)
|
||||
.map(|headers| {
|
||||
headers
|
||||
.into_iter()
|
||||
.map(|header| (header.qobuz_playlist_id, (header.name, header.track_count)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Playlists whose persisted membership contains at least one playable cache
|
||||
/// entry. An expired subscription grace window makes the whole set empty.
|
||||
pub fn available_offline_blocking() -> HashSet<u64> {
|
||||
if !crate::offline_fwd::offline_playback_allowed() {
|
||||
return HashSet::new();
|
||||
}
|
||||
let cached = crate::offline_qt::cached_ids_set();
|
||||
if cached.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
crate::library_db_qt::with_db(false, |db| Ok(db.with_connection(repo::all_track_ids)))
|
||||
.and_then(Result::ok)
|
||||
.map(|memberships| {
|
||||
memberships
|
||||
.into_iter()
|
||||
.filter(|(_, ids)| ids.iter().any(|id| cached.contains(id)))
|
||||
.map(|(playlist_id, _)| playlist_id)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -181,35 +181,30 @@ pub fn teardown() {
|
||||
|
||||
/// Fetch playlists (Qobuz) + folders + membership + hidden set (library.db).
|
||||
///
|
||||
/// The Qobuz half is GATED on connectivity and, when refused, KEEPS whatever
|
||||
/// the cache already holds. That distinction is load-bearing: this function is
|
||||
/// also the body of `crate::reload_sidebar_including_local()`, the refresh verb
|
||||
/// every local mutation uses, and it runs offline by design. Attempting the
|
||||
/// fetch there would yield `Vec::new()` (the gate answers with an error), so
|
||||
/// hiding a local playlist after going offline mid-session would WIPE the
|
||||
/// Qobuz playlists out of the tree. The folders + locals reads below are
|
||||
/// unconditional — they are the whole sidebar for a user with no Qobuz account.
|
||||
/// Offline, the remote half is synthesized from local sidecars plus persisted
|
||||
/// playlist membership that intersects the ready download cache. This is what
|
||||
/// keeps only playable Qobuz playlists — and therefore only useful folders —
|
||||
/// visible across both a connectivity flip and a cold app restart.
|
||||
pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let offline = crate::offline_fwd::engine().status().is_offline();
|
||||
let playlists: Vec<SidebarPlaylist> = if offline {
|
||||
// Preserve, never substitute: an empty vector here is a WIPE, not a
|
||||
// refresh. Empty on a cold offline start, which is correct — there is
|
||||
// nothing to preserve yet.
|
||||
let cached = CACHE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|d| d.playlists.clone())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"[qbz-qt] sidebar load: offline — Qobuz fetch skipped, {} cached playlist(s) kept",
|
||||
cached.len()
|
||||
);
|
||||
cached
|
||||
log::info!("[qbz-qt] sidebar load: offline — Qobuz fetch skipped");
|
||||
Vec::new()
|
||||
} else {
|
||||
log::debug!("[qbz-qt] sidebar load: fetching user playlists");
|
||||
match runtime.core().get_user_playlists().await {
|
||||
Ok(pls) => {
|
||||
crate::playlist_snapshot_qt::record_names_detached(
|
||||
pls.iter()
|
||||
.map(|playlist| crate::playlist_snapshot_qt::SnapshotNameEntry {
|
||||
qobuz_playlist_id: playlist.id,
|
||||
name: playlist.name.clone(),
|
||||
owner: Some(playlist.owner.name.clone())
|
||||
.filter(|owner| !owner.is_empty()),
|
||||
track_count: Some(playlist.tracks_count),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
// Same response, second consumer: the playlist ownership /
|
||||
// follow snapshot every PlaylistCard's tri-state overlay reads
|
||||
// (`playlist_qt::set_user_playlists`). This is the earliest point
|
||||
@@ -263,9 +258,63 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
};
|
||||
|
||||
log::debug!("[qbz-qt] sidebar load: playlists fetch settled, reading folders");
|
||||
let (folders, folder_map, positions, hidden_playlists) = folders_blocking();
|
||||
let (folders, folder_map, positions, hidden_playlists, local_counts) = folders_blocking();
|
||||
log::debug!("[qbz-qt] sidebar load: folders read");
|
||||
let mut playlists = playlists;
|
||||
if offline {
|
||||
let prior: HashMap<u64, SidebarPlaylist> = CACHE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|data| {
|
||||
data.playlists
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|playlist| (playlist.id, playlist))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let headers = crate::playlist_snapshot_qt::headers_blocking();
|
||||
let available = crate::playlist_snapshot_qt::available_offline_blocking();
|
||||
let mut ids: Vec<u64> = local_counts
|
||||
.iter()
|
||||
.filter(|(_, count)| **count > 0)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in available {
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
for id in ids {
|
||||
let local_count = local_counts.get(&id).copied().unwrap_or(0);
|
||||
if let Some(mut cached) = prior.get(&id).cloned() {
|
||||
if let Some((name, track_count)) = headers.get(&id) {
|
||||
cached.name = name.clone();
|
||||
cached.tracks_count = track_count.unwrap_or(local_count);
|
||||
}
|
||||
playlists.push(cached);
|
||||
continue;
|
||||
}
|
||||
let (name, tracks_count) = headers
|
||||
.get(&id)
|
||||
.map(|(name, track_count)| (name.clone(), track_count.unwrap_or(local_count)))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
qbz_i18n::t_args("Playlist ({} local)", &[&local_count.to_string()]),
|
||||
local_count,
|
||||
)
|
||||
});
|
||||
playlists.push(SidebarPlaylist {
|
||||
id,
|
||||
name,
|
||||
tracks_count,
|
||||
cover_urls: Vec::new(),
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
for p in &mut playlists {
|
||||
// Assign, don't merge: a preserved cached row carries its previous
|
||||
// position, and a settings row that has since been deleted must reset
|
||||
@@ -302,10 +351,16 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
|
||||
/// library.db queries (folders.rs equivalents), run inline — rusqlite is
|
||||
/// sync but the three reads are tiny.
|
||||
fn folders_blocking() -> (Vec<(String, String)>, HashMap<u64, String>, HashMap<u64, i32>, HashSet<u64>) {
|
||||
let Some(uid) = UserDataPaths::load_last_user_id() else {
|
||||
return Default::default();
|
||||
};
|
||||
fn folders_blocking() -> (
|
||||
Vec<(String, String)>,
|
||||
HashMap<u64, String>,
|
||||
HashMap<u64, i32>,
|
||||
HashSet<u64>,
|
||||
HashMap<u64, u32>,
|
||||
) {
|
||||
// Guest/offline-only users live in users/0, the same convention as every
|
||||
// other Qt library.db accessor.
|
||||
let uid = UserDataPaths::load_last_user_id().unwrap_or(0);
|
||||
let Some(path) = dirs::data_dir().map(|p| {
|
||||
p.join("qbz")
|
||||
.join("users")
|
||||
@@ -339,7 +394,8 @@ fn folders_blocking() -> (Vec<(String, String)>, HashMap<u64, String>, HashMap<u
|
||||
hidden.insert(s.qobuz_playlist_id);
|
||||
}
|
||||
}
|
||||
(folders, folder_map, positions, hidden)
|
||||
let local_counts = db.get_all_playlist_local_track_counts().unwrap_or_default();
|
||||
(folders, folder_map, positions, hidden, local_counts)
|
||||
}
|
||||
|
||||
/// A Qobuz playlist's track count from the session cache (`sidebar.rs:389`).
|
||||
@@ -388,6 +444,7 @@ pub fn rebuild() -> Vec<SidebarEntry> {
|
||||
let expanded = EXPANDED.lock().unwrap().clone();
|
||||
let query = SEARCH.lock().unwrap().clone();
|
||||
let searching = !query.is_empty();
|
||||
let offline = crate::offline_fwd::engine().status().is_offline();
|
||||
let folder_ids: HashSet<&String> = data.folders.iter().map(|f| &f.0).collect();
|
||||
|
||||
let sorted = sort_playlists(&data.playlists);
|
||||
@@ -445,8 +502,10 @@ pub fn rebuild() -> Vec<SidebarEntry> {
|
||||
.filter(|p| local_matches(p))
|
||||
.collect(),
|
||||
);
|
||||
// While searching, skip folders with nothing matching in EITHER set.
|
||||
if searching && members.is_empty() && local_members.is_empty() {
|
||||
// Search hides empty result branches. Offline mode additionally hides
|
||||
// folders with no playable member; Playlist Manager still reads every
|
||||
// folder directly from library.db and therefore remains complete.
|
||||
if (searching || offline) && members.is_empty() && local_members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// When searching, force-expand so matches inside are visible.
|
||||
|
||||
Reference in new issue
Block a user