feat(library): add Library Explorer

Add the chained genre, artist, and album browser with persistent layout and tab ordering. Reuse the shared album semantics for grouped versions and disc-aware details, enforce active source filters, and bound foreground detail work so chained searches stay responsive.

cx
This commit is contained in:
vicrodh committed 2026-08-25 17:21:07 -06:00
1 parent dc0a37487b
commit 9c22a909d6
40 files changed
+5374 -752

No files matched your search

+56 -8
View File
@@ -6,7 +6,9 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule};
/// baked icon variants (qml/assets/icons/<tint>/<name>.svg) are too many to
/// list by hand.
fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
for entry in std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())) {
for entry in
std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
{
let path = entry.expect("dir entry").path();
if path.is_dir() {
collect_qrc_files(&path, out);
@@ -293,10 +295,7 @@ fn build_rhi_items() {
.expect("Qt6 not found for the RHI items (cxx-qt-build would fail too)");
let mut cc = cc::Build::new();
cc.cpp(true)
.std("c++17")
.pic(true)
.include("cxx"); // the moc outputs do `#include "<name>.h"`
cc.cpp(true).std("c++17").pic(true).include("cxx"); // the moc outputs do `#include "<name>.h"`
for item in [
"linebed_item",
"plasma_item",
@@ -334,8 +333,10 @@ fn build_rhi_items() {
let libs = qtbuild.qmake_query("QT_INSTALL_LIBS");
cc.flag(&format!("-F{}", libs.trim()));
for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] {
let private =
format!("{}/{module}.framework/Headers/{version}/{module}", libs.trim());
let private = format!(
"{}/{module}.framework/Headers/{version}/{module}",
libs.trim()
);
if Path::new(&private).is_dir() {
cc.include(private);
}
@@ -400,7 +401,49 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs", "src/purchases_bridge.rs", "src/offline_manager_bridge.rs", "src/track_replace_bridge.rs", "src/disc_meta_bridge.rs", "src/tag_editor_bridge.rs"],
rust_files: &[
"src/bridge.rs",
"src/session_bridge.rs",
"src/shell_bridge.rs",
"src/player_bridge.rs",
"src/queue_bridge.rs",
"src/home_bridge.rs",
"src/viz_bridge.rs",
"src/immersive_bridge.rs",
"src/shader_scene_bridge.rs",
"src/suggestions_bridge.rs",
"src/hotkeys_bridge.rs",
"src/search_bridge.rs",
"src/local_bridge.rs",
"src/library_bridge.rs",
"src/album_bridge.rs",
"src/artist_bridge.rs",
"src/scene_bridge.rs",
"src/musician_bridge.rs",
"src/lyrics_qt.rs",
"src/icon_tint_qt.rs",
"src/cast_bridge.rs",
"src/myqbz_bridge.rs",
"src/myqbz_add_bridge.rs",
"src/disco_bridge.rs",
"src/blacklist_bridge.rs",
"src/playlist_picker_bridge.rs",
"src/playlist_manager_bridge.rs",
"src/playlist_import_bridge.rs",
"src/dac_wizard_bridge.rs",
"src/folder_edit_bridge.rs",
"src/playlist_edit_bridge.rs",
"src/qconnect_bridge.rs",
"src/kiosk_nav_bridge.rs",
"src/mini_bridge.rs",
"src/tray_bridge.rs",
"src/about_bridge.rs",
"src/purchases_bridge.rs",
"src/offline_manager_bridge.rs",
"src/track_replace_bridge.rs",
"src/disc_meta_bridge.rs",
"src/tag_editor_bridge.rs",
],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -661,6 +704,7 @@ fn main() {
"qml/settings/LibFolderEditModal.qml",
"qml/settings/LibraryFolderTable.qml",
"qml/settings/LocalLibrarySettings.qml",
"qml/settings/LocalTabsConfigModal.qml",
"qml/settings/OfflineSettings.qml",
"qml/settings/PlaybackSettings.qml",
// ONE component instantiated twice (Jellyfin + Subsonic).
@@ -697,8 +741,12 @@ fn main() {
"qml/views/local/LocalChrome.qml",
"qml/views/local/LocalEphemeralPane.qml",
"qml/views/local/LocalFilterPopup.qml",
"qml/views/local/LocalFilterButton.qml",
"qml/views/local/LocalFolderDetail.qml",
"qml/views/local/LocalFoldersTab.qml",
"qml/views/local/LocalGenreColumn.qml",
"qml/views/local/LocalGenreDetails.qml",
"qml/views/local/LocalGenresTab.qml",
"qml/views/local/LocalNote.qml",
"qml/views/local/LocalSearchBox.qml",
"qml/views/local/LocalToolbar.qml",
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="3" />
<path d="M5 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h1M19 5h1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1" />
<path d="M14 14.5a2.5 2.5 0 1 1-1.5-2.29V8l4-1v2.5" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

@@ -36,6 +36,9 @@ Column {
/// previews — every call site guards, so a preview degrades to the old
/// unconfirmed behaviour rather than swallowing the click.
property var confirmHost: null
/// View-level reorder modal. It must live outside this scrolled Loader so
/// its scrim covers Settings instead of moving with the panel.
property var tabOrderModal: null
QbzTheme { id: theme }
@@ -118,6 +121,46 @@ Column {
Item { width: 1; height: 22 }
// ============================ TAB ORDER ==============================
GroupHeader { text: QbzSession.tr("LIBRARY TABS", QbzSession.trRev) }
SettingRow {
label: QbzSession.tr("Tab order and default", QbzSession.trRev)
description: QbzSession.tr("Choose the order of Local Library tabs. The first one opens by default, including when QBZ starts without a Qobuz session.", QbzSession.trRev)
SettingsButton {
text: QbzSession.tr("Customize", QbzSession.trRev)
trailingIconName: "chevron-right"
onClicked: if (root.tabOrderModal) root.tabOrderModal.open()
}
}
Item { width: 1; height: 22 }
// =========================== GENRES VIEW ============================
GroupHeader { text: QbzSession.tr("GENRES VIEW", QbzSession.trRev) }
SettingRow {
label: QbzSession.tr("Genre filters position", QbzSession.trRev)
description: QbzSession.tr("Place the chained genre filters above, beside or below the album results.", QbzSession.trRev)
QbzSelect {
menuWidth: 170
options: [
QbzSession.tr("Top", QbzSession.trRev),
QbzSession.tr("Right", QbzSession.trRev),
QbzSession.tr("Left", QbzSession.trRev),
QbzSession.tr("Bottom", QbzSession.trRev)
]
currentIndex: {
var value = root.doc.genreFiltersPosition || "top"
var index = ["top", "right", "left", "bottom"].indexOf(value)
return index >= 0 ? index : 0
}
onSelected: function (index) {
QbzBridge.settingsSelect("genre-filters-position", index)
}
}
}
Item { width: 1; height: 22 }
// ============================ ALBUMS VIEW ============================
GroupHeader { text: QbzSession.tr("ALBUMS VIEW", QbzSession.trRev) }
SettingRow {
@@ -0,0 +1,304 @@
// Settings > Local Library tab order. Same interaction as Discover's Home
// configurator: live up/down reordering plus Reset, with no Save ceremony.
// The first row is both the ordinary Local Library landing and the page shown
// when an unauthenticated user chooses Start offline.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
property bool _open: false
property var draftOrder: []
readonly property var defaultOrder: ["genres", "albums", "artists", "folders", "tracks"]
readonly property bool opened: root._open
QbzTheme { id: theme }
function storedOrder() {
try {
var value = JSON.parse(QbzBridge.settingsJson).localTabOrder
if (Array.isArray(value) && value.length > 0) return value.slice()
} catch (e) { /* boot fallback below */ }
return root.defaultOrder.slice()
}
function labelFor(id) {
if (id === "albums") return QbzSession.tr("Albums", QbzSession.trRev)
if (id === "artists") return QbzSession.tr("Artists", QbzSession.trRev)
if (id === "folders") return QbzSession.tr("Folders", QbzSession.trRev)
if (id === "tracks") return QbzSession.tr("Tracks", QbzSession.trRev)
return QbzSession.tr("Library Explorer", QbzSession.trRev)
}
function iconFor(id) {
if (id === "albums") return "disc"
if (id === "artists") return "user"
if (id === "folders") return "folder"
if (id === "tracks") return "music"
return "music-note-slider"
}
function open() {
root.draftOrder = root.storedOrder()
root._open = true
}
function close() { root._open = false }
function publish(order) {
root.draftOrder = order
QbzBridge.settingsString("local-tab-order", JSON.stringify(order))
}
function move(index, delta) {
var nextIndex = index + delta
if (index < 0 || nextIndex < 0 || nextIndex >= root.draftOrder.length)
return
var next = root.draftOrder.slice()
var held = next[index]
next[index] = next[nextIndex]
next[nextIndex] = held
root.publish(next)
}
Connections {
target: QbzBridge
function onSettingsJsonChanged() {
if (root._open) root.draftOrder = root.storedOrder()
}
}
visible: root._open
enabled: root._open
z: 3100
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.close()
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: panel
width: Math.min(root.width - 80, 500)
height: panelCol.implicitHeight + 40
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
MouseArea {
anchors.fill: parent
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: panelCol
x: 20
y: 20
width: parent.width - 40
spacing: 14
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Local Library tabs", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 6
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.close()
}
}
}
Text {
width: parent.width
text: QbzSession.tr("Reorder the Local Library tabs. The first tab opens by default and is shown when starting without a Qobuz session.", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: 2
Repeater {
model: root.draftOrder
delegate: Rectangle {
id: tabRow
required property string modelData
required property int index
width: parent.width
height: 44
radius: theme.radiusSm
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 6
spacing: 10
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.iconFor(tabRow.modelData)
width: 17
height: 17
tintName: tabRow.index === 0 ? "accent" : "secondary"
}
Text {
width: Math.max(0, parent.width - 17 - defaultPill.width - 56 - 4 * 10)
height: parent.height
text: root.labelFor(tabRow.modelData)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Rectangle {
id: defaultPill
anchors.verticalCenter: parent.verticalCenter
visible: tabRow.index === 0
width: visible ? defaultText.implicitWidth + 14 : 0
height: 22
radius: 11
color: Qt.rgba(theme.accent.r, theme.accent.g,
theme.accent.b, 0.14)
Text {
id: defaultText
anchors.centerIn: parent
text: QbzSession.tr("Default", QbzSession.trRev)
color: theme.accent
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
ReorderButton {
anchors.verticalCenter: parent.verticalCenter
glyph: "chevron-up"
buttonEnabled: tabRow.index > 0
onClicked: root.move(tabRow.index, -1)
}
ReorderButton {
anchors.verticalCenter: parent.verticalCenter
glyph: "chevron-down"
buttonEnabled: tabRow.index < root.draftOrder.length - 1
onClicked: root.move(tabRow.index, 1)
}
}
}
}
}
Rectangle {
width: resetRow.width + 24
height: 34
radius: theme.radiusSm
color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
id: resetRow
anchors.centerIn: parent
spacing: 8
QbzIcon {
name: "rotate-ccw"
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Reset to defaults", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
}
}
MouseArea {
id: resetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.draftOrder = root.defaultOrder.slice()
QbzBridge.settingsString("local-tab-order-reset", "")
}
}
}
}
}
component ReorderButton: Rectangle {
id: reorderRoot
property string glyph: "chevron-up"
property bool buttonEnabled: true
signal clicked()
width: 28
height: 28
radius: theme.radiusSm
opacity: reorderRoot.buttonEnabled ? 1.0 : 0.3
color: reorderRoot.buttonEnabled && reorderArea.containsMouse
? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: reorderRoot.glyph
width: 16
height: 16
tintName: "secondary"
}
MouseArea {
id: reorderArea
anchors.fill: parent
enabled: reorderRoot.buttonEnabled
hoverEnabled: reorderRoot.buttonEnabled
cursorShape: reorderRoot.buttonEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: reorderRoot.clicked()
}
}
}
+9 -1
View File
@@ -333,7 +333,8 @@ Item {
LocalLibrarySettings {
width: parent.width
doc: root.doc
confirmHost: confirmHost
confirmHost: confirmHost
tabOrderModal: localTabsConfigModal
}
}
}
@@ -398,6 +399,13 @@ Item {
// / PlexSettings `confirmHost`).
SettingsConfirmHost { id: confirmHost }
// App-wide Local Library order. Mounted at the view root so its scrim
// covers the sub-navigation and the scrolled panel alike.
LocalTabsConfigModal {
id: localTabsConfigModal
anchors.fill: parent
}
// The per-folder settings modal. Same reasoning as the confirm host: it
// must overlay the whole view, so it cannot live inside the scrolled
// panel that opens it. The reference mounts its counterpart at the
+48 -18
View File
@@ -62,13 +62,20 @@ Item {
// Slint gates the Recommendations entry on SettingsState.show-recommendations
// (discover_prefs `show_recommendations`, default ON) — the port publishes
// it in QbzBridge.settingsJson.
readonly property bool showRecommendations: {
readonly property var settingsDoc: {
try {
return JSON.parse(QbzBridge.settingsJson).showRecommendations !== false
return JSON.parse(QbzBridge.settingsJson)
} catch (e) {
return true
return ({})
}
}
readonly property bool showRecommendations:
nav.settingsDoc.showRecommendations !== false
readonly property var localTabOrder: {
var stored = nav.settingsDoc.localTabOrder
return Array.isArray(stored) && stored.length > 0
? stored : ["genres", "albums", "artists", "folders", "tracks"]
}
// MyQBZ branding — `label` (user-editable in Settings > Appearance,
// coerced back to "My QBZ" on the Rust side when cleared), `iconPath` +
@@ -108,7 +115,8 @@ Item {
// buildSections purely so the binding re-runs on a language switch
// (QbzSession.trRev is the translation revision counter every other
// tr() call site binds to).
readonly property var sections: buildSections(QbzSession.trRev, showRecommendations)
readonly property var sections:
buildSections(QbzSession.trRev, showRecommendations, localTabOrder)
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
@@ -121,7 +129,7 @@ Item {
// `icon`"; a non-empty path is a user-supplied image
// rendered raw by shell/NavSectionGlyph.qml. Only the
// MyQBZ section can carry one. }
function buildSections(rev, reco) {
function buildSections(rev, reco, localOrder) {
// Discover — glyph-less rows (Slint has-glyph: false).
var discover = [
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
@@ -131,6 +139,40 @@ Item {
if (reco) {
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
}
// Local Library's fixed surfaces have ONE authoritative user order.
// Build both flyout hosts from it, then append the ephemeral Open
// submenu: Open is an action/session, not a reorderable landing tab.
var localById = {
"albums": { "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
"artists": { "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
"genres": { "label": nav.trs("Library Explorer"), "icon": "music-note-slider", "view": "local", "tab": "genres", "enabled": true },
"folders": { "label": QbzSession.offline ? nav.trs("Folder view") : nav.trs("Folders"),
"icon": "folder", "view": "local", "tab": "folders", "enabled": true },
"tracks": { "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
}
var localEntries = []
var seenLocal = ({})
for (var li = 0; li < localOrder.length; li++) {
var localId = localOrder[li]
if (localById[localId] && seenLocal[localId] !== true) {
localEntries.push(localById[localId])
seenLocal[localId] = true
}
}
// Defensive completion for the construction-time/pre-migration frame.
var localFallback = ["genres", "albums", "artists", "folders", "tracks"]
for (var lf = 0; lf < localFallback.length; lf++) {
var fallbackId = localFallback[lf]
if (seenLocal[fallbackId] !== true)
localEntries.push(localById[fallbackId])
}
localEntries.push({
"label": QbzLocal.localEphemeralActive
? QbzLocal.localEphemeralLabel
: (QbzSession.offline ? nav.trs("Open media") : nav.trs("Open")),
"icon": "folder-open", "view": "", "tab": "", "enabled": true,
"hasSubmenu": true, "submenu": nav.openMediaEntries()
})
return [
{
"id": "discover",
@@ -161,19 +203,7 @@ Item {
"label": nav.trs("Local Library"),
"qobuz": false,
"enabled": true,
"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("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,
"hasSubmenu": true,
"submenu": nav.openMediaEntries() }
]
"entries": localEntries
},
{
"id": "myqbz",
+483 -38
View File
@@ -67,8 +67,33 @@ Rectangle {
readonly property var nativeArtistAlbumsModel: QbzLocalArtistAlbums
// ============================ state ==================================
// Slint defaults, verbatim (state.slint LocalLibraryState).
property string activeTab: "albums"
// One app-wide order drives this bar, both nav flyouts and logged-off
// startup. Rust seeds the key synchronously before the full settings
// snapshot, so the first frame never flashes a different default.
readonly property var localTabOrder: {
try {
var stored = JSON.parse(QbzBridge.settingsJson).localTabOrder
if (Array.isArray(stored) && stored.length > 0) return stored
} catch (e) { /* construction fallback below */ }
return ["genres", "albums", "artists", "folders", "tracks"]
}
readonly property string localDefaultTab:
localTabOrder.length > 0 ? localTabOrder[0] : "genres"
readonly property string genreFiltersPosition: {
try {
var value = JSON.parse(QbzBridge.settingsJson).genreFiltersPosition || "top"
if (["top", "right", "left", "bottom"].indexOf(value) >= 0) return value
} catch (e) { /* construction fallback below */ }
return "top"
}
property string activeTab: localDefaultTab
// Session-only file reachability. Keys are `source:id`; values are the
// user-facing reason. This is deliberately not persisted: a moved file or
// unavailable NAS must remain retryable and a rescan remains authoritative.
property var localTrackErrors: ({})
function localTrackErrorKey(source, id) {
return (source || "local") + ":" + String(id || "")
}
// Albums tab.
property string albumsSearch: ""
@@ -77,7 +102,7 @@ Rectangle {
property string albumsView: "grid" // grid | list
property bool albumsMultiSelect: false
property var albumsSelected: ({})
readonly property int albumsSelectedCount: QbzLocal.localAlbumsNativeActive
readonly property int albumsSelectedCount: root.albumsNativeViewActive
? QbzLocal.localAlbumsNativeSelectedCount
: Object.keys(albumsSelected).length
@@ -96,6 +121,7 @@ Rectangle {
// Artists tab.
property string artistsSearch: ""
property string artistsSort: "name-asc"
property string selectedArtist: ""
// Tracks tab.
@@ -112,18 +138,194 @@ Rectangle {
? QbzLocal.localTracksNativeSelectedCount
: Object.keys(tracksSelected).length
// Albums quality/format/source filter (LibAlbumFilterState).
// Genres column browser. Selections are maps so Ctrl/Cmd toggles stay
// O(1), and an empty map is the explicit All state.
property string genresSearch: ""
property string genreArtistsSearch: ""
property string genreAlbumsSearch: ""
property var selectedGenres: ({})
property var selectedGenreArtists: ({})
property var selectedGenreAlbums: ({})
readonly property int selectedGenreCount: Object.keys(selectedGenres).length
readonly property int selectedGenreArtistCount: Object.keys(selectedGenreArtists).length
readonly property int selectedGenreAlbumCount: Object.keys(selectedGenreAlbums).length
property bool genresBrowserCollapsed: false
property string genresView: "details" // grid | list | details
property string genresSort: "title-asc"
// Routed history state. Scroll is handled independently by ScrollMemory;
// this document restores the controls that determine what that offset
// means before the tab asks for data.
property bool _restoringNavigationState: false
readonly property string navigationStateJson: JSON.stringify({
activeTab: root.activeTab,
albumsSearch: root.albumsSearch,
albumsSort: root.albumsSort,
albumsGroup: root.albumsGroup,
albumsView: root.albumsView,
albumsFilter: root.albumsFilter,
artistsFilter: root.artistsFilter,
tracksFilter: root.tracksFilter,
genresFilter: root.genresFilter,
foldersMode: root.foldersMode,
foldersSearch: root.foldersSearch,
foldersSort: root.foldersSort,
foldersGroup: root.foldersGroup,
foldersGridView: root.foldersGridView,
treeSearch: root.treeSearch,
selectedFolder: root.selectedFolder,
folderDetailSearch: root.folderDetailSearch,
folderDetailView: root.folderDetailView,
treeRailWidth: root.treeRailWidth,
artistsSearch: root.artistsSearch,
artistsSort: root.artistsSort,
selectedArtist: root.selectedArtist,
tracksSearch: root.tracksSearch,
genresSearch: root.genresSearch,
genreArtistsSearch: root.genreArtistsSearch,
genreAlbumsSearch: root.genreAlbumsSearch,
selectedGenres: root.selectedGenres,
selectedGenreArtists: root.selectedGenreArtists,
selectedGenreAlbums: root.selectedGenreAlbums,
genresBrowserCollapsed: root.genresBrowserCollapsed,
genresView: root.genresView,
genresSort: root.genresSort
})
onNavigationStateJsonChanged: {
if (!root._restoringNavigationState && QbzShell.currentView === "local")
QbzShell.reportNavState("local", root.navigationStateJson)
}
function restoreNavigationState() {
if (QbzShell.restoreStateScope !== "local" || QbzShell.stateRestore === "")
return
var saved
try { saved = JSON.parse(QbzShell.stateRestore) }
catch (e) { QbzShell.restoreStateScope = ""; return }
root._restoringNavigationState = true
function text(name, fallback) {
return typeof saved[name] === "string" ? saved[name] : fallback
}
root.activeTab = text("activeTab", root.activeTab)
root.albumsSearch = text("albumsSearch", root.albumsSearch)
root.albumsSort = text("albumsSort", root.albumsSort)
root.albumsGroup = text("albumsGroup", root.albumsGroup)
root.albumsView = text("albumsView", root.albumsView)
if (saved.albumsFilter && typeof saved.albumsFilter === "object")
root.albumsFilter = saved.albumsFilter
else if (saved.filter && typeof saved.filter === "object")
root.albumsFilter = saved.filter
if (saved.artistsFilter && typeof saved.artistsFilter === "object")
root.artistsFilter = saved.artistsFilter
if (saved.tracksFilter && typeof saved.tracksFilter === "object")
root.tracksFilter = saved.tracksFilter
if (saved.genresFilter && typeof saved.genresFilter === "object")
root.genresFilter = saved.genresFilter
// Older history entries kept four independent funnels. Adopt the one
// belonging to the restored tab as the shared quality/format/source
// state, while preserving the Albums/Genres-only favorite predicate.
var restoredFilter = root.activeTab === "tracks" ? root.tracksFilter
: root.activeTab === "artists" ? root.artistsFilter
: root.activeTab === "genres" ? root.genresFilter
: root.albumsFilter
if (root.albumsFilter.favorite === true
|| root.genresFilter.favorite === true) {
restoredFilter = Object.assign({}, restoredFilter)
restoredFilter.favorite = true
}
root.synchronizeFilters(root.activeTab, restoredFilter, false)
root.foldersMode = text("foldersMode", root.foldersMode)
root.foldersSearch = text("foldersSearch", root.foldersSearch)
root.foldersSort = text("foldersSort", root.foldersSort)
root.foldersGroup = text("foldersGroup", root.foldersGroup)
root.foldersGridView = text("foldersGridView", root.foldersGridView)
root.treeSearch = text("treeSearch", root.treeSearch)
root.selectedFolder = text("selectedFolder", root.selectedFolder)
root.folderDetailSearch = text("folderDetailSearch", root.folderDetailSearch)
root.folderDetailView = text("folderDetailView", root.folderDetailView)
if (typeof saved.treeRailWidth === "number") root.treeRailWidth = saved.treeRailWidth
root.artistsSearch = text("artistsSearch", root.artistsSearch)
root.artistsSort = text("artistsSort", root.artistsSort)
root.selectedArtist = text("selectedArtist", root.selectedArtist)
root.tracksSearch = text("tracksSearch", root.tracksSearch)
root.genresSearch = text("genresSearch", root.genresSearch)
root.genreArtistsSearch = text("genreArtistsSearch", root.genreArtistsSearch)
root.genreAlbumsSearch = text("genreAlbumsSearch", root.genreAlbumsSearch)
if (saved.selectedGenres && typeof saved.selectedGenres === "object")
root.selectedGenres = saved.selectedGenres
if (saved.selectedGenreArtists && typeof saved.selectedGenreArtists === "object")
root.selectedGenreArtists = saved.selectedGenreArtists
if (saved.selectedGenreAlbums && typeof saved.selectedGenreAlbums === "object")
root.selectedGenreAlbums = saved.selectedGenreAlbums
if (typeof saved.genresBrowserCollapsed === "boolean")
root.genresBrowserCollapsed = saved.genresBrowserCollapsed
root.genresView = text("genresView", root.genresView)
root.genresSort = text("genresSort", root.genresSort)
QbzShell.restoreStateScope = ""
root._restoringNavigationState = false
QbzShell.reportNavState("local", root.navigationStateJson)
}
// One quality/format/source funnel follows the user across Albums,
// Artists, Genres and Tracks. Favorites remains an Albums/Genres-only
// predicate, but it survives visits to tabs where that chip is absent.
property bool filterOpen: false
// SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on
// every navigation away (see the bridge's `albums_filter` doc), so a
// view-local default meant the funnel reset the moment the user visited
// Discover — and never survived a restart at all. `QbzLocal.albumsFilter`
// outlives the view and mirrors `ui_prefs.json`.
property var filter: root.parseFilter(QbzLocal.albumsFilter)
property var albumsFilter: root.parseFilter(QbzLocal.albumsFilter)
property var artistsFilter: root.commonFilter(albumsFilter)
property var tracksFilter: root.commonFilter(albumsFilter)
property var genresFilter: Object.assign({}, albumsFilter)
// Live heart fan-out. Album JSON/native pages are immutable snapshots;
// this tiny id map updates every mounted card and the Favorites-only
// predicate without republishing thousands of album rows (and therefore
// without moving the scroll position).
property var favoriteOverrides: ({})
readonly property bool albumsNativeViewActive:
QbzLocal.localAlbumsNativeActive
&& albumsFilter.favorite !== true
&& activeTab === "albums"
readonly property var filter: activeTab === "artists" ? artistsFilter
: activeTab === "tracks" ? tracksFilter
: activeTab === "genres" ? genresFilter
: albumsFilter
function parseFilter(json) {
if (!json || json === "") return ({})
try { return JSON.parse(json) || ({}) } catch (e) { return ({}) }
}
function commonFilter(value) {
var out = Object.assign({}, value || ({}))
delete out.favorite
return out
}
function synchronizeFilters(sourceTab, value, persist) {
var wasFavoriteOnly = albumsFilter.favorite === true
var favorite = sourceTab === "albums" || sourceTab === "genres"
? value.favorite === true : wasFavoriteOnly
var common = commonFilter(value)
var albumValue = Object.assign({}, common)
var genreValue = Object.assign({}, common)
if (favorite) {
albumValue.favorite = true
genreValue.favorite = true
}
albumsFilter = albumValue
artistsFilter = Object.assign({}, common)
tracksFilter = Object.assign({}, common)
genresFilter = genreValue
if (!persist) return
QbzLocal.setAlbumsFilterJson(Object.keys(albumValue).length === 0
? "" : JSON.stringify(albumValue))
// This setter resets the Tracks query, so call it only while Tracks
// is the visible consumer. A later tab switch applies the already
// shared state before loading page one.
if (activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(common))
if (!wasFavoriteOnly && favorite && activeTab === "albums")
QbzLocal.loadTab("albums-legacy")
}
// A LATER republish still wins: the gates and the saved funnel are
// published together when a media server is connected or removed, and the
// pruning that happens there (a tick whose chip is now hidden) has to
@@ -133,8 +335,20 @@ Rectangle {
target: QbzLocal
function onAlbumsFilterChanged() {
var next = root.parseFilter(QbzLocal.albumsFilter)
if (JSON.stringify(next) !== JSON.stringify(root.filter))
root.filter = next
if (JSON.stringify(next) !== JSON.stringify(root.albumsFilter))
root.synchronizeFilters("albums", next, false)
}
function onLocalAlbumFavoriteChanged(id, favorite) {
var next = Object.assign({}, root.favoriteOverrides)
next[id] = favorite
root.favoriteOverrides = next
}
function onLocalTrackAvailability(source, id, message) {
var next = Object.assign({}, root.localTrackErrors)
var key = root.localTrackErrorKey(source, id)
if (message === "") delete next[key]
else next[key] = message
root.localTrackErrors = next
}
}
readonly property int filterCount: {
@@ -142,12 +356,10 @@ Rectangle {
for (var k in filter) if (filter[k]) n++
return n
}
/// The ONE writer. Both mutators funnel through it, so neither can change
/// the funnel and forget to persist it.
/// The ONE writer. Both mutators funnel through it, so the four tabs
/// cannot drift into different source/format/quality states.
function setFilter(f) {
filter = f
QbzLocal.setAlbumsFilterJson(
Object.keys(f).length === 0 ? "" : JSON.stringify(f))
synchronizeFilters(activeTab, f, true)
}
function toggleFilter(key) {
var f = Object.assign({}, filter)
@@ -157,7 +369,7 @@ Rectangle {
}
function clearFilter() { setFilter({}) }
/// The funnel's state as the applied-filters tooltip wants it: three groups
/// The funnel's state as the applied-filters tooltip wants it: grouped
/// in the popup's own order, each holding the labels of the keys that are
/// on. Built HERE because this is where the state lives — the toolbar only
/// draws the trigger, and `LocalFilterPopup` only draws chips.
@@ -175,6 +387,8 @@ Rectangle {
}
var tr = QbzSession.trRev
return [
{ group: QbzSession.tr("Favorites", tr),
values: pick(["favorite"], [QbzSession.tr("Favorites only", tr)]) },
{ group: QbzSession.tr("Quality", tr),
values: pick(["hires", "cd", "lossy"],
[QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr),
@@ -216,10 +430,12 @@ Rectangle {
/// The ephemeral tab is the only one that can VANISH while the user stands
/// on it (the folder is closed, the disc is ejected). Without this the view
/// keeps `activeTab === "ephemeral"` with no tab in the bar and no Loader
/// active: a blank content area and no way back except another tab. Albums
/// is the fallback because it is this view's own default (:63).
/// active: a blank content area and no way back except another tab. The
/// user's first ordered surface is the same fallback every other Local
/// Library entry point uses.
onEphemeralActiveChanged: {
if (!ephemeralActive && activeTab === "ephemeral") activeTab = "albums"
if (!ephemeralActive && activeTab === "ephemeral")
activeTab = root.localDefaultTab
}
/// Opening something must LAND you on it. While the pane was an arm of the
@@ -240,6 +456,11 @@ Rectangle {
function onLocalEphemeralOpenSeqChanged() { root.activeTab = "ephemeral" }
}
function loadTabForView(tab) {
QbzLocal.loadTab(tab === "albums" && root.albumsFilter.favorite === true
? "albums-legacy" : tab)
}
// ---------------------------- documents ------------------------------
function parseDoc(json, fallback) {
if (json === "") return fallback
@@ -415,7 +636,17 @@ Rectangle {
// Mount: load the default tab. Tab switches load on demand (each tab is
// one query; the Albums/Folders/Artists sets are bounded).
Component.onCompleted: {
QbzLocal.loadTab(root.activeTab)
restoreNavigationState()
// Initial bindings are not a reliable change notification contract:
// explicitly seed history even when this is a fresh entry whose state
// happens to equal every default.
QbzShell.reportNavState("local", root.navigationStateJson)
if (root.activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(root.tracksFilter))
else
root.loadTabForView(root.activeTab)
if (root.activeTab === "tracks" && root.tracksSearch !== "")
QbzLocal.tracksSearch(root.tracksSearch)
consumePendingArtist()
consumePendingRoute()
}
@@ -424,7 +655,14 @@ Rectangle {
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
}
onActiveTabChanged: {
QbzLocal.loadTab(activeTab)
// Tracks owns a server-side descriptor; install the shared funnel
// before its first page is queried. The setter performs that load.
if (!root._restoringNavigationState) {
if (activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(root.tracksFilter))
else
root.loadTabForView(activeTab)
}
// The tab that just appeared has covers to ask for and the one that
// left has covers to let go of. Each surface answers for itself.
artworkRefresh()
@@ -503,6 +741,9 @@ Rectangle {
// and an AND across sections — an empty section means "any".
function sourceBucket(word) {
var source = (word || "local").toLowerCase()
// library.db stores ordinary scanner rows as `user`; every Local
// Library surface exposes that storage word through the Local chip.
if (source === "" || source === "user") return "local"
if (source === "qobuz_purchase" || source === "qobuz_download")
return "offline"
if (source === "navidrome" || source === "gonic"
@@ -511,28 +752,30 @@ Rectangle {
return source
}
function applyFilter(rows) {
if (filterCount === 0) return rows
var qAny = filter.hires || filter.cd || filter.lossy
var fAny = filter.flac || filter.alac || filter.ape || filter.wav
|| filter.mp3 || filter.aac || filter.other
var sAny = filter.local || filter.offline || filter.plex
|| filter.jellyfin || filter.subsonic
function applyFilter(rows, selectedFilter) {
var selected = selectedFilter || ({})
if (Object.keys(selected).length === 0) return rows
var qAny = selected.hires || selected.cd || selected.lossy
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex
|| selected.jellyfin || selected.subsonic
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
var out = []
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
if (selected.favorite === true && !root.albumFavorite(r)) continue
var tier = (r.qualityTier || "").toLowerCase()
var fmt = (r.format || "").toLowerCase()
if (qAny) {
var qok = (filter.hires && (tier === "hires" || tier === "max"))
|| (filter.cd && tier === "cd")
|| (filter.lossy && (tier === "mp3" || tier === "lossy"))
var qok = (selected.hires && (tier === "hires" || tier === "max"))
|| (selected.cd && tier === "cd")
|| (selected.lossy && (tier === "mp3" || tier === "lossy"))
if (!qok) continue
}
if (fAny) {
var fok = filter[fmt] === true
|| (filter.other === true && known[fmt] !== 1)
var fok = selected[fmt] === true
|| (selected.other === true && known[fmt] !== 1)
if (!fok) continue
}
if (sAny) {
@@ -550,7 +793,7 @@ Rectangle {
? r.sources : [r.source || "local"]
var sourceMatches = false
for (var si = 0; si < sources.length; si++) {
if (filter[sourceBucket(sources[si])] === true) {
if (selected[sourceBucket(sources[si])] === true) {
sourceMatches = true
break
}
@@ -562,6 +805,24 @@ Rectangle {
return out
}
function albumFavorite(album) {
if (!album || !album.id) return false
var override = root.favoriteOverrides[album.id]
return override === undefined ? album.isFavorite === true : override === true
}
function toggleAlbumFavorite(album, artworkUrl) {
if (!album || !album.id || album.favoriteable !== true) return
var sources = album.sources && album.sources.length > 0
? album.sources : [album.sourceRaw || album.source || ""]
QbzLocal.albumToggleFavorite(
album.id,
album.title || "",
album.artist || "",
artworkUrl || "",
JSON.stringify(sources))
}
// A-Z / by-artist grouping — [{ letter, items }] plus the alpha jumps the
// strips consume (the strip derives its own indices from the groups).
function groupRows(rows, mode) {
@@ -591,9 +852,8 @@ Rectangle {
// Once F1 owns the Albums tab, never run the legacy O(n) JS pipeline in
// parallel. The old document may still be resident because Artists uses
// it until F2; it is evaluated only while that legacy surface is active.
readonly property var albumsVisible: QbzLocal.localAlbumsNativeActive
&& activeTab === "albums" ? []
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort))
readonly property var albumsVisible: root.albumsNativeViewActive ? []
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort), albumsFilter)
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
readonly property var foldersVisible:
@@ -606,14 +866,72 @@ Rectangle {
// track hundreds of indices whenever a later page belonged ahead of it.
readonly property var tracksVisible: tracks
function artistMatchesFilter(artist, selected) {
if (!selected || Object.keys(selected).length === 0) return true
var qAny = selected.hires || selected.cd || selected.lossy
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex
|| selected.jellyfin || selected.subsonic
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1,
"mp3": 1, "aac": 1 }
var i, value
if (qAny) {
var qualityOk = false
for (i = 0; i < (artist.qualityTiers || []).length; i++) {
value = (artist.qualityTiers[i] || "").toLowerCase()
if ((selected.hires && (value === "hires" || value === "max"))
|| (selected.cd && value === "cd")
|| (selected.lossy && (value === "mp3" || value === "lossy"))) {
qualityOk = true; break
}
}
if (!qualityOk) return false
}
if (fAny) {
var formatOk = false
for (i = 0; i < (artist.formats || []).length; i++) {
value = (artist.formats[i] || "").toLowerCase()
if (selected[value] === true
|| (selected.other === true && known[value] !== 1)) {
formatOk = true; break
}
}
if (!formatOk) return false
}
if (sAny) {
var sourceOk = false
for (i = 0; i < (artist.sources || []).length; i++) {
if (selected[sourceBucket(artist.sources[i])] === true) {
sourceOk = true; break
}
}
if (!sourceOk) return false
}
return true
}
readonly property var artistsVisible: {
if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return []
var q = artistsSearch.trim().toLowerCase()
if (q === "") return artists
var out = []
for (var i = 0; i < artists.length; i++) {
if ((artists[i].name || "").toLowerCase().indexOf(q) >= 0) out.push(artists[i])
var artist = artists[i]
if (q !== "" && (artist.name || "").toLowerCase().indexOf(q) < 0)
continue
if (!artistMatchesFilter(artist, artistsFilter)) continue
out.push(artist)
}
out.sort(function(a, b) {
var key = root.artistsSort
var year = key.indexOf("year-") === 0
var av = year ? Number(a.year || 0) : (a.name || "").toLowerCase()
var bv = year ? Number(b.year || 0) : (b.name || "").toLowerCase()
if (year && av === 0) return 1
if (year && bv === 0) return -1
var cmp = av < bv ? -1 : av > bv ? 1 : 0
return key.slice(-4) === "desc" ? -cmp : cmp
})
return out
}
readonly property int artistsVisibleCount: artistsVisible.length
@@ -636,6 +954,127 @@ Rectangle {
return out
}
// --------------------- Genres column browser -------------------------
// Empty selection maps are the explicit All state. Multiple selections
// are OR-ed within one column and AND-ed across the chained columns.
readonly property var genreBaseAlbums: applyFilter(albums, genresFilter)
function albumArtistNames(album) {
if (album.artists && album.artists.length > 0)
return album.artists
var values = []
var seen = {}
var raw = (album.allArtists || "").trim()
var parts = raw === "" ? [album.artist || ""] : raw.split(",")
if ((album.artist || "").trim() !== "") parts.unshift(album.artist)
for (var i = 0; i < parts.length; i++) {
var display = (parts[i] || "").trim()
var key = display.toLowerCase()
if (display !== "" && !seen[key]) { seen[key] = true; values.push(display) }
}
return values
}
function albumHasSelectedGenre(album) {
if (selectedGenreCount === 0) return true
var genres = album.genres || []
for (var i = 0; i < genres.length; i++)
if (selectedGenres[(genres[i] || "").toLowerCase()] === true) return true
return false
}
function albumHasSelectedArtist(album) {
if (selectedGenreArtistCount === 0) return true
var names = albumArtistNames(album)
for (var i = 0; i < names.length; i++)
if (selectedGenreArtists[names[i].toLowerCase()] === true) return true
return false
}
readonly property var genreNames: {
var values = {}, display = {}
for (var i = 0; i < genreBaseAlbums.length; i++) {
var genres = genreBaseAlbums[i].genres || []
for (var j = 0; j < genres.length; j++) {
var name = (genres[j] || "").trim(), key = name.toLowerCase()
if (name !== "" && !values[key]) { values[key] = true; display[key] = name }
}
}
var out = []
var q = genresSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || display[key].toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": display[key] })
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreArtistOptions: {
var values = {}, display = {}
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album)) continue
var names = albumArtistNames(album)
for (var j = 0; j < names.length; j++) {
var key = names[j].toLowerCase()
if (!values[key]) { values[key] = true; display[key] = names[j] }
}
}
var out = [], q = genreArtistsSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || display[key].toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": display[key] })
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreAlbumOptions: {
var out = [], q = genreAlbumsSearch.trim().toLowerCase()
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album) || !albumHasSelectedArtist(album)) continue
if (q !== "" && (album.title || "").toLowerCase().indexOf(q) < 0) continue
out.push({ "key": album.id, "label": album.title || "", "album": album })
}
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreAlbumsVisible: {
var rows = []
for (var i = 0; i < genreAlbumOptions.length; i++) {
var option = genreAlbumOptions[i]
if (selectedGenreAlbumCount === 0 || selectedGenreAlbums[option.key] === true)
rows.push(option.album)
}
return sortRows(rows, genresSort)
}
function toggleGenre(key, modifiers) {
selectedGenres = nextFacetSelection(selectedGenres, key, modifiers)
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
}
function toggleGenreArtist(key, modifiers) {
selectedGenreArtists = nextFacetSelection(selectedGenreArtists, key, modifiers)
selectedGenreAlbums = ({})
}
function toggleGenreAlbum(key, modifiers) {
selectedGenreAlbums = nextFacetSelection(selectedGenreAlbums, key, modifiers)
}
function nextFacetSelection(current, key, modifiers) {
if (key === "") return ({})
var additive = (modifiers & Qt.ControlModifier) !== 0
|| (modifiers & Qt.MetaModifier) !== 0
if (!additive) {
var only = {}; only[key] = true; return only
}
var out = Object.assign({}, current)
if (out[key]) delete out[key]
else out[key] = true
return out
}
// The selected artist's albums.
//
// The MATCH lives in Rust — `QbzLocal.artistAlbumIds` ->
@@ -907,7 +1346,7 @@ Rectangle {
// ============================ actions ================================
function openAlbum(id) {
QbzLocal.openAlbum(id)
QbzLocal.openAlbumFiltered(id, JSON.stringify(root.filter || {}))
QbzShell.navigateTo("localalbum")
}
function selectFolder(path) {
@@ -1083,6 +1522,11 @@ Rectangle {
active: QbzLocal.localAvailable && root.activeTab === "artists"
sourceComponent: LocalArtistsTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "genres"
sourceComponent: LocalGenresTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "folders"
@@ -1113,7 +1557,8 @@ Rectangle {
// it instead of taking a layout slot (the Slint mount, :2470).
LocalFilterPopup {
anchors.fill: parent
visible: root.filterOpen && root.activeTab === "albums"
visible: root.filterOpen
&& ["albums", "artists", "genres", "tracks"].indexOf(root.activeTab) >= 0
view: root
}
}
@@ -104,7 +104,7 @@ Item {
readonly property var emptySlot: ({
"id": "", "title": "", "artist": "", "year": "",
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": "",
"sources": []
"sources": [], "isFavorite": false, "favoriteable": false
})
// ---------------------------------------------------------------------
@@ -223,10 +223,13 @@ Item {
onTriggered: root.resetNativeQuery()
}
function scheduleNativeReset() {
if (nativeSurface && cols > 0) nativeQueryCoalescer.restart()
if (nativeSurface && cols > 0 && view
&& view.albumsFilter.favorite !== true)
nativeQueryCoalescer.restart()
}
function resetNativeQuery() {
if (!nativeSurface || !view || cols <= 0) return
if (!nativeSurface || !view || cols <= 0
|| view.albumsFilter.favorite === true) return
QbzLocal.albumsNativeReset(
view.albumsSearch,
view.albumsSort,
@@ -433,44 +436,8 @@ Item {
width: 200
height: 246
// Right-click menu for the card (AlbumCard.slint's
// album-menu, local arm: no favourite, no Block —
// the Slint hides Block for source local/plex).
// The card's own ⋯ overlay button opens AlbumCard's
// built-in menu; see the GLUE note about aligning
// that one's entries with these.
// LAZY, like AlbumCard's own. This cell carried a
// SECOND popup on top of the card's, so a grid of
// 1267 local albums paid two Popup constructions
// per cell — and the ListView recycles a screenful
// of cells on every scroll step, which is what the
// owner reports as heavy scrolling here.
Loader {
id: cardMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.slot.id)
else if (a === "play") root.playRequested(cardCell.slot.id)
else root.enqueueRequested(cardCell.slot.id, a)
}
}
}
/// Build the popup on first use, then open it.
function openCardMenu(anchor, x, y) {
cardMenuLoader.active = true
cardMenuLoader.item.openAtCursor(anchor, x, y)
}
AlbumCard {
id: albumCard
localMode: true
albumId: cardCell.slot.id
title: cardCell.slot.title
@@ -499,6 +466,14 @@ Item {
|| cardCell.slot.source
sources: cardCell.slot.sources || []
showSourceBadge: root.showSource
hostFavorite: cardCell.slot.favoriteable === true
isFavorite: root.view
? root.view.albumFavorite(cardCell.slot)
: cardCell.slot.isFavorite === true
onFavoriteRequested: if (root.view) {
root.view.toggleAlbumFavorite(
cardCell.slot, albumCard.artSource)
}
// SELECT MODE IS THE CARD'S (AlbumCard's
// `selectMode`/`selected`/`selectToggled`, the
// port of discover/AlbumCard.slint:83, :179-197,
@@ -565,7 +540,7 @@ Item {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
cardCell.openCardMenu(cardRc, mouse.x, mouse.y)
albumCard.openAlbumMenu(cardRc, mouse.x, mouse.y)
}
}
// (The multi-select tick that used to sit here is
@@ -586,6 +561,9 @@ Item {
artSource: root.view
? (modelData.items[0].artPath
|| root.view.artMap[modelData.items[0].artKey] || "") : ""
isFavorite: root.view
? root.view.albumFavorite(modelData.items[0])
: modelData.items[0].isFavorite === true
showSource: root.showSource
selectMode: root.selectMode
checked: root.nativeActive
@@ -596,6 +574,9 @@ Item {
onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m)
}
onFavoriteRequested: if (root.view) {
root.view.toggleAlbumFavorite(modelData.items[0], artSource)
}
onToggleSelect: function (mods) {
if (root.nativeActive)
root.nativeToggleSelect(modelData.items[0].nativeIndex, mods)
+226 -23
View File
@@ -6,16 +6,14 @@
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
// in front of the cover, as the collection view does.
//
// The ⋯ menu is AlbumListRow.slint:381 minus its two Qobuz-only entries: the
// favourite heart (local albums are not catalog favourites) and "Block this
// album" (which the Slint itself hides for source local/plex, :435). What is
// left is the five entries Slint always shows — Open album, Play, Play next,
// Play later, Add to queue — and all five are wired. Right-clicking the row
// opens the same menu at the pointer.
// The ⋯ menu carries the same local/Plex favorite as the grid overlay. Remote-
// only rows omit it because the shared LocalFavoritesService deliberately
// accepts genuine local and Plex snapshots only.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../rows"
import "../../theme"
Rectangle {
@@ -23,6 +21,7 @@ Rectangle {
property var item: ({})
property string artSource: ""
property bool isFavorite: root.item.isFavorite === true
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
/// artwork gate and the settle bound all live there (never duplicated
/// per row, so the rule cannot drift between surfaces).
@@ -30,19 +29,43 @@ Rectangle {
property bool showSource: true
property bool selectMode: false
property bool checked: false
/// Details surfaces keep albums open by default but must not replace the
/// album row with bespoke chrome. The optional caret is the one extra
/// affordance; clicking the rest of the row still opens AlbumView.
property bool expandable: false
property bool expanded: true
/// Genres Details uses the same album semantics/menu but a taller table
/// header whose trailing columns line up with LocalTrackRow.
property bool detailsMode: false
property var versions: []
property int versionIndex: 0
signal opened()
signal playRequested()
signal shuffleRequested()
signal enqueueRequested(string mode)
signal favoriteRequested()
signal toggleExpanded()
signal versionPicked(int index)
/// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme }
TrackCols { id: cols }
height: 56
height: detailsMode ? 72 : 56
radius: 6
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
function openRowMenuAt(anchor, x, y) {
rowMenuLoader.active = true
rowMenuLoader.item.openAtCursor(anchor, x, y)
}
function openRowMenuBelow(anchor) {
rowMenuLoader.active = true
rowMenuLoader.item.openBelowRight(anchor)
}
MouseArea {
id: rowArea
anchors.fill: parent
@@ -51,7 +74,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
root.openRowMenuAt(rowArea, mouse.x, mouse.y)
return
}
if (root.selectMode) root.toggleSelect(mouse.modifiers)
@@ -60,24 +83,43 @@ Rectangle {
onDoubleClicked: if (!root.selectMode) root.playRequested()
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.opened()
else if (a === "play") root.playRequested()
else root.enqueueRequested(a)
// Same cold-menu contract as LocalTrackRow: a list/detail viewport should
// construct album rows, not five invisible popup delegates per row.
Loader {
id: rowMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: {
var rows = [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }
]
if (root.item.favoriteable === true) {
rows.push({
"label": root.isFavorite
? QbzSession.tr("Remove from Library", QbzSession.trRev)
: QbzSession.tr("Add to Library", QbzSession.trRev),
"icon": root.isFavorite ? "heart-filled" : "heart",
"action": "favorite"
})
}
return rows
}
onPicked: function (a) {
if (a === "open") root.opened()
else if (a === "play") root.playRequested()
else if (a === "favorite") root.favoriteRequested()
else root.enqueueRequested(a)
}
}
}
Row {
visible: !root.detailsMode
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 12
@@ -124,6 +166,7 @@ Rectangle {
width: parent.width - 40 - 70 - 90 - 92 - 32
- (root.showSource ? 34 : 0)
- (root.selectMode ? 28 : 0) - 6 * 12
- (root.expandable ? 28 + 12 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
@@ -197,6 +240,28 @@ Rectangle {
}
}
}
Rectangle {
visible: root.expandable
width: visible ? 28 : 0
height: 28
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: expandArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: root.expanded ? "chevron-down" : "chevron-right"
width: 14
height: 14
anchors.centerIn: parent
tintName: expandArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: expandArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleExpanded()
}
}
// Trailing ⋯ overflow (AlbumListRow.slint:360).
Rectangle {
width: 32
@@ -216,8 +281,146 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
onClicked: function (mouse) {
root.openRowMenuAt(moreArea, mouse.x, mouse.y)
}
}
}
}
// Genres Details header. LocalTrackRow holds back 26px for its source
// indicator and 46px for its local menu; the formulas below use those
// same gutters so quality, menu and version/source share exact centres.
Item {
id: detailsLayout
visible: root.detailsMode
anchors.fill: parent
readonly property int qualityX: width - 26 - (cols.colMenu + cols.gap)
- cols.padH - cols.colQuality
readonly property int menuX: width - 70
QbzIconButton {
id: detailsFold
visible: root.expandable
x: 8
anchors.verticalCenter: parent.verticalCenter
btnSize: 26
iconSize: 12
name: root.expanded ? "minus" : "plus"
onClicked: root.toggleExpanded()
}
Rectangle {
x: 46
width: 48
height: 48
anchors.verticalCenter: parent.verticalCenter
radius: 5
color: theme.surfaceElevated
clip: true
RoundedImage {
id: detailsArt
anchors.fill: parent
source: root.artSource
radius: 5
}
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 5
pending: root.view ? root.view.artWanted(root.item.artKey) : false
coverReady: detailsArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {
x: 106
y: 9
width: Math.max(0, albumActions.x - x - 14)
spacing: 1
Text {
width: parent.width
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.item.artist || ""
color: theme.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
var parts = []
if ((root.item.year || "") !== "") parts.push(root.item.year)
parts.push((root.item.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev))
return parts.join(" · ")
}
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
// Compact and deliberately not centred: it floats at the right edge
// of the metadata region, before the table's quality/actions columns.
Row {
id: albumActions
x: detailsLayout.qualityX - width - 16
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIconButton {
btnSize: 26
iconSize: 13
name: "play-fill"
onClicked: root.playRequested()
}
QbzIconButton {
btnSize: 26
iconSize: 13
name: "shuffle"
onClicked: root.shuffleRequested()
}
}
Item {
x: detailsLayout.qualityX
width: cols.colQuality
height: parent.height
QualityMini {
tier: root.item.qualityTier || ""
anchors.centerIn: parent
}
}
QbzIconButton {
id: detailsMenuButton
x: detailsLayout.menuX
anchors.verticalCenter: parent.verticalCenter
btnSize: cols.colMenu
iconSize: 16
name: "ellipsis"
onClicked: root.openRowMenuBelow(detailsMenuButton)
}
VersionPicker {
visible: root.showSource && root.versions.length > 0
x: parent.width - width
anchors.verticalCenter: parent.verticalCenter
compact: true
versions: root.versions
current: root.versionIndex
onPicked: function (index) { root.versionPicked(index) }
}
}
}
@@ -19,7 +19,7 @@ Item {
id: root
property var view: null
readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive
readonly property bool nativeActive: root.view.albumsNativeViewActive
readonly property int albumTotal: nativeActive
? QbzLocal.localAlbumsNativeTotal : root.view.albums.length
@@ -68,7 +68,7 @@ Item {
SettingsButton {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Retry", QbzSession.trRev)
onClicked: QbzLocal.loadTab("albums")
onClicked: root.view.loadTabForView("albums")
}
}
@@ -138,8 +138,12 @@ Item {
selectMode: root.view.albumsMultiSelect
selected: root.view.albumsSelected
onOpenRequested: function (id) { root.view.openAlbum(id) }
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
onPlayRequested: function (id) {
QbzLocal.playAlbumFiltered(id, JSON.stringify(root.view.albumsFilter || {}), false)
}
onEnqueueRequested: function (id, m) {
QbzLocal.enqueueAlbumFiltered(id, JSON.stringify(root.view.albumsFilter || {}), m)
}
onToggleSelect: function (id, mods) { root.view.toggleAlbumSelected(id, mods) }
onNativeToggleSelect: function (index, mods) {
root.view.toggleNativeAlbumSelected(index, mods)
@@ -84,6 +84,8 @@ Item {
target: root.view
function onArtistsGroupedChanged() { root.rebuild() }
function onArtistsSearchChanged() { nativeQueryCoalescer.restart() }
function onArtistsSortChanged() { nativeQueryCoalescer.restart() }
function onArtistsFilterChanged() { nativeQueryCoalescer.restart() }
function onSelectedArtistChanged() { detailQueryCoalescer.restart() }
function onArtworkRefresh() { root.reportSoon() }
}
@@ -107,7 +109,10 @@ Item {
id: nativeQueryCoalescer
interval: 0
onTriggered: if (root.view)
QbzLocal.artistsNativeReset(root.view.artistsSearch)
QbzLocal.artistsNativeReset(
root.view.artistsSearch,
root.view.artistsSort,
JSON.stringify(root.view.artistsFilter || ({})))
}
Timer {
id: detailQueryCoalescer
@@ -387,8 +392,12 @@ Item {
nativeJumpsJson: "[]"
onColsChanged: detailQueryCoalescer.restart()
onOpenRequested: function (id) { root.view.openAlbum(id) }
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
onPlayRequested: function (id) {
QbzLocal.playAlbumFiltered(id, JSON.stringify(root.view.artistsFilter || {}), false)
}
onEnqueueRequested: function (id, m) {
QbzLocal.enqueueAlbumFiltered(id, JSON.stringify(root.view.artistsFilter || {}), m)
}
}
}
}
+12 -6
View File
@@ -199,12 +199,18 @@ Item {
// click that can only fail. `ephemeralActive` is the same flag the
// pane itself keys on, so the tab and its body cannot disagree.
tabs: {
var t = [
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
{ "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 },
]
var byId = {
"albums": { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
"artists": { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
"genres": { "id": "genres", "label": QbzSession.tr("Library Explorer", QbzSession.trRev), "count": root.view.genreNames.length },
"folders": { "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
"tracks": { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 }
}
var t = []
for (var i = 0; i < root.view.localTabOrder.length; i++) {
var id = root.view.localTabOrder[i]
if (byId[id]) t.push(byId[id])
}
if (root.view.ephemeralActive)
t.push({ "id": "ephemeral",
"label": root.view.ephemeralLabel,
@@ -0,0 +1,80 @@
// Shared quality/format/source funnel trigger for Local Library surfaces.
// The popup and state stay on LocalLibraryView; this component only draws
// the inline toolbar control so Albums, Artists, Genres and Tracks cannot
// drift into four subtly different buttons.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
property string ownerKey: "local-filter"
QbzTheme { id: theme }
width: 34
height: 30
Rectangle {
anchors.fill: parent
radius: 6
border.width: 1
border.color: root.view && root.view.filterCount > 0
? theme.accent : theme.borderSubtle
color: area.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
name: "list-filter"
width: 15
height: 15
anchors.centerIn: parent
tintName: root.view && root.view.filterCount > 0 ? "accent" : "secondary"
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
tip.exit()
root.view.filterOpen = !root.view.filterOpen
}
onEntered: tip.enter()
onExited: tip.exit()
}
}
QbzFilterTip {
id: tip
ownerKey: root.ownerKey
anchor: area
groups: root.view ? root.view.filterSummaryGroups : []
}
LocalTip {
visible: area.containsMouse && !tip.hasSummary
text: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
}
Rectangle {
visible: root.view && root.view.filterCount > 0
x: parent.width - width + 3
y: -4
width: 15
height: 15
radius: 7.5
color: theme.accent
Text {
anchors.centerIn: parent
text: root.view ? root.view.filterCount : 0
color: theme.accentGlyphColor
font.pixelSize: 9
font.weight: theme.weightBold
}
}
}
@@ -51,6 +51,7 @@ Item {
qualityRow.implicitWidth,
formatRow1.implicitWidth,
formatRow2.implicitWidth,
favoriteRow.visible ? favoriteRow.implicitWidth : 0,
sourceRow.implicitWidth)
width: Math.min(Math.max(372, card.contentWidth + 32),
Math.max(372, root.width - 72))
@@ -112,6 +113,29 @@ Item {
}
}
// Album membership lives here, not in the three chained Genres
// columns: it narrows the resulting album set just like quality,
// format and source do.
Text {
visible: root.view.activeTab === "albums"
|| root.view.activeTab === "genres"
text: QbzSession.tr("Favorites", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Row {
id: favoriteRow
visible: root.view.activeTab === "albums"
|| root.view.activeTab === "genres"
spacing: 8
FilterChip {
label: QbzSession.tr("Favorites only", QbzSession.trRev)
active: root.view.filter.favorite === true
onToggled: root.view.toggleFilter("favorite")
}
}
// ---- Quality ----
Text {
text: QbzSession.tr("Quality", QbzSession.trRev)
@@ -0,0 +1,139 @@
// One column of the Local Library Genres browser. Selection is controlled by
// the host: plain click replaces, Ctrl (or Command on macOS) toggles.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
id: root
property string title: ""
property string allLabel: ""
property string query: ""
property string editQuery: query
// The last column is a direct title lookup over the already-projected
// logical albums, so it can publish sooner than the upstream facets whose
// edits rebuild the options to their right.
property int debounceMs: 90
property var options: []
property var selected: ({})
signal queryEdited(string value)
signal toggled(string key, int modifiers)
onQueryChanged: {
if (editQuery !== query) editQuery = query
}
Timer {
id: queryDebounce
interval: root.debounceMs
repeat: false
onTriggered: root.queryEdited(root.editQuery)
}
QbzTheme { id: theme }
// Keep the browser legible over album artwork without turning Ambient
// back into an opaque black panel. The 50% neutral base lets the palette
// through while the frost edge keeps the three chained columns distinct.
radius: theme.radiusSm
clip: true
color: theme.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
Text {
id: heading
x: 10
y: 7
text: root.title
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
QbzLineEdit {
id: search
x: 8
y: 27
width: parent.width - 16
height: 30
searchMode: true
sm: true
text: root.editQuery
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function(value) {
root.editQuery = value
if (value === "") {
queryDebounce.stop()
root.queryEdited(value)
} else {
queryDebounce.restart()
}
}
}
ListView {
id: list
x: 1
y: 64
width: parent.width - 2
height: parent.height - y - 1
clip: true
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: [{ "key": "", "label": root.allLabel }].concat(root.options || [])
delegate: Rectangle {
required property var modelData
width: list.width
height: 28
readonly property bool active: modelData.key === ""
? Object.keys(root.selected || {}).length === 0
: root.selected[modelData.key] === true
color: active
? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.alphaTier(10))
: area.containsMouse
? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceHover)
: "transparent"
Text {
x: 10
width: parent.width - 32
height: parent.height
verticalAlignment: Text.AlignVCenter
text: modelData.label || ""
color: parent.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
font.weight: parent.active ? theme.weightSemibold : theme.weightRegular
elide: Text.ElideRight
}
QbzIcon {
visible: parent.active
name: "check"
width: 12
height: 12
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) { root.toggled(modelData.key, mouse.modifiers) }
}
}
}
QbzScrollBar {
target: list
anchors.right: parent.right
anchors.top: list.top
anchors.bottom: list.bottom
}
}
@@ -0,0 +1,606 @@
// Expanded-album list for the Genres browser. Only visible albums request
// tracks; every album starts expanded, matching the classic column browser
// while keeping the delegate count proportional to the viewport.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
property var albums: []
// album id -> {tracks, discs, versions, versionIndex}. Only the selected
// version carries heavy rows; the picker metadata is compact.
property var detailCache: ({})
// album id -> mediaFilterJson used to build the cached document. Keeping
// this separate lets a filter change retain the mounted track geometry
// while the replacement picker document is derived from Rust's raw cache.
property var detailFilters: ({})
property var pending: ({})
property var cacheOrder: []
// A cold viewport can contain eight compact album headers. Starting one
// cross-source detail query for every header at once contends on the same
// SQLite caches, then most results move off-screen as the first expanded
// album acquires its real height. Keep a small foreground window instead:
// the first result reshapes the ListView before we decide what is useful
// to fetch next.
readonly property int maxConcurrentDetailRequests: 2
function stableFilterJson(value) {
var source = value || ({})
var keys = Object.keys(source).sort()
var normalized = {}
for (var i = 0; i < keys.length; i++)
if (source[keys[i]] === true) normalized[keys[i]] = true
return JSON.stringify(normalized)
}
readonly property string mediaFilterJson:
stableFilterJson(view ? (view.genresFilter || {}) : {})
// Absence means expanded: albums are open by default, while a sparse map
// remembers only the rows the user explicitly folded.
property var collapsed: ({})
// "album id\ndisc number" -> true. Discs, like albums, start open and
// only explicit folds occupy this sparse map.
property var collapsedDiscs: ({})
readonly property bool initialDetailLoading:
Object.keys(detailCache).length === 0
&& Object.keys(pending).length > 0
QbzTheme { id: theme }
// Model/filter changes can invalidate this Loader before a deferred QML
// closure runs. Object-owned timers are cancelled with the view, and the
// short report debounce also coalesces the layout churn from a large box
// set into one visible-window update.
Timer {
id: ensureVisibleTimer
interval: 16
repeat: false
onTriggered: root.ensureVisible()
}
Timer {
id: reportTimer
interval: 16
repeat: false
onTriggered: root.report()
}
function scheduleEnsureVisible() {
ensureVisibleTimer.restart()
}
function scheduleReport() {
reportTimer.restart()
}
function ensure(albumId) {
if (!albumId) return false
if (detailCache[albumId] !== undefined
&& detailFilters[albumId] === mediaFilterJson) return false
// A quick source A -> A+B -> A sequence may leave an older worker in
// flight. Dedupe only the exact funnel; Rust generation-checks and
// suppresses any older result before it reaches this signal.
if (pending[albumId] === mediaFilterJson) return false
var next = Object.assign({}, pending)
next[albumId] = mediaFilterJson
pending = next
QbzLocal.genreAlbumTracks(albumId, mediaFilterJson)
return true
}
function currentPendingCount() {
var count = 0
var keys = Object.keys(pending)
for (var i = 0; i < keys.length; i++)
if (pending[keys[i]] === mediaFilterJson) count++
return count
}
function ensureVisible() {
if (albums.length === 0 || !list.visible) return
var first = list.indexAt(2, list.contentY + 1)
var last = list.indexAt(2, list.contentY + list.height - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(albums.length - 1, first + 4)
var available = maxConcurrentDetailRequests - currentPendingCount()
if (available <= 0) return
for (var i = first; i <= last && i < albums.length; i++) {
if (ensure(albums[i].id) && --available === 0) break
}
}
onMediaFilterJsonChanged: {
// Do not collapse a loaded 247-track box merely because another
// source was enabled. Its visible delegate asks for the new funnel
// below; the old rows remain stable until the picker/selection update
// arrives, avoiding a full destroy/recreate cycle and scroll jump.
// Old-generation workers are suppressed in Rust and publish no signal;
// do not let their QML bookkeeping occupy the new funnel's two slots.
pending = ({})
scheduleEnsureVisible()
}
function toggle(albumId) {
var next = Object.assign({}, collapsed)
if (next[albumId]) delete next[albumId]
else next[albumId] = true
collapsed = next
}
function discKey(albumId, disc) { return albumId + "\n" + disc }
function discCollapsed(albumId, disc) {
return collapsedDiscs[discKey(albumId, disc)] === true
}
function toggleDisc(albumId, disc) {
var key = discKey(albumId, disc)
var next = Object.assign({}, collapsedDiscs)
if (next[key]) delete next[key]
else next[key] = true
collapsedDiscs = next
}
Connections {
target: QbzLocal
function onLocalGenreAlbumReady(albumId, json, filterJson) {
// A queued cache hit can cross a source-chip change even though
// the worker paths are generation-checked. Never label that old
// physical-version document as belonging to the current funnel.
if (filterJson !== root.mediaFilterJson) return
var doc = { "artKey": "", "tracks": [], "discs": [],
"versions": [], "versionIndex": 0 }
try {
var parsed = JSON.parse(json || "{}")
// Tolerate an in-flight publish from an older process during
// development; release builds always send the object shape.
doc = Array.isArray(parsed)
? { "artKey": "", "tracks": parsed, "discs": [],
"versions": [], "versionIndex": 0 }
: {
"artKey": parsed.artKey || "",
"tracks": parsed.tracks || [],
"discs": parsed.discs || [],
"versions": parsed.versions || [],
"versionIndex": Number(parsed.versionIndex || 0)
}
}
catch (e) { console.warn("[qbz-qt] genres: bad album rows — " + e) }
var cache = Object.assign({}, root.detailCache)
var filters = Object.assign({}, root.detailFilters)
var order = root.cacheOrder.slice()
var existing = order.indexOf(albumId)
if (existing >= 0) order.splice(existing, 1)
order.push(albumId)
while (order.length > 32) {
var evicted = order.shift()
delete cache[evicted]
delete filters[evicted]
}
// If the selected physical version itself did not change, retain
// the exact track/disc arrays. The picker can gain a second source
// without resetting hundreds of nested delegates that are already
// correct. `key` fingerprints the filtered physical row set, so a
// format/quality change with the same directory cannot reuse it.
var previous = cache[albumId]
if (previous) {
var previousVersions = previous.versions || []
var previousIndex = Number(previous.versionIndex || 0)
var previousVersion = previousVersions[previousIndex] || ({})
var nextVersion = doc.versions[doc.versionIndex] || ({})
if ((previousVersion.key || "") !== ""
&& previousVersion.key === (nextVersion.key || "")) {
doc.artKey = previous.artKey || doc.artKey
doc.tracks = previous.tracks || doc.tracks
doc.discs = previous.discs || doc.discs
}
}
cache[albumId] = doc
filters[albumId] = root.mediaFilterJson
root.cacheOrder = order
root.detailCache = cache
root.detailFilters = filters
var live = Object.assign({}, root.pending)
if (live[albumId] === root.mediaFilterJson) delete live[albumId]
root.pending = live
// The selected version can name a different artwork key. Report
// after the cache publish so the artwork window requests it even
// when the outer album list itself did not change.
root.scheduleReport()
root.scheduleEnsureVisible()
}
}
function report() {
if (!view || albums.length === 0 || !list.visible) return
var first = list.indexAt(2, list.contentY + 1)
var last = list.indexAt(2, list.contentY + list.height - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(albums.length - 1, first + 4)
// Only build the visible slice. Mapping all 1,800 albums on every
// scroll tick would undo the virtualization this view relies on.
var artworkRows = []
for (var i = first; i <= last && i < albums.length; i++) {
var album = albums[i]
var detail = detailCache[album.id]
artworkRows.push({ "artKey": detail && detail.artKey
? detail.artKey : (album.artKey || "") })
var discs = detail ? (detail.discs || []) : []
for (var d = 0; d < discs.length; d++)
if (discs[d].artKey)
artworkRows.push({ "artKey": discs[d].artKey })
}
if (artworkRows.length > 0)
view.queueWindowReport(artworkRows, 0, artworkRows.length - 1,
"genres-details")
}
ListView {
id: list
anchors.fill: parent
anchors.rightMargin: 14
clip: true
reuseItems: true
// One expanded album may be thousands of pixels tall. A 600px outer
// cache eagerly queried whole albums above and below the viewport;
// the row-level window below supplies the useful look-ahead instead.
cacheBuffer: 240
boundsBehavior: Flickable.StopAtBounds
model: root.albums
onContentYChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
onHeightChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
onModelChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
delegate: Item {
id: albumBlock
required property var modelData
required property int index
readonly property var detail: root.detailCache[modelData.id]
|| ({ "artKey": "", "tracks": [], "discs": [],
"versions": [], "versionIndex": 0 })
readonly property var tracks: detail.tracks || []
readonly property var discs: detail.discs || []
readonly property var versions: detail.versions || []
readonly property int versionIndex: Number(detail.versionIndex || 0)
readonly property bool loaded: root.detailCache[modelData.id] !== undefined
readonly property bool expanded: root.collapsed[modelData.id] !== true
readonly property int discHeaders: loaded ? discs.length : 0
function startsDisc(rowIndex) {
if (albumBlock.discHeaders === 0) return false
if (rowIndex === 0) return true
return Number(tracks[rowIndex - 1].disc || 1)
!== Number(tracks[rowIndex].disc || 1)
}
function discInfo(number) {
for (var i = 0; i < discs.length; i++)
if (Number(discs[i].disc || 0) === number) return discs[i]
return null
}
function discIsCollapsed(number) {
return root.discCollapsed(modelData.id, number)
}
function discArt(info) {
if (!info) return ""
return (root.view && info.artKey
&& root.view.artMap[info.artKey])
|| info.cover || ""
}
readonly property bool discArtDistinct: {
if (discs.length < 2) return false
var seen = {}
for (var i = 0; i < discs.length; i++) {
var path = discArt(discs[i])
if (path === "" || seen[path]) return false
seen[path] = true
}
return true
}
// The server-backed track count is useful metadata, not geometry.
// Reserving `trackCount * rowHeight` before the detail arrives made
// a cold 13-track album look like 650 px of empty content, and a
// large box set could consume several whole screens with only a
// spinner at its top. Keep cold rows compact; once the visible
// album resolves, its real track rows become its real height.
readonly property int visibleTracks: {
if (!loaded) return 0
var count = 0
for (var i = 0; i < tracks.length; i++) {
if (!discIsCollapsed(Number(tracks[i].disc || 1))) count++
}
return count
}
readonly property int headerH: 72
readonly property int discHeaderH: 46
readonly property int loadingBodyH: 50
function ensureCurrent() {
// Delegate creation is only a visibility hint. Let the view's
// bounded scheduler choose foreground albums after ListView
// has settled their current geometry.
root.scheduleEnsureVisible()
}
width: list.width
height: headerH + (expanded
? (loaded
? visibleTracks * 50 + discHeaders * discHeaderH + 12
: loadingBodyH)
: 0)
// The Rust bridge always publishes cache hits on the next Qt
// event-loop turn, so requesting synchronously is safe and leaves
// no delegate-owned callback behind when ListView recycles it.
Component.onCompleted: ensureCurrent()
onModelDataChanged: ensureCurrent()
LocalAlbumRow {
x: 4
width: parent.width - 8
view: root.view
item: albumBlock.modelData
artSource: root.view
? ((albumBlock.detail.artKey
&& root.view.artMap[albumBlock.detail.artKey])
|| albumBlock.modelData.artPath
|| root.view.artMap[albumBlock.modelData.artKey] || "") : ""
isFavorite: root.view
? root.view.albumFavorite(albumBlock.modelData)
: albumBlock.modelData.isFavorite === true
showSource: true
expandable: true
expanded: albumBlock.expanded
detailsMode: true
versions: albumBlock.versions.length > 0
? albumBlock.versions
: [{
"version": "",
"trackCount": albumBlock.modelData.trackCount || 0,
"quality": albumBlock.modelData.qualityDetail || "",
"source": albumBlock.modelData.sourceRaw
|| albumBlock.modelData.source || "local"
}]
versionIndex: albumBlock.versionIndex
onOpened: root.view.openAlbum(albumBlock.modelData.id)
onPlayRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "play", "")
onShuffleRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "shuffle", "")
onEnqueueRequested: function(mode) {
QbzLocal.genreAlbumAction(albumBlock.modelData.id, mode, "")
}
onFavoriteRequested: if (root.view) {
root.view.toggleAlbumFavorite(albumBlock.modelData, artSource)
}
onToggleExpanded: root.toggle(albumBlock.modelData.id)
onVersionPicked: function(index) {
QbzLocal.genreAlbumSelectVersion(albumBlock.modelData.id, index)
}
}
QbzSpinner {
visible: albumBlock.expanded && !albumBlock.loaded
anchors.horizontalCenter: parent.horizontalCenter
y: albumBlock.headerH
+ (albumBlock.loadingBodyH - height) / 2
size: 18
}
Column {
visible: albumBlock.expanded && albumBlock.loaded
x: 4
y: albumBlock.headerH
width: parent.width - 8
Repeater {
model: albumBlock.tracks
delegate: Item {
id: trackBlock
required property var modelData
required property int index
width: parent ? parent.width : 0
readonly property bool showDisc: albumBlock.startsDisc(index)
readonly property int discNumber: Number(modelData.disc || 1)
readonly property bool discFolded:
albumBlock.discIsCollapsed(discNumber)
height: (showDisc ? albumBlock.discHeaderH : 0)
+ (discFolded ? 0 : 50)
// The outer ListView virtualizes by ALBUM. A 150-track
// box is one delegate, so its nested Repeater would
// otherwise build 150 full TrackRows even though only
// ~18 fit on screen. Keep cheap spacers for exact
// geometry and mount heavy rows in a six-row lookahead.
readonly property real contentTop:
albumBlock.y + albumBlock.headerH + y
readonly property bool inViewportBand:
contentTop + height >= list.contentY - 300
&& contentTop <= list.contentY + list.height + 300
// A divider used to be an always-instantiated Item
// whose `visible` flag was false on ordinary tracks.
// That still constructed its art, six controls and
// popup tree once PER TRACK. A 100-track box therefore
// paid for 100 dividers to paint five. Loader makes the
// object count match the number of actual boundaries.
Loader {
width: parent.width
height: trackBlock.showDisc ? albumBlock.discHeaderH : 0
active: trackBlock.showDisc && trackBlock.inViewportBand
sourceComponent: Item {
id: discHeader
width: parent.width
height: albumBlock.discHeaderH
readonly property var info:
albumBlock.discInfo(trackBlock.discNumber)
QbzIconButton {
x: 8
anchors.verticalCenter: parent.verticalCenter
btnSize: 26
iconSize: 12
name: trackBlock.discFolded ? "plus" : "minus"
onClicked: root.toggleDisc(
albumBlock.modelData.id, trackBlock.discNumber)
}
RoundedImage {
id: discThumb
x: 44
width: 30
height: 30
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
visible: albumBlock.discArtDistinct && source !== ""
source: albumBlock.discArt(parent.info)
}
Text {
x: discThumb.visible ? 84 : 44
width: Math.max(0, discActions.x - x - 12)
anchors.verticalCenter: parent.verticalCenter
text: {
var base = QbzSession.tr("Disc", QbzSession.trRev)
+ " " + trackBlock.discNumber
return parent.info && parent.info.title
? base + " — " + parent.info.title : base
}
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
// Smaller than AlbumView's 32/44px header actions:
// these float right, before the trailing table
// columns, and do not become a centred hero rail.
Row {
id: discActions
x: parent.width - 176
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIconButton {
btnSize: 26
iconSize: 13
name: "play-fill"
onClicked: QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
"play")
}
QbzIconButton {
btnSize: 26
iconSize: 13
name: "shuffle"
onClicked: QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
"shuffle")
}
}
QbzIconButton {
id: discMenuButton
x: parent.width - 70
anchors.verticalCenter: parent.verticalCenter
btnSize: 32
name: "ellipsis"
iconSize: 16
onClicked: {
discMenuLoader.active = true
discMenuLoader.item.openBelowRight(discMenuButton)
}
}
// A popup with five delegated rows is useful only
// after the click. Keep it out of every box-set
// divider's steady-state scene tree.
Loader {
id: discMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Shuffle", QbzSession.trRev), "icon": "shuffle", "action": "shuffle" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }
]
onPicked: function (action) {
QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
action)
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: theme.borderSubtle
}
}
}
Loader {
y: trackBlock.showDisc ? albumBlock.discHeaderH : 0
width: parent.width
height: trackBlock.discFolded ? 0 : 50
active: !trackBlock.discFolded
&& trackBlock.inViewportBand
sourceComponent: LocalTrackRow {
view: root.view
item: trackBlock.modelData
number: trackBlock.modelData.number > 0
? trackBlock.modelData.number : trackBlock.index + 1
showAlbum: false
showArtwork: false
zebra: true
onPlayRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "play",
trackBlock.modelData.id)
onEnqueueRequested: function(mode) {
QbzLocal.enqueue("track", trackBlock.modelData.id, mode)
}
}
}
}
}
}
}
}
// The per-album spinner can sit below a restored scroll offset while its
// first document is cold. This viewport-level affordance stays visible
// until any detail document lands, then never flashes for ordinary
// look-ahead requests during scrolling.
Rectangle {
visible: root.initialDetailLoading
z: 3
anchors.centerIn: parent
width: 44
height: 44
radius: 8
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
QbzSpinner {
anchors.centerIn: parent
size: 20
}
}
ScrollMemory { target: list; scope: "local:genres" }
QbzScrollBar {
target: list
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Component.onCompleted: scheduleReport()
Component.onDestruction: if (view) view.releaseWindow("genres-details")
}
@@ -0,0 +1,401 @@
// Local Library Genres — a retractable three-stage browser over the logical
// album set, surrounding grid/list/expanded-details results from any edge.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
readonly property string filterPosition: view ? view.genreFiltersPosition : "top"
readonly property bool verticalFilters: filterPosition === "left" || filterPosition === "right"
readonly property int sideWidth: 300 // ArtistView's Network sidebar width.
readonly property int collapsedThickness: 24
readonly property int horizontalHeight: 266
function facetTitle(index) {
if (index === 0) return QbzSession.tr("Genres", QbzSession.trRev)
if (index === 1) return QbzSession.tr("Artists", QbzSession.trRev)
return QbzSession.tr("Albums", QbzSession.trRev)
}
function facetAllLabel(index) {
if (index === 0) return QbzSession.tr("All genres", QbzSession.trRev)
if (index === 1) return QbzSession.tr("All artists", QbzSession.trRev)
return QbzSession.tr("All albums", QbzSession.trRev)
}
function facetQuery(index) {
if (index === 0) return root.view.genresSearch
if (index === 1) return root.view.genreArtistsSearch
return root.view.genreAlbumsSearch
}
function facetOptions(index) {
if (index === 0) return root.view.genreNames
if (index === 1) return root.view.genreArtistOptions
return root.view.genreAlbumOptions
}
function facetSelected(index) {
if (index === 0) return root.view.selectedGenres
if (index === 1) return root.view.selectedGenreArtists
return root.view.selectedGenreAlbums
}
function setFacetQuery(index, value) {
if (index === 0) root.view.genresSearch = value
else if (index === 1) root.view.genreArtistsSearch = value
else root.view.genreAlbumsSearch = value
}
function toggleFacet(index, key, modifiers) {
if (index === 0) root.view.toggleGenre(key, modifiers)
else if (index === 1) root.view.toggleGenreArtist(key, modifiers)
else {
// The details Loader already exists while the third column is
// visible. Ask it immediately instead of waiting for the filtered
// ListView to rebuild and mount its replacement delegate.
if (key !== "" && root.view.genresView === "details"
&& resultsLoader.item && resultsLoader.item.ensure)
resultsLoader.item.ensure(key)
root.view.toggleGenreAlbum(key, modifiers)
}
}
function facetSelectionLabel(index, key) {
var options = root.facetOptions(index)
for (var i = 0; i < options.length; i++)
if (options[i].key === key) return options[i].label
// A query can hide a selected option from its own column. Resolve
// albums against the unfiltered logical set; genre/artist keys are
// already readable names, merely normalized to lower case.
if (index === 2) {
for (var j = 0; j < root.view.albums.length; j++)
if (root.view.albums[j].id === key)
return root.view.albums[j].title || key
}
return key
}
function buildActiveFilterChips() {
if (!root.view) return []
var out = []
for (var facet = 0; facet < 3; facet++) {
var query = root.facetQuery(facet).trim()
if (query !== "")
out.push({ kind: "query", facet: facet,
label: root.facetTitle(facet) + ": " + query })
var selected = root.facetSelected(facet)
var keys = Object.keys(selected)
for (var i = 0; i < keys.length; i++) {
if (selected[keys[i]] !== true) continue
out.push({ kind: "selection", facet: facet, key: keys[i],
label: root.facetTitle(facet) + ": "
+ root.facetSelectionLabel(facet, keys[i]) })
}
}
var tr = QbzSession.trRev
var filters = [
{ key: "favorite", label: QbzSession.tr("Favorites only", tr) },
{ key: "hires", label: QbzSession.tr("Hi-Res", tr) },
{ key: "cd", label: QbzSession.tr("CD", tr) },
{ key: "lossy", label: QbzSession.tr("Lossy", tr) },
{ key: "flac", label: "FLAC" }, { key: "alac", label: "ALAC" },
{ key: "ape", label: "APE" }, { key: "wav", label: "WAV" },
{ key: "mp3", label: "MP3" }, { key: "aac", label: "AAC" },
{ key: "other", label: QbzSession.tr("Other", tr) },
{ key: "local", label: QbzSession.tr("Local", tr) },
{ key: "offline", label: QbzSession.tr("Offline cache", tr) },
{ key: "plex", label: "Plex" }, { key: "jellyfin", label: "Jellyfin" },
{ key: "subsonic", label: "Subsonic" }
]
for (i = 0; i < filters.length; i++)
if (root.view.filter[filters[i].key] === true)
out.push({ kind: "filter", key: filters[i].key,
label: filters[i].label })
return out
}
function clearFilterChip(chip) {
if (chip.kind === "query") root.setFacetQuery(chip.facet, "")
else if (chip.kind === "selection")
root.toggleFacet(chip.facet, chip.key, Qt.ControlModifier)
else root.view.toggleFilter(chip.key)
}
function clearAllActiveFilters() {
for (var i = 0; i < 3; i++) root.setFacetQuery(i, "")
root.view.selectedGenres = ({})
root.view.selectedGenreArtists = ({})
root.view.selectedGenreAlbums = ({})
root.view.clearFilter()
}
readonly property var activeFilterChips: buildActiveFilterChips()
function caretName() {
var collapsed = root.view && root.view.genresBrowserCollapsed
if (filterPosition === "bottom") return collapsed ? "chevron-up" : "chevron-down"
if (filterPosition === "left") return collapsed ? "chevron-right" : "chevron-left"
if (filterPosition === "right") return collapsed ? "chevron-left" : "chevron-right"
return collapsed ? "chevron-down" : "chevron-up"
}
component GenreFacet: LocalGenreColumn {
required property int facetIndex
title: root.facetTitle(facetIndex)
allLabel: root.facetAllLabel(facetIndex)
query: root.facetQuery(facetIndex)
options: root.facetOptions(facetIndex)
selected: root.facetSelected(facetIndex)
// A title query can temporarily match hundreds of expanded albums.
// Publishing after each fast keystroke rebuilds that result tree on
// the GUI thread and makes the next keystroke feel stuck. The edit
// itself remains immediate; only the derived result waits for the
// short typing pause.
debounceMs: facetIndex === 2 ? 140 : 90
onQueryEdited: function(value) { root.setFacetQuery(facetIndex, value) }
onToggled: function(key, modifiers) { root.toggleFacet(facetIndex, key, modifiers) }
}
Component {
id: horizontalBrowser
Row {
spacing: 8
Repeater {
model: 3
delegate: GenreFacet {
required property int index
facetIndex: index
width: (parent.width - 16) / 3
height: parent.height
}
}
}
}
Component {
id: verticalBrowser
Column {
spacing: 8
Repeater {
model: 3
delegate: GenreFacet {
required property int index
facetIndex: index
width: parent.width
height: (parent.height - 16) / 3
}
}
}
}
Item {
id: browser
readonly property bool collapsed: root.view && root.view.genresBrowserCollapsed
x: root.verticalFilters
? (root.filterPosition === "left" ? 8 : root.width - width - 8)
: 32
y: root.verticalFilters
? 10
: (root.filterPosition === "bottom" ? root.height - height - 10 : 10)
width: root.verticalFilters
? (collapsed ? root.collapsedThickness : root.sideWidth)
: Math.max(0, root.width - 64)
height: root.verticalFilters
? Math.max(0, root.height - 20)
: (collapsed ? root.collapsedThickness : root.horizontalHeight)
Loader {
visible: !browser.collapsed
x: root.verticalFilters && root.filterPosition === "right"
? root.collapsedThickness : 0
y: !root.verticalFilters && root.filterPosition === "bottom"
? root.collapsedThickness : 0
width: root.verticalFilters
? Math.max(0, parent.width - root.collapsedThickness)
: parent.width
height: root.verticalFilters
? parent.height
: Math.max(0, parent.height - root.collapsedThickness)
sourceComponent: root.verticalFilters ? verticalBrowser : horizontalBrowser
}
Rectangle {
x: root.verticalFilters
? (root.filterPosition === "left" ? parent.width - width : 0)
: (parent.width - width) / 2
y: root.verticalFilters
? (parent.height - height) / 2
: (root.filterPosition === "bottom" ? 0 : parent.height - height)
width: root.verticalFilters ? 20 : 42
height: root.verticalFilters ? 42 : 20
radius: 5
color: theme.ambientOn
? theme.surfaceElevatedA50
: (caretArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
QbzIcon {
anchors.centerIn: parent
name: root.caretName()
width: 13
height: 13
tintName: "secondary"
}
MouseArea {
id: caretArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.view.genresBrowserCollapsed = !root.view.genresBrowserCollapsed
}
}
}
Item {
id: results
// Keep already-matching rows during refresh, but never leave an empty
// filtered viewport blank while the album projection is rebuilding.
readonly property bool initialLoading: QbzLocal.localAlbumsLoading
&& root.view.genreAlbumsVisible.length === 0
x: root.verticalFilters && root.filterPosition === "left"
? browser.x + browser.width + 8 : 32
y: !root.verticalFilters && root.filterPosition === "top"
? browser.y + browser.height + 8 : 10
width: root.verticalFilters
? (root.filterPosition === "left"
? Math.max(0, root.width - x - 32)
: Math.max(0, browser.x - x - 8))
: Math.max(0, root.width - 64)
height: root.verticalFilters
? Math.max(0, root.height - 20)
: (root.filterPosition === "bottom"
? Math.max(0, browser.y - y - 8)
: Math.max(0, root.height - y - 10))
QbzSkeleton {
visible: results.initialLoading && root.view.genresView === "grid"
anchors.fill: parent
variant: "cardGrid"
cellW: 220
cellH: 266
phase: root.view.skelPhase
}
QbzSkeleton {
visible: results.initialLoading && root.view.genresView !== "grid"
anchors.fill: parent
variant: "rowList"
rowH: root.view.genresView === "details" ? 72 : 56
rowGap: 0
rowArtSize: root.view.genresView === "details" ? 48 : 40
phase: root.view.skelPhase
}
Column {
id: emptyPanel
visible: !results.initialLoading
&& root.view.genreAlbumsVisible.length === 0
anchors.centerIn: parent
width: Math.min(Math.max(0, parent.width - 64), 720)
spacing: 14
QbzEmptyState {
width: parent.width
iconName: "list-filter"
title: QbzSession.tr("No albums match these filters", QbzSession.trRev)
body: QbzSession.tr("Try a different genre, artist, album, source or quality.",
QbzSession.trRev)
}
Flow {
visible: root.activeFilterChips.length > 0
width: parent.width
height: childrenRect.height
spacing: 8
Repeater {
model: root.activeFilterChips
delegate: Rectangle {
required property var modelData
width: chipRow.implicitWidth + 18
height: 28
radius: 6
color: chipArea.containsMouse
? theme.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50
: theme.surfaceElevated)
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
Row {
id: chipRow
anchors.centerIn: parent
spacing: 7
Text {
text: parent.parent.modelData.label
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
QbzIcon {
name: "x"
width: 11
height: 11
anchors.verticalCenter: parent.verticalCenter
tintName: chipArea.containsMouse ? "textPrimary" : "muted"
}
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clearFilterChip(parent.modelData)
}
}
}
}
SettingsButton {
visible: root.activeFilterChips.length > 0
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Clear", QbzSession.trRev)
minWidth: 0
btnHeight: 30
onClicked: root.clearAllActiveFilters()
}
}
Loader {
id: resultsLoader
anchors.fill: parent
active: !results.initialLoading && root.view.genreAlbumsVisible.length > 0
sourceComponent: root.view.genresView === "details" ? detailsComponent : collectionComponent
}
}
Component {
id: collectionComponent
LocalAlbumCollection {
view: root.view
surface: "genres"
scrollScope: "local:genres"
rows: root.view.genreAlbumsVisible
groups: []
grouped: false
viewMode: root.view.genresView
showSource: true
onOpenRequested: function(id) { root.view.openAlbum(id) }
onPlayRequested: function(id) {
QbzLocal.playAlbumFiltered(id, JSON.stringify(root.view.genresFilter || {}), false)
}
onEnqueueRequested: function(id, mode) {
QbzLocal.enqueueAlbumFiltered(id, JSON.stringify(root.view.genresFilter || {}), mode)
}
}
}
Component {
id: detailsComponent
LocalGenreDetails {
view: root.view
albums: root.view.genreAlbumsVisible
}
}
}
+77 -71
View File
@@ -86,6 +86,7 @@ Row {
id: albumsSearchBox
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
text: root.view ? root.view.albumsSearch : ""
// DEBOUNCED, like the Tracks box below — which had it and this one
// did not, in the same file.
//
@@ -102,7 +103,7 @@ Row {
//
// The box keeps showing what was typed immediately; only the
// filter waits.
property string pending: ""
property string pending: root.view ? root.view.albumsSearch : ""
onEdited: function (v) {
albumsSearchBox.pending = v
albumsSearchDebounce.restart()
@@ -149,76 +150,10 @@ Row {
QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder")
}
}
// Quality/format/source filter popup trigger + active-count badge.
Item {
width: 34
height: 30
LocalFilterButton {
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.fill: parent
radius: 6
border.width: 1
border.color: root.view.filterCount > 0 ? theme.accent : theme.borderSubtle
color: filtArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
name: "list-filter"
width: 15
height: 15
anchors.centerIn: parent
tintName: root.view.filterCount > 0 ? "accent" : "secondary"
}
MouseArea {
id: filtArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
localFilterTip.exit()
root.view.filterOpen = !root.view.filterOpen
}
onEntered: localFilterTip.enter()
onExited: localFilterTip.exit()
}
}
// WHAT is filtered, not just that something is. The funnel is a
// 10-key one and its badge only ever said a number, so a user
// coming back to a session had to open the popup to find out what
// they had set.
QbzFilterTip {
id: localFilterTip
view: root.view
ownerKey: "local-albums-filter"
anchor: filtArea
groups: root.view.filterSummaryGroups
}
// The plain one-line tip stays for the UNFILTERED case: with
// nothing applied there is no summary to show, and "Quality, format
// and source filters" is what the control needs to say then.
LocalTip {
visible: filtArea.containsMouse && !localFilterTip.hasSummary
text: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
}
Rectangle {
visible: root.view.filterCount > 0
x: parent.width - width + 3
y: -4
width: 15
height: 15
radius: 7.5
color: theme.accent
Text {
anchors.centerIn: parent
text: root.view.filterCount
// 9px bold on a 15px accent dot — the smallest on-accent
// text in the app, so the floor matters most here. NOT a
// departure from locallibrary/LocalLibraryView.slint:887
// (`Theme.accent-text`); the twin returns accent-text on
// 34 of the 35 palettes and only overrides rose-pine-dawn
// (2.56:1 -> 7.38:1).
color: theme.accentGlyphColor
font.pixelSize: 9
font.weight: theme.weightBold
}
}
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
@@ -247,6 +182,7 @@ Row {
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
text: root.view ? root.view.tracksSearch : ""
onEdited: function (v) {
root.view.tracksSearch = v
tracksSearchDebounce.restart()
@@ -282,6 +218,11 @@ Row {
// global across every keyset page.
onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) }
}
LocalFilterButton {
anchors.verticalCenter: parent.verticalCenter
view: root.view
ownerKey: "local-tracks-filter"
}
// Multi-select LAST — never to the LEFT of a search box. The expandable
// field keeps a 30px footprint and grows its inner box LEFT
// (QbzLineEdit.qml:157-161, `x: root.width - width`), so anything
@@ -310,6 +251,7 @@ Row {
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
text: root.view ? root.view.foldersSearch : ""
onEdited: function (v) { root.view.foldersSearch = v }
}
LocalIconSelect {
@@ -353,16 +295,20 @@ Row {
}
// ============================ ARTISTS ================================
LocalSearchBox {
Row {
visible: root.view && root.view.activeTab === "artists"
&& ((QbzLocal.localArtistsNativeActive
? QbzLocal.localArtistsNativeTotal > 0
: root.view.artists.length > 0)
|| root.view.artistsSearch !== ""
|| QbzLocal.localArtistsLoading)
spacing: 8
height: parent.height
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
property string pending: ""
text: root.view ? root.view.artistsSearch : ""
property string pending: root.view ? root.view.artistsSearch : ""
onEdited: function (v) {
pending = v
artistsSearchDebounce.restart()
@@ -373,4 +319,64 @@ Row {
onTriggered: root.view.artistsSearch = parent.pending
}
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 180
options: [
QbzSession.tr("Name A-Z", QbzSession.trRev),
QbzSession.tr("Name Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
]
currentIndex: Math.max(0, ["name-asc", "name-desc", "year-desc", "year-asc"]
.indexOf(root.view.artistsSort))
onSelected: function(i) {
root.view.artistsSort = ["name-asc", "name-desc", "year-desc", "year-asc"][i]
}
}
LocalFilterButton {
anchors.verticalCenter: parent.verticalCenter
view: root.view
ownerKey: "local-artists-filter"
}
}
// ============================ GENRES =================================
Row {
visible: root.view && root.view.activeTab === "genres"
spacing: 8
height: parent.height
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 180
options: [
QbzSession.tr("Name A-Z", QbzSession.trRev),
QbzSession.tr("Name Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
]
currentIndex: Math.max(0, ["title-asc", "title-desc", "year-desc", "year-asc"]
.indexOf(root.view.genresSort))
onSelected: function(i) {
root.view.genresSort = ["title-asc", "title-desc", "year-desc", "year-asc"][i]
}
}
LocalFilterButton {
anchors.verticalCenter: parent.verticalCenter
view: root.view
ownerKey: "local-genres-filter"
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" },
{ "id": "details", "icon": "rows-3" }]
mode: root.view.genresView
onSetMode: function(v) { root.view.genresView = v }
}
}
}
@@ -88,6 +88,10 @@ Item {
readonly property var rowItem: root.artSource === ""
? root.item
: Object.assign({}, root.item, { "artPath": root.artSource })
readonly property string runtimeError: root.view
? (root.view.localTrackErrors[
root.view.localTrackErrorKey(root.item.source || "local", root.item.id)] || "")
: ""
// Local rows have a local album GROUP KEY and a bare artist NAME; both
// routes are name/key routes, so each entry is gated on its own datum.
@@ -126,6 +130,10 @@ Item {
// the sidebar drop handler forwards it as a Qobuz catalog id.
draggable: false
qualityStyle: "icon"
// A failed physical row stays clickable so the user can retry after a
// drive returns. The preflight prevents any queue/NPB mutation until
// that retry succeeds; the marker is cleared by the success signal.
leadingMarkerIcon: root.runtimeError !== "" ? "circle-alert" : ""
// Multi-select is the SHARED row's arm now (rows/TrackRow.qml:116,
// the port of TrackRow.slint:58 `multi-select-mode`). This file used
// to overlay its own SelectCheck on the number cell because the
+95 -50
View File
@@ -18,6 +18,10 @@ Rectangle {
/// [{ version, trackCount, quality, source }]
property var versions: []
property int current: 0
/// Details headers have one 28px trailing origin/version column. In this
/// arm the current source is the button and the full description stays in
/// the flyout; the routed AlbumView keeps the labelled select.
property bool compact: false
signal picked(int index)
QbzTheme { id: theme }
@@ -36,12 +40,14 @@ Rectangle {
}
height: 30
width: row.width
width: compact ? 28 : row.width
radius: 6
color: pickArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
color: pickArea.containsMouse && versions.length > 1
? theme.surfaceHover : (compact ? "transparent" : theme.surfaceElevated)
Row {
id: row
visible: !root.compact
height: parent.height
leftPadding: 10
rightPadding: 8
@@ -71,62 +77,101 @@ Rectangle {
tintName: "muted"
}
}
Item {
visible: root.compact
anchors.fill: parent
SourceIcon {
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.versions.length > 1 ? -2 : 0
kind: root.currentVersion.source || "local"
mono: true
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
}
QbzIcon {
visible: root.versions.length > 1
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 1
anchors.bottomMargin: 2
name: "chevron-down"
width: 8
height: 8
tintName: "accent"
}
}
MouseArea {
id: pickArea
anchors.fill: parent
enabled: root.versions.length > 1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menu.openBelowRight(pickArea)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
versionMenuLoader.active = true
versionMenuLoader.item.openBelowRight(pickArea)
}
}
QbzContextMenu {
id: menu
menuWidth: 380
Repeater {
model: root.versions
delegate: Rectangle {
id: opt
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 30
radius: 6
color: index === root.current ? theme.surfaceElevated
: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
SourceIcon {
anchors.verticalCenter: parent.verticalCenter
kind: opt.modelData.source || "local"
mono: true
// The popup and every option delegate are cold until the user asks for
// them. Genres Details mounts a picker per visible album, while AlbumView
// mounts one; neither should pay for an invisible Controls.Popup tree.
Loader {
id: versionMenuLoader
active: false
sourceComponent: QbzContextMenu {
id: versionMenu
menuWidth: 380
Repeater {
model: root.versions
delegate: Rectangle {
id: opt
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 30
radius: 6
color: index === root.current ? theme.surfaceElevated
: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
SourceIcon {
anchors.verticalCenter: parent.verticalCenter
kind: opt.modelData.source || "local"
mono: true
}
Text {
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
height: parent.height
text: root.optionText(opt.modelData)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzIcon {
visible: opt.index === root.current
anchors.verticalCenter: parent.verticalCenter
name: "check"
width: 12
height: 12
tintName: "accent"
}
}
Text {
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
height: parent.height
text: root.optionText(opt.modelData)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
versionMenu.close()
root.picked(opt.index)
}
}
QbzIcon {
visible: opt.index === root.current
anchors.verticalCenter: parent.verticalCenter
name: "check"
width: 12
height: 12
tintName: "accent"
}
}
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { menu.close(); root.picked(opt.index) }
}
}
}
+12 -9
View File
@@ -45,7 +45,6 @@ pub mod qbz_bridge {
// Blacklist. This is Slint's `SettingsState.section`, which is a global
// for the same reason (SettingsView.slint:121-200).
#[qproperty(i32, settings_section)]
// --- Search: MOVED (cortinilla-parity contract C0) -----------------
// cortinilla_* + search_json + intelligent_search -> QbzSearch
// (search_bridge.rs). `selected_index` was RENAMED on the way out to
@@ -57,7 +56,6 @@ pub mod qbz_bridge {
// ONE JSON document (playlist_qt.rs PlaylistDoc: header + track
// rows + ownership/follow/pin/sort/search state).
#[qproperty(QString, playlist_json)]
// --- Filter by genre (shared, per context) -------------------------
// ONE JSON document (genre_filter_qt.rs FilterDoc): the popup model
// (chips / tree / remember / advanced / loading) PLUS the per-context
@@ -66,12 +64,10 @@ pub mod qbz_bridge {
// "discover", server-side via get_discover_index) and Library > All
// (context "library-all", client-side over the feed).
#[qproperty(QString, genre_filter_json)]
// --- Discover section configurator (the toolbar gear) --------------
// ONE JSON document (discover_config_qt.rs ConfigDoc: the active
// tab's ordered rows + enabled/total counts).
#[qproperty(QString, discover_config_json)]
type QbzBridge = super::QbzBridgeRust;
/// TEMP (phase 23 split): registers the QbzBridge Qt-thread
@@ -244,16 +240,18 @@ pub struct QbzBridgeRust {
impl Default for QbzBridgeRust {
fn default() -> Self {
Self {
settings_json: QString::from("{}"),
// The Local Library order is boot-critical: NavFlyout and a
// logged-off shell can mount before the async full settings
// snapshot. Seed that global pref synchronously so their first
// frame already has the user's real landing tab.
settings_json: QString::from(crate::settings_qt::settings_seed_json().as_str()),
// 0 = Audio, the section Settings opens on.
settings_section: 0,
playlist_json: QString::from("{}"),
// Seeded from the PERSISTED selection (no network): a remembered
// filter colors both genre buttons and narrows the Library feed
// before the popup has ever been opened.
genre_filter_json: QString::from(
crate::genre_filter_qt::current_json().as_str(),
),
genre_filter_json: QString::from(crate::genre_filter_qt::current_json().as_str()),
// Seeded for the tab Discover mounts on.
discover_config_json: QString::from(
crate::discover_config_qt::rows_json("home").as_str(),
@@ -415,7 +413,12 @@ impl qbz_bridge::QbzBridge {
crate::discover_config_qt::toggle_section(&tab.to_string(), &section_id.to_string());
}
pub fn discover_move_section(self: Pin<&mut Self>, tab: QString, section_id: QString, dir: i32) {
pub fn discover_move_section(
self: Pin<&mut Self>,
tab: QString,
section_id: QString,
dir: i32,
) {
crate::discover_config_qt::move_section(&tab.to_string(), &section_id.to_string(), dir);
}
+567 -65
View File
@@ -23,9 +23,11 @@
//! selected version rather than the logical collection.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use cxx_qt_lib::QString;
use qbz_app::settings::local_favorites::LocalFavItem;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_library::{AudioFormat, LocalTrack};
@@ -33,10 +35,10 @@ use qbz_models::QueueTrack;
use serde::Serialize;
use crate::local_bridge::ui;
use crate::local_playback::local_queue_track;
use crate::local_playback::{fill_missing_covers, local_queue_track};
use crate::local_rows::{
album_key, badge_source, badge_source_raw, map_track, tier_of, to_json, total_duration,
AlbumRow, TrackRow,
album_favorite_source, album_key, badge_source, badge_source_raw, map_track, tier_of, to_json,
total_duration, AlbumRow, TrackRow,
};
use crate::local_state::{state, with_art};
@@ -51,6 +53,10 @@ type Runtime = Arc<AppRuntime<LoggingAdapter>>;
/// `qml/views/local/VersionPicker.qml`.
#[derive(Clone, Default, Serialize)]
pub struct AlbumVersion {
/// Stable identity of the FILTERED physical row set. QML uses it to keep
/// an unchanged selected version mounted when a newly enabled source only
/// adds another picker option.
pub key: String,
/// "Remastered", "Deluxe Edition", … when metadata makes it inferable.
pub version: String,
#[serde(rename = "trackCount")]
@@ -102,7 +108,6 @@ pub struct DiscRow {
pub cover: String,
}
/// `{album:{…}, tracks:[…], discs:[…]}` — the `localAlbumJson` document.
#[derive(Clone, Serialize)]
pub struct AlbumDetailDoc {
@@ -113,6 +118,23 @@ pub struct AlbumDetailDoc {
pub discs: Vec<DiscRow>,
}
/// The bounded payload consumed by one expanded row in the Genres browser.
/// It deliberately reuses `DiscRow`: AlbumView and Genres must not disagree
/// about a box set's per-disc subtitle or cover merely because they are two
/// surfaces over the same selected physical version.
#[derive(Clone, Serialize)]
pub struct GenreAlbumDetailDoc {
/// Version-specific artwork key. Empty only when no physical copy has a
/// cover, in which case QML retains the logical card placeholder.
#[serde(rename = "artKey")]
pub art_key: String,
pub tracks: Vec<TrackRow>,
pub discs: Vec<DiscRow>,
pub versions: Vec<AlbumVersion>,
#[serde(rename = "versionIndex")]
pub version_index: i32,
}
// ---------------------------------------------------------------------------
// Version splitting (local_library.rs `open_local_album` 1:1)
// ---------------------------------------------------------------------------
@@ -130,8 +152,7 @@ fn version_rank(t: &LocalTrack) -> (u8, u32, u64) {
);
let sample_rate = t.sample_rate.max(0.0) as u64;
let depth = t.bit_depth.unwrap_or(0);
let tier = if t.format == AudioFormat::Dsd
|| (lossless && (depth > 16 || sample_rate > 48_000))
let tier = if t.format == AudioFormat::Dsd || (lossless && (depth > 16 || sample_rate > 48_000))
{
3
} else if lossless {
@@ -180,11 +201,21 @@ pub fn split_versions(tracks: Vec<LocalTrack>) -> Vec<(String, Vec<LocalTrack>)>
fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
match tracks.iter().max_by_key(|track| version_rank(track)) {
Some(t) => {
let mut identity = std::collections::hash_map::DefaultHasher::new();
for track in tracks {
track.album_group_key.hash(&mut identity);
track.source.hash(&mut identity);
track.id.hash(&mut identity);
track.file_path.hash(&mut identity);
track.disc_number.hash(&mut identity);
track.track_number.hash(&mut identity);
}
let detail =
crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
let fmt = t.format.to_string();
let raw_source = badge_source_raw(t.source.as_deref());
AlbumVersion {
key: format!("{:016x}", identity.finish()),
version: crate::local_albums::edition_descriptor(&t.album_group_title),
track_count: tracks.len() as u32,
quality: if detail.is_empty() {
@@ -203,6 +234,49 @@ fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
}
}
/// The selected copy owns the cover when it has one. A coverless selected
/// copy falls back to the first artwork-bearing copy in `versions`, which is
/// already ordered by audio quality then track count. Returning the source
/// beside the token is essential: a Plex metadata key is meaningless when
/// resolved through Jellyfin credentials (and vice versa).
fn version_artwork_ref(
selected: &[LocalTrack],
versions: &[(String, Vec<LocalTrack>)],
) -> Option<(Option<String>, String)> {
let find = |tracks: &[LocalTrack]| {
tracks
.iter()
.find_map(|track| {
track
.collection_artwork_path
.as_ref()
.filter(|path| !path.is_empty())
.map(|path| (track.source.clone(), path.clone()))
})
.or_else(|| {
tracks.iter().find_map(|track| {
track
.artwork_path
.as_ref()
.filter(|path| !path.is_empty())
.map(|path| (track.source.clone(), path.clone()))
})
})
};
find(selected).or_else(|| {
versions
.iter()
.find_map(|(_, tracks)| find(tracks.as_slice()))
})
}
/// A cover request is keyed by physical VERSION, not only logical album.
/// Reusing `album:{id}` let an earlier async resolve publish after the picker
/// changed and overwrite the newly selected edition's art.
fn album_version_art_key(id: &str, index: usize) -> String {
format!("album-version:{id}:{index}")
}
/// Open `id`: cache its versions, select the best-quality one and return its
/// document. Called by `local_albums::load_album_detail_blocking` (BLOCKING
/// context — no Qt, no await).
@@ -213,7 +287,10 @@ pub fn open_versions(id: &str, tracks: Vec<LocalTrack>) -> Option<AlbumDetailDoc
}
// Keep the RAW rows of EVERY version so a context-menu enqueue on a Plex
// detail row resolves without a DB id (`local_playback::find_track_blocking`).
let all: Vec<LocalTrack> = versions.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
let all: Vec<LocalTrack> = versions
.iter()
.flat_map(|(_, v)| v.iter().cloned())
.collect();
state(|s| {
s.album_id = id.to_string();
s.album_versions = versions;
@@ -226,7 +303,7 @@ pub fn open_versions(id: &str, tracks: Vec<LocalTrack>) -> Option<AlbumDetailDoc
/// Build the document for version `index` of the OPEN album. Reads the cached
/// split — no DB round-trip (the Slint's `apply_album_version`).
pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
let (infos, tracks) = state(|s| {
let (infos, tracks, artwork) = state(|s| {
let infos: Vec<AlbumVersion> = s
.album_versions
.iter()
@@ -237,25 +314,28 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
.get(index)
.map(|(_, v)| v.clone())
.unwrap_or_default();
(infos, tracks)
let artwork = version_artwork_ref(&tracks, &s.album_versions);
(infos, tracks, artwork)
});
if tracks.is_empty() {
return None;
}
let row = album_header(id, &tracks);
let mut row = album_header(id, &tracks);
row.art_key = album_version_art_key(id, index);
// Register the album cover + the per-row sources in the art index (the
// windowed artwork channel resolves them; nothing rides the document).
let rows = with_art(|art| {
if let Some(p) = tracks
.iter()
.find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty()))
{
let source = tracks.first().and_then(|t| t.source.as_deref());
if let Some(t) = crate::local_rows::art_token(source, p) {
art.entry(row.art_key.clone()).or_insert(t);
if let Some((source, path)) = artwork.as_ref() {
if let Some(token) = crate::local_rows::art_token(source.as_deref(), path) {
art.insert(row.art_key.clone(), token);
}
} else {
art.remove(&row.art_key);
}
tracks.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>()
tracks
.iter()
.map(|t| map_track(t, art))
.collect::<Vec<TrackRow>>()
});
let discs = disc_rows(&tracks, &row.title);
Some(AlbumDetailDoc {
@@ -295,7 +375,7 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
/// 3. nothing, and the divider stays the bare "Disc N" it has always been.
///
/// EMPTY for a single-disc album: there is no divider there to feed.
fn disc_rows(tracks: &[LocalTrack], group_title: &str) -> Vec<DiscRow> {
pub(crate) fn disc_rows(tracks: &[LocalTrack], group_title: &str) -> Vec<DiscRow> {
use std::collections::BTreeMap;
let mut first: BTreeMap<u32, &LocalTrack> = BTreeMap::new();
for t in tracks {
@@ -315,6 +395,66 @@ fn disc_rows(tracks: &[LocalTrack], group_title: &str) -> Vec<DiscRow> {
.collect()
}
/// Map one selected physical version for Genres Details while carrying the
/// compact picker metadata for every other version. The heavy track rows stay
/// bounded to the selected copy; switching copies republishes this document
/// from `LocalState::genre_detail_versions` without another database query.
pub(crate) fn genre_detail_doc(
album_id: &str,
versions: &[(String, Vec<LocalTrack>)],
version_index: usize,
) -> GenreAlbumDetailDoc {
let tracks = versions
.get(version_index)
.map(|(_, tracks)| tracks.as_slice())
.unwrap_or_default();
let group_title = tracks
.first()
.map(|track| track.album_group_title.as_str())
.unwrap_or_default();
let discs = disc_rows(tracks, group_title);
let artwork = version_artwork_ref(tracks, versions);
let art_key = if artwork.is_some() {
album_version_art_key(album_id, version_index)
} else {
String::new()
};
let rows = with_art(|art| {
if let Some((source, path)) = artwork.as_ref() {
if let Some(token) = crate::local_rows::art_token(source.as_deref(), path) {
art.insert(art_key.clone(), token);
}
}
tracks
.iter()
.map(|track| map_track(track, art))
.collect::<Vec<_>>()
});
GenreAlbumDetailDoc {
art_key,
tracks: rows,
discs,
versions: versions
.iter()
.map(|(_, tracks)| version_info(tracks))
.collect(),
version_index: version_index as i32,
}
}
/// Change one Genres album's selected physical copy. This cache is keyed by
/// album id because several expanded rows coexist; the routed AlbumView's
/// singleton `album_version_index` would make the last opened album win.
pub(crate) fn genre_select_version(album_id: &str, index: i32) -> Option<GenreAlbumDetailDoc> {
let index = usize::try_from(index).ok()?;
let versions = state(|s| s.genre_detail_versions.get(album_id).cloned())?;
let selected = versions.get(index)?.1.clone();
state(|s| {
s.genre_detail_raw.insert(album_id.to_string(), selected);
});
Some(genre_detail_doc(album_id, &versions, index))
}
/// The header for ONE version — recomputed per version (the Slint recomputes
/// title / artist / info-line / quality in `apply_album_version`, because two
/// copies of the same album differ exactly in those). `directoryPath` and
@@ -325,7 +465,7 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
let artist_of = |t: &LocalTrack| t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
let lead = artist_of(first);
let artist = if tracks.iter().all(|t| artist_of(t) == lead) {
lead
lead.clone()
} else {
qbz_i18n::t("Various Artists")
};
@@ -350,11 +490,37 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
.find(|a| a.id == id)
.cloned()
});
let sources = card.as_ref().map(|c| c.sources.clone()).unwrap_or_else(|| {
let mut values = Vec::new();
for track in state(|s| {
s.album_versions
.iter()
.filter_map(|(_, tracks)| tracks.first().cloned())
.collect::<Vec<_>>()
}) {
let raw = badge_source_raw(track.source.as_deref());
let source = if raw.is_empty() {
badge_source(track.source.as_deref())
} else {
raw
};
if !values.contains(&source) {
values.push(source);
}
}
values
});
let favoriteable = album_favorite_source(&sources).is_some();
AlbumRow {
id: id.to_string(),
title: first.album_group_title.clone(),
artist,
all_artists: all_artists.join(", "),
artists: {
let names = all_artists.to_vec();
let aliases = crate::local_artist_match::build_artist_family_aliases(&names);
crate::local_artist_match::album_credit_names(&lead, &all_artists.join(","), &aliases)
},
year: first.year.map(|y| y.to_string()).unwrap_or_default(),
track_count: tracks.len() as u32,
duration: total_duration(tracks.iter().map(|t| t.duration_secs).sum()),
@@ -365,32 +531,89 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
best.sample_rate,
),
format: best.format.to_string(),
genres: {
let mut values = tracks
.iter()
.flat_map(|track| track.genres.iter().cloned())
.collect::<Vec<_>>();
values.sort_by_key(|genre| genre.to_lowercase());
values.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
values
},
art_key: album_key(id),
source: badge_source(first.source.as_deref()),
sources: card.as_ref().map(|c| c.sources.clone()).unwrap_or_else(|| {
let mut values = Vec::new();
for track in state(|s| {
s.album_versions
.iter()
.filter_map(|(_, tracks)| tracks.first().cloned())
.collect::<Vec<_>>()
}) {
let raw = badge_source_raw(track.source.as_deref());
let source = if raw.is_empty() {
badge_source(track.source.as_deref())
} else {
raw
};
if !values.contains(&source) {
values.push(source);
sources,
source_raw: badge_source_raw(first.source.as_deref()),
directory_path: card
.as_ref()
.map(|c| c.directory_path.clone())
.unwrap_or_default(),
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", id),
favoriteable,
}
}
/// Toggle a Local Library album using the card's denormalized display
/// snapshot. This is the Qt port of Slint's `toggle_album_favorite`, with the
/// source guard made explicit so a remote-only row cannot write a bogus local
/// favorite.
pub(crate) fn toggle_album_favorite(
id: String,
title: String,
artist: String,
artwork_url: String,
sources_json: String,
) {
let sources: Vec<String> = serde_json::from_str(&sources_json).unwrap_or_default();
let Some(source) = album_favorite_source(&sources) else {
log::warn!("[qbz-qt] local favorite refused for remote-only album {id}");
return;
};
let item = LocalFavItem {
kind: "album".to_string(),
id: id.clone(),
title,
subtitle: artist.clone(),
artwork_url,
artist,
source: source.to_string(),
favorited_at: 0,
};
crate::spawn(async move {
let id_for_write = id.clone();
let result = tokio::task::spawn_blocking(move || {
let current = crate::library_qt::is_local_favorite("album", &id_for_write);
(
crate::library_qt::toggle_local_favorite_snapshot(item),
current,
)
})
.await;
let favorite = match result {
Ok((Some(value), _)) => value,
Ok((None, current)) => {
log::warn!("[qbz-qt] local favorite store unavailable for album {id}");
current
}
Err(error) => {
log::error!("[qbz-qt] local favorite worker failed for album {id}: {error}");
crate::library_qt::is_local_favorite("album", &id)
}
};
state(|local| {
for row in local.albums.iter_mut().chain(local.folders.iter_mut()) {
if row.id == id {
row.is_favorite = favorite;
}
}
values
}),
source_raw: badge_source_raw(first.source.as_deref()),
directory_path: card.as_ref().map(|c| c.directory_path.clone()).unwrap_or_default(),
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
}
});
ui(move |mut bridge| {
bridge
.as_mut()
.local_album_favorite_changed(QString::from(id.as_str()), favorite);
});
});
}
// ---------------------------------------------------------------------------
@@ -427,6 +650,21 @@ fn current_disc_tracks(disc: i32) -> Vec<LocalTrack> {
.collect()
}
/// One disc from one concurrently expanded Genres album. Unlike the routed
/// AlbumView cache, this is keyed by album id: opening/scrolling a second
/// details block must not retarget the first one's menu.
fn genre_disc_tracks(album_id: &str, disc: i32) -> Vec<LocalTrack> {
state(|s| {
s.genre_detail_raw
.get(album_id)
.into_iter()
.flatten()
.filter(|t| t.disc_number.unwrap_or(1) as i32 == disc)
.cloned()
.collect()
})
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
@@ -454,17 +692,92 @@ pub fn select_version(index: i32) {
/// ("play" | "next" | "later" | "queue") over THIS disc's tracks only, reusing
/// the same queue ops as the header play button and the per-row menu.
pub fn disc_action(disc: i32, action: String) {
let tracks = current_disc_tracks(disc);
dispatch_disc_action(
"local album".to_string(),
current_disc_tracks(disc),
disc,
action,
);
}
/// The same four actions for a disc header inside Genres Details. It cannot
/// call `disc_action`: that function intentionally reads the ONE routed
/// AlbumView version, while Genres may keep 32 expanded albums alive.
pub fn genre_disc_action(album_id: String, disc: i32, action: String) {
let tracks = genre_disc_tracks(&album_id, disc);
dispatch_disc_action(format!("genres album {album_id}"), tracks, disc, action);
}
/// Album and row actions for one selected Genres version. Unlike the generic
/// album action this never re-fetches the logical album (which would include
/// every physical copy and duplicate the queue).
pub fn genre_album_action(album_id: String, action: String, track_id: Option<i64>) {
let tracks = state(|s| {
s.genre_detail_raw
.get(&album_id)
.cloned()
.unwrap_or_default()
});
if tracks.is_empty() {
log::debug!("[qbz-qt] local album: disc {disc} has no tracks for '{action}'");
log::debug!("[qbz-qt] genres album {album_id}: no selected rows for '{action}'");
return;
}
let runtime = crate::app();
crate::spawn(async move {
match action.as_str() {
"play" => play_rows(&runtime, tracks).await,
"play" | "shuffle" => {
let start = track_id
.and_then(|id| tracks.iter().position(|track| track.id == id))
.unwrap_or(0);
crate::local_playback::play_rows(&runtime, tracks, start, action == "shuffle")
.await;
}
"next" | "later" | "queue" => enqueue_rows(&runtime, tracks, &action).await,
other => log::debug!("[qbz-qt] local album: unhandled disc action '{other}'"),
other => {
log::debug!("[qbz-qt] genres album {album_id}: unhandled album action '{other}'")
}
}
});
}
/// Play the selected physical copy in the routed AlbumView. Re-querying the
/// logical album here would concatenate every copy and would also bypass the
/// source funnel used to open this view.
pub fn selected_album_action(action: String, track_id: Option<i64>) {
let tracks = current_version_tracks();
if tracks.is_empty() {
log::debug!("[qbz-qt] local album: no selected rows for '{action}'");
return;
}
let runtime = crate::app();
crate::spawn(async move {
match action.as_str() {
"play" | "shuffle" => {
let start = track_id
.and_then(|id| tracks.iter().position(|track| track.id == id))
.unwrap_or(0);
crate::local_playback::play_rows(&runtime, tracks, start, action == "shuffle")
.await;
}
"next" | "later" | "queue" => enqueue_rows(&runtime, tracks, &action).await,
other => log::debug!("[qbz-qt] local album: unhandled selected action '{other}'"),
}
});
}
fn dispatch_disc_action(context: String, tracks: Vec<LocalTrack>, disc: i32, action: String) {
if tracks.is_empty() {
log::debug!("[qbz-qt] {context}: disc {disc} has no tracks for '{action}'");
return;
}
let runtime = crate::app();
crate::spawn(async move {
match action.as_str() {
"play" | "shuffle" => {
crate::local_playback::play_rows(&runtime, tracks, 0, action == "shuffle").await
}
"next" | "later" | "queue" => enqueue_rows(&runtime, tracks, &action).await,
other => log::debug!("[qbz-qt] {context}: unhandled disc action '{other}'"),
}
});
}
@@ -616,25 +929,6 @@ fn publish_doc(doc: Option<AlbumDetailDoc>) {
});
}
/// A track SUBSET becomes the queue and starts at its first row
/// (`local_playback::play_rows` over an explicit list — that one is private and
/// only reachable by album/folder id, which a per-disc menu cannot express).
async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>) {
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
let Some(first) = queue.first().cloned() else {
return;
};
runtime.core().set_queue(queue, Some(0)).await;
crate::playback_qt::publish_queue(runtime).await;
// THE shared audible step, not a second copy of it. The last hand-copy of
// this function lived here and was kept in sync by comment; it drifted
// anyway — it never learned that an OFFLINE row belongs to the offline
// tier, so a per-disc play of a downloaded album went silent exactly like
// the album funnel did.
crate::local_playback::play_audible(runtime, &first).await;
crate::playback_qt::refresh_now_playing(runtime).await;
}
/// "Play next" / "Play later" / "Add to queue" over a track SUBSET — the same
/// core helpers `local_playback::enqueue` uses, just without the id round-trip.
///
@@ -642,6 +936,13 @@ async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>) {
/// local-only batch is never Qobuz-castable, so the predicate is always false
/// (Slint local_album_actions.rs:486-494 is likewise unhooked).
async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: &str) {
let tracks = tokio::task::spawn_blocking(move || {
let mut tracks = tracks;
fill_missing_covers(&mut tracks);
tracks
})
.await
.unwrap_or_default();
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
if queue.is_empty() {
return;
@@ -718,4 +1019,205 @@ mod version_tests {
assert_eq!(info.source, "subsonic");
assert!(info.quality.contains("FLAC"));
}
#[test]
fn genres_detail_keeps_each_named_disc_and_its_own_cover() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"qbz-genres-eternal-cd-box-{}-{nonce}",
std::process::id()
));
let disc_one = root.join("Disc 01 - TV Series Soundtrack #01");
let disc_two = root.join("Disc 02 - TV Series Soundtrack #02");
std::fs::create_dir_all(&disc_one).unwrap();
std::fs::create_dir_all(&disc_two).unwrap();
std::fs::write(disc_one.join("cover.jpg"), b"disc-one").unwrap();
std::fs::write(disc_two.join("folder.jpg"), b"disc-two").unwrap();
let track = |id, disc, folder: &std::path::Path| LocalTrack {
id,
file_path: folder.join("01.flac").to_string_lossy().into_owned(),
title: format!("Movement {disc}"),
artist: "Seiji Yokoyama".to_string(),
album: "Saint Seiya Eternal CD-Box".to_string(),
album_artist: Some("Seiji Yokoyama".to_string()),
album_group_key: root.to_string_lossy().into_owned(),
album_group_title: "Saint Seiya Eternal CD-Box".to_string(),
track_number: Some(1),
disc_number: Some(disc),
format: AudioFormat::Flac,
bit_depth: Some(16),
sample_rate: 44_100.0,
..Default::default()
};
let versions = vec![(
root.to_string_lossy().into_owned(),
vec![track(1, 1, &disc_one), track(2, 2, &disc_two)],
)];
let doc = genre_detail_doc("test:eternal-cd-box", &versions, 0);
assert_eq!(doc.tracks.len(), 2);
assert_eq!(doc.discs.len(), 2);
assert_eq!(doc.versions.len(), 1);
assert_eq!(doc.version_index, 0);
assert_eq!(doc.discs[0].title, "TV Series Soundtrack #01");
assert_eq!(doc.discs[1].title, "TV Series Soundtrack #02");
assert!(!doc.discs[0].cover.is_empty());
assert!(!doc.discs[1].cover.is_empty());
assert_ne!(doc.discs[0].cover, doc.discs[1].cover);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn remote_disc_headers_keep_each_sources_track_art_token() {
let track = |id, disc, token: &str| LocalTrack {
id,
file_path: format!("remote-{id}"),
title: format!("Movement {disc}"),
artist: "Seiji Yokoyama".to_string(),
album: "Saint Seiya Eternal CD-Box".to_string(),
album_group_key: "subsonic:eternal-box".to_string(),
album_group_title: "Saint Seiya Eternal CD-Box".to_string(),
track_number: Some(1),
disc_number: Some(disc),
artwork_path: Some(token.to_string()),
source: Some("subsonic".to_string()),
format: AudioFormat::Flac,
..Default::default()
};
let versions = vec![(
"subsonic:eternal-box".to_string(),
vec![track(910_001, 1, "dc-disc-one"), track(910_002, 2, "dc-disc-two")],
)];
let doc = genre_detail_doc("test:remote-disc-art", &versions, 0);
assert_eq!(doc.discs.len(), 2);
assert_eq!(doc.discs[0].art_key, "track:910001");
assert_eq!(doc.discs[1].art_key, "track:910002");
state(|s| {
assert_eq!(s.art_index["track:910001"].1, "dc-disc-one");
assert_eq!(s.art_index["track:910002"].1, "dc-disc-two");
s.art_index.remove("track:910001");
s.art_index.remove("track:910002");
s.art_index.remove(&doc.art_key);
});
}
#[test]
fn selected_version_owns_art_and_coverless_version_falls_back() {
let mut jellyfin = copy("jf-hires", "jellyfin", "Album", 2, 24);
let mut plex = copy("plex-cd", "plex", "Album", 2, 16);
plex[0].artwork_path = Some("/library/metadata/album/thumb".to_string());
let versions = vec![
("jf-hires".to_string(), jellyfin.clone()),
("plex-cd".to_string(), plex.clone()),
];
let fallback = version_artwork_ref(&jellyfin, &versions).expect("Plex fallback");
assert_eq!(fallback.0.as_deref(), Some("plex"));
assert_eq!(fallback.1, "/library/metadata/album/thumb");
jellyfin[1].artwork_path = Some("jf:item:cover".to_string());
let selected = version_artwork_ref(&jellyfin, &versions).expect("selected cover");
assert_eq!(selected.0.as_deref(), Some("jellyfin"));
assert_eq!(selected.1, "jf:item:cover");
assert_ne!(
album_version_art_key("logical:album", 0),
album_version_art_key("logical:album", 1)
);
}
#[test]
fn genres_version_selection_is_scoped_to_one_expanded_album() {
let album = "test:genres-version-picker";
let mut cd = copy("genres-cd", "plex", "Album", 2, 16);
let mut hires = copy("genres-hires", "jellyfin", "Album (Remastered)", 3, 24);
for track in &mut cd {
track.id += 100;
}
for track in &mut hires {
track.id += 200;
}
let versions = vec![
("genres-hires".to_string(), hires.clone()),
("genres-cd".to_string(), cd.clone()),
];
state(|s| {
s.genre_detail_raw.insert(album.to_string(), hires);
s.genre_detail_versions
.insert(album.to_string(), versions.clone());
});
let doc = genre_select_version(album, 1).expect("second version");
assert_eq!(doc.version_index, 1);
assert_eq!(doc.versions.len(), 2);
assert_eq!(doc.tracks.len(), 2);
assert_eq!(
state(|s| s.genre_detail_raw[album]
.iter()
.map(|track| track.id)
.collect::<Vec<_>>()),
cd.iter().map(|track| track.id).collect::<Vec<_>>()
);
let back = genre_select_version(album, 0).expect("first version again");
assert_eq!(back.version_index, 0);
assert_eq!(back.versions.len(), 2);
assert_eq!(back.tracks.len(), 3);
assert_eq!(
state(|s| s.genre_detail_raw[album]
.iter()
.map(|track| track.id)
.collect::<Vec<_>>()),
versions[0]
.1
.iter()
.map(|track| track.id)
.collect::<Vec<_>>()
);
assert!(genre_select_version(album, 2).is_none());
state(|s| {
s.genre_detail_raw.remove(album);
s.genre_detail_versions.remove(album);
});
}
#[test]
fn genres_disc_menu_targets_its_album_and_disc_only() {
let album = "test:genres-disc-actions";
let rows = vec![
LocalTrack {
id: 11,
disc_number: Some(1),
..Default::default()
},
LocalTrack {
id: 21,
disc_number: Some(2),
..Default::default()
},
LocalTrack {
id: 22,
disc_number: Some(2),
..Default::default()
},
];
state(|s| {
s.genre_detail_raw.insert(album.to_string(), rows);
});
let selected = genre_disc_tracks(album, 2);
assert_eq!(selected.iter().map(|t| t.id).collect::<Vec<_>>(), [21, 22]);
assert!(genre_disc_tracks("test:another-album", 2).is_empty());
state(|s| {
s.genre_detail_raw.remove(album);
});
}
}
+246 -47
View File
@@ -20,11 +20,12 @@ use qbz_library::{AlbumTrackEvidence, AudioFormat, LocalAlbum, LocalTrack};
use crate::local_album_actions::AlbumDetailDoc;
use crate::local_artist_match::{
album_matches_artist_with_aliases, build_artist_family_aliases, merge_artists,
normalize_artist, AlbumCredit, ArtistInput,
album_credit_names, album_matches_artist_with_aliases, build_artist_family_aliases,
merge_artists, normalize_artist, AlbumCredit, ArtistInput,
};
use crate::local_rows::{
artist_key, map_album, map_track, AlbumRow, ArtistRow, LocalCounts, TrackRow,
artist_key, badge_source, map_album, map_album_with_artists, map_track, tier_of, AlbumRow,
ArtistRow, LocalCounts, TrackRow,
};
use crate::local_state::{
commit_tracks_page, group_mode, state, tracks_generation, with_art, with_db,
@@ -74,10 +75,21 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
let total = albums.len() as u64;
let n = albums.len();
let t1 = std::time::Instant::now();
let mut family_names = albums
.iter()
.map(|album| album.artist.as_str())
.collect::<Vec<_>>();
for album in &albums {
family_names.extend(album.all_artists.split(',').filter(|name| !name.is_empty()));
}
let aliases = build_artist_family_aliases(&family_names);
let rows = with_art(|art| {
albums
.into_iter()
.map(|a| map_album(a, art))
.map(|a| {
let artists = album_credit_names(&a.artist, &a.all_artists, &aliases);
map_album_with_artists(a, art, artists)
})
.collect::<Vec<AlbumRow>>()
});
log::info!(
@@ -159,7 +171,53 @@ fn coalesce_album_versions(
.iter()
.map(|album| album.id.clone())
.collect::<Vec<_>>();
let mut genres = group
.iter()
.flat_map(|album| album.genres.iter().cloned())
.collect::<Vec<_>>();
genres.sort_by_key(|genre| genre.to_lowercase());
genres.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
let mut all_artists = group
.iter()
.flat_map(|album| {
std::iter::once(album.artist.clone()).chain(
album
.all_artists
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string),
)
})
.collect::<Vec<_>>();
all_artists.sort_by_key(|artist| normalize_artist(artist));
all_artists.dedup_by(|left, right| normalize_artist(left) == normalize_artist(right));
// `group` is already best-audio-first. Pick the best-ranked copy
// that actually has artwork, but keep its provenance separate
// from the representative audio source. A coverless Jellyfin
// hi-res copy must not erase the Plex cover on the logical card.
let best_artwork = group.iter().find_map(|album| {
album
.artwork_path
.as_ref()
.filter(|path| !path.is_empty())
.map(|path| {
(
path.clone(),
album
.artwork_source
.clone()
.unwrap_or_else(|| album.source.clone()),
)
})
});
let mut representative = group.remove(0);
representative.genres = genres;
representative.all_artists = all_artists.join(",");
if let Some((path, source)) = best_artwork {
representative.artwork_path = Some(path);
representative.artwork_source = Some(source);
}
if !group.is_empty() {
let logical_id = logical_album_id(&ids);
let mut source_words = std::iter::once(&representative)
@@ -355,8 +413,7 @@ fn strip_edition_suffix(value: &str) -> &str {
pub fn load_folders_blocking() -> Result<Vec<AlbumRow>, String> {
let albums = with_db(|db| {
db.get_albums_with_full_filter(
/* include_hidden */ false,
/* include_qobuz_downloads */ true,
/* include_hidden */ false, /* include_qobuz_downloads */ true,
/* exclude_network_folders */ false,
)
})
@@ -404,8 +461,7 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
// so an artist's album count matches the grid the user sees.
let (artists, albums, custom) = with_db(|db| {
let artists = db.get_artists_with_filter(
/* include_qobuz_downloads */ true,
/* exclude_network_folders */ false,
/* include_qobuz_downloads */ true, /* exclude_network_folders */ false,
)?;
let albums = if plex_on || remote_on {
db.get_albums_metadata_page(
@@ -424,8 +480,7 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
.albums
} else {
db.get_albums_with_full_filter(
/* include_hidden */ false,
/* include_qobuz_downloads */ true,
/* include_hidden */ false, /* include_qobuz_downloads */ true,
/* exclude_network_folders */ false,
)?
};
@@ -495,6 +550,19 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
plex_on || remote_on,
);
// One alias corpus for both the identity merge above and the facet pass.
// Facets follow credited albums, never the portrait chosen for the row:
// the latter is one image from one source and cannot describe a mixed
// artist's actual source/format/quality/year availability.
let mut family_names = albums
.iter()
.map(|album| album.artist.as_str())
.collect::<Vec<_>>();
for album in &albums {
family_names.extend(album.all_artists.split(',').filter(|name| !name.is_empty()));
}
let aliases = build_artist_family_aliases(&family_names);
// The artwork index is keyed on the DISPLAY name (`artist:{name}`), so the
// portrait is registered under the CANONICAL spelling the row carries —
// registering it under the Plex spelling is what left a merged row with a
@@ -513,12 +581,62 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
art.insert(key.clone(), t);
}
}
let selected = normalize_artist(&m.name);
let mut sources = Vec::new();
let mut formats = Vec::new();
let mut quality_tiers = Vec::new();
let mut years = Vec::new();
for album in albums.iter().filter(|album| {
album_matches_artist_with_aliases(
&album.artist,
&album.all_artists,
&selected,
&aliases,
)
}) {
let source_values = if album.sources.is_empty() {
std::slice::from_ref(&album.source)
} else {
album.sources.as_slice()
};
for source in source_values {
let source = badge_source(Some(source));
if !sources.iter().any(|value: &String| value == &source) {
sources.push(source);
}
}
let format = album.format.to_string().to_ascii_lowercase();
if !formats.iter().any(|value: &String| value == &format) {
formats.push(format);
}
let tier =
tier_of(&album.format, album.bit_depth, album.sample_rate).to_string();
if !tier.is_empty()
&& !quality_tiers.iter().any(|value: &String| value == &tier)
{
quality_tiers.push(tier);
}
if let Some(year) = album.year {
if !years.contains(&year) {
years.push(year);
}
}
}
sources.sort();
formats.sort();
quality_tiers.sort();
years.sort_unstable();
ArtistRow {
art_key: key,
name: m.name,
album_count: m.album_count,
track_count: m.track_count,
source: m.source,
sources,
formats,
quality_tiers,
year: years.last().map(u32::to_string).unwrap_or_default(),
years,
}
})
.collect()
@@ -637,26 +755,38 @@ pub fn load_tracks_page_blocking(
let effective_sort = effective_tracks_sort(&request.sort, &request.group);
let query_started = std::time::Instant::now();
// No library.db is valid for a remote-only installation.
let local = with_db(|db| {
db.search_with_filter_page(
request.query.trim(),
request.offsets.local,
candidate_limit,
true,
false,
effective_sort,
)
})
.unwrap_or_default();
let local =
if request.filter.source_enabled("local") || request.filter.source_enabled("offline") {
with_db(|db| {
db.search_with_filter_page_faceted(
request.query.trim(),
request.offsets.local,
candidate_limit,
true,
false,
effective_sort,
&request.filter.formats,
request.filter.other_formats,
&request.filter.qualities,
&request.filter.sources,
)
})
.unwrap_or_default()
} else {
Vec::new()
};
if tracks_generation() != request.generation {
return Ok(None);
}
let plex = if crate::local_plex::is_enabled() {
let plex = if crate::local_plex::is_enabled() && request.filter.source_enabled("plex") {
crate::local_plex::search_tracks_page(
&request.query,
request.offsets.plex,
candidate_limit,
effective_sort,
&request.filter.formats,
request.filter.other_formats,
&request.filter.qualities,
)
} else {
Vec::new()
@@ -664,23 +794,37 @@ pub fn load_tracks_page_blocking(
if tracks_generation() != request.generation {
return Ok(None);
}
let jellyfin = crate::media_servers_qt::search_tracks_page(
"jellyfin",
&request.query,
request.offsets.jellyfin,
candidate_limit,
effective_sort,
);
let jellyfin = if request.filter.source_enabled("jellyfin") {
crate::media_servers_qt::search_tracks_page(
"jellyfin",
&request.query,
request.offsets.jellyfin,
candidate_limit,
effective_sort,
&request.filter.formats,
request.filter.other_formats,
&request.filter.qualities,
)
} else {
Vec::new()
};
if tracks_generation() != request.generation {
return Ok(None);
}
let subsonic = crate::media_servers_qt::search_tracks_page(
"subsonic",
&request.query,
request.offsets.subsonic,
candidate_limit,
effective_sort,
);
let subsonic = if request.filter.source_enabled("subsonic") {
crate::media_servers_qt::search_tracks_page(
"subsonic",
&request.query,
request.offsets.subsonic,
candidate_limit,
effective_sort,
&request.filter.formats,
request.filter.other_formats,
&request.filter.qualities,
)
} else {
Vec::new()
};
let query_time = query_started.elapsed();
let candidates = TrackSourceCounts {
local: local.len(),
@@ -695,10 +839,22 @@ pub fn load_tracks_page_blocking(
let merge_started = std::time::Instant::now();
let merged = merge_track_pages(
vec![
CandidatePage { source: TrackSourcePage::Local, rows: local },
CandidatePage { source: TrackSourcePage::Plex, rows: plex },
CandidatePage { source: TrackSourcePage::Jellyfin, rows: jellyfin },
CandidatePage { source: TrackSourcePage::Subsonic, rows: subsonic },
CandidatePage {
source: TrackSourcePage::Local,
rows: local,
},
CandidatePage {
source: TrackSourcePage::Plex,
rows: plex,
},
CandidatePage {
source: TrackSourcePage::Jellyfin,
rows: jellyfin,
},
CandidatePage {
source: TrackSourcePage::Subsonic,
rows: subsonic,
},
],
effective_sort,
TRACKS_PAGE as usize,
@@ -782,7 +938,12 @@ fn merge_track_pages(pages: Vec<CandidatePage>, sort: &str, limit: usize) -> Mer
source.bump_count(&mut published);
rows.push(row);
}
MergedTrackPage { rows, consumed, published, has_more }
MergedTrackPage {
rows,
consumed,
published,
has_more,
}
}
/// The tab badges. Cheap: the Tracks count never materializes the table.
@@ -857,7 +1018,13 @@ fn fetch_source_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
/// header, the picker entries and the raw-row cache are all owned by
/// `local_album_actions`, which is also what the version picker and the
/// per-disc menu read back.
pub fn load_album_detail_blocking(id: &str) -> Option<AlbumDetailDoc> {
/// The routed Local Library detail, constrained to the media funnel that was
/// active at the click site. A logical card may represent several sources;
/// opening it must not make filtered-out physical versions selectable again.
pub fn load_album_detail_filtered_blocking(
id: &str,
filter_json: &str,
) -> Option<AlbumDetailDoc> {
let mut tracks = fetch_album_tracks_blocking(id);
if tracks.is_empty() {
return None;
@@ -868,6 +1035,8 @@ pub fn load_album_detail_blocking(id: &str) -> Option<AlbumDetailDoc> {
// rows AND everything that later reads `detail_raw` (the per-disc menu, the
// bulk bar, `find_track_blocking`) carry the cover the folder has.
crate::local_playback::fill_missing_covers(&mut tracks);
let filter = crate::local_filter::MediaFilter::from_json(filter_json);
tracks.retain(|track| filter.track_enabled(track));
crate::local_album_actions::open_versions(id, tracks)
}
@@ -953,11 +1122,7 @@ mod phase_a_tests {
}
}
fn native_tie(
source: TrackSourcePage,
a: &LocalTrack,
b: &LocalTrack,
) -> std::cmp::Ordering {
fn native_tie(source: TrackSourcePage, a: &LocalTrack, b: &LocalTrack) -> std::cmp::Ordering {
match source {
TrackSourcePage::Plex => a.file_path.cmp(&b.file_path),
_ => a.id.cmp(&b.id),
@@ -1295,7 +1460,9 @@ mod phase_a_tests {
all_artists: "Talk Talk".to_string(),
year: Some(1988),
catalog_number: None,
genres: Vec::new(),
artwork_path: None,
artwork_source: None,
track_count: tracks.len() as u32,
total_duration_secs: tracks.iter().map(|(_, duration)| duration).sum(),
format: AudioFormat::Flac,
@@ -1357,6 +1524,38 @@ mod phase_a_tests {
assert_eq!(rows.len(), 2, "title and artist alone must never merge");
}
#[test]
fn coverless_best_audio_copy_uses_the_best_available_group_cover() {
let tracks = [("Eres Toda una Mujer", 209), ("Amar y Querer", 191)];
let mut jellyfin = album_copy(
"jellyfin:romanticos",
"jellyfin",
"Siempre Romanticos!",
&tracks,
24,
96_000.0,
);
jellyfin.artwork_path = None;
let mut plex = album_copy(
"plex:romanticos",
"plex",
"Siempre Romanticos!",
&tracks,
16,
44_100.0,
);
plex.artwork_path = Some("/library/metadata/romanticos/thumb".to_string());
let (rows, _) = coalesce_album_versions(vec![plex, jellyfin]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, "jellyfin");
assert_eq!(
rows[0].artwork_path.as_deref(),
Some("/library/metadata/romanticos/thumb")
);
assert_eq!(rows[0].artwork_source.as_deref(), Some("plex"));
}
#[test]
fn album_copy_matching_allows_bonus_tracks_but_not_one_shared_compilation_track() {
let base = [
+29 -7
View File
@@ -16,7 +16,9 @@ use qbz_source::SourceId;
use serde::Serialize;
use crate::local_bridge::ui;
use crate::local_rows::{badge_source, badge_source_raw, detail_of, total_duration, AlbumRow};
use crate::local_rows::{
album_favorite_source, badge_source, badge_source_raw, detail_of, total_duration, AlbumRow,
};
const PAGE_ENTRIES: usize = 100;
const FLAT_QUERY_ROWS: usize = 500;
@@ -1023,12 +1025,33 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
}
}
let format = audio_format(&record.format);
let names = [record.artist.as_str()]
.into_iter()
.chain(record.all_artists.split(','))
.collect::<Vec<_>>();
let aliases = crate::local_artist_match::build_artist_family_aliases(&names);
let artists = crate::local_artist_match::album_credit_names(
&record.artist,
&record.all_artists,
&aliases,
);
let source = badge_source(Some(record.source_raw_or_kind()));
let source_raw = badge_source_raw(Some(record.source_raw_or_kind()));
let sources = vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}];
let favoriteable = album_favorite_source(&sources).is_some();
NativeAlbum {
row: AlbumRow {
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", &id),
favoriteable,
id,
title: record.title.clone(),
artist: record.artist.clone(),
all_artists: record.all_artists.clone(),
artists,
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
track_count: record.track_count,
duration: total_duration(record.total_duration_ms / 1_000),
@@ -1039,10 +1062,11 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
record.sample_rate_hz.unwrap_or(0) as f64,
),
format: record.format.to_ascii_uppercase(),
genres: Vec::new(),
art_key,
source: badge_source(Some(record.source_raw_or_kind())),
sources: vec![badge_source(Some(record.source_raw_or_kind()))],
source_raw: badge_source_raw(Some(record.source_raw_or_kind())),
source,
sources,
source_raw,
directory_path: record.directory_path.clone(),
folder_count: record.folder_count,
},
@@ -1170,9 +1194,7 @@ fn clear_selection_generation(generation: u64) {
fn set_loading() {
ui(|mut bridge| {
bridge.as_mut().set_local_albums_loading(true);
bridge
.as_mut()
.set_local_albums_error(QString::default());
bridge.as_mut().set_local_albums_error(QString::default());
bridge
.as_mut()
.set_local_albums_native_error(QString::default());
+50 -8
View File
@@ -284,10 +284,17 @@ pub(crate) fn requested() -> bool {
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
}
pub(crate) fn reset(search: String) -> bool {
pub(crate) fn reset(search: String, sort: String, filter_json: String) -> bool {
if !requested() {
return false;
}
if sort != "name-asc"
|| crate::local_filter::MediaFilter::from_json(&filter_json)
!= crate::local_filter::MediaFilter::default()
{
deactivate_for_legacy("unsupported-artist-facets");
return false;
}
*LAST_SEARCH
.lock()
.unwrap_or_else(|error| error.into_inner()) = Some(search.clone());
@@ -325,7 +332,7 @@ pub(crate) fn retry_last() -> bool {
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();
search.is_some_and(reset)
search.is_some_and(|search| reset(search, "name-asc".to_string(), String::new()))
}
pub(crate) fn request_page(page: i32, generation: i32) {
@@ -585,9 +592,7 @@ fn activate_artists(opened: OpenedArtists) {
cpp_artist_reset(generation, total, artist_total);
let bytes = publish_artist_page_now(generation, 0, &wire);
bridge.as_mut().set_local_artists_native_active(true);
bridge
.as_mut()
.set_local_artists_json(QString::from("[]"));
bridge.as_mut().set_local_artists_json(QString::from("[]"));
bridge
.as_mut()
.set_local_artists_native_total(artist_total.min(i64::MAX as u64) as i64);
@@ -1327,6 +1332,11 @@ fn map_artist(record: &ArtistRecord) -> NativeArtist {
track_count: record.track_count,
art_key,
source: record.source.clone(),
sources: Vec::new(),
formats: Vec::new(),
quality_tiers: Vec::new(),
years: Vec::new(),
year: String::new(),
},
artist_key: record.artist_key.clone(),
art_path: String::new(),
@@ -1344,12 +1354,33 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
}
}
let format = audio_format(&record.format);
let names = [record.artist.as_str()]
.into_iter()
.chain(record.all_artists.split(','))
.collect::<Vec<_>>();
let aliases = crate::local_artist_match::build_artist_family_aliases(&names);
let artists = crate::local_artist_match::album_credit_names(
&record.artist,
&record.all_artists,
&aliases,
);
let source = badge_source(Some(source_word(record)));
let source_raw = badge_source_raw(Some(source_word(record)));
let sources = vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}];
let favoriteable = crate::local_rows::album_favorite_source(&sources).is_some();
NativeAlbum {
row: AlbumRow {
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", &id),
favoriteable,
id,
title: record.title.clone(),
artist: record.artist.clone(),
all_artists: record.all_artists.clone(),
artists,
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
track_count: record.track_count,
duration: total_duration(record.total_duration_ms / 1_000),
@@ -1360,10 +1391,11 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
record.sample_rate_hz.unwrap_or(0) as f64,
),
format: record.format.to_ascii_uppercase(),
genres: Vec::new(),
art_key,
source: badge_source(Some(source_word(record))),
sources: vec![badge_source(Some(source_word(record)))],
source_raw: badge_source_raw(Some(source_word(record))),
source,
sources,
source_raw,
directory_path: record.directory_path.clone(),
folder_count: record.folder_count,
},
@@ -1597,6 +1629,11 @@ mod tests {
track_count: 1,
art_key: format!("artist:{index}"),
source: "local".to_string(),
sources: vec!["local".to_string()],
formats: Vec::new(),
quality_tiers: Vec::new(),
years: Vec::new(),
year: String::new(),
},
artist_key: format!("artist {index}"),
art_path: String::new(),
@@ -1656,6 +1693,11 @@ mod tests {
_ => "subsonic",
}
.to_string(),
sources: Vec::new(),
formats: Vec::new(),
quality_tiers: Vec::new(),
years: Vec::new(),
year: String::new(),
})
.collect::<Vec<_>>();
let legacy_bytes = serde_json::to_vec(&legacy).unwrap().len();
+11 -4
View File
@@ -351,9 +351,9 @@ pub fn large_art_blocking(src: &str, track_id: Option<u64>) -> Option<PathBuf> {
}
// `src` is a 500px thumb — its hash is one-way, so the original is
// re-derived from the track row.
let Some(track) = track_id.and_then(|id| {
crate::local_state::with_db(|db| db.get_track(id as i64)).flatten()
}) else {
let Some(track) =
track_id.and_then(|id| crate::local_state::with_db(|db| db.get_track(id as i64)).flatten())
else {
return Some(path.to_path_buf());
};
// Serialize per source (the module-docs race): the large tier shares the
@@ -366,7 +366,7 @@ pub fn large_art_blocking(src: &str, track_id: Option<u64>) -> Option<PathBuf> {
out
}
/// Embedded bytes first (the scanner's precedence), folder art second.
/// Embedded bytes first, THIS disc directory second, collection fallback last.
fn large_art_for_track(track: &qbz_library::LocalTrack) -> Option<PathBuf> {
let file = Path::new(&track.file_path);
if let Some(bytes) = qbz_library::MetadataExtractor::extract_artwork_bytes(file) {
@@ -376,6 +376,13 @@ fn large_art_for_track(track: &qbz_library::LocalTrack) -> Option<PathBuf> {
return Some(p);
}
}
if let Some(dir) = file.parent() {
if let Some(art) = qbz_library::MetadataExtractor::folder_artwork_in_dir(dir) {
if let Ok(path) = qbz_library::get_or_generate_large_thumbnail(Path::new(&art)) {
return Some(path);
}
}
}
let art = qbz_library::MetadataExtractor::find_folder_artwork(file, Some(&track.album))?;
qbz_library::get_or_generate_large_thumbnail(Path::new(&art)).ok()
}
+541 -105
View File
@@ -51,7 +51,6 @@ pub mod qbz_local {
/// Album identity: "folder" | "metadata" (persisted; shared with
/// the Slint frontend's locallibrary_ui.json).
#[qproperty(QString, local_album_mode)]
// --- Albums tab ---------------------------------------------------
#[qproperty(bool, local_albums_loading)]
#[qproperty(QString, local_albums_error)]
@@ -64,7 +63,6 @@ pub mod qbz_local {
/// Bounded A-Z jump metadata (`[{letter,index}]`), never album rows.
#[qproperty(QString, local_albums_native_jumps_json)]
#[qproperty(QString, local_albums_native_error)]
// --- Artists tab --------------------------------------------------
#[qproperty(bool, local_artists_loading)]
#[qproperty(QString, local_artists_json)]
@@ -75,11 +73,9 @@ pub mod qbz_local {
#[qproperty(bool, local_artist_albums_loading)]
#[qproperty(QString, local_artists_native_jumps_json)]
#[qproperty(QString, local_artists_native_error)]
// --- Folders tab, FLAT mode ---------------------------------------
#[qproperty(bool, local_folders_loading)]
#[qproperty(QString, local_folders_json)]
// --- Folders tab, TREE mode ---------------------------------------
#[qproperty(bool, local_tree_loading)]
/// The FLATTENED, search-filtered visible tree — a plain array the
@@ -91,12 +87,13 @@ pub mod qbz_local {
#[qproperty(bool, local_detail_loading)]
/// {path, name, trackCount, subfolders[], tracks[]}
#[qproperty(QString, local_detail_json)]
// --- Tracks tab (server-paginated) --------------------------------
#[qproperty(bool, local_tracks_loading)]
#[qproperty(bool, local_tracks_loading_more)]
#[qproperty(bool, local_tracks_has_more)]
#[qproperty(QString, local_tracks_sort)]
/// Persisted quality/format/source funnel JSON for the Tracks query.
#[qproperty(QString, local_tracks_filter)]
/// Tracks-tab grouping: "off" | "album" | "artist" | "name". A
/// CLIENT-side visual reorder (unlike the sort, which is the SQL
/// ORDER BY), but persisted in the SAME locallibrary_ui.json key the
@@ -111,7 +108,6 @@ pub mod qbz_local {
/// Bounded A-Z jump metadata (`[{letter,index}]`), never track rows.
#[qproperty(QString, local_tracks_native_jumps_json)]
#[qproperty(QString, local_tracks_native_error)]
// --- Local album detail (the album pane) ---------------------------
#[qproperty(bool, local_album_loading)]
/// {album:{...}, tracks:[...]} — "" while nothing is open.
@@ -188,7 +184,6 @@ pub mod qbz_local {
/// destination, index, count, fraction, overall, tracks}`. Rate-limited
/// to ~10 Hz at the source — the rip callback fires per chunk.
#[qproperty(QString, local_rip_status)]
// --- Plex ----------------------------------------------------------
/// Master toggle (Settings > Local Library > Plex). Drives whether
/// the browse union includes Plex at all + the source filter chip.
@@ -241,7 +236,6 @@ pub mod qbz_local {
#[qproperty(QString, pin_auth_url)]
/// The pin/start request is in flight ("Working...").
#[qproperty(bool, pin_busy)]
type QbzLocal = super::QbzLocalRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
@@ -272,17 +266,9 @@ pub mod qbz_local {
columns: i32,
);
#[qinvokable]
fn albums_native_page_miss(
self: Pin<&mut QbzLocal>,
page: i32,
generation: i32,
);
fn albums_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
#[qinvokable]
fn albums_native_toggle_select(
self: Pin<&mut QbzLocal>,
index: i64,
shift: bool,
);
fn albums_native_toggle_select(self: Pin<&mut QbzLocal>, index: i64, shift: bool);
#[qinvokable]
fn albums_native_select_all(self: Pin<&mut QbzLocal>);
#[qinvokable]
@@ -292,25 +278,18 @@ pub mod qbz_local {
// --- Artists tab (native paged models) ---------------------------
#[qinvokable]
fn artists_native_reset(self: Pin<&mut QbzLocal>, search: QString);
#[qinvokable]
fn artists_native_page_miss(
fn artists_native_reset(
self: Pin<&mut QbzLocal>,
page: i32,
generation: i32,
search: QString,
sort: QString,
filter_json: QString,
);
#[qinvokable]
fn artists_native_select(
self: Pin<&mut QbzLocal>,
artist: QString,
columns: i32,
);
fn artists_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
#[qinvokable]
fn artist_albums_native_page_miss(
self: Pin<&mut QbzLocal>,
page: i32,
generation: i32,
);
fn artists_native_select(self: Pin<&mut QbzLocal>, artist: QString, columns: i32);
#[qinvokable]
fn artist_albums_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
// --- Tracks tab ----------------------------------------------------
/// Toolbar search (server-side; resets to page 1).
@@ -319,6 +298,8 @@ pub mod qbz_local {
/// Toolbar sort (SQL ORDER BY; resets to page 1).
#[qinvokable]
fn tracks_set_sort(self: Pin<&mut QbzLocal>, sort: QString);
#[qinvokable]
fn tracks_set_filter_json(self: Pin<&mut QbzLocal>, json: QString);
/// Toolbar grouping ("off" | "album" | "artist" | "name"): persist +
/// republish. NO re-query — the group modes reorder what is already
/// loaded, on top of the SQL sort.
@@ -329,17 +310,9 @@ pub mod qbz_local {
fn tracks_load_more(self: Pin<&mut QbzLocal>);
/// QAbstractListModel page miss -> async catalog keyset page.
#[qinvokable]
fn tracks_native_page_miss(
self: Pin<&mut QbzLocal>,
page: i32,
generation: i32,
);
fn tracks_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
#[qinvokable]
fn tracks_native_toggle_select(
self: Pin<&mut QbzLocal>,
index: i64,
shift: bool,
);
fn tracks_native_toggle_select(self: Pin<&mut QbzLocal>, index: i64, shift: bool);
#[qinvokable]
fn tracks_native_select_all(self: Pin<&mut QbzLocal>);
#[qinvokable]
@@ -349,17 +322,9 @@ pub mod qbz_local {
#[qinvokable]
fn tracks_native_play(self: Pin<&mut QbzLocal>, index: i64);
#[qinvokable]
fn tracks_native_enqueue(
self: Pin<&mut QbzLocal>,
index: i64,
mode: QString,
);
fn tracks_native_enqueue(self: Pin<&mut QbzLocal>, index: i64, mode: QString);
#[qinvokable]
fn tracks_native_row_action(
self: Pin<&mut QbzLocal>,
index: i64,
action: QString,
);
fn tracks_native_row_action(self: Pin<&mut QbzLocal>, index: i64, action: QString);
#[qinvokable]
fn tracks_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
@@ -387,7 +352,12 @@ pub mod qbz_local {
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
/// Albums-grid / Tracks-table bulk bar. scope = "album" | "track".
#[qinvokable]
fn bulk_action(self: Pin<&mut QbzLocal>, scope: QString, ids_json: QString, action: QString);
fn bulk_action(
self: Pin<&mut QbzLocal>,
scope: QString,
ids_json: QString,
action: QString,
);
/// Row body: select a folder and load its detail pane.
#[qinvokable]
fn select_folder(self: Pin<&mut QbzLocal>, path: QString);
@@ -397,6 +367,14 @@ pub mod qbz_local {
/// tracks). A `plex:<hash>` id is served from the Plex cache.
#[qinvokable]
fn open_album(self: Pin<&mut QbzLocal>, id: QString);
/// Open a logical album while preserving the Local Library funnel at
/// the click site; filtered-out physical copies stay inaccessible.
#[qinvokable]
fn open_album_filtered(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
);
/// Close the album pane (back to the grid).
#[qinvokable]
fn close_album(self: Pin<&mut QbzLocal>);
@@ -422,6 +400,35 @@ pub mod qbz_local {
/// QML uses it to filter the very array it renders.
#[qinvokable]
fn artist_album_ids(self: &QbzLocal, artist: QString) -> QString;
/// Genres details mode: fetch one logical album's rows without
/// navigating away. Results are id-keyed so concurrent visible albums
/// may finish in any order without publishing over each other.
#[qinvokable]
fn genre_album_tracks(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
);
/// Genres details version picker: switch one expanded album without
/// retargeting the routed AlbumView or re-querying the database.
#[qinvokable]
fn genre_album_select_version(self: Pin<&mut QbzLocal>, album_id: QString, index: i32);
/// Play/shuffle/enqueue the selected version; `track_id` is empty for
/// an album header action and set for a displayed track-row click.
#[qinvokable]
fn genre_album_action(
self: Pin<&mut QbzLocal>,
album_id: QString,
action: QString,
track_id: QString,
);
/// Play/enqueue the currently selected physical copy in AlbumView.
#[qinvokable]
fn album_selected_action(
self: Pin<&mut QbzLocal>,
action: QString,
track_id: QString,
);
/// Album header pencil — LOGGED SEAM (no tag-editor modal yet).
#[qinvokable]
fn album_edit_tags(self: Pin<&mut QbzLocal>, id: QString);
@@ -437,6 +444,16 @@ pub mod qbz_local {
/// Per-disc menu: "play" | "next" | "later" | "queue".
#[qinvokable]
fn album_disc_action(self: Pin<&mut QbzLocal>, disc: i32, action: QString);
/// Same menu for one concurrently expanded Genres album. Album id is
/// required because the routed AlbumView's singleton cache is not the
/// source of these rows.
#[qinvokable]
fn genre_album_disc_action(
self: Pin<&mut QbzLocal>,
album_id: QString,
disc: i32,
action: QString,
);
// --- Ephemeral folder ------------------------------------------------
/// Pick a folder OUTSIDE the library, scan it, show the pane.
#[qinvokable]
@@ -493,6 +510,14 @@ pub mod qbz_local {
/// Album card / detail header Play (optionally shuffled).
#[qinvokable]
fn play_album(self: Pin<&mut QbzLocal>, id: QString, shuffle: bool);
/// Album card Play constrained to the active Local Library funnel.
#[qinvokable]
fn play_album_filtered(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
shuffle: bool,
);
/// Album-detail row click: play the album from that track.
#[qinvokable]
fn play_album_track(self: Pin<&mut QbzLocal>, id: QString, track_id: QString);
@@ -517,6 +542,25 @@ pub mod qbz_local {
/// mode = "next" | "later" | "queue".
#[qinvokable]
fn enqueue(self: Pin<&mut QbzLocal>, kind: QString, id: QString, mode: QString);
/// Album enqueue constrained to the active Local Library funnel.
#[qinvokable]
fn enqueue_album_filtered(
self: Pin<&mut QbzLocal>,
id: QString,
filter_json: QString,
mode: QString,
);
/// Heart a genuine local/Plex logical album using the display
/// snapshot already rendered by the Local Library card.
#[qinvokable]
fn album_toggle_favorite(
self: Pin<&mut QbzLocal>,
id: QString,
title: QString,
artist: QString,
artwork_url: QString,
sources_json: QString,
);
// --- Plex ------------------------------------------------------------
/// Header Sync button (#573): re-fetch the Plex sections + tracks
@@ -620,6 +664,27 @@ pub mod qbz_local {
/// wrong row.
#[qsignal]
fn local_artwork_ready(self: Pin<&mut QbzLocal>, key: QString, path: QString);
#[qsignal]
fn local_genre_album_ready(
self: Pin<&mut QbzLocal>,
id: QString,
tracks_json: QString,
filter_json: QString,
);
/// Session-only reachability result for one physical local row.
/// An empty message clears a previous failure after a successful
/// retry; no database row is rewritten from this transient evidence.
#[qsignal]
fn local_track_availability(
self: Pin<&mut QbzLocal>,
source: QString,
id: QString,
message: QString,
);
/// Successful store result (also carries rollback after a failed
/// optimistic card flip).
#[qsignal]
fn local_album_favorite_changed(self: Pin<&mut QbzLocal>, id: QString, favorite: bool);
}
impl cxx_qt::Threading for QbzLocal {}
@@ -659,6 +724,7 @@ pub struct QbzLocalRust {
local_tracks_loading_more: bool,
local_tracks_has_more: bool,
local_tracks_sort: QString,
local_tracks_filter: QString,
local_tracks_group: QString,
local_tracks_json: QString,
local_tracks_native_active: bool,
@@ -732,6 +798,7 @@ impl Default for QbzLocalRust {
local_tracks_loading_more: false,
local_tracks_has_more: false,
local_tracks_sort: QString::from("default"),
local_tracks_filter: QString::default(),
local_tracks_group: QString::from("off"),
local_tracks_json: QString::from("[]"),
local_tracks_native_active: false,
@@ -788,6 +855,47 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLocal>) + Send + 'static) {
}
}
/// Publish a transient playback reachability result without changing the
/// authoritative library row. A missing file may have moved and an
/// unreachable NAS may return later; both must remain retryable and neither
/// is evidence that authorizes pruning.
pub(crate) fn emit_track_availability(source: &str, id: u64, message: Option<&str>) {
let source = source.to_string();
let id = id.to_string();
let message = message.unwrap_or_default().to_string();
ui(move |mut b| {
b.as_mut().local_track_availability(
QString::from(source.as_str()),
QString::from(id.as_str()),
QString::from(message.as_str()),
);
});
}
/// Publish genre details on the next Qt event-loop turn for both cache hits
/// and freshly queried rows. A synchronous cache-hit signal can fire while a
/// Loader is still mounting `LocalGenreDetails`; that first response then has
/// no live `Connections` receiver and its delegate remains pending forever.
fn queue_genre_album_ready(
id: String,
json: String,
filter_json: String,
phase: &'static str,
started: std::time::Instant,
) {
log::info!(
"[qbz-qt][perf] genre album detail phase={phase} id={id} bytes={} elapsed={:?}",
json.len(),
started.elapsed()
);
ui(move |mut bridge| {
bridge.as_mut().local_genre_album_ready(
QString::from(id.as_str()),
QString::from(json.as_str()),
QString::from(filter_json.as_str()),
);
});
}
impl qbz_local::QbzLocal {
pub fn boot(self: Pin<&mut Self>) {
@@ -802,6 +910,7 @@ impl qbz_local::QbzLocal {
let mode = lib::album_mode();
let sort = lib::tracks_sort();
let group = lib::tracks_group();
let tracks_filter = lib::tracks_filter();
crate::spawn(async move {
let available = tokio::task::spawn_blocking(lib::has_library)
.await
@@ -814,6 +923,8 @@ impl qbz_local::QbzLocal {
.set_local_tracks_sort(QString::from(sort.as_str()));
b.as_mut()
.set_local_tracks_group(QString::from(group.as_str()));
b.as_mut()
.set_local_tracks_filter(QString::from(tracks_filter.as_str()));
});
});
publish_plex_state();
@@ -867,19 +978,11 @@ impl qbz_local::QbzLocal {
);
}
pub fn albums_native_page_miss(
self: Pin<&mut Self>,
page: i32,
generation: i32,
) {
pub fn albums_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
crate::local_albums_model_qt::request_page(page, generation);
}
pub fn albums_native_toggle_select(
self: Pin<&mut Self>,
index: i64,
shift: bool,
) {
pub fn albums_native_toggle_select(self: Pin<&mut Self>, index: i64, shift: bool) {
crate::local_albums_model_qt::toggle_selection(index, shift);
}
@@ -895,31 +998,30 @@ impl qbz_local::QbzLocal {
crate::local_albums_model_qt::bulk_action(action.to_string());
}
pub fn artists_native_reset(self: Pin<&mut Self>, search: QString) {
crate::local_artists_model_qt::reset(search.to_string());
pub fn artists_native_reset(
self: Pin<&mut Self>,
search: QString,
sort: QString,
filter_json: QString,
) {
if !crate::local_artists_model_qt::reset(
search.to_string(),
sort.to_string(),
filter_json.to_string(),
) {
crate::local_bridge_ops::load_artists_legacy();
}
}
pub fn artists_native_page_miss(
self: Pin<&mut Self>,
page: i32,
generation: i32,
) {
pub fn artists_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
crate::local_artists_model_qt::request_page(page, generation);
}
pub fn artists_native_select(
self: Pin<&mut Self>,
artist: QString,
columns: i32,
) {
pub fn artists_native_select(self: Pin<&mut Self>, artist: QString, columns: i32) {
crate::local_artists_model_qt::select_artist(artist.to_string(), columns);
}
pub fn artist_albums_native_page_miss(
self: Pin<&mut Self>,
page: i32,
generation: i32,
) {
pub fn artist_albums_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
crate::local_artists_model_qt::request_detail_page(page, generation);
}
@@ -938,6 +1040,16 @@ impl qbz_local::QbzLocal {
load_tracks(true);
}
pub fn tracks_set_filter_json(self: Pin<&mut Self>, json: QString) {
let json = json.to_string();
lib::set_tracks_filter(&json);
ui(move |mut b| {
b.as_mut()
.set_local_tracks_filter(QString::from(json.as_str()));
});
load_tracks(true);
}
pub fn tracks_set_group(self: Pin<&mut Self>, mode: QString) {
let mode = mode.to_string();
lib::set_tracks_group(&mode);
@@ -963,11 +1075,7 @@ impl qbz_local::QbzLocal {
crate::local_tracks_model_qt::request_page(page, generation);
}
pub fn tracks_native_toggle_select(
self: Pin<&mut Self>,
index: i64,
shift: bool,
) {
pub fn tracks_native_toggle_select(self: Pin<&mut Self>, index: i64, shift: bool) {
crate::local_tracks_model_qt::toggle_selection(index, shift);
}
@@ -987,11 +1095,7 @@ impl qbz_local::QbzLocal {
crate::local_tracks_model_qt::enqueue(index, mode.to_string());
}
pub fn tracks_native_row_action(
self: Pin<&mut Self>,
index: i64,
action: QString,
) {
pub fn tracks_native_row_action(self: Pin<&mut Self>, index: i64, action: QString) {
crate::local_tracks_model_qt::row_action(index, action.to_string());
}
@@ -1026,9 +1130,10 @@ impl qbz_local::QbzLocal {
let path = path.to_string();
ui(|mut b| b.as_mut().set_local_detail_loading(true));
crate::spawn(async move {
let detail = tokio::task::spawn_blocking(move || lib::load_folder_detail_blocking(&path))
.await
.ok();
let detail =
tokio::task::spawn_blocking(move || lib::load_folder_detail_blocking(&path))
.await
.ok();
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
ui(move |mut b| {
b.as_mut()
@@ -1042,6 +1147,14 @@ impl qbz_local::QbzLocal {
open_album_by_id(id.to_string());
}
pub fn open_album_filtered(
self: Pin<&mut Self>,
id: QString,
filter_json: QString,
) {
open_album_by_id_filtered(id.to_string(), filter_json.to_string());
}
pub fn close_album(self: Pin<&mut Self>) {
ui(|mut b| {
b.as_mut().set_local_album_json(QString::from(""));
@@ -1057,6 +1170,19 @@ impl qbz_local::QbzLocal {
});
}
pub fn play_album_filtered(
self: Pin<&mut Self>,
id: QString,
filter_json: QString,
shuffle: bool,
) {
let (id, filter_json) = (id.to_string(), filter_json.to_string());
let runtime = crate::app();
crate::spawn(async move {
lib::play_album_filtered(&runtime, id, filter_json, shuffle).await;
});
}
pub fn play_album_track(self: Pin<&mut Self>, id: QString, track_id: QString) {
let id = id.to_string();
let row = track_id.to_string().parse::<i64>().ok();
@@ -1085,11 +1211,7 @@ impl qbz_local::QbzLocal {
});
}
pub fn play_tracks_visible(
self: Pin<&mut Self>,
visible_ids_json: QString,
track_id: QString,
) {
pub fn play_tracks_visible(self: Pin<&mut Self>, visible_ids_json: QString, track_id: QString) {
let Ok(row) = track_id.to_string().parse::<i64>() else {
return;
};
@@ -1108,6 +1230,40 @@ impl qbz_local::QbzLocal {
});
}
pub fn enqueue_album_filtered(
self: Pin<&mut Self>,
id: QString,
filter_json: QString,
mode: QString,
) {
let (id, filter_json, mode) = (
id.to_string(),
filter_json.to_string(),
mode.to_string(),
);
let runtime = crate::app();
crate::spawn(async move {
lib::enqueue_album_filtered(&runtime, id, filter_json, mode).await;
});
}
pub fn album_toggle_favorite(
self: Pin<&mut Self>,
id: QString,
title: QString,
artist: QString,
artwork_url: QString,
sources_json: QString,
) {
crate::local_album_actions::toggle_album_favorite(
id.to_string(),
title.to_string(),
artist.to_string(),
artwork_url.to_string(),
sources_json.to_string(),
);
}
// --- Plex --------------------------------------------------------------
pub fn sync_plex(self: Pin<&mut Self>) {
@@ -1123,7 +1279,8 @@ impl qbz_local::QbzLocal {
let text = json.to_string();
// The qproperty is what the view re-seeds from on its next mount, so
// it is written on EVERY toggle, not only on close.
self.as_mut().set_albums_filter(QString::from(text.as_str()));
self.as_mut()
.set_albums_filter(QString::from(text.as_str()));
crate::local_bridge_ops::save_albums_filter(&text);
}
@@ -1402,6 +1559,267 @@ impl qbz_local::QbzLocal {
QString::from(crate::local_albums::artist_album_ids(&artist.to_string()).as_str())
}
pub fn genre_album_tracks(
_self: Pin<&mut Self>,
id: QString,
filter_json: QString,
) {
let started = std::time::Instant::now();
let id = id.to_string();
let filter_json = filter_json.to_string();
enum Request {
Cached(String),
Pending,
Spawn,
}
let request = crate::local_state::state(|state| {
if state.genre_detail_filters.get(&id) == Some(&filter_json) {
if let Some(json) = state.genre_detail_docs.get(&id).cloned() {
return Request::Cached(json);
}
}
if state.genre_detail_requests.get(&id) == Some(&filter_json) {
return Request::Pending;
}
state
.genre_detail_requests
.insert(id.clone(), filter_json.clone());
// A version switch serialized against the old funnel must not
// publish over the replacement document that starts here.
let generation = state
.genre_detail_version_generations
.get(&id)
.copied()
.unwrap_or(0)
.wrapping_add(1);
state
.genre_detail_version_generations
.insert(id.clone(), generation);
Request::Spawn
});
match request {
Request::Cached(json) => {
queue_genre_album_ready(id, json, filter_json, "cache-hit", started);
return;
}
// The original worker owns the one eventual signal. Spawning a
// duplicate here used to double the database/map/serialize work
// for every ListView remount during a filter transition.
Request::Pending => return,
Request::Spawn => {}
}
crate::spawn(async move {
let request_id = id.clone();
let request_filter = filter_json.clone();
let result = tokio::task::spawn_blocking(move || {
let fetch_started = std::time::Instant::now();
let cached_tracks = crate::local_state::state(|state| {
state.genre_detail_all_tracks.get(&request_id).cloned()
});
let raw_cache_hit = cached_tracks.is_some();
let all_tracks = cached_tracks.unwrap_or_else(|| {
let mut tracks =
crate::local_albums::fetch_album_tracks_blocking(&request_id);
crate::local_playback::fill_missing_covers(&mut tracks);
std::sync::Arc::new(tracks)
});
let fetch_elapsed = fetch_started.elapsed();
let project_started = std::time::Instant::now();
let filter = crate::local_filter::MediaFilter::from_json(&request_filter);
// The raw cache is shared: changing a source/quality chip must
// not deep-clone every physical copy before throwing most of
// them away. Clone only the rows admitted by this funnel.
let tracks = all_tracks
.iter()
.filter(|track| filter.track_enabled(track))
.cloned()
.collect::<Vec<_>>();
let filtered_rows = tracks.len();
let versions = crate::local_album_actions::split_versions(tracks);
let selected = versions
.first()
.map(|(_, rows)| rows.clone())
.unwrap_or_default();
let doc = crate::local_album_actions::genre_detail_doc(&request_id, &versions, 0);
let json = crate::local_rows::to_json(&doc);
let project_elapsed = project_started.elapsed();
let versions_empty = versions.is_empty();
log::info!(
"[qbz-qt][perf] genre album detail phase=worker id={} raw_cache={} raw_rows={} filtered_rows={} versions={} fetch={:?} project={:?}",
request_id,
raw_cache_hit,
all_tracks.len(),
filtered_rows,
doc.versions.len(),
fetch_elapsed,
project_elapsed,
);
let cached_json = json.clone();
let current = crate::local_state::state(move |state| {
if state.genre_detail_requests.get(&request_id) != Some(&request_filter) {
return false;
}
if !all_tracks.is_empty() {
if state.genre_detail_all_tracks.len() >= 32
&& !state.genre_detail_all_tracks.contains_key(&request_id)
{
if let Some(oldest) =
state.genre_detail_all_tracks.keys().min().cloned()
{
state.genre_detail_raw.remove(&oldest);
state.genre_detail_all_tracks.remove(&oldest);
state.genre_detail_versions.remove(&oldest);
state.genre_detail_docs.remove(&oldest);
state.genre_detail_filters.remove(&oldest);
state.genre_detail_requests.remove(&oldest);
state.genre_detail_version_generations.remove(&oldest);
}
}
state
.genre_detail_all_tracks
.insert(request_id.clone(), all_tracks);
}
state.genre_detail_requests.remove(&request_id);
// An unavailable NAS/server is not an authoritative empty
// album. Let this view render the miss, but keep the old
// completed-cache tag (if any) so a later retry is not
// mistaken for a completed hit of this funnel.
if versions_empty {
return true;
}
state.genre_detail_raw.insert(request_id.clone(), selected);
state
.genre_detail_versions
.insert(request_id.clone(), versions);
state
.genre_detail_docs
.insert(request_id.clone(), cached_json);
state
.genre_detail_filters
.insert(request_id, request_filter);
true
});
if !current {
return None;
}
// An unavailable NAS/server is not an authoritative empty
// album. Publish the miss for this view, but do not make it a
// process-lifetime cache hit that prevents a later retry.
if versions_empty {
return Some((json, raw_cache_hit));
}
Some((json, raw_cache_hit))
})
.await
.ok()
.flatten();
if let Some((result, raw_cache_hit)) = result {
queue_genre_album_ready(
id,
result,
filter_json,
if raw_cache_hit { "filter-cache" } else { "query" },
started,
);
}
});
}
pub fn genre_album_select_version(self: Pin<&mut Self>, album_id: QString, index: i32) {
let album_id = album_id.to_string();
let generation = crate::local_state::state(|state| {
let generation = state
.genre_detail_version_generations
.get(&album_id)
.copied()
.unwrap_or(0)
.wrapping_add(1);
state
.genre_detail_version_generations
.insert(album_id.clone(), generation);
generation
});
crate::spawn(async move {
let request_id = album_id.clone();
let selection = tokio::task::spawn_blocking(move || {
let index = usize::try_from(index).ok()?;
let (versions, filter_json) = crate::local_state::state(|state| {
Some((
state.genre_detail_versions.get(&request_id)?.clone(),
state
.genre_detail_filters
.get(&request_id)
.cloned()
.unwrap_or_default(),
))
})?;
let selected = versions.get(index)?.1.clone();
let doc =
crate::local_album_actions::genre_detail_doc(&request_id, &versions, index);
Some((
request_id,
selected,
crate::local_rows::to_json(&doc),
filter_json,
))
})
.await
.ok()
.flatten();
if let Some((request_id, selected, json, filter_json)) = selection {
let current = crate::local_state::state(|state| {
if state
.genre_detail_version_generations
.get(&request_id)
.copied()
!= Some(generation)
{
return false;
}
state.genre_detail_raw.insert(request_id.clone(), selected);
state
.genre_detail_docs
.insert(request_id.clone(), json.clone());
true
});
if !current {
return;
}
ui(move |mut bridge| {
bridge.as_mut().local_genre_album_ready(
QString::from(request_id.as_str()),
QString::from(json.as_str()),
QString::from(filter_json.as_str()),
);
});
}
});
}
pub fn genre_album_action(
self: Pin<&mut Self>,
album_id: QString,
action: QString,
track_id: QString,
) {
crate::local_album_actions::genre_album_action(
album_id.to_string(),
action.to_string(),
track_id.to_string().parse::<i64>().ok(),
);
}
pub fn album_selected_action(
self: Pin<&mut Self>,
action: QString,
track_id: QString,
) {
crate::local_album_actions::selected_album_action(
action.to_string(),
track_id.to_string().parse::<i64>().ok(),
);
}
pub fn album_edit_tags(self: Pin<&mut Self>, id: QString) {
crate::local_album_actions::edit_tags(id.to_string());
}
@@ -1422,6 +1840,19 @@ impl qbz_local::QbzLocal {
crate::local_album_actions::disc_action(disc, action.to_string());
}
pub fn genre_album_disc_action(
self: Pin<&mut Self>,
album_id: QString,
disc: i32,
action: QString,
) {
crate::local_album_actions::genre_disc_action(
album_id.to_string(),
disc,
action.to_string(),
);
}
// --- Ephemeral folder ----------------------------------------------------
pub fn ephemeral_open(self: Pin<&mut Self>) {
@@ -1520,7 +1951,6 @@ impl qbz_local::QbzLocal {
crate::local_ephemeral::play_track(&runtime, row).await;
});
}
}
// ---------------------------------------------------------------------------
@@ -1530,6 +1960,10 @@ impl qbz_local::QbzLocal {
/// Open a LOCAL/Plex album. Lifted out of the invokable so `open_album` in
/// main.rs can route to it when a local id reaches the catalog path.
pub(crate) fn open_album_by_id(id: String) {
open_album_by_id_filtered(id, String::new());
}
pub(crate) fn open_album_by_id_filtered(id: String, filter_json: String) {
ui(|mut b| {
// Clear the previous local album with the loading flag — the same
// stale-render the catalog album view had.
@@ -1537,10 +1971,12 @@ pub(crate) fn open_album_by_id(id: String) {
b.as_mut().set_local_album_loading(true);
});
crate::spawn(async move {
let detail = tokio::task::spawn_blocking(move || lib::load_album_detail_blocking(&id))
.await
.ok()
.flatten();
let detail = tokio::task::spawn_blocking(move || {
lib::load_album_detail_filtered_blocking(&id, &filter_json)
})
.await
.ok()
.flatten();
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
ui(move |mut b| {
b.as_mut()
+25 -11
View File
@@ -96,7 +96,8 @@ pub(crate) fn publish_media_gates() {
ui(move |mut b| {
b.as_mut().set_media_has_jellyfin(jf);
b.as_mut().set_media_has_subsonic(sub);
b.as_mut().set_albums_filter(cxx_qt_lib::QString::from(filter.as_str()));
b.as_mut()
.set_albums_filter(cxx_qt_lib::QString::from(filter.as_str()));
});
}
@@ -108,7 +109,8 @@ pub(crate) fn save_albums_filter(json: &str) {
let value = if json.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(json).unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
serde_json::from_str(json)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
};
crate::settings_qt::save_pref(ALBUMS_FILTER_KEY, value);
}
@@ -121,8 +123,7 @@ pub(crate) fn save_albums_filter(json: &str) {
/// ever match zero rows, and the chip that would let the user untick it is
/// hidden by the same gate.
fn pruned_albums_filter(has_jellyfin: bool, has_subsonic: bool) -> String {
let Some(serde_json::Value::Object(mut map)) =
crate::settings_qt::read_pref(ALBUMS_FILTER_KEY)
let Some(serde_json::Value::Object(mut map)) = crate::settings_qt::read_pref(ALBUMS_FILTER_KEY)
else {
return String::new();
};
@@ -148,6 +149,21 @@ pub(crate) fn reload_browse() {
// wrong. This is the single chokepoint for local-library mutations, which
// is why the invalidation lives here rather than at each call site.
crate::search_cache_qt::invalidate();
// Expanded Genres albums cache their physical versions so switching the
// picker never re-queries four sources. A source reconciliation changes
// exactly that membership (and may prune provider ids), so keeping these
// documents would republish a removed copy after the cards themselves had
// refreshed. The visible QML document remains stable until its next open;
// every subsequent expand/select is rebuilt from authoritative caches.
crate::local_state::state(|state| {
state.genre_detail_raw.clear();
state.genre_detail_all_tracks.clear();
state.genre_detail_versions.clear();
state.genre_detail_docs.clear();
state.genre_detail_filters.clear();
state.genre_detail_requests.clear();
state.genre_detail_version_generations.clear();
});
load_albums();
load_artists();
load_tracks(true);
@@ -211,6 +227,9 @@ pub(crate) fn load_tab_impl(tab: String) {
// commit, and it still derives album credits from that document.
"albums-legacy" => load_albums_legacy(),
"artists" => load_artists(),
// The browser needs the bounded logical-album document even when the
// Albums surface itself is using the optional native model.
"genres" => load_albums_legacy(),
"folders" => load_folders(),
"tracks" => load_tracks(true),
// The ephemeral session lives in memory and is published the moment it
@@ -286,8 +305,7 @@ pub(crate) fn load_albums_legacy() {
Err(e) => {
log::warn!("[qbz-qt] local albums load failed: {e}");
ui(move |mut b| {
b.as_mut()
.set_local_albums_error(QString::from(e.as_str()));
b.as_mut().set_local_albums_error(QString::from(e.as_str()));
b.as_mut().set_local_albums_loading(false);
});
}
@@ -386,11 +404,7 @@ pub(crate) fn load_tracks(reset: bool) {
log_surface_route("tracks", "catalog", "feature-enabled");
return;
}
log_surface_route(
"tracks",
"legacy",
"feature-disabled-or-session-failed",
);
log_surface_route("tracks", "legacy", "feature-disabled-or-session-failed");
} else {
log_surface_route("tracks", "legacy", "legacy-pagination");
}
+183
View File
@@ -0,0 +1,183 @@
//! Allowlisted Local Library quality/format/source filter descriptor.
//!
//! QML owns the presentation JSON; every backend path consumes this parsed
//! value so native and compatibility readers cannot disagree about a chip.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaFilter {
pub formats: Vec<String>,
pub other_formats: bool,
pub qualities: Vec<String>,
pub sources: Vec<String>,
}
impl MediaFilter {
pub fn from_json(json: &str) -> Self {
let value: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
let on = |key: &str| value.get(key).and_then(|value| value.as_bool()) == Some(true);
Self {
formats: ["flac", "alac", "ape", "wav", "mp3", "aac"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect(),
other_formats: on("other"),
qualities: ["hires", "cd", "lossy"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect(),
sources: ["local", "offline", "plex", "jellyfin", "subsonic"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect(),
}
}
pub fn source_enabled(&self, source: &str) -> bool {
self.sources.is_empty() || self.sources.iter().any(|value| value == source)
}
/// Apply the same source/format/quality funnel to one physical copy.
/// Logical album cards may match through any represented source; version
/// pickers and their actions must not reintroduce copies the active funnel
/// excluded.
pub fn track_enabled(&self, track: &qbz_library::LocalTrack) -> bool {
let source = match track.source.as_deref().unwrap_or("local") {
// Scanned files are stored as `user` in library.db. The UI and
// every album projection expose that source as `local`; letting
// the raw storage word reach the allowlist made physical local
// copies disappear as soon as any source chip was active.
"" | "user" | "local" => "local",
"qobuz_purchase" | "qobuz_download" => "offline",
"navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic",
source => source,
};
if !self.source_enabled(source) {
return false;
}
let format = track.format.to_string().to_ascii_lowercase();
if !self.formats.is_empty() || self.other_formats {
let known = ["flac", "alac", "ape", "wav", "mp3", "aac"];
let format_matches = self.formats.iter().any(|value| value == &format)
|| (self.other_formats && !known.contains(&format.as_str()));
if !format_matches {
return false;
}
}
if !self.qualities.is_empty() {
let tier =
crate::local_rows::tier_of(&track.format, track.bit_depth, track.sample_rate);
let quality = match tier {
"hires" | "max" => "hires",
"cd" => "cd",
_ => "lossy",
};
if !self.qualities.iter().any(|value| value == quality) {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::MediaFilter;
use qbz_library::{AudioFormat, LocalTrack};
#[test]
fn parser_ignores_unknown_keys_and_normalizes_source_gate() {
let filter = MediaFilter::from_json(
r#"{"flac":true,"other":true,"hires":true,"plex":true,"bogus":true}"#,
);
assert_eq!(filter.formats, ["flac"]);
assert!(filter.other_formats);
assert_eq!(filter.qualities, ["hires"]);
assert!(filter.source_enabled("plex"));
assert!(!filter.source_enabled("local"));
}
#[test]
fn physical_versions_use_folded_source_and_media_facets() {
let filter = MediaFilter::from_json(r#"{"subsonic":true,"flac":true,"hires":true}"#);
let track = LocalTrack {
source: Some("navidrome".into()),
format: AudioFormat::Flac,
bit_depth: Some(24),
sample_rate: 96_000.0,
..Default::default()
};
assert!(filter.track_enabled(&track));
let mut local = track.clone();
// `user` is the authoritative library.db spelling for a scanned
// physical file; presentation folds it to the Local source chip.
local.source = Some("user".into());
assert!(!filter.track_enabled(&local));
let mut mp3 = track;
mp3.format = AudioFormat::Mp3;
mp3.bit_depth = None;
mp3.sample_rate = 44_100.0;
assert!(!filter.track_enabled(&mp3));
}
#[test]
fn source_filter_removes_other_physical_versions_before_picker_grouping() {
let filter = MediaFilter::from_json(r#"{"jellyfin":true,"subsonic":true}"#);
let copy = |id: i64, key: &str, source: &str| LocalTrack {
id,
file_path: format!("{key}/01.flac"),
title: "Track 1".into(),
artist: "Seiji Yokoyama".into(),
album: "Saint Seiya Eternal CD-Box".into(),
album_group_key: key.into(),
album_group_title: "Saint Seiya Eternal CD-Box".into(),
track_number: Some(1),
disc_number: Some(1),
format: AudioFormat::Flac,
source: Some(source.into()),
..Default::default()
};
let tracks = vec![
copy(1, "local-copy", "user"),
copy(2, "plex-copy", "plex"),
copy(3, "jellyfin-copy", "jellyfin"),
copy(4, "navidrome-copy", "navidrome"),
];
let filtered = tracks
.into_iter()
.filter(|track| filter.track_enabled(track))
.collect();
let versions = crate::local_album_actions::split_versions(filtered);
assert_eq!(versions.len(), 2);
let sources: Vec<&str> = versions
.iter()
.map(|(_, rows)| rows[0].source.as_deref().unwrap())
.collect();
assert!(sources.contains(&"jellyfin"));
assert!(sources.contains(&"navidrome"));
assert!(!sources.contains(&"local"));
assert!(!sources.contains(&"plex"));
}
#[test]
fn local_source_chip_accepts_scanner_storage_word() {
let filter = MediaFilter::from_json(r#"{"local":true}"#);
let scanned = LocalTrack {
source: Some("user".into()),
format: AudioFormat::Flac,
..Default::default()
};
assert!(filter.track_enabled(&scanned));
let all_sources = MediaFilter::from_json(
r#"{"local":true,"offline":true,"plex":true,"jellyfin":true,"subsonic":true}"#,
);
assert!(all_sources.track_enabled(&scanned));
}
}
+361 -57
View File
@@ -24,12 +24,87 @@ use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_library::LocalTrack;
use qbz_models::QueueTrack;
use qbz_source::PlaybackTicket;
use crate::local_albums::fetch_album_tracks_blocking;
use crate::local_state::{state, with_db};
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
const LOCAL_FILE_UNAVAILABLE: &str = "File not available — is the drive mounted?";
fn is_probeable_local_queue_row(track: &QueueTrack) -> bool {
matches!(
track.source.as_deref(),
Some("local" | "user" | "ephemeral")
)
}
/// Prove that a physical local row can be opened before any caller changes
/// the queue cursor or publishes now-playing metadata. Local decoding starts
/// lazily, so discovering a moved file inside the audible step is too late:
/// the old audio can still be heard while queue, NPB and MPRIS already claim
/// the failed replacement is playing.
///
/// The bounded reachability probe preserves the NAS rule: timeout is
/// transient evidence only. It marks the row for this session and never
/// deletes or rewrites the authoritative library database.
pub(crate) async fn preflight_queue_track(track: &QueueTrack) -> Result<(), String> {
if !is_probeable_local_queue_row(track) {
return Ok(());
}
let ticket = match qbz_source::registry().playback(track).await {
Ok(ticket) => ticket,
Err(error) => {
let detail = error.to_string();
log::warn!(
"[qbz-qt] local playback preflight: track {} could not resolve: {detail}",
track.id
);
crate::local_bridge::emit_track_availability(
"local",
track.id,
Some(LOCAL_FILE_UNAVAILABLE),
);
crate::toast_qt::warning(qbz_i18n::t(LOCAL_FILE_UNAVAILABLE));
return Err(detail);
}
};
let path = match ticket {
PlaybackTicket::File { path, .. } | PlaybackTicket::DsdFile { path, .. } => path,
PlaybackTicket::Bytes { .. }
| PlaybackTicket::Stream { .. }
| PlaybackTicket::Catalog { .. }
| PlaybackTicket::SeekLoaded { .. }
| PlaybackTicket::CdTrack { .. } => return Ok(()),
};
// SACD references deliberately ride the DSD ticket but are not filesystem
// paths; their demuxer owns medium availability and must not be rejected by
// a path probe that cannot understand the reference.
if qbz_disc::SacdRef::is_sacd_path(&path.to_string_lossy()) {
return Ok(());
}
let reach = tokio::task::spawn_blocking(move || qbz_library::probe_default(&path))
.await
.unwrap_or(qbz_library::Reach::Unreachable);
if reach.is_playable() {
crate::local_bridge::emit_track_availability("local", track.id, None);
return Ok(());
}
let detail = match reach {
qbz_library::Reach::Missing => "local file is missing",
qbz_library::Reach::Unreachable => "local file is temporarily unreachable",
qbz_library::Reach::Present => unreachable!(),
};
log::warn!(
"[qbz-qt] local playback preflight: track {} {detail}",
track.id
);
crate::local_bridge::emit_track_availability("local", track.id, Some(LOCAL_FILE_UNAVAILABLE));
crate::toast_qt::warning(qbz_i18n::t(LOCAL_FILE_UNAVAILABLE));
Err(detail.to_string())
}
// ---------------------------------------------------------------------------
// On-disk cover backfill (playback.rs `fill_missing_covers` +
// local_library.rs `find_folder_cover`, both 1:1)
@@ -96,8 +171,15 @@ pub(crate) fn find_folder_cover(folder: &Path) -> Option<String> {
.map(|p| p.to_string_lossy().into_owned())
}
/// Fill `artwork_path` for the rows that lack one, from a cover image sitting
/// in the track's folder — the port of `playback.rs:1490 fill_missing_covers`,
/// Fill `artwork_path` from a cover image sitting in the track's own folder —
/// the port of `playback.rs:1490 fill_missing_covers`, extended for box sets:
/// a folder-local cover replaces an album-root value stored by an older scan.
/// Current scans already resolve embedded/disc/collection art in that order;
/// this queue-time pass keeps pre-migration rows equally safe until rescan.
///
/// Rows with no folder-local cover keep their existing artwork and only then
/// use the reference fallback. Remote rows never touch the filesystem.
///
/// which the reference runs on EVERY local play/enqueue path before it maps
/// rows to `QueueTrack`s (playback.rs:1094/1322/1349/1382, local_library.rs:1826,
/// main.rs:10636, offline_favorites.rs:128). This port had none of it, so a
@@ -113,7 +195,15 @@ pub(crate) fn fill_missing_covers(tracks: &mut [LocalTrack]) {
use std::collections::HashMap;
let mut memo: HashMap<String, Option<String>> = HashMap::new();
for t in tracks.iter_mut() {
if t.artwork_path.as_deref().is_some_and(|s| !s.is_empty()) {
// Media-server `file_path` values are ids/URLs, not audio files whose
// parent may be scanned. A Qobuz download is different: its path is
// the encrypted bundle directory and that directory can legitimately
// contain the only `cover.jpg`, so retain the old missing-art fallback
// for it without overriding an already authoritative artwork path.
if matches!(
t.source.as_deref(),
Some("plex" | "jellyfin" | "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga")
) {
continue;
}
let p = Path::new(&t.file_path);
@@ -126,12 +216,16 @@ pub(crate) fn fill_missing_covers(tracks: &mut [LocalTrack]) {
}
};
let key = folder.to_string_lossy().into_owned();
let cover = memo
let folder_cover = memo
.entry(key)
.or_insert_with(|| find_folder_cover(&folder))
.clone();
if cover.is_some() {
t.artwork_path = cover;
let may_override = t.source.as_deref() != Some("qobuz_download");
let artwork_missing = t.artwork_path.as_deref().is_none_or(str::is_empty);
if folder_cover.is_some() && (may_override || artwork_missing) {
// Override is intentional: `artwork_path` can name Box/cover.jpg
// while THIS row lives under Box/Disc 05/cover.jpg.
t.artwork_path = folder_cover;
}
}
}
@@ -160,21 +254,33 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
// A Plex row carries a RAW server-relative thumb path; it must stay raw
// so the now-playing bar / queue resolve it from current creds.
// `file://`-prefixing it poisons it into a local-read miss.
let artwork_url = t.artwork_path.as_ref().map(|p| {
if is_plex || p.starts_with("file://") {
p.clone()
} else if is_media {
// SOURCE-TAGGED, because a media server's token is meaningless on
// its own: a Jellyfin image tag and a Subsonic coverArt id are
// both opaque, and the now-playing bar / queue / MPRIS resolve an
// `artwork_url` through the shared taxonomy with no row in hand.
// The tag is what lets that taxonomy hand it back to the source
// that issued it instead of guessing (`artwork_qt::classify`).
format!("{src}:{p}")
} else {
format!("file://{p}")
}
});
let artwork_url = t
.artwork_path
.as_ref()
.filter(|path| !path.is_empty())
.or_else(|| {
t.collection_artwork_path
.as_ref()
.filter(|path| !path.is_empty())
})
.map(|p| {
if is_plex || p.starts_with("file://") {
p.clone()
} else if is_media {
// SOURCE-TAGGED, because a media server's token is meaningless on
// its own: a Jellyfin image tag and a Subsonic coverArt id are
// both opaque, and the now-playing bar / queue / MPRIS resolve an
// `artwork_url` through the shared taxonomy with no row in hand.
// The tag is what lets that taxonomy hand it back to the source
// that issued it instead of guessing (`artwork_qt::classify`).
format!("{src}:{p}")
} else {
// Encode `#`, `?` and `%` exactly like every QML artwork path.
// Box/disc folder names commonly contain these; a raw file URL
// would make MPRIS treat the tail as a fragment.
crate::artwork_qt::file_url(p)
}
});
let sample_rate_khz = if t.sample_rate >= 1000.0 {
t.sample_rate / 1000.0
} else {
@@ -327,6 +433,28 @@ pub(crate) async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool
}
}
fn report_staged_playback_failure(track: &QueueTrack) {
let source = track.source.as_deref().unwrap_or("local");
let message = if is_probeable_local_queue_row(track) {
qbz_i18n::t(LOCAL_FILE_UNAVAILABLE)
} else {
format!(
"{} playback failed; the previous track is still playing",
source
)
};
crate::local_bridge::emit_track_availability(source, track.id, Some(&message));
crate::toast_qt::warning(message);
}
fn clear_staged_playback_failure(track: &QueueTrack) {
crate::local_bridge::emit_track_availability(
track.source.as_deref().unwrap_or("local"),
track.id,
None,
);
}
/// What the local/Plex audible step did with a queue row.
///
/// This used to be a `bool`, and the two `false`s meant OPPOSITE things: "this
@@ -423,6 +551,20 @@ pub(crate) async fn play_rows(
if tracks.is_empty() {
return;
}
// Establish the actual first row before doing any filesystem artwork work
// or mutating shuffle/queue state. A shuffle click must validate the row it
// will really start, not the old ordered anchor.
let mut tracks = tracks;
let start = if shuffle {
crate::playback_qt::xorshift_shuffle(&mut tracks);
0
} else {
start.min(tracks.len() - 1)
};
let candidate = local_queue_track(&tracks[start]);
if preflight_queue_track(&candidate).await.is_err() {
return;
}
// Folder-cover backfill BEFORE the mapping (the reference runs it at every
// local play site: playback.rs:1094 / :1322 / :1349 / :1382). This is the
// funnel for album / folder / folder-track / Tracks-tab play, so one call
@@ -437,21 +579,13 @@ pub(crate) async fn play_rows(
if tracks.is_empty() {
return;
}
let mut queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
// Shuffle reorders THIS list and starts at the top of the mixed order. The
// mode alone only randomises what comes next, so the first track the user
// hears was the folder's #1 every time — owner ruling 2026-08-01, every
// shuffle must be genuinely random. The caller's anchor is meaningless once
// the order is mixed, so it is dropped (`play_track_list_in` does the same).
let start = if shuffle {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
crate::playback_qt::xorshift_shuffle(&mut queue);
0
} else {
start.min(queue.len() - 1)
};
let first = queue[start].clone();
let start = if shuffle { 0 } else { start };
// Through the SHARED seam (not `core().set_queue`) so the origin is derived
// from the queue: one album -> ("album", group_key), a mixed folder queue ->
// nothing, and the per-track album fallback lands it on the same view.
@@ -461,15 +595,36 @@ pub(crate) async fn play_rows(
// the anchor always comes back and always names `first`. Answered anyway
// rather than discarded with a `let _`: the day a Qobuz row reaches this
// path, this returns instead of trying to play a track the core dropped.
if crate::playback_qt::set_queue_stamped(runtime, queue, Some(start), None)
.await
.is_none()
{
let Some(prepared) = crate::playback_qt::prepare_queue_stamped(queue, Some(start), None) else {
log::warn!("[qbz-qt] local play: the queue was filtered to nothing");
return;
};
let Some(first) = prepared.anchor_track().cloned() else {
log::warn!("[qbz-qt] local play: prepared queue has no anchor");
return;
};
// A source-owned play is staged: first prove that the player accepted the
// replacement, then publish its queue. A failed Plex part or moved local
// file therefore leaves the currently audible track, Queue, NPB and MPRIS
// in agreement. Offline Qobuz bundles are the one exception because their
// resolver reads the current core row to find its encrypted container.
let offline = belongs_to_the_offline_tier(&first);
if !offline && !play_audible(runtime, &first).await {
report_staged_playback_failure(&first);
return;
}
crate::playback_qt::commit_prepared_queue(runtime, prepared).await;
if shuffle {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
}
crate::playback_qt::publish_queue(runtime).await;
play_audible(runtime, &first).await;
if offline && !play_audible(runtime, &first).await {
report_staged_playback_failure(&first);
return;
}
clear_staged_playback_failure(&first);
crate::playback_qt::refresh_now_playing(runtime).await;
}
@@ -482,16 +637,37 @@ pub async fn play_album(
shuffle: bool,
) {
let key = album_id.clone();
let tracks =
tokio::task::spawn_blocking(move || fetch_album_tracks_blocking(&key))
.await
.unwrap_or_default();
let tracks = tokio::task::spawn_blocking(move || fetch_album_tracks_blocking(&key))
.await
.unwrap_or_default();
let start = start_track_id
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
.unwrap_or(0);
play_rows(runtime, tracks, start, shuffle).await;
}
/// Play only the physical copies admitted by the active Local Library media
/// funnel. A logical album card can represent several servers and a local
/// directory; reopening the unfiltered group here would silently bypass the
/// source/format/quality choices the user is looking at.
pub async fn play_album_filtered(
runtime: &Runtime,
album_id: String,
filter_json: String,
shuffle: bool,
) {
let tracks = tokio::task::spawn_blocking(move || {
let filter = crate::local_filter::MediaFilter::from_json(&filter_json);
fetch_album_tracks_blocking(&album_id)
.into_iter()
.filter(|track| filter.track_enabled(track))
.collect()
})
.await
.unwrap_or_default();
play_rows(runtime, tracks, 0, shuffle).await;
}
/// Play everything under a folder, recursively, in path order.
pub async fn play_folder(runtime: &Runtime, path: String) {
let tracks = tokio::task::spawn_blocking(move || {
@@ -579,6 +755,7 @@ fn find_track_blocking(row_id: i64) -> Option<LocalTrack> {
s.tracks_raw
.iter()
.chain(s.detail_raw.iter())
.chain(s.genre_detail_raw.values().flatten())
.find(|t| t.id == row_id)
.cloned()
});
@@ -617,15 +794,39 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String)
})
.await
.unwrap_or_default();
enqueue_rows(runtime, tracks, mode).await;
}
/// Enqueue only the physical album copies admitted by the active media
/// funnel. This is the card/context-menu counterpart of
/// `play_album_filtered`.
pub async fn enqueue_album_filtered(
runtime: &Runtime,
album_id: String,
filter_json: String,
mode: String,
) {
let tracks = tokio::task::spawn_blocking(move || {
let filter = crate::local_filter::MediaFilter::from_json(&filter_json);
let mut rows: Vec<LocalTrack> = fetch_album_tracks_blocking(&album_id)
.into_iter()
.filter(|track| filter.track_enabled(track))
.collect();
fill_missing_covers(&mut rows);
rows
})
.await
.unwrap_or_default();
enqueue_rows(runtime, tracks, mode).await;
}
async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: String) {
if tracks.is_empty() {
return;
}
// Same stamping seam the Qobuz enqueue paths use, so an appended local
// block carries its own origin instead of inheriting whatever is playing.
let queue = crate::playback_qt::stamped(
tracks.iter().map(local_queue_track).collect(),
None,
);
let queue = crate::playback_qt::stamped(tracks.iter().map(local_queue_track).collect(), None);
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
// (reversed so a multi-track insert keeps its order), "later" appends to
// the block tail, anything else appends.
@@ -752,6 +953,101 @@ mod tests {
);
}
}
/// The album card may correctly use the box cover, but every queue row
/// must carry its own disc folder's cover. NPB, MPRIS and notifications
/// all consume `QueueTrack.artwork_url`; if this mapping collapses, those
/// three surfaces disagree with the disc divider and track hover.
#[test]
fn box_set_queue_rows_keep_each_discs_own_artwork() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let box_dir =
std::env::temp_dir().join(format!("qbz-queue-disc-art-{}-{nonce}", std::process::id()));
let disc_one = box_dir.join("Disc 01 - Sanctuary");
let disc_five = box_dir.join("Disc 05 - Movie OST");
std::fs::create_dir_all(&disc_one).unwrap();
std::fs::create_dir_all(&disc_five).unwrap();
std::fs::write(box_dir.join("cover.jpg"), b"box").unwrap();
std::fs::write(disc_one.join("cover.jpg"), b"one").unwrap();
std::fs::write(disc_five.join("cover.jpg"), b"five").unwrap();
let box_cover = box_dir.join("cover.jpg").to_string_lossy().into_owned();
let mut tracks = vec![
LocalTrack {
id: 1,
source: Some("user".into()),
file_path: disc_one.join("01.flac").to_string_lossy().into_owned(),
artwork_path: Some(box_cover.clone()),
..Default::default()
},
LocalTrack {
id: 5,
source: Some("user".into()),
file_path: disc_five.join("01.flac").to_string_lossy().into_owned(),
artwork_path: Some(box_cover),
..Default::default()
},
];
super::fill_missing_covers(&mut tracks);
let one = super::local_queue_track(&tracks[0]);
let five = super::local_queue_track(&tracks[1]);
assert_ne!(one.artwork_url, five.artwork_url);
assert!(one.artwork_url.as_deref().unwrap().contains("Disc 01"));
assert!(five.artwork_url.as_deref().unwrap().contains("Disc 05"));
std::fs::remove_dir_all(&box_dir).unwrap();
}
/// Qobuz downloads are encrypted bundle directories, not ordinary album
/// folders. Keep their historical on-disk fallback when the index has no
/// art, but never replace an artwork URL already supplied by the catalog.
#[test]
fn qobuz_bundle_cover_is_fallback_only() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let bundle = std::env::temp_dir().join(format!(
"qbz-qobuz-bundle-cover-{}-{nonce}",
std::process::id()
));
std::fs::create_dir_all(&bundle).unwrap();
std::fs::write(bundle.join("cover.jpg"), b"bundle").unwrap();
let row = |artwork_path| LocalTrack {
source: Some("qobuz_download".into()),
file_path: bundle.to_string_lossy().into_owned(),
artwork_path,
..Default::default()
};
let mut tracks = vec![
row(None),
row(Some(String::new())),
row(Some("https://catalog/art.jpg".into())),
];
super::fill_missing_covers(&mut tracks);
assert!(tracks[0]
.artwork_path
.as_deref()
.unwrap()
.ends_with("cover.jpg"));
assert!(tracks[1]
.artwork_path
.as_deref()
.unwrap()
.ends_with("cover.jpg"));
assert_eq!(
tracks[2].artwork_path.as_deref(),
Some("https://catalog/art.jpg")
);
std::fs::remove_dir_all(&bundle).unwrap();
}
}
// ---------------------------------------------------------------------------
@@ -785,12 +1081,12 @@ pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: &
let track = match source {
// A library row: the DB has everything, and `local_queue_track` builds
// the QueueTrack the audible step expects.
"local" | "qobuz_download" => {
tokio::task::spawn_blocking(move || with_db(|db| db.get_track(track_id as i64)).flatten())
.await
.ok()
.flatten()
}
"local" | "qobuz_download" => tokio::task::spawn_blocking(move || {
with_db(|db| db.get_track(track_id as i64)).flatten()
})
.await
.ok()
.flatten(),
// A Plex row is SYNTHETIC — `local_plex::map_cached_to_local_track`
// mints it and it never touches library.db, so there is no row to read.
// The cache is queried by ALBUM, and the recently-played entry carries
@@ -830,16 +1126,24 @@ pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: &
};
return match qbz_source::registry().tracks_of(&raw).await {
Ok(tracks) if !tracks.is_empty() => {
let first = tracks[0].clone();
if crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None)
.await
.is_none()
{
let Some(prepared) =
crate::playback_qt::prepare_queue_stamped(tracks, Some(0), None)
else {
log::warn!("[qbz-qt] {source} play: the queue was filtered to nothing");
return false;
}
crate::playback_qt::publish_queue(runtime).await;
};
let Some(first) = prepared.anchor_track().cloned() else {
log::warn!("[qbz-qt] {source} play: prepared queue has no anchor");
return false;
};
let played = play_audible(runtime, &first).await;
if !played {
report_staged_playback_failure(&first);
return false;
}
crate::playback_qt::commit_prepared_queue(runtime, prepared).await;
crate::playback_qt::publish_queue(runtime).await;
clear_staged_playback_failure(&first);
crate::playback_qt::refresh_now_playing(runtime).await;
played
}
+115 -6
View File
@@ -40,6 +40,8 @@ pub struct AlbumRow {
pub artist: String,
#[serde(rename = "allArtists")]
pub all_artists: String,
/// Canonical individual contributors used by chained artist facets.
pub artists: Vec<String>,
pub year: String,
#[serde(rename = "trackCount")]
pub track_count: u32,
@@ -49,6 +51,8 @@ pub struct AlbumRow {
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub format: String,
/// Every genre represented by this logical album, across all versions.
pub genres: Vec<String>,
#[serde(rename = "artKey")]
pub art_key: String,
/// "local" | "offline" | "plex" (the source badge).
@@ -67,6 +71,13 @@ pub struct AlbumRow {
/// the "album spans N folders" tooltip case).
#[serde(rename = "folderCount")]
pub folder_count: u32,
/// Heart state from the shared LocalFavoritesService.
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// True only when the logical album includes a genuine local or Plex
/// copy. Jellyfin/Subsonic and Qobuz-offline-only rows are intentionally
/// outside the existing local-favorites database contract.
pub favoriteable: bool,
}
/// One track row (Tracks tab, album detail, folder detail).
@@ -93,6 +104,7 @@ pub struct TrackRow {
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub format: String,
pub genres: Vec<String>,
pub year: String,
#[serde(rename = "artKey")]
pub art_key: String,
@@ -121,6 +133,18 @@ pub struct ArtistRow {
pub art_key: String,
/// "local" | "plex" | "mixed" — the rail's provenance hint.
pub source: String,
/// Facets aggregated from every album credited to this artist. Keeping
/// these on the bounded artist document lets the Artists toolbar reuse
/// the Albums funnel without materialising tracks or guessing from the
/// representative portrait source.
pub sources: Vec<String>,
pub formats: Vec<String>,
#[serde(rename = "qualityTiers")]
pub quality_tiers: Vec<String>,
pub years: Vec<u32>,
/// Most recent known release year; empty when the source supplied none.
/// The reversible year sort is deliberately defined over one stable key.
pub year: String,
}
/// One row of the FLATTENED folder tree (the rail renders a windowed list
@@ -300,10 +324,9 @@ pub fn disc_display_title(t: &LocalTrack, group_title: &str) -> String {
/// THIS disc's own folder cover as an encoded `file://` url, or empty.
///
/// NOT the track's scan-time `artwork_path`: `find_folder_artwork` gives the
/// album ROOT a +5 bonus against a strict `>`, so `Box/cover.jpg` beats
/// `Box/Disc 01 - X/cover.jpg` on every track of every disc and a per-disc
/// thumbnail drawn from it would be N copies of one image.
/// This direct URL remains the local-file fallback beside the track's id-keyed
/// `artwork_path`. Current scans preserve embedded/disc/collection precedence;
/// older database rows can still carry one album-root image for every disc.
///
/// Encoded through `artwork_qt::file_url`, which is not optional: these folder
/// names contain '#', and in a URL that opens a FRAGMENT — a raw concatenation
@@ -404,9 +427,25 @@ pub fn art_token(source_word: Option<&str>, token: &str) -> Option<(SourceId, St
// ---------------------------------------------------------------------------
pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -> AlbumRow {
let names = [a.artist.as_str()]
.into_iter()
.chain(a.all_artists.split(','))
.collect::<Vec<_>>();
let aliases = crate::local_artist_match::build_artist_family_aliases(&names);
let artists =
crate::local_artist_match::album_credit_names(&a.artist, &a.all_artists, &aliases);
map_album_with_artists(a, art, artists)
}
pub fn map_album_with_artists(
a: LocalAlbum,
art: &mut HashMap<String, (SourceId, String)>,
artists: Vec<String>,
) -> AlbumRow {
let key = album_key(&a.id);
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) {
if let Some(t) = art_token(Some(a.source.as_str()), p) {
let artwork_source = a.artwork_source.as_deref().unwrap_or(a.source.as_str());
if let Some(t) = art_token(Some(artwork_source), p) {
art.insert(key.clone(), t);
}
}
@@ -437,10 +476,13 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
.collect::<Vec<_>>()
};
sources.dedup();
let favoriteable = album_favorite_source(&sources).is_some();
let is_favorite = favoriteable && crate::library_qt::is_local_favorite("album", &a.id);
AlbumRow {
quality_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(),
quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate),
format: a.format.to_string(),
genres: a.genres,
year: a.year.map(|y| y.to_string()).unwrap_or_default(),
duration: total_duration(a.total_duration_secs),
track_count: a.track_count,
@@ -450,16 +492,47 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
source_raw,
directory_path: a.directory_path,
all_artists: a.all_artists,
artists,
folder_count,
is_favorite,
favoriteable,
id: a.id,
title: a.title,
artist: a.artist,
}
}
/// Resolve the source word accepted by `local_favorites.db`. Prefer a real
/// local copy when a logical album spans both local and Plex; both are valid,
/// but the local snapshot remains usable if the server is later disconnected.
pub fn album_favorite_source(sources: &[String]) -> Option<&'static str> {
if sources
.iter()
.any(|source| source.eq_ignore_ascii_case("local"))
{
Some("local")
} else if sources
.iter()
.any(|source| source.eq_ignore_ascii_case("plex"))
{
Some("plex")
} else {
None
}
}
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>) -> TrackRow {
let key = track_key(t.id);
if let Some(p) = t.artwork_path.as_ref().filter(|p| !p.is_empty()) {
if let Some(p) = t
.artwork_path
.as_ref()
.filter(|p| !p.is_empty())
.or_else(|| {
t.collection_artwork_path
.as_ref()
.filter(|p| !p.is_empty())
})
{
if let Some(tok) = art_token(t.source.as_deref(), p) {
art.insert(key.clone(), tok);
}
@@ -477,6 +550,7 @@ pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>)
quality_tier: tier_of(&t.format, t.bit_depth, t.sample_rate).into(),
quality_detail: detail_of(&t.format, t.bit_depth, t.sample_rate),
format: t.format.to_string(),
genres: t.genres.clone(),
year: t.year.map(|y| y.to_string()).unwrap_or_default(),
art_key: key,
art_path: String::new(),
@@ -491,3 +565,38 @@ pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>)
pub fn to_json<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
#[cfg(test)]
mod tests {
use super::album_favorite_source;
fn sources(words: &[&str]) -> Vec<String> {
words.iter().map(|word| (*word).to_string()).collect()
}
#[test]
fn album_favorite_prefers_a_local_copy() {
assert_eq!(
album_favorite_source(&sources(&["plex", "jellyfin", "local"])),
Some("local")
);
}
#[test]
fn album_favorite_accepts_plex_only() {
assert_eq!(album_favorite_source(&sources(&["plex"])), Some("plex"));
}
#[test]
fn album_favorite_refuses_remote_and_offline_only() {
assert_eq!(
album_favorite_source(&sources(&[
"jellyfin",
"subsonic",
"qobuz_purchase",
"offline",
])),
None
);
}
}
+131 -4
View File
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use qbz_app::user_data::UserDataPaths;
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
@@ -45,6 +45,7 @@ pub struct TracksLoadRequest {
pub query: String,
pub sort: String,
pub group: String,
pub filter: crate::local_filter::MediaFilter,
}
// ---------------------------------------------------------------------------
@@ -113,6 +114,36 @@ pub struct LocalState {
/// rationale as `tracks_raw`: a context-menu enqueue on a Plex detail row
/// has no `local_tracks` id to re-query with.
pub detail_raw: Vec<LocalTrack>,
/// The currently selected physical version for every album expanded by
/// Genres Details. Kept separate from the routed album pane so concurrent
/// expanded albums cannot clobber one another's row actions.
pub genre_detail_raw: HashMap<String, Vec<LocalTrack>>,
/// Unfiltered authoritative rows behind the same bounded detail cache.
/// Source/format/quality chip changes derive a new picker document from
/// these rows instead of re-querying every enabled backend.
pub genre_detail_all_tracks: HashMap<String, Arc<Vec<LocalTrack>>>,
/// Every physical version behind the same expanded albums. The QML
/// version picker changes `genre_detail_raw` from this cache, without a
/// DB round-trip and without retargeting another expanded album.
pub genre_detail_versions: HashMap<String, Vec<(String, Vec<LocalTrack>)>>,
/// Last serialized selected-version document for the same bounded set.
/// A Genres view destroyed by navigation can republish this immediately
/// instead of re-querying every authoritative source on Back.
pub genre_detail_docs: HashMap<String, String>,
/// Media funnel used to build each cached Genres detail. The album id
/// alone is insufficient because the same logical row can expose a
/// different set of physical versions after a source/filter change.
pub genre_detail_filters: HashMap<String, String>,
/// Requested (possibly in-flight) funnel per expanded album. This must be
/// separate from `genre_detail_filters`: marking an old document with the
/// new filter before its worker finished let a duplicate request publish
/// that stale document as a cache hit, so the version picker appeared not
/// to gain a newly enabled source.
pub genre_detail_requests: HashMap<String, String>,
/// Last version-selection request for each expanded album. Version
/// documents are serialized off the UI thread, so a quick A -> B -> A
/// sequence needs a per-album generation to keep B from publishing last.
pub genre_detail_version_generations: HashMap<String, u64>,
pub tracks_offsets: TrackSourceOffsets,
/// Search/sort generation. An older worker may not mutate or publish.
pub tracks_generation: u64,
@@ -122,6 +153,7 @@ pub struct LocalState {
/// (locallibrary_ui `tracks_group`). It is part of the immutable paged
/// query: otherwise a later page can sort ahead of the visible prefix.
pub tracks_group: String,
pub tracks_filter: String,
pub tracks_has_more: bool,
/// The FULL flattened tree (visible derivation applies the rail search).
pub tree: Vec<TreeNode>,
@@ -169,6 +201,7 @@ pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
album_mode: prefs.albums_id_mode,
tracks_sort: prefs.tracks_sort,
tracks_group: prefs.tracks_group,
tracks_filter: prefs.tracks_filter,
..LocalState::default()
}
});
@@ -200,6 +233,8 @@ pub struct Prefs {
pub tracks_group: String,
#[serde(default = "d_default")]
pub tracks_sort: String,
#[serde(default)]
pub tracks_filter: String,
#[serde(default = "d_folder")]
pub albums_id_mode: String,
#[serde(default)]
@@ -221,6 +256,7 @@ impl Default for Prefs {
Self {
tracks_group: d_off(),
tracks_sort: d_default(),
tracks_filter: String::new(),
albums_id_mode: d_folder(),
ephemeral_folder: None,
}
@@ -314,7 +350,11 @@ pub fn group_mode() -> qbz_library::album_grouping::AlbumGroupMode {
}
pub fn set_album_mode(mode: &str) {
let mode = if mode == "metadata" { "metadata" } else { "folder" };
let mode = if mode == "metadata" {
"metadata"
} else {
"folder"
};
state(|s| s.album_mode = mode.to_string());
update_prefs(|p| p.albums_id_mode = mode.to_string());
// The grouping IS the query: `LocalSource` resolves an album's tracks
@@ -362,6 +402,15 @@ pub fn tracks_query() -> String {
state(|s| s.tracks_query.clone())
}
pub fn set_tracks_filter(json: &str) {
state(|s| s.tracks_filter = json.to_string());
update_prefs(|p| p.tracks_filter = json.to_string());
}
pub fn tracks_filter() -> String {
state(|s| s.tracks_filter.clone())
}
pub fn begin_tracks_load(reset: bool) -> TracksLoadRequest {
state(|s| {
if reset {
@@ -380,6 +429,7 @@ pub fn begin_tracks_load(reset: bool) -> TracksLoadRequest {
query: s.tracks_query.clone(),
sort: s.tracks_sort.clone(),
group: s.tracks_group.clone(),
filter: crate::local_filter::MediaFilter::from_json(&s.tracks_filter),
}
})
}
@@ -456,8 +506,7 @@ mod phase_a_tests {
use std::sync::{mpsc, Arc};
use super::{
library_sources_available, tracks_request_is_current, TrackSourceOffsets,
TracksLoadRequest,
library_sources_available, tracks_request_is_current, TrackSourceOffsets, TracksLoadRequest,
};
#[test]
@@ -481,6 +530,7 @@ mod phase_a_tests {
query: "old".to_string(),
sort: "title-asc".to_string(),
group: "off".to_string(),
filter: Default::default(),
};
let old = std::thread::spawn(move || {
old_started_tx.send(()).unwrap();
@@ -500,6 +550,7 @@ mod phase_a_tests {
query: "new".to_string(),
sort: "artist-asc".to_string(),
group: "off".to_string(),
filter: Default::default(),
};
allow_old_finish_tx.send(()).unwrap();
@@ -561,6 +612,75 @@ mod phase_a_tests {
assert!(!view_qml.contains("readonly property var tracksVisible: {"));
}
#[test]
fn genres_details_virtualizes_the_nested_box_set_rows() {
// The outer ListView is album-granular: a 150-track box is one visible
// delegate. A plain nested Repeater therefore creates every TrackRow,
// plus one hidden disc-header tree per track. Keep only cheap geometry
// items outside the viewport and instantiate controls in a look-ahead
// band. Menus are click-cold for the same reason.
let details = include_str!("../qml/views/local/LocalGenreDetails.qml");
let column = include_str!("../qml/views/local/LocalGenreColumn.qml");
let tab = include_str!("../qml/views/local/LocalGenresTab.qml");
let versions = include_str!("../qml/views/local/VersionPicker.qml");
let album_row = include_str!("../qml/views/local/LocalAlbumRow.qml");
assert!(details.contains("readonly property bool inViewportBand:"));
assert!(details.contains("active: trackBlock.showDisc && trackBlock.inViewportBand"));
assert!(details.contains("&& trackBlock.inViewportBand"));
assert!(!details.contains("visible: trackBlock.showDisc"));
// A cold album is one compact loading row. `trackCount` is metadata,
// never permission to reserve hundreds or thousands of blank pixels
// before the detail query has returned.
assert!(details.contains("readonly property int loadingBodyH: 50"));
assert!(details.contains("? (loaded"));
assert!(details.contains(": loadingBodyH)"));
// Cache-hit publication is deferred by the Rust bridge, so delegates
// can request synchronously. All remaining delayed work belongs to the
// view: those timers are cancelled when its Loader is destroyed and
// cannot retain a recycled delegate's QML context.
assert!(details.contains("function ensureCurrent()"));
assert!(details.contains("Component.onCompleted: ensureCurrent()"));
assert!(details.contains("id: ensureVisibleTimer"));
assert!(details.contains("id: reportTimer"));
assert!(details.contains("interval: 16"));
assert!(!details.contains("Qt.callLater"));
assert!(column.contains("interval: root.debounceMs"));
assert!(tab.contains("debounceMs: facetIndex === 2 ? 140 : 90"));
assert!(details.contains("maxConcurrentDetailRequests: 2"));
assert!(details.contains("maxConcurrentDetailRequests - currentPendingCount()"));
assert!(details.contains("property var detailFilters: ({})"));
assert!(details.contains("function stableFilterJson(value)"));
assert!(details.contains("detailFilters[albumId] === mediaFilterJson"));
assert!(details.contains("previousVersion.key === (nextVersion.key || \"\")"));
assert!(!details.contains("detailCache = ({})"));
let bridge = include_str!("local_bridge.rs");
assert!(bridge.contains("state.genre_detail_requests.get(&id) == Some(&filter_json)"));
assert!(bridge.contains("state.genre_detail_requests.remove(&request_id)"));
let bridge = include_str!("local_bridge.rs");
let cache_path = bridge
.split("pub fn genre_album_tracks")
.nth(1)
.and_then(|tail| tail.split("crate::spawn").next())
.expect("genre album cache-hit path");
assert!(cache_path.contains("queue_genre_album_ready(id, json, filter_json, \"cache-hit\", started)"));
assert!(!cache_path.contains("local_genre_album_ready"));
assert!(
details.contains("id: discMenuLoader\n active: false")
);
assert!(versions.contains("id: versionMenuLoader\n active: false"));
assert!(album_row.contains("id: rowMenuLoader\n active: false"));
}
#[test]
fn explicit_ephemeral_open_routes_globally_and_keeps_a_loading_surface() {
let router = include_str!("../qml/shell/ContentRouter.qml");
let pane = include_str!("../qml/views/local/LocalEphemeralPane.qml");
assert!(router.contains("function onLocalEphemeralOpenSeqChanged()"));
assert!(router.contains("QbzShell.navigateToTab(\"local\", \"ephemeral\")"));
assert!(pane.contains("visible: QbzLocal.localEphemeralLoading"));
assert!(pane.contains("variant: \"rowList\""));
}
#[test]
fn explicit_remote_resync_is_authoritative() {
// Jellyfin deltas cannot enumerate deleted/reminted provider ids. The
@@ -572,4 +692,11 @@ mod phase_a_tests {
assert!(!chrome.contains("QbzLocal.mediaSync(\"jellyfin\", false)"));
assert!(!chrome.contains("QbzLocal.mediaSync(\"subsonic\", false)"));
}
#[test]
fn genre_columns_use_the_shared_app_radius() {
let column = include_str!("../qml/views/local/LocalGenreColumn.qml");
assert!(column.contains("radius: theme.radiusSm"));
assert!(column.contains("clip: true"));
}
}
+7 -1
View File
@@ -224,10 +224,15 @@ pub(crate) fn reset() -> bool {
let query = crate::local_state::tracks_query();
let sort = parse_sort(&crate::local_state::tracks_sort());
let group = parse_group(&crate::local_state::tracks_group());
let filter = crate::local_filter::MediaFilter::from_json(&crate::local_state::tracks_filter());
let descriptor = QueryDescriptor::tracks()
.with_search(query.clone())
.with_sort(sort)
.with_group(group);
.with_group(group)
.with_formats(filter.formats)
.including_other_formats(filter.other_formats)
.with_quality_tiers(filter.qualities)
.with_source_buckets(filter.sources);
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None;
if !query.is_empty() && query.chars().count() < 3 {
@@ -1177,6 +1182,7 @@ fn map_record(record: &TrackRecord, art: &mut HashMap<String, (SourceId, String)
record.sample_rate_hz.unwrap_or(0) as f64,
),
format: record.format.to_ascii_uppercase(),
genres: Vec::new(),
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
art_key,
art_path: String::new(),
+161 -104
View File
@@ -20,11 +20,11 @@ mod auth_qt;
// object's Qt event loop (no-op pre-registration).
// All singletons live in the SAME QML module (com.blitzfc.qbz); the
// invokables stay one-line forwards into the domain controllers below.
mod session_bridge;
mod shell_bridge;
mod home_bridge;
mod player_bridge;
mod queue_bridge;
mod home_bridge;
mod session_bridge;
mod shell_bridge;
mod viz_bridge;
// Immersive mode (2026-08-02 immersive-port contract, block B1): the
// QbzImmersive singleton (§3) — open funnel + view persistence + the §3.4
@@ -47,32 +47,32 @@ mod search_bridge;
// + blocking mappers shared by the desktop and immersive dropdowns.
mod search_local;
// Instant cached paint for the cortinilla (contract C11, rulings R1+R6).
mod search_cache_qt;
mod local_bridge;
mod library_bridge;
mod album_bridge;
mod artist_bridge;
mod cast_bridge;
mod library_bridge;
mod local_bridge;
mod musician_bridge;
mod scene_bridge;
mod cast_bridge;
mod search_cache_qt;
// MyQBZ splits across THREE singletons rather than one, because the two
// modals are global overlays with their own lifetime: QbzMyQbz carries the
// two grids + the detail page + the edit/mix modals + the branding,
// QbzMyQbzAdd carries only the app-wide "Add to Mixtape/Collection" picker
// (mounted in AppShell, reachable from any view's row menu), and QbzDisco
// carries the Artist-Collection builder.
mod myqbz_bridge;
mod myqbz_add_bridge;
mod disco_bridge;
mod blacklist_bridge;
mod disco_bridge;
mod myqbz_add_bridge;
mod myqbz_bridge;
mod playlist_picker_bridge;
// Playlist Manager. THREE singletons, not one (contract D2): the manager
// document + folder list + organisation writes here, the folder modals on
// QbzFolderEdit and the shared playlist editor on QbzPlaylistEdit — so a
// folder save cannot perturb an open playlist editor.
mod playlist_manager_bridge;
mod folder_edit_bridge;
mod playlist_edit_bridge;
mod playlist_manager_bridge;
// Purchases (2026-08-15 purchases contract §G.1): the QbzPurchases singleton —
// two documents (list + album detail) and a publish counter. Its controller
// half is `purchases_qt` below, a PLAIN module. This file DOES belong in
@@ -113,13 +113,13 @@ mod mini_bridge;
// System tray (the same contract, block B5): the QbzTray singleton — the
// window verbs' Qt-thread hop. Its controllers are `tray_qt` (portable) and
// `tray_linux` (the ksni item), both PLAIN modules below.
mod tray_bridge;
mod kiosk_nav_qt;
mod tray_bridge;
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
mod kiosk_profile_qt;
mod artwork_qt;
mod atmosphere_qt;
mod kiosk_profile_qt;
// THE audible step (design 02 §9 stage 3): one exhaustive match over
// `qbz_source::PlaybackTicket`, replacing the three drifted copies of
// `play_audible` that each matched on `track.source` by hand.
@@ -134,11 +134,11 @@ mod media_servers_qt;
// The library sweep. Lives in the frontend because it needs the tokio runtime
// and a channel to the progress UI; it writes through the cache handle the
// SOURCE owns, so sweep and reads can never point at different users.
mod media_sync_qt;
mod custom_theme_qt;
mod diagnostics_qt;
mod discover_config_qt;
mod fav_cache_qt;
mod media_sync_qt;
// The folder-modal controller. A plain module — it declares no
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod folder_edit_qt;
@@ -154,12 +154,12 @@ mod dac_wizard_qt;
// what may be offered because of it (the Qt analogue of Slint's
// `use_gpu_renderer`). A plain module: it declares no #[cxx_qt::bridge], so it
// must NOT appear in build.rs's rust_files.
mod renderer_qt;
mod foryou_qt;
mod genre_filter_qt;
mod home_qt;
mod icon_tint_qt;
mod recently_qt;
mod renderer_qt;
// The in-app log viewer over `qbz_log::ring` — the read surface that turns
// "Share logs" from an `open::that(path)` handoff into a filterable view with
// copy / bundle / upload.
@@ -169,18 +169,19 @@ mod log_viewer_qt;
// other two are plain controller modules and must NOT be.
mod about_bridge;
mod about_qt;
mod whats_new_qt;
mod recommendations_qt;
mod library_db_qt;
mod library_qt;
mod library_bulk;
mod library_db_qt;
mod library_prefs;
mod library_qt;
mod library_sidepanel;
mod local_library_qt;
mod local_artist_match;
mod local_catalog_qt;
mod local_filter;
mod local_library_qt;
mod local_rows;
mod local_state;
mod local_catalog_qt;
mod recommendations_qt;
mod whats_new_qt;
// Watcher hints + periodic root reconciliation for the incremental scanner.
mod local_scan_maintenance_qt;
// Native bounded Tracks QAbstractListModel + catalog query controller (phase E).
@@ -195,37 +196,37 @@ mod local_plex;
// Check-connection ping. Over `qbz_plex`'s existing pin/start, pin/check and
// ping calls — the protocol was always in the shared crate, only the glue was
// missing.
mod plex_pin_qt;
mod local_album_actions;
mod local_albums;
mod local_tree;
mod local_artwork;
mod local_playback;
mod local_playlist_qt;
mod local_bridge_ops;
mod local_bulk;
mod local_ephemeral;
mod local_album_actions;
mod local_playback;
mod local_playlist_qt;
mod local_tree;
mod plex_pin_qt;
// MyQBZ domain controllers. One module per concern, all driven by the three
// bridges above: the grids + Create modal (myqbz_qt), the per-user branding
// and per-collection view prefs (myqbz_prefs_qt), the detail page
// (myqbz_detail_qt) and its playback / edit / cover / DJ-mix arms, the
// app-wide Add picker (myqbz_add_qt) and the Artist-Collection builder
// (myqbz_builder_qt + its Qobuz/local/Plex fetchers).
mod myqbz_qt;
mod myqbz_prefs_qt;
mod myqbz_detail_qt;
mod myqbz_play_qt;
mod myqbz_edit_qt;
mod myqbz_cover_qt;
mod myqbz_mix_qt;
mod myqbz_add_qt;
mod myqbz_builder_qt;
mod myqbz_builder_fetch_qt;
mod myqbz_builder_qt;
mod myqbz_cover_qt;
mod myqbz_detail_qt;
mod myqbz_edit_qt;
mod myqbz_mix_qt;
mod myqbz_play_qt;
mod myqbz_prefs_qt;
mod myqbz_qt;
// Blacklist: the per-user store (artist_blacklist), the Recommendations
// dismissal store (reco_dismiss_qt) and the manager view's controller.
mod artist_blacklist;
mod reco_dismiss_qt;
mod blacklist_qt;
mod reco_dismiss_qt;
// Artist page per-section release sort, persisted by release_type. Co-owns
// `<data-dir>/qbz/artist_ui.json` with the Slint's crates/qbz/src/artist_prefs.rs
// exactly the way library_prefs co-owns favorites_ui.json.
@@ -245,52 +246,52 @@ mod toast_qt;
// Share links + the system clipboard (the port of crates/qbz/src/share.rs).
// A plain module, NOT a bridge — the artist header's ⋯ → Share reaches it
// through QbzArtist.share (artist_bridge.rs).
mod share_qt;
mod nav_qt;
mod browse_qt;
mod cast_qt;
mod cdda_qt;
mod sacd_qt;
mod disc_identity;
mod disc_meta_bridge;
mod disc_meta_qt;
mod tag_editor_bridge;
mod tag_editor_qt;
mod rip_qt;
mod rip_wizard_qt;
mod label_qt;
mod nav_qt;
mod now_playing;
mod offline_fwd;
mod output_labels;
mod quality_qt;
mod quality_state;
mod offline_fwd;
mod rip_qt;
mod rip_wizard_qt;
mod sacd_qt;
mod share_qt;
mod tag_editor_bridge;
mod tag_editor_qt;
// 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 offline_manager_qt;
mod offline_manager_bridge;
mod offline_manager_qt;
mod offline_qt;
// Shared multi-select bulk actions for Qobuz track listings (playlist,
// artist, label — the album page has its own album-ordered variant).
mod bulk_tracks_qt;
mod album_qt;
mod award_qt;
mod bulk_tracks_qt;
mod track_info_qt;
// Album Info (Credits/Review) modal controller — info_modals.rs port.
mod album_info_qt;
// Album custom covers + cover file actions — the album half of the Slint
// `custom_artwork.rs` store (SAME json file, shared between both apps).
mod ambient_qt;
mod cover_artwork_qt;
mod external_reco_qt;
mod ambient_qt;
// Tunnel Flow scene (B1, 2026-08-15 immersive-completion contract): the
// Tauri line-palette extraction, published on QbzShaderScene per track.
mod tunnelflow_qt;
mod artist_qt;
mod lyrics_qt;
mod playback_qt;
mod playlist_picker_qt;
mod tunnelflow_qt;
// Playlist Importer controller (the `qbz-playlist-import` crate's frontend
// half). Plain module — it declares no #[cxx_qt::bridge], so it must NOT
// appear in build.rs's rust_files.
@@ -302,8 +303,8 @@ 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;
mod playlist_snapshot_qt;
// The SHARED playlist editor's controller (rename · description ·
// offline-only · delete), driven from the manager's three delegates, the
// sidebar row menu and the playlist detail header. Plain module — it declares
@@ -333,11 +334,11 @@ mod suggestions_qt;
// faithful port of the Slint crates/qbz/src/keybindings.rs. Plain module —
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod hotkeys_qt;
mod integrations_qt;
mod settings_qt;
mod sidebar_qt;
mod sleep_timer_qt;
mod theme_qt;
mod integrations_qt;
mod viz_qt;
// Qobuz Connect port (2026-08-01 contract), blocks B1/B2: transport config +
// credential discovery + device identity, and the renderer engine over
@@ -360,9 +361,9 @@ mod mini_qt;
// the ksni StatusNotifierItem and carries no inner cfg, so the gate is on the
// mod line (the shape of `crates/qbz/src/tray/mod.rs:26-27`). Owner ruling K2
// keeps macOS and Windows out — neither has a tray in either frontend.
mod tray_qt;
#[cfg(target_os = "linux")]
mod tray_linux;
mod tray_qt;
// macOS menu-bar tray (NSStatusItem), ported 2026-08-05. The K2 ruling that
// kept macOS out was reasoned from a premise that reads the reference
// backwards — see the module header.
@@ -453,7 +454,11 @@ fn restore_session_once() {
// ignored, which is what we want (the runtime is process-global anyway).
qbz_app::session_persist::bind_exit_ctx(
app(),
TOKIO.get().expect("tokio runtime set before the shell mounts").handle().clone(),
TOKIO
.get()
.expect("tokio runtime set before the shell mounts")
.handle()
.clone(),
);
// Crash-chain rung 3: two consecutive boots died even after the view reset,
// so skip the queue restore for THIS boot only. The persisted queue stays
@@ -582,7 +587,7 @@ pub(crate) fn on_boot() {
Ok(Some(session)) => enter_shell(session),
Ok(None) => session_bridge::ui(|mut b| b.as_mut().set_screen(QString::from("login"))),
Err(e) => session_bridge::ui(move |mut b| {
b.as_mut().set_restore_error(QString::from(e.as_str()));
b.as_mut().set_restore_error(QString::from(e.as_str()));
b.as_mut().set_screen(QString::from("login"));
}),
}
@@ -644,9 +649,28 @@ fn on_session_entered() {
// Library / Mixtapes / Collections and painted an EMPTY page — the view is
// fed by a Rust document that only `navigate_to` ever asked for, and a
// restore does not go through `navigate_to`.
let entry_view = nav_qt::shell_entry_view();
// Always consume the ordinary one-shot entry decision, even when this is
// an unauthenticated offline session. A later OAuth login in the SAME
// process must keep the existing fresh-session behaviour (Home), not
// replay a remembered startup route that the offline entry skipped.
let ordinary_entry = nav_qt::shell_entry_view();
let logged_off = offline_fwd::engine().status().offline_session;
let entry_view = if logged_off {
"local".to_string()
} else {
ordinary_entry
};
nav_qt::record(&entry_view);
hydrate_view(&entry_view);
// Logged-off startup is the ONLY automatic redirect. An authenticated
// account keeps the established startup/restore flow even when physical
// connectivity is down. The selected tab is the first user-ordered Local
// Library tab; kiosk takes the first surface it can render (Genres is a
// desktop column browser).
if logged_off {
let landing = settings_qt::local_landing_tab(kiosk_profile_qt::active());
navigate_to_tab("local", &landing);
}
now_playing::publish_current();
// Phase 3: fetch Discover > Home (online sessions only — the offline
// engine gates Qobuz calls anyway, and the view shows the offline
@@ -720,7 +744,9 @@ fn on_session_entered() {
// Restore the persisted player volume so audio starts at the SAVED level
// (1:1 with qbz/src/main.rs:220-227). Without it every launch started at
// 100% — on an exclusive-mode DAC that is not a cosmetic default.
let restored = crate::settings_qt::read_pref_f32("volume").unwrap_or(1.0).clamp(0.0, 1.0);
let restored = crate::settings_qt::read_pref_f32("volume")
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let rt = app();
spawn(async move { playback_qt::set_volume(&rt, restored).await });
// ...and seed the UI model with the SAME value, or the engine and the
@@ -781,8 +807,10 @@ fn on_session_entered() {
fn enter_shell(session: auth_qt::SessionInfo) {
on_session_entered();
session_bridge::ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
b.as_mut()
.set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut()
.set_session_subscription(QString::from(session.subscription.as_str()));
b.as_mut().set_has_previous_session(true);
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_restore_error(QString::from(""));
@@ -806,7 +834,7 @@ pub(crate) fn start_login() {
}
}
session_bridge::ui(|mut b| {
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_login_phase(1);
});
@@ -824,7 +852,7 @@ pub(crate) fn start_login() {
match result {
Ok(session) => enter_shell(session),
Err(e) => session_bridge::ui(move |mut b| {
b.as_mut().set_login_phase(0);
b.as_mut().set_login_phase(0);
b.as_mut().set_login_error(QString::from(e.as_str()));
}),
}
@@ -860,7 +888,8 @@ pub(crate) fn start_offline() {
// uses it — NOT the two-line subset this used to run.
on_session_entered();
session_bridge::ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(name.as_str()));
b.as_mut()
.set_session_user_name(QString::from(name.as_str()));
b.as_mut().set_session_subscription(QString::from(""));
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_restore_error(QString::from(""));
@@ -1058,11 +1087,16 @@ pub(crate) fn reload_sidebar_including_local() {
pub(crate) fn publish_sidebar() {
let entries = sidebar_qt::rebuild();
let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into());
log::debug!("[qbz-qt] sidebar published: {} entries ({} bytes)", entries.len(), json.len());
log::debug!(
"[qbz-qt] sidebar published: {} entries ({} bytes)",
entries.len(),
json.len()
);
let (sort_by, sort_asc) = sidebar_qt::sort_state();
shell_bridge::ui(move |mut b| {
b.as_mut().set_sidebar_json(QString::from(json.as_str()));
b.as_mut().set_sidebar_sort_by(QString::from(sort_by.as_str()));
b.as_mut()
.set_sidebar_sort_by(QString::from(sort_by.as_str()));
b.as_mut().set_sidebar_sort_asc(sort_asc);
});
}
@@ -1111,7 +1145,8 @@ pub(crate) fn sidebar_open_folder_popup(folder_id: &str) {
let json = doc.to_string();
log::debug!("[qbz-qt] sidebar folder popup: {folder_id} ({count} rows)");
shell_bridge::ui(move |mut b| {
b.as_mut().set_sidebar_folder_popup_json(QString::from(json.as_str()));
b.as_mut()
.set_sidebar_folder_popup_json(QString::from(json.as_str()));
});
}
@@ -1177,12 +1212,10 @@ pub(crate) fn open_album(album_id: String) {
});
spawn(async move {
match album_qt::load_album_view(&runtime, &album_id).await {
Ok(json) => {
album_bridge::ui(move |mut b| {
b.as_mut().set_album_json(QString::from(json.as_str()));
b.as_mut().set_album_loading(false);
})
},
Ok(json) => album_bridge::ui(move |mut b| {
b.as_mut().set_album_json(QString::from(json.as_str()));
b.as_mut().set_album_loading(false);
}),
Err(e) => {
log::warn!("[qbz-qt] album view load failed: {e}");
album_bridge::ui(move |mut b| b.as_mut().set_album_loading(false));
@@ -1226,8 +1259,13 @@ pub(crate) fn open_artist(artist_id: String) {
pub(crate) fn load_release_section(artist_id: String, release_type: String, offset: i32) {
let runtime = app();
spawn(async move {
match artist_qt::load_release_page(&runtime, &artist_id, &release_type, offset.max(0) as u32)
.await
match artist_qt::load_release_page(
&runtime,
&artist_id,
&release_type,
offset.max(0) as u32,
)
.await
{
Ok((cards, has_more)) => {
// The user may have opened ANOTHER artist while this page was
@@ -1297,7 +1335,13 @@ pub(crate) fn enqueue_album(album_id: String, mode: String) {
/// EVERY consumer here is a cache patch or an in-place badge signal. The rule
/// this encodes: a per-click mutation must never republish a document. See
/// `home_qt::apply_pin_change`'s docs for what republishing cost.
pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: String, artwork_url: String) {
pub(crate) fn toggle_pin(
kind: String,
id: String,
title: String,
subtitle: String,
artwork_url: String,
) {
if let Some(value) = sidebar_qt::toggle_pin(&kind, &id, &title, &subtitle, &artwork_url) {
let key = format!("{kind}:{id}");
library_bridge::ui(move |mut b| {
@@ -1319,7 +1363,10 @@ pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: Stri
static LYRICS_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn toggle_lyrics() {
let open = !LYRICS_OPEN.swap(!LYRICS_OPEN.load(std::sync::atomic::Ordering::SeqCst), std::sync::atomic::Ordering::SeqCst);
let open = !LYRICS_OPEN.swap(
!LYRICS_OPEN.load(std::sync::atomic::Ordering::SeqCst),
std::sync::atomic::Ordering::SeqCst,
);
shell_bridge::ui(move |mut b| {
b.as_mut().set_lyrics_open(open);
});
@@ -1375,12 +1422,7 @@ pub(crate) fn queue_extended_play(phase: String, index: i32, expected_id: String
});
}
pub(crate) fn queue_extended_drop(
phase: String,
index: i32,
expected_id: String,
slot: i32,
) {
pub(crate) fn queue_extended_drop(phase: String, index: i32, expected_id: String, slot: i32) {
let Ok(track_id) = expected_id.parse::<u64>() else {
return;
};
@@ -1419,7 +1461,9 @@ pub(crate) fn queue_remove_all_after(index: i32) {
pub(crate) fn queue_move_track(from: i32, to: i32) {
let runtime = app();
spawn(async move { queue_qt::move_track(&runtime, from.max(0) as usize, to.max(0) as usize).await });
spawn(
async move { queue_qt::move_track(&runtime, from.max(0) as usize, to.max(0) as usize).await },
);
}
pub(crate) fn queue_play_history(index: i32) {
@@ -1486,7 +1530,8 @@ pub(crate) fn play_album_from_track(album_id: String, track_id: u64) {
pub(crate) fn enqueue_album_track(album_id: String, track_id: u64, mode: String) {
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::enqueue_album_track(&runtime, &album_id, track_id, &mode).await {
if let Err(e) = playback_qt::enqueue_album_track(&runtime, &album_id, track_id, &mode).await
{
log::error!("[qbz-qt] enqueue_album_track failed: {e}");
}
});
@@ -1531,7 +1576,11 @@ pub(crate) fn enqueue_artist_top(mode: String) {
/// Track-row click (Library tracks): one-track queue through the core.
pub(crate) fn play_track(track_id: u64) {
search_qt::record_page_interaction("track", &track_id.to_string(), search_qt::InteractionAction::Play);
search_qt::record_page_interaction(
"track",
&track_id.to_string(),
search_qt::InteractionAction::Play,
);
now_playing::begin_loading();
let runtime = app();
spawn(async move {
@@ -1576,7 +1625,11 @@ pub(crate) fn open_playlist(playlist_id: String) {
// Learn from results-page interactions too, not only from the
// cortinilla. Self-gated on the Search view being current, so every other
// caller of this router is unaffected.
search_qt::record_page_interaction("playlist", &playlist_id, search_qt::InteractionAction::Open);
search_qt::record_page_interaction(
"playlist",
&playlist_id,
search_qt::InteractionAction::Open,
);
// Drop the previous detail's "mixed" latch before either arm runs. `load`
// sets it authoritatively at the end, but between here and there sits a
// network fetch, and a remove clicked inside that window would otherwise
@@ -1766,9 +1819,7 @@ pub(crate) fn playlist_remove_track(row_id: String) {
// A sidecar row of a MIXED Qobuz playlist: it comes out of
// `library.db`, not out of the Qobuz membership. Returns false when the
// row turns out to be an ordinary Qobuz one, so this falls through.
if playlist_qt::is_mixed()
&& playlist_qt::remove_sidecar_row(&runtime, &row_id).await
{
if playlist_qt::is_mixed() && playlist_qt::remove_sidecar_row(&runtime, &row_id).await {
return;
}
let Ok(playlist_track_id) = row_id.parse::<u64>() else {
@@ -1851,7 +1902,8 @@ pub(crate) fn drag_start(track_id: String, title: String, subtitle: String, x: f
shell_bridge::ui(move |mut b| {
b.as_mut().set_drag_count(if id > 0 { 1 } else { 0 });
b.as_mut().set_drag_title(QString::from(title.as_str()));
b.as_mut().set_drag_subtitle(QString::from(subtitle.as_str()));
b.as_mut()
.set_drag_subtitle(QString::from(subtitle.as_str()));
b.as_mut().set_drag_x(x);
b.as_mut().set_drag_y(y);
b.as_mut().set_drag_over_playlist_id(QString::default());
@@ -2345,7 +2397,9 @@ fn republish_artist(artist_id: String) {
let runtime = app();
spawn(async move {
if let Ok(json) = artist_qt::load_artist_view(&runtime, &artist_id).await {
artist_bridge::ui(move |mut b| b.as_mut().set_artist_json(QString::from(json.as_str())));
artist_bridge::ui(move |mut b| {
b.as_mut().set_artist_json(QString::from(json.as_str()))
});
}
});
}
@@ -2411,9 +2465,7 @@ pub(crate) fn settings_bool(key: String, value: bool) {
pub(crate) fn settings_select(key: String, index: i32) {
let runtime = app();
spawn(async move {
settings_qt::settings_select(&runtime, &key, index.max(0) as usize).await
});
spawn(async move { settings_qt::settings_select(&runtime, &key, index.max(0) as usize).await });
}
pub(crate) fn settings_slider(key: String, value: i32) {
@@ -2556,7 +2608,8 @@ pub(crate) fn reload_library() {
t.elapsed(),
);
library_bridge::ui(move |mut b| {
b.as_mut().set_library_json(QString::from(feed_json.as_str()));
b.as_mut()
.set_library_json(QString::from(feed_json.as_str()));
b.as_mut()
.set_library_counts_json(QString::from(counts_json.as_str()));
b.as_mut().set_library_loading(false);
@@ -2599,7 +2652,8 @@ pub(crate) fn publish_library_document() {
return;
};
library_bridge::ui(move |mut b| {
b.as_mut().set_library_json(QString::from(feed_json.as_str()));
b.as_mut()
.set_library_json(QString::from(feed_json.as_str()));
b.as_mut()
.set_library_counts_json(QString::from(counts_json.as_str()));
});
@@ -2819,7 +2873,11 @@ pub(crate) fn reload_home() {
missing.dedup();
let count: usize = sections.home.iter().map(|s| s.items.len()).sum::<usize>()
+ sections.editor.iter().map(|s| s.items.len()).sum::<usize>()
+ sections.for_you.iter().map(|s| s.items.len()).sum::<usize>();
+ sections
.for_you
.iter()
.map(|s| s.items.len())
.sum::<usize>();
publish_home_sections(&sections);
// LIVENESS. The shell has a mounted view with real content, so
@@ -2863,9 +2921,11 @@ pub(crate) fn reload_home() {
fn publish_home_sections(sections: &home_qt::DiscoverSections) {
let home_json = serde_json::to_string(&sections.home).unwrap_or_else(|_| "[]".to_string());
let editor_json = serde_json::to_string(&sections.editor).unwrap_or_else(|_| "[]".to_string());
let for_you_json = serde_json::to_string(&sections.for_you).unwrap_or_else(|_| "[]".to_string());
let for_you_json =
serde_json::to_string(&sections.for_you).unwrap_or_else(|_| "[]".to_string());
home_bridge::ui(move |mut b| {
b.as_mut().set_home_sections_json(QString::from(home_json.as_str()));
b.as_mut()
.set_home_sections_json(QString::from(home_json.as_str()));
b.as_mut()
.set_editor_sections_json(QString::from(editor_json.as_str()));
b.as_mut()
@@ -2873,7 +2933,6 @@ fn publish_home_sections(sections: &home_qt::DiscoverSections) {
});
}
/// The Settings > Appearance RENDERER row, consumed at startup (PARITY-DEBT
/// #104 — the row rendered and persisted into the SAME ui_prefs the Slint
/// reads, but nothing consumed it, so it lied).
@@ -3033,7 +3092,9 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) {
if HARD_EXIT_ARMED.swap(true, Ordering::SeqCst) {
return;
}
log::info!("[shutdown] hard-exit watchdog armed ({source}); force-exit in 5s if shutdown wedges");
log::info!(
"[shutdown] hard-exit watchdog armed ({source}); force-exit in 5s if shutdown wedges"
);
let _ = std::thread::Builder::new()
.name("qbz-exit-watchdog".into())
.spawn(move || {
@@ -3108,9 +3169,7 @@ fn main() {
// so this costs nothing until the Large dock's band is shown. It is a
// read-only copy downstream of the bit-perfect stream — no device/stream
// init is touched.
let runtime = Arc::new(AppRuntime::with_visualizer(LoggingAdapter::new(
"[qbz-qt]",
)));
let runtime = Arc::new(AppRuntime::with_visualizer(LoggingAdapter::new("[qbz-qt]")));
if let Some(tap) = runtime.visualizer_tap().cloned() {
viz_qt::install(tap);
viz_qt::set_mode(settings_qt::large_spectrum_mode());
@@ -3197,9 +3256,7 @@ fn main() {
}
if let Some(engine) = engine.as_mut() {
engine.load(&QUrl::from(
"qrc:/qt/qml/com/blitzfc/qbz/qml/Main.qml",
));
engine.load(&QUrl::from("qrc:/qt/qml/com/blitzfc/qbz/qml/Main.qml"));
}
if let Some(app) = app.as_mut() {
+252 -61
View File
@@ -72,8 +72,13 @@ fn playback() -> &'static PlaybackPreferencesState {
}
fn with_audio<T>(f: impl FnOnce(&AudioSettingsStore) -> Result<T, String>) -> Result<T, String> {
let guard = audio().store.lock().map_err(|_| "audio store lock poisoned".to_string())?;
let store = guard.as_ref().ok_or_else(|| "audio settings store not open".to_string())?;
let guard = audio()
.store
.lock()
.map_err(|_| "audio store lock poisoned".to_string())?;
let store = guard
.as_ref()
.ok_or_else(|| "audio settings store not open".to_string())?;
f(store)
}
@@ -432,7 +437,10 @@ pub fn streaming_quality() -> String {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("streaming_quality").and_then(|q| q.as_str().map(str::to_string)))
.and_then(|v| {
v.get("streaming_quality")
.and_then(|q| q.as_str().map(str::to_string))
})
.unwrap_or_else(|| "hires_plus".to_string())
}
@@ -630,7 +638,10 @@ pub fn shell_pulse_ms() -> i32 {
/// the pane, and because a SCROLLING list invalidates it every frame — it is
/// here to be measured, not assumed.
pub fn pane_layer() -> bool {
matches!(std::env::var("QBZ_PANE_LAYER").as_deref(), Ok("1") | Ok("true"))
matches!(
std::env::var("QBZ_PANE_LAYER").as_deref(),
Ok("1") | Ok("true")
)
}
/// Offscreen render-target multiplier for the ambient field, QBZ_BG_SCALE.
@@ -732,7 +743,10 @@ pub fn npb_mode_index() -> i32 {
let key = std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("npb_mode").and_then(|q| q.as_str().map(str::to_string)))
.and_then(|v| {
v.get("npb_mode")
.and_then(|q| q.as_str().map(str::to_string))
})
.unwrap_or_else(|| "new".to_string());
match key.as_str() {
"classic" => 1,
@@ -1158,6 +1172,94 @@ pub fn pref_json(key: &str) -> Option<serde_json::Value> {
.and_then(|v| v.get(key).cloned())
}
// ---------------------------------------------------------------------------
// Local Library tab order + landing tab
// ---------------------------------------------------------------------------
/// The shipped Local Library order. The first entry is also the landing tab
/// for a fresh Local Library mount and for an unauthenticated offline entry.
///
/// This preference is deliberately APP-WIDE (`ui_prefs.json`), not per-user:
/// "Start offline" must resolve it before a Qobuz account exists. Unknown,
/// duplicated and missing ids are normalized so a stale/future document can
/// never make a tab disappear or leave the view without a valid landing.
pub(crate) const LOCAL_TAB_DEFAULT_ORDER: &[&str] =
&["genres", "albums", "artists", "folders", "tracks"];
/// Settings order is user-facing and therefore stable: Top remains index 0
/// and the default, followed by the two sidebar positions and Bottom.
const LOCAL_GENRE_FILTER_POSITION_VALUES: &[&str] = &["top", "right", "left", "bottom"];
fn local_genre_filters_position_from(raw: &str) -> String {
LOCAL_GENRE_FILTER_POSITION_VALUES
.iter()
.copied()
.find(|value| *value == raw)
.unwrap_or("top")
.to_string()
}
pub(crate) fn local_genre_filters_position() -> String {
local_genre_filters_position_from(&pref_str("local_genre_filters_position", "top"))
}
fn normalize_local_tab_order(value: Option<&serde_json::Value>) -> Vec<String> {
let mut order = Vec::with_capacity(LOCAL_TAB_DEFAULT_ORDER.len());
if let Some(serde_json::Value::Array(items)) = value {
for id in items.iter().filter_map(serde_json::Value::as_str) {
if LOCAL_TAB_DEFAULT_ORDER.contains(&id) && !order.iter().any(|v| v == id) {
order.push(id.to_string());
}
}
}
for id in LOCAL_TAB_DEFAULT_ORDER {
if !order.iter().any(|v| v == id) {
order.push((*id).to_string());
}
}
order
}
pub(crate) fn local_tab_order() -> Vec<String> {
let value = pref_json("local_tab_order");
normalize_local_tab_order(value.as_ref())
}
/// First tab that the active shell can actually render. Desktop supports the
/// full order; kiosk has no Genres column browser, so it takes the first of the
/// remaining four instead of mounting a blank surface.
pub(crate) fn local_landing_tab(kiosk: bool) -> String {
local_landing_tab_from(&local_tab_order(), kiosk)
}
fn local_landing_tab_from(order: &[String], kiosk: bool) -> String {
order
.into_iter()
.find(|id| !kiosk || id.as_str() != "genres")
.cloned()
.unwrap_or_else(|| "albums".to_string())
}
/// Construction-time seed for `QbzBridge.settingsJson`. NavFlyout and Local
/// Library can be built before the asynchronous full settings snapshot lands;
/// seeding this one boot-critical key prevents a default-order flash and makes
/// the logged-off landing deterministic on the first frame.
pub(crate) fn settings_seed_json() -> String {
serde_json::json!({ "localTabOrder": local_tab_order() }).to_string()
}
fn save_local_tab_order_payload(raw: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
return false;
};
if !value.is_array() {
return false;
}
let order = normalize_local_tab_order(Some(&value));
save_pref("local_tab_order", serde_json::json!(order));
true
}
/// f32 reader WITH a default — same additive single-key discipline as
/// `pref_bool`/`pref_str`/`pref_i32`. Added for the miniplayer geometry keys
/// (`mini_width`/`mini_height`), which the Slint app declares as f32 and
@@ -1293,9 +1395,14 @@ const DSD_MODE_LABELS: &[&str] = &[
"Native DSD (kernel support required)",
];
const DSD_MODE_VALUES: &[&str] = &["convert", "dop", "native"];
const ALSA_PLUGIN_LABELS: &[&str] = &["hw (Direct Hardware)", "plughw (Auto-convert)", "pcm (Most compatible)"];
const ALSA_PLUGIN_LABELS: &[&str] = &[
"hw (Direct Hardware)",
"plughw (Auto-convert)",
"pcm (Most compatible)",
];
const ALSA_PLUGIN_VALUES: &[AlsaPlugin] = &[AlsaPlugin::Hw, AlsaPlugin::PlugHw, AlsaPlugin::Pcm];
const RETRY_BEHAVIOR_LABELS: &[&str] = &["Ask me", "Always try lowest quality", "Always skip track"];
const RETRY_BEHAVIOR_LABELS: &[&str] =
&["Ask me", "Always try lowest quality", "Always skip track"];
const RETRY_BEHAVIOR_VALUES: &[&str] = &["ask", "always_fallback", "always_skip"];
const QCONNECT_STARTUP_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"];
const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"];
@@ -1303,7 +1410,14 @@ const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"];
const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"];
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
const LANGUAGE_LABELS: &[&str] = &[
"Auto", "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語",
"Auto",
"English",
"Español",
"Français",
"Deutsch",
"Português",
"Русский",
"日本語",
"Nederlands",
];
const LANGUAGE_VALUES: &[&str] = &["auto", "en", "es", "fr", "de", "pt", "ru", "ja", "nl"];
@@ -1362,22 +1476,50 @@ pub fn app_font_family() -> String {
.unwrap_or(&"")
.to_string()
}
const IMMERSIVE_SEARCH_LABELS: &[&str] =
&["Disabled", "Replace current queue", "Play next", "Add to queue"];
const IMMERSIVE_SEARCH_LABELS: &[&str] = &[
"Disabled",
"Replace current queue",
"Play next",
"Add to queue",
];
const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"];
const IMMERSIVE_VIEW_LABELS: &[&str] =
&["Remember last", "Album Reactive", "Static", "Coverflow", "Spectrum", "Lyrics", "Queue"];
const IMMERSIVE_VIEW_VALUES: &[&str] =
&["remember", "reactive", "static", "coverflow", "spectrum", "lyrics", "queue"];
const MINI_VIEW_LABELS: &[&str] =
&["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"];
const IMMERSIVE_VIEW_LABELS: &[&str] = &[
"Remember last",
"Album Reactive",
"Static",
"Coverflow",
"Spectrum",
"Lyrics",
"Queue",
];
const IMMERSIVE_VIEW_VALUES: &[&str] = &[
"remember",
"reactive",
"static",
"coverflow",
"spectrum",
"lyrics",
"queue",
];
const MINI_VIEW_LABELS: &[&str] = &[
"Remember last used",
"Micro",
"Compact",
"Artwork",
"Queue",
"Lyrics",
];
const MINI_VIEW_VALUES: &[&str] = &["remember", "micro", "compact", "artwork", "queue", "lyrics"];
const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"];
const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"];
const WC_POSITION_LABELS: &[&str] = &["Left", "Right"];
const WC_POSITION_VALUES: &[&str] = &["left", "right"];
const RENDERER_LABELS: &[&str] =
&["Auto (recommended)", "GPU", "GPU (compatibility)", "Software"];
const RENDERER_LABELS: &[&str] = &[
"Auto (recommended)",
"GPU",
"GPU (compatibility)",
"Software",
];
const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"];
const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"];
const TRAY_ICON_VALUES: &[&str] = &["auto", "mono-light", "mono-dark", "color"];
@@ -1608,6 +1750,12 @@ pub struct SettingsDoc {
/// the section's flyout and the user picks a tab from it.
#[serde(rename = "navClickFirstTab")]
pub nav_click_first_tab: bool,
/// Authoritative Local Library tab order. First = default landing.
#[serde(rename = "localTabOrder")]
pub local_tab_order: Vec<String>,
/// Where the Genres three-stage browser sits around its album results.
#[serde(rename = "genreFiltersPosition")]
pub genre_filters_position: String,
pub renderers: Vec<String>,
#[serde(rename = "rendererIndex")]
pub renderer_index: i32,
@@ -1924,10 +2072,7 @@ pub async fn publish_snapshot() {
backend_is_jack: active_backend == AudioBackendType::Jack,
devices,
device_index: device_index as i32,
alsa_plugins: ALSA_PLUGIN_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
alsa_plugins: ALSA_PLUGIN_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
alsa_plugin_index: alsa_plugin_index as i32,
alsa_plugin_is_hw: alsa_plugin == AlsaPlugin::Hw,
alsa_hardware_volume: audio_settings.alsa_hardware_volume,
@@ -2002,7 +2147,13 @@ pub async fn publish_snapshot() {
app_fonts: APP_FONT_LABELS
.iter()
.enumerate()
.map(|(i, l)| if i == 0 { qbz_i18n::t(l) } else { (*l).to_string() })
.map(|(i, l)| {
if i == 0 {
qbz_i18n::t(l)
} else {
(*l).to_string()
}
})
.collect(),
app_font_index: app_font_index(),
immersive_search_actions: IMMERSIVE_SEARCH_LABELS
@@ -2036,30 +2187,23 @@ pub async fn publish_snapshot() {
window_title_show: pref_bool("window_title_show", false),
use_system_title_bar: use_system_title_bar(),
hide_title_bar: pref_bool("hide_title_bar", false),
wc_positions: WC_POSITION_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
wc_positions: WC_POSITION_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1),
show_window_controls: pref_bool("show_window_controls", true),
show_volume_steppers: pref_bool("show_volume_steppers", false),
mini_default_views: MINI_VIEW_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
mini_default_views: MINI_VIEW_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
mini_default_view_index: index_of(
MINI_VIEW_VALUES,
&pref_str("mini_default_view", "remember"),
0,
),
startup_pages: STARTUP_PAGE_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
startup_pages: STARTUP_PAGE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0),
show_purchases: pref_bool("show_purchases", false),
nav_tb_purchases: pref_bool("nav_tb_purchases", false),
nav_click_first_tab: pref_bool("nav_click_first_tab", false),
local_tab_order: local_tab_order(),
genre_filters_position: local_genre_filters_position(),
renderers: RENDERER_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0),
gpu_powers: gpu_power_choice().0,
@@ -2093,10 +2237,7 @@ pub async fn publish_snapshot() {
.map(|t| t.mac_hide_dock)
.unwrap_or(false),
is_macos: cfg!(target_os = "macos"),
tray_icon_themes: TRAY_ICON_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
tray_icon_themes: TRAY_ICON_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
tray_icon_theme_index: tray()
.get_settings()
.map(|t| index_of(TRAY_ICON_VALUES, &t.tray_icon_theme, 0))
@@ -2207,15 +2348,11 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
"alsa-hardware-volume" => {
with_audio(|s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit)
}
"exclusive-mode" => {
with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit)
}
"exclusive-mode" => with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit),
"reserve-dac" => {
with_audio(|s| s.set_reserve_dac_while_running(value)).map(|_| Apply::Reload)
}
"dac-passthrough" => {
with_audio(|s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit)
}
"dac-passthrough" => with_audio(|s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit),
"pw-force-bitperfect" => {
with_audio(|s| s.set_pw_force_bitperfect(value)).map(|_| Apply::Reload)
}
@@ -2225,19 +2362,13 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
"sync-audio-on-startup" => {
with_audio(|s| s.set_sync_audio_on_startup(value)).map(|_| Apply::Reload)
}
"skip-sink-switch" => {
with_audio(|s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit)
}
"skip-sink-switch" => with_audio(|s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit),
"gapless" => with_audio(|s| s.set_gapless_enabled(value)).map(|_| Apply::Reload),
"normalization" => {
with_audio(|s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
}
"stream-uncached" => {
with_audio(|s| s.set_stream_first_track(value)).map(|_| Apply::Reload)
}
"streaming-only" => {
with_audio(|s| s.set_streaming_only(value)).map(|_| Apply::Reload)
}
"stream-uncached" => with_audio(|s| s.set_stream_first_track(value)).map(|_| Apply::Reload),
"streaming-only" => with_audio(|s| s.set_streaming_only(value)).map(|_| Apply::Reload),
"continue-playback" => {
let mode = if value {
AutoplayMode::ContinueWithinSource
@@ -2447,15 +2578,11 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
"scrobble-collapse" => {
crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
}
"lastfm-enable" => {
crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None)
}
"lastfm-enable" => crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None),
"listenbrainz-enable" => {
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
}
"discord-rpc" => {
crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None)
}
"discord-rpc" => crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None),
// --- Offline -------------------------------------------------------
// Induced offline. The engine takes the #279 stream-first snapshot,
// so the audio settings can change under us -> Reload the player.
@@ -2595,7 +2722,11 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
let Some(id) = id else {
return;
};
let device_opt = if id.is_empty() { None } else { Some(id.as_str()) };
let device_opt = if id.is_empty() {
None
} else {
Some(id.as_str())
};
if let Err(e) = with_audio(|s| s.set_output_device(device_opt)) {
log::error!("[qbz-qt] persist output device failed: {e}");
return;
@@ -2720,6 +2851,12 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
};
save_pref("startup_page", serde_json::json!(v));
}
"genre-filters-position" => {
let Some(v) = LOCAL_GENRE_FILTER_POSITION_VALUES.get(index) else {
return;
};
save_pref("local_genre_filters_position", serde_json::json!(v));
}
"renderer" => {
let Some(v) = RENDERER_VALUES.get(index) else {
return;
@@ -2818,6 +2955,15 @@ pub async fn settings_string(key: &str, value: String) {
return;
}
// --- Local Library -------------------------------------------------
"local-tab-order" => {
if !save_local_tab_order_payload(&value) {
log::warn!("[qbz-qt] rejected malformed Local Library tab order");
}
}
"local-tab-order-reset" => save_pref(
"local_tab_order",
serde_json::json!(LOCAL_TAB_DEFAULT_ORDER),
),
"library-add-folder" => library::add_folder(value).await,
// --- Per-folder settings modal (LibFolderEditModal) ------------------
"library-folder-edit-open" => {
@@ -3095,3 +3241,48 @@ fn qconnect_default_name() -> String {
format!("Qbz - {host}")
})
}
#[cfg(test)]
mod local_tab_order_tests {
use super::*;
#[test]
fn absent_or_invalid_order_uses_the_complete_default() {
let expected: Vec<String> = LOCAL_TAB_DEFAULT_ORDER
.iter()
.map(|id| (*id).to_string())
.collect();
assert_eq!(normalize_local_tab_order(None), expected);
assert_eq!(
normalize_local_tab_order(Some(&serde_json::json!("albums"))),
expected
);
}
#[test]
fn stored_order_deduplicates_filters_and_appends_missing_tabs() {
let value = serde_json::json!(["tracks", "future-tab", "tracks", "albums", 7, "artists"]);
assert_eq!(
normalize_local_tab_order(Some(&value)),
["tracks", "albums", "artists", "genres", "folders"]
);
}
#[test]
fn kiosk_fallback_contract_always_has_a_supported_tab() {
let normalized = normalize_local_tab_order(Some(&serde_json::json!([
"genres", "folders", "albums", "artists", "tracks"
])));
assert_eq!(local_landing_tab_from(&normalized, false), "genres");
assert_eq!(local_landing_tab_from(&normalized, true), "folders");
}
#[test]
fn genre_filter_position_is_closed_and_defaults_to_top() {
for value in LOCAL_GENRE_FILTER_POSITION_VALUES {
assert_eq!(local_genre_filters_position_from(value), *value);
}
assert_eq!(local_genre_filters_position_from("future-edge"), "top");
assert_eq!(local_genre_filters_position_from(""), "top");
}
}