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:
40 files changed
+5374
-752
No files matched your search
+56
-8
@@ -6,7 +6,9 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule};
|
|||||||
/// baked icon variants (qml/assets/icons/<tint>/<name>.svg) are too many to
|
/// baked icon variants (qml/assets/icons/<tint>/<name>.svg) are too many to
|
||||||
/// list by hand.
|
/// list by hand.
|
||||||
fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
|
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();
|
let path = entry.expect("dir entry").path();
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
collect_qrc_files(&path, out);
|
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)");
|
.expect("Qt6 not found for the RHI items (cxx-qt-build would fail too)");
|
||||||
|
|
||||||
let mut cc = cc::Build::new();
|
let mut cc = cc::Build::new();
|
||||||
cc.cpp(true)
|
cc.cpp(true).std("c++17").pic(true).include("cxx"); // the moc outputs do `#include "<name>.h"`
|
||||||
.std("c++17")
|
|
||||||
.pic(true)
|
|
||||||
.include("cxx"); // the moc outputs do `#include "<name>.h"`
|
|
||||||
for item in [
|
for item in [
|
||||||
"linebed_item",
|
"linebed_item",
|
||||||
"plasma_item",
|
"plasma_item",
|
||||||
@@ -334,8 +333,10 @@ fn build_rhi_items() {
|
|||||||
let libs = qtbuild.qmake_query("QT_INSTALL_LIBS");
|
let libs = qtbuild.qmake_query("QT_INSTALL_LIBS");
|
||||||
cc.flag(&format!("-F{}", libs.trim()));
|
cc.flag(&format!("-F{}", libs.trim()));
|
||||||
for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] {
|
for module in ["QtCore", "QtGui", "QtQml", "QtQuick"] {
|
||||||
let private =
|
let private = format!(
|
||||||
format!("{}/{module}.framework/Headers/{version}/{module}", libs.trim());
|
"{}/{module}.framework/Headers/{version}/{module}",
|
||||||
|
libs.trim()
|
||||||
|
);
|
||||||
if Path::new(&private).is_dir() {
|
if Path::new(&private).is_dir() {
|
||||||
cc.include(private);
|
cc.include(private);
|
||||||
}
|
}
|
||||||
@@ -400,7 +401,49 @@ fn main() {
|
|||||||
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
||||||
// modules and must NOT be listed — only files that declare a
|
// modules and must NOT be listed — only files that declare a
|
||||||
// #[cxx_qt::bridge] mod belong in this array.
|
// #[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_files: &[
|
||||||
"qml/LoginScreen.qml",
|
"qml/LoginScreen.qml",
|
||||||
"qml/Main.qml",
|
"qml/Main.qml",
|
||||||
@@ -661,6 +704,7 @@ fn main() {
|
|||||||
"qml/settings/LibFolderEditModal.qml",
|
"qml/settings/LibFolderEditModal.qml",
|
||||||
"qml/settings/LibraryFolderTable.qml",
|
"qml/settings/LibraryFolderTable.qml",
|
||||||
"qml/settings/LocalLibrarySettings.qml",
|
"qml/settings/LocalLibrarySettings.qml",
|
||||||
|
"qml/settings/LocalTabsConfigModal.qml",
|
||||||
"qml/settings/OfflineSettings.qml",
|
"qml/settings/OfflineSettings.qml",
|
||||||
"qml/settings/PlaybackSettings.qml",
|
"qml/settings/PlaybackSettings.qml",
|
||||||
// ONE component instantiated twice (Jellyfin + Subsonic).
|
// ONE component instantiated twice (Jellyfin + Subsonic).
|
||||||
@@ -697,8 +741,12 @@ fn main() {
|
|||||||
"qml/views/local/LocalChrome.qml",
|
"qml/views/local/LocalChrome.qml",
|
||||||
"qml/views/local/LocalEphemeralPane.qml",
|
"qml/views/local/LocalEphemeralPane.qml",
|
||||||
"qml/views/local/LocalFilterPopup.qml",
|
"qml/views/local/LocalFilterPopup.qml",
|
||||||
|
"qml/views/local/LocalFilterButton.qml",
|
||||||
"qml/views/local/LocalFolderDetail.qml",
|
"qml/views/local/LocalFolderDetail.qml",
|
||||||
"qml/views/local/LocalFoldersTab.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/LocalNote.qml",
|
||||||
"qml/views/local/LocalSearchBox.qml",
|
"qml/views/local/LocalSearchBox.qml",
|
||||||
"qml/views/local/LocalToolbar.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
|
/// previews — every call site guards, so a preview degrades to the old
|
||||||
/// unconfirmed behaviour rather than swallowing the click.
|
/// unconfirmed behaviour rather than swallowing the click.
|
||||||
property var confirmHost: null
|
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 }
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
@@ -118,6 +121,46 @@ Column {
|
|||||||
|
|
||||||
Item { width: 1; height: 22 }
|
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 ============================
|
// ============================ ALBUMS VIEW ============================
|
||||||
GroupHeader { text: QbzSession.tr("ALBUMS VIEW", QbzSession.trRev) }
|
GroupHeader { text: QbzSession.tr("ALBUMS VIEW", QbzSession.trRev) }
|
||||||
SettingRow {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,7 +333,8 @@ Item {
|
|||||||
LocalLibrarySettings {
|
LocalLibrarySettings {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
doc: root.doc
|
doc: root.doc
|
||||||
confirmHost: confirmHost
|
confirmHost: confirmHost
|
||||||
|
tabOrderModal: localTabsConfigModal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +399,13 @@ Item {
|
|||||||
// / PlexSettings `confirmHost`).
|
// / PlexSettings `confirmHost`).
|
||||||
SettingsConfirmHost { id: 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
|
// The per-folder settings modal. Same reasoning as the confirm host: it
|
||||||
// must overlay the whole view, so it cannot live inside the scrolled
|
// must overlay the whole view, so it cannot live inside the scrolled
|
||||||
// panel that opens it. The reference mounts its counterpart at the
|
// panel that opens it. The reference mounts its counterpart at the
|
||||||
|
|||||||
@@ -62,13 +62,20 @@ Item {
|
|||||||
// Slint gates the Recommendations entry on SettingsState.show-recommendations
|
// Slint gates the Recommendations entry on SettingsState.show-recommendations
|
||||||
// (discover_prefs `show_recommendations`, default ON) — the port publishes
|
// (discover_prefs `show_recommendations`, default ON) — the port publishes
|
||||||
// it in QbzBridge.settingsJson.
|
// it in QbzBridge.settingsJson.
|
||||||
readonly property bool showRecommendations: {
|
readonly property var settingsDoc: {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(QbzBridge.settingsJson).showRecommendations !== false
|
return JSON.parse(QbzBridge.settingsJson)
|
||||||
} catch (e) {
|
} 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,
|
// MyQBZ branding — `label` (user-editable in Settings > Appearance,
|
||||||
// coerced back to "My QBZ" on the Rust side when cleared), `iconPath` +
|
// 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
|
// buildSections purely so the binding re-runs on a language switch
|
||||||
// (QbzSession.trRev is the translation revision counter every other
|
// (QbzSession.trRev is the translation revision counter every other
|
||||||
// tr() call site binds to).
|
// 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) }
|
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||||
|
|
||||||
@@ -121,7 +129,7 @@ Item {
|
|||||||
// `icon`"; a non-empty path is a user-supplied image
|
// `icon`"; a non-empty path is a user-supplied image
|
||||||
// rendered raw by shell/NavSectionGlyph.qml. Only the
|
// rendered raw by shell/NavSectionGlyph.qml. Only the
|
||||||
// MyQBZ section can carry one. }
|
// MyQBZ section can carry one. }
|
||||||
function buildSections(rev, reco) {
|
function buildSections(rev, reco, localOrder) {
|
||||||
// Discover — glyph-less rows (Slint has-glyph: false).
|
// Discover — glyph-less rows (Slint has-glyph: false).
|
||||||
var discover = [
|
var discover = [
|
||||||
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
|
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
|
||||||
@@ -131,6 +139,40 @@ Item {
|
|||||||
if (reco) {
|
if (reco) {
|
||||||
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": "discover",
|
"id": "discover",
|
||||||
@@ -161,19 +203,7 @@ Item {
|
|||||||
"label": nav.trs("Local Library"),
|
"label": nav.trs("Local Library"),
|
||||||
"qobuz": false,
|
"qobuz": false,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"entries": [
|
"entries": localEntries
|
||||||
{ "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() }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "myqbz",
|
"id": "myqbz",
|
||||||
|
|||||||
@@ -67,8 +67,33 @@ Rectangle {
|
|||||||
readonly property var nativeArtistAlbumsModel: QbzLocalArtistAlbums
|
readonly property var nativeArtistAlbumsModel: QbzLocalArtistAlbums
|
||||||
|
|
||||||
// ============================ state ==================================
|
// ============================ state ==================================
|
||||||
// Slint defaults, verbatim (state.slint LocalLibraryState).
|
// One app-wide order drives this bar, both nav flyouts and logged-off
|
||||||
property string activeTab: "albums"
|
// 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.
|
// Albums tab.
|
||||||
property string albumsSearch: ""
|
property string albumsSearch: ""
|
||||||
@@ -77,7 +102,7 @@ Rectangle {
|
|||||||
property string albumsView: "grid" // grid | list
|
property string albumsView: "grid" // grid | list
|
||||||
property bool albumsMultiSelect: false
|
property bool albumsMultiSelect: false
|
||||||
property var albumsSelected: ({})
|
property var albumsSelected: ({})
|
||||||
readonly property int albumsSelectedCount: QbzLocal.localAlbumsNativeActive
|
readonly property int albumsSelectedCount: root.albumsNativeViewActive
|
||||||
? QbzLocal.localAlbumsNativeSelectedCount
|
? QbzLocal.localAlbumsNativeSelectedCount
|
||||||
: Object.keys(albumsSelected).length
|
: Object.keys(albumsSelected).length
|
||||||
|
|
||||||
@@ -96,6 +121,7 @@ Rectangle {
|
|||||||
|
|
||||||
// Artists tab.
|
// Artists tab.
|
||||||
property string artistsSearch: ""
|
property string artistsSearch: ""
|
||||||
|
property string artistsSort: "name-asc"
|
||||||
property string selectedArtist: ""
|
property string selectedArtist: ""
|
||||||
|
|
||||||
// Tracks tab.
|
// Tracks tab.
|
||||||
@@ -112,18 +138,194 @@ Rectangle {
|
|||||||
? QbzLocal.localTracksNativeSelectedCount
|
? QbzLocal.localTracksNativeSelectedCount
|
||||||
: Object.keys(tracksSelected).length
|
: 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
|
property bool filterOpen: false
|
||||||
// SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on
|
// SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on
|
||||||
// every navigation away (see the bridge's `albums_filter` doc), so a
|
// every navigation away (see the bridge's `albums_filter` doc), so a
|
||||||
// view-local default meant the funnel reset the moment the user visited
|
// view-local default meant the funnel reset the moment the user visited
|
||||||
// Discover — and never survived a restart at all. `QbzLocal.albumsFilter`
|
// Discover — and never survived a restart at all. `QbzLocal.albumsFilter`
|
||||||
// outlives the view and mirrors `ui_prefs.json`.
|
// 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) {
|
function parseFilter(json) {
|
||||||
if (!json || json === "") return ({})
|
if (!json || json === "") return ({})
|
||||||
try { return JSON.parse(json) || ({}) } catch (e) { 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
|
// A LATER republish still wins: the gates and the saved funnel are
|
||||||
// published together when a media server is connected or removed, and the
|
// 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
|
// pruning that happens there (a tick whose chip is now hidden) has to
|
||||||
@@ -133,8 +335,20 @@ Rectangle {
|
|||||||
target: QbzLocal
|
target: QbzLocal
|
||||||
function onAlbumsFilterChanged() {
|
function onAlbumsFilterChanged() {
|
||||||
var next = root.parseFilter(QbzLocal.albumsFilter)
|
var next = root.parseFilter(QbzLocal.albumsFilter)
|
||||||
if (JSON.stringify(next) !== JSON.stringify(root.filter))
|
if (JSON.stringify(next) !== JSON.stringify(root.albumsFilter))
|
||||||
root.filter = next
|
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: {
|
readonly property int filterCount: {
|
||||||
@@ -142,12 +356,10 @@ Rectangle {
|
|||||||
for (var k in filter) if (filter[k]) n++
|
for (var k in filter) if (filter[k]) n++
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
/// The ONE writer. Both mutators funnel through it, so neither can change
|
/// The ONE writer. Both mutators funnel through it, so the four tabs
|
||||||
/// the funnel and forget to persist it.
|
/// cannot drift into different source/format/quality states.
|
||||||
function setFilter(f) {
|
function setFilter(f) {
|
||||||
filter = f
|
synchronizeFilters(activeTab, f, true)
|
||||||
QbzLocal.setAlbumsFilterJson(
|
|
||||||
Object.keys(f).length === 0 ? "" : JSON.stringify(f))
|
|
||||||
}
|
}
|
||||||
function toggleFilter(key) {
|
function toggleFilter(key) {
|
||||||
var f = Object.assign({}, filter)
|
var f = Object.assign({}, filter)
|
||||||
@@ -157,7 +369,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
function clearFilter() { setFilter({}) }
|
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
|
/// 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
|
/// on. Built HERE because this is where the state lives — the toolbar only
|
||||||
/// draws the trigger, and `LocalFilterPopup` only draws chips.
|
/// draws the trigger, and `LocalFilterPopup` only draws chips.
|
||||||
@@ -175,6 +387,8 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
var tr = QbzSession.trRev
|
var tr = QbzSession.trRev
|
||||||
return [
|
return [
|
||||||
|
{ group: QbzSession.tr("Favorites", tr),
|
||||||
|
values: pick(["favorite"], [QbzSession.tr("Favorites only", tr)]) },
|
||||||
{ group: QbzSession.tr("Quality", tr),
|
{ group: QbzSession.tr("Quality", tr),
|
||||||
values: pick(["hires", "cd", "lossy"],
|
values: pick(["hires", "cd", "lossy"],
|
||||||
[QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr),
|
[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
|
/// 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
|
/// 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
|
/// 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
|
/// active: a blank content area and no way back except another tab. The
|
||||||
/// is the fallback because it is this view's own default (:63).
|
/// user's first ordered surface is the same fallback every other Local
|
||||||
|
/// Library entry point uses.
|
||||||
onEphemeralActiveChanged: {
|
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
|
/// 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 onLocalEphemeralOpenSeqChanged() { root.activeTab = "ephemeral" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTabForView(tab) {
|
||||||
|
QbzLocal.loadTab(tab === "albums" && root.albumsFilter.favorite === true
|
||||||
|
? "albums-legacy" : tab)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------- documents ------------------------------
|
// ---------------------------- documents ------------------------------
|
||||||
function parseDoc(json, fallback) {
|
function parseDoc(json, fallback) {
|
||||||
if (json === "") return fallback
|
if (json === "") return fallback
|
||||||
@@ -415,7 +636,17 @@ Rectangle {
|
|||||||
// Mount: load the default tab. Tab switches load on demand (each tab is
|
// Mount: load the default tab. Tab switches load on demand (each tab is
|
||||||
// one query; the Albums/Folders/Artists sets are bounded).
|
// one query; the Albums/Folders/Artists sets are bounded).
|
||||||
Component.onCompleted: {
|
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()
|
consumePendingArtist()
|
||||||
consumePendingRoute()
|
consumePendingRoute()
|
||||||
}
|
}
|
||||||
@@ -424,7 +655,14 @@ Rectangle {
|
|||||||
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
|
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
|
||||||
}
|
}
|
||||||
onActiveTabChanged: {
|
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
|
// 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.
|
// left has covers to let go of. Each surface answers for itself.
|
||||||
artworkRefresh()
|
artworkRefresh()
|
||||||
@@ -503,6 +741,9 @@ Rectangle {
|
|||||||
// and an AND across sections — an empty section means "any".
|
// and an AND across sections — an empty section means "any".
|
||||||
function sourceBucket(word) {
|
function sourceBucket(word) {
|
||||||
var source = (word || "local").toLowerCase()
|
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")
|
if (source === "qobuz_purchase" || source === "qobuz_download")
|
||||||
return "offline"
|
return "offline"
|
||||||
if (source === "navidrome" || source === "gonic"
|
if (source === "navidrome" || source === "gonic"
|
||||||
@@ -511,28 +752,30 @@ Rectangle {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(rows) {
|
function applyFilter(rows, selectedFilter) {
|
||||||
if (filterCount === 0) return rows
|
var selected = selectedFilter || ({})
|
||||||
var qAny = filter.hires || filter.cd || filter.lossy
|
if (Object.keys(selected).length === 0) return rows
|
||||||
var fAny = filter.flac || filter.alac || filter.ape || filter.wav
|
var qAny = selected.hires || selected.cd || selected.lossy
|
||||||
|| filter.mp3 || filter.aac || filter.other
|
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|
||||||
var sAny = filter.local || filter.offline || filter.plex
|
|| selected.mp3 || selected.aac || selected.other
|
||||||
|| filter.jellyfin || filter.subsonic
|
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 known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
|
||||||
var out = []
|
var out = []
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
var r = rows[i]
|
var r = rows[i]
|
||||||
|
if (selected.favorite === true && !root.albumFavorite(r)) continue
|
||||||
var tier = (r.qualityTier || "").toLowerCase()
|
var tier = (r.qualityTier || "").toLowerCase()
|
||||||
var fmt = (r.format || "").toLowerCase()
|
var fmt = (r.format || "").toLowerCase()
|
||||||
if (qAny) {
|
if (qAny) {
|
||||||
var qok = (filter.hires && (tier === "hires" || tier === "max"))
|
var qok = (selected.hires && (tier === "hires" || tier === "max"))
|
||||||
|| (filter.cd && tier === "cd")
|
|| (selected.cd && tier === "cd")
|
||||||
|| (filter.lossy && (tier === "mp3" || tier === "lossy"))
|
|| (selected.lossy && (tier === "mp3" || tier === "lossy"))
|
||||||
if (!qok) continue
|
if (!qok) continue
|
||||||
}
|
}
|
||||||
if (fAny) {
|
if (fAny) {
|
||||||
var fok = filter[fmt] === true
|
var fok = selected[fmt] === true
|
||||||
|| (filter.other === true && known[fmt] !== 1)
|
|| (selected.other === true && known[fmt] !== 1)
|
||||||
if (!fok) continue
|
if (!fok) continue
|
||||||
}
|
}
|
||||||
if (sAny) {
|
if (sAny) {
|
||||||
@@ -550,7 +793,7 @@ Rectangle {
|
|||||||
? r.sources : [r.source || "local"]
|
? r.sources : [r.source || "local"]
|
||||||
var sourceMatches = false
|
var sourceMatches = false
|
||||||
for (var si = 0; si < sources.length; si++) {
|
for (var si = 0; si < sources.length; si++) {
|
||||||
if (filter[sourceBucket(sources[si])] === true) {
|
if (selected[sourceBucket(sources[si])] === true) {
|
||||||
sourceMatches = true
|
sourceMatches = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -562,6 +805,24 @@ Rectangle {
|
|||||||
return out
|
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
|
// A-Z / by-artist grouping — [{ letter, items }] plus the alpha jumps the
|
||||||
// strips consume (the strip derives its own indices from the groups).
|
// strips consume (the strip derives its own indices from the groups).
|
||||||
function groupRows(rows, mode) {
|
function groupRows(rows, mode) {
|
||||||
@@ -591,9 +852,8 @@ Rectangle {
|
|||||||
// Once F1 owns the Albums tab, never run the legacy O(n) JS pipeline in
|
// 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
|
// parallel. The old document may still be resident because Artists uses
|
||||||
// it until F2; it is evaluated only while that legacy surface is active.
|
// it until F2; it is evaluated only while that legacy surface is active.
|
||||||
readonly property var albumsVisible: QbzLocal.localAlbumsNativeActive
|
readonly property var albumsVisible: root.albumsNativeViewActive ? []
|
||||||
&& activeTab === "albums" ? []
|
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort), albumsFilter)
|
||||||
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort))
|
|
||||||
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
|
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
|
||||||
|
|
||||||
readonly property var foldersVisible:
|
readonly property var foldersVisible:
|
||||||
@@ -606,14 +866,72 @@ Rectangle {
|
|||||||
// track hundreds of indices whenever a later page belonged ahead of it.
|
// track hundreds of indices whenever a later page belonged ahead of it.
|
||||||
readonly property var tracksVisible: tracks
|
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: {
|
readonly property var artistsVisible: {
|
||||||
if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return []
|
if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return []
|
||||||
var q = artistsSearch.trim().toLowerCase()
|
var q = artistsSearch.trim().toLowerCase()
|
||||||
if (q === "") return artists
|
|
||||||
var out = []
|
var out = []
|
||||||
for (var i = 0; i < artists.length; i++) {
|
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
|
return out
|
||||||
}
|
}
|
||||||
readonly property int artistsVisibleCount: artistsVisible.length
|
readonly property int artistsVisibleCount: artistsVisible.length
|
||||||
@@ -636,6 +954,127 @@ Rectangle {
|
|||||||
return out
|
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 selected artist's albums.
|
||||||
//
|
//
|
||||||
// The MATCH lives in Rust — `QbzLocal.artistAlbumIds` ->
|
// The MATCH lives in Rust — `QbzLocal.artistAlbumIds` ->
|
||||||
@@ -907,7 +1346,7 @@ Rectangle {
|
|||||||
|
|
||||||
// ============================ actions ================================
|
// ============================ actions ================================
|
||||||
function openAlbum(id) {
|
function openAlbum(id) {
|
||||||
QbzLocal.openAlbum(id)
|
QbzLocal.openAlbumFiltered(id, JSON.stringify(root.filter || {}))
|
||||||
QbzShell.navigateTo("localalbum")
|
QbzShell.navigateTo("localalbum")
|
||||||
}
|
}
|
||||||
function selectFolder(path) {
|
function selectFolder(path) {
|
||||||
@@ -1083,6 +1522,11 @@ Rectangle {
|
|||||||
active: QbzLocal.localAvailable && root.activeTab === "artists"
|
active: QbzLocal.localAvailable && root.activeTab === "artists"
|
||||||
sourceComponent: LocalArtistsTab { view: root }
|
sourceComponent: LocalArtistsTab { view: root }
|
||||||
}
|
}
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
active: QbzLocal.localAvailable && root.activeTab === "genres"
|
||||||
|
sourceComponent: LocalGenresTab { view: root }
|
||||||
|
}
|
||||||
Loader {
|
Loader {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
active: QbzLocal.localAvailable && root.activeTab === "folders"
|
active: QbzLocal.localAvailable && root.activeTab === "folders"
|
||||||
@@ -1113,7 +1557,8 @@ Rectangle {
|
|||||||
// it instead of taking a layout slot (the Slint mount, :2470).
|
// it instead of taking a layout slot (the Slint mount, :2470).
|
||||||
LocalFilterPopup {
|
LocalFilterPopup {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: root.filterOpen && root.activeTab === "albums"
|
visible: root.filterOpen
|
||||||
|
&& ["albums", "artists", "genres", "tracks"].indexOf(root.activeTab) >= 0
|
||||||
view: root
|
view: root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ Item {
|
|||||||
readonly property var emptySlot: ({
|
readonly property var emptySlot: ({
|
||||||
"id": "", "title": "", "artist": "", "year": "",
|
"id": "", "title": "", "artist": "", "year": "",
|
||||||
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": "",
|
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": "",
|
||||||
"sources": []
|
"sources": [], "isFavorite": false, "favoriteable": false
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -223,10 +223,13 @@ Item {
|
|||||||
onTriggered: root.resetNativeQuery()
|
onTriggered: root.resetNativeQuery()
|
||||||
}
|
}
|
||||||
function scheduleNativeReset() {
|
function scheduleNativeReset() {
|
||||||
if (nativeSurface && cols > 0) nativeQueryCoalescer.restart()
|
if (nativeSurface && cols > 0 && view
|
||||||
|
&& view.albumsFilter.favorite !== true)
|
||||||
|
nativeQueryCoalescer.restart()
|
||||||
}
|
}
|
||||||
function resetNativeQuery() {
|
function resetNativeQuery() {
|
||||||
if (!nativeSurface || !view || cols <= 0) return
|
if (!nativeSurface || !view || cols <= 0
|
||||||
|
|| view.albumsFilter.favorite === true) return
|
||||||
QbzLocal.albumsNativeReset(
|
QbzLocal.albumsNativeReset(
|
||||||
view.albumsSearch,
|
view.albumsSearch,
|
||||||
view.albumsSort,
|
view.albumsSort,
|
||||||
@@ -433,44 +436,8 @@ Item {
|
|||||||
width: 200
|
width: 200
|
||||||
height: 246
|
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 {
|
AlbumCard {
|
||||||
|
id: albumCard
|
||||||
localMode: true
|
localMode: true
|
||||||
albumId: cardCell.slot.id
|
albumId: cardCell.slot.id
|
||||||
title: cardCell.slot.title
|
title: cardCell.slot.title
|
||||||
@@ -499,6 +466,14 @@ Item {
|
|||||||
|| cardCell.slot.source
|
|| cardCell.slot.source
|
||||||
sources: cardCell.slot.sources || []
|
sources: cardCell.slot.sources || []
|
||||||
showSourceBadge: root.showSource
|
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
|
// SELECT MODE IS THE CARD'S (AlbumCard's
|
||||||
// `selectMode`/`selected`/`selectToggled`, the
|
// `selectMode`/`selected`/`selectToggled`, the
|
||||||
// port of discover/AlbumCard.slint:83, :179-197,
|
// port of discover/AlbumCard.slint:83, :179-197,
|
||||||
@@ -565,7 +540,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.RightButton
|
||||||
onClicked: function (mouse) {
|
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
|
// (The multi-select tick that used to sit here is
|
||||||
@@ -586,6 +561,9 @@ Item {
|
|||||||
artSource: root.view
|
artSource: root.view
|
||||||
? (modelData.items[0].artPath
|
? (modelData.items[0].artPath
|
||||||
|| root.view.artMap[modelData.items[0].artKey] || "") : ""
|
|| root.view.artMap[modelData.items[0].artKey] || "") : ""
|
||||||
|
isFavorite: root.view
|
||||||
|
? root.view.albumFavorite(modelData.items[0])
|
||||||
|
: modelData.items[0].isFavorite === true
|
||||||
showSource: root.showSource
|
showSource: root.showSource
|
||||||
selectMode: root.selectMode
|
selectMode: root.selectMode
|
||||||
checked: root.nativeActive
|
checked: root.nativeActive
|
||||||
@@ -596,6 +574,9 @@ Item {
|
|||||||
onEnqueueRequested: function (m) {
|
onEnqueueRequested: function (m) {
|
||||||
root.enqueueRequested(modelData.items[0].id, m)
|
root.enqueueRequested(modelData.items[0].id, m)
|
||||||
}
|
}
|
||||||
|
onFavoriteRequested: if (root.view) {
|
||||||
|
root.view.toggleAlbumFavorite(modelData.items[0], artSource)
|
||||||
|
}
|
||||||
onToggleSelect: function (mods) {
|
onToggleSelect: function (mods) {
|
||||||
if (root.nativeActive)
|
if (root.nativeActive)
|
||||||
root.nativeToggleSelect(modelData.items[0].nativeIndex, mods)
|
root.nativeToggleSelect(modelData.items[0].nativeIndex, mods)
|
||||||
|
|||||||
@@ -6,16 +6,14 @@
|
|||||||
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
|
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
|
||||||
// in front of the cover, as the collection view does.
|
// in front of the cover, as the collection view does.
|
||||||
//
|
//
|
||||||
// The ⋯ menu is AlbumListRow.slint:381 minus its two Qobuz-only entries: the
|
// The ⋯ menu carries the same local/Plex favorite as the grid overlay. Remote-
|
||||||
// favourite heart (local albums are not catalog favourites) and "Block this
|
// only rows omit it because the shared LocalFavoritesService deliberately
|
||||||
// album" (which the Slint itself hides for source local/plex, :435). What is
|
// accepts genuine local and Plex snapshots only.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import com.blitzfc.qbz
|
import com.blitzfc.qbz
|
||||||
import "../../controls"
|
import "../../controls"
|
||||||
|
import "../../rows"
|
||||||
import "../../theme"
|
import "../../theme"
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -23,6 +21,7 @@ Rectangle {
|
|||||||
|
|
||||||
property var item: ({})
|
property var item: ({})
|
||||||
property string artSource: ""
|
property string artSource: ""
|
||||||
|
property bool isFavorite: root.item.isFavorite === true
|
||||||
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
|
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
|
||||||
/// artwork gate and the settle bound all live there (never duplicated
|
/// artwork gate and the settle bound all live there (never duplicated
|
||||||
/// per row, so the rule cannot drift between surfaces).
|
/// per row, so the rule cannot drift between surfaces).
|
||||||
@@ -30,19 +29,43 @@ Rectangle {
|
|||||||
property bool showSource: true
|
property bool showSource: true
|
||||||
property bool selectMode: false
|
property bool selectMode: false
|
||||||
property bool checked: 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 opened()
|
||||||
signal playRequested()
|
signal playRequested()
|
||||||
|
signal shuffleRequested()
|
||||||
signal enqueueRequested(string mode)
|
signal enqueueRequested(string mode)
|
||||||
|
signal favoriteRequested()
|
||||||
|
signal toggleExpanded()
|
||||||
|
signal versionPicked(int index)
|
||||||
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
||||||
/// a click into a range (controls/SelectionModel.qml).
|
/// a click into a range (controls/SelectionModel.qml).
|
||||||
signal toggleSelect(int modifiers)
|
signal toggleSelect(int modifiers)
|
||||||
|
|
||||||
QbzTheme { id: theme }
|
QbzTheme { id: theme }
|
||||||
|
TrackCols { id: cols }
|
||||||
|
|
||||||
height: 56
|
height: detailsMode ? 72 : 56
|
||||||
radius: 6
|
radius: 6
|
||||||
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
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 {
|
MouseArea {
|
||||||
id: rowArea
|
id: rowArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -51,7 +74,7 @@ Rectangle {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: function (mouse) {
|
onClicked: function (mouse) {
|
||||||
if (mouse.button === Qt.RightButton) {
|
if (mouse.button === Qt.RightButton) {
|
||||||
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
root.openRowMenuAt(rowArea, mouse.x, mouse.y)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (root.selectMode) root.toggleSelect(mouse.modifiers)
|
if (root.selectMode) root.toggleSelect(mouse.modifiers)
|
||||||
@@ -60,24 +83,43 @@ Rectangle {
|
|||||||
onDoubleClicked: if (!root.selectMode) root.playRequested()
|
onDoubleClicked: if (!root.selectMode) root.playRequested()
|
||||||
}
|
}
|
||||||
|
|
||||||
CardMenu {
|
// Same cold-menu contract as LocalTrackRow: a list/detail viewport should
|
||||||
id: rowMenu
|
// construct album rows, not five invisible popup delegates per row.
|
||||||
menuWidth: 196
|
Loader {
|
||||||
entries: [
|
id: rowMenuLoader
|
||||||
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
active: false
|
||||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
sourceComponent: CardMenu {
|
||||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
menuWidth: 196
|
||||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
entries: {
|
||||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
var rows = [
|
||||||
]
|
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
||||||
onPicked: function (a) {
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||||
if (a === "open") root.opened()
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||||
else if (a === "play") root.playRequested()
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||||
else root.enqueueRequested(a)
|
{ "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 {
|
Row {
|
||||||
|
visible: !root.detailsMode
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 10
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
@@ -124,6 +166,7 @@ Rectangle {
|
|||||||
width: parent.width - 40 - 70 - 90 - 92 - 32
|
width: parent.width - 40 - 70 - 90 - 92 - 32
|
||||||
- (root.showSource ? 34 : 0)
|
- (root.showSource ? 34 : 0)
|
||||||
- (root.selectMode ? 28 : 0) - 6 * 12
|
- (root.selectMode ? 28 : 0) - 6 * 12
|
||||||
|
- (root.expandable ? 28 + 12 : 0)
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 2
|
spacing: 2
|
||||||
Text {
|
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).
|
// Trailing ⋯ overflow (AlbumListRow.slint:360).
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 32
|
width: 32
|
||||||
@@ -216,8 +281,146 @@ Rectangle {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
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
|
id: root
|
||||||
|
|
||||||
property var view: null
|
property var view: null
|
||||||
readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive
|
readonly property bool nativeActive: root.view.albumsNativeViewActive
|
||||||
readonly property int albumTotal: nativeActive
|
readonly property int albumTotal: nativeActive
|
||||||
? QbzLocal.localAlbumsNativeTotal : root.view.albums.length
|
? QbzLocal.localAlbumsNativeTotal : root.view.albums.length
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Item {
|
|||||||
SettingsButton {
|
SettingsButton {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
text: QbzSession.tr("Retry", QbzSession.trRev)
|
text: QbzSession.tr("Retry", QbzSession.trRev)
|
||||||
onClicked: QbzLocal.loadTab("albums")
|
onClicked: root.view.loadTabForView("albums")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +138,12 @@ Item {
|
|||||||
selectMode: root.view.albumsMultiSelect
|
selectMode: root.view.albumsMultiSelect
|
||||||
selected: root.view.albumsSelected
|
selected: root.view.albumsSelected
|
||||||
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
||||||
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
|
onPlayRequested: function (id) {
|
||||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
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) }
|
onToggleSelect: function (id, mods) { root.view.toggleAlbumSelected(id, mods) }
|
||||||
onNativeToggleSelect: function (index, mods) {
|
onNativeToggleSelect: function (index, mods) {
|
||||||
root.view.toggleNativeAlbumSelected(index, mods)
|
root.view.toggleNativeAlbumSelected(index, mods)
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ Item {
|
|||||||
target: root.view
|
target: root.view
|
||||||
function onArtistsGroupedChanged() { root.rebuild() }
|
function onArtistsGroupedChanged() { root.rebuild() }
|
||||||
function onArtistsSearchChanged() { nativeQueryCoalescer.restart() }
|
function onArtistsSearchChanged() { nativeQueryCoalescer.restart() }
|
||||||
|
function onArtistsSortChanged() { nativeQueryCoalescer.restart() }
|
||||||
|
function onArtistsFilterChanged() { nativeQueryCoalescer.restart() }
|
||||||
function onSelectedArtistChanged() { detailQueryCoalescer.restart() }
|
function onSelectedArtistChanged() { detailQueryCoalescer.restart() }
|
||||||
function onArtworkRefresh() { root.reportSoon() }
|
function onArtworkRefresh() { root.reportSoon() }
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,10 @@ Item {
|
|||||||
id: nativeQueryCoalescer
|
id: nativeQueryCoalescer
|
||||||
interval: 0
|
interval: 0
|
||||||
onTriggered: if (root.view)
|
onTriggered: if (root.view)
|
||||||
QbzLocal.artistsNativeReset(root.view.artistsSearch)
|
QbzLocal.artistsNativeReset(
|
||||||
|
root.view.artistsSearch,
|
||||||
|
root.view.artistsSort,
|
||||||
|
JSON.stringify(root.view.artistsFilter || ({})))
|
||||||
}
|
}
|
||||||
Timer {
|
Timer {
|
||||||
id: detailQueryCoalescer
|
id: detailQueryCoalescer
|
||||||
@@ -387,8 +392,12 @@ Item {
|
|||||||
nativeJumpsJson: "[]"
|
nativeJumpsJson: "[]"
|
||||||
onColsChanged: detailQueryCoalescer.restart()
|
onColsChanged: detailQueryCoalescer.restart()
|
||||||
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
||||||
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
|
onPlayRequested: function (id) {
|
||||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
QbzLocal.playAlbumFiltered(id, JSON.stringify(root.view.artistsFilter || {}), false)
|
||||||
|
}
|
||||||
|
onEnqueueRequested: function (id, m) {
|
||||||
|
QbzLocal.enqueueAlbumFiltered(id, JSON.stringify(root.view.artistsFilter || {}), m)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,12 +199,18 @@ Item {
|
|||||||
// click that can only fail. `ephemeralActive` is the same flag the
|
// click that can only fail. `ephemeralActive` is the same flag the
|
||||||
// pane itself keys on, so the tab and its body cannot disagree.
|
// pane itself keys on, so the tab and its body cannot disagree.
|
||||||
tabs: {
|
tabs: {
|
||||||
var t = [
|
var byId = {
|
||||||
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
|
"albums": { "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 },
|
"artists": { "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 },
|
"genres": { "id": "genres", "label": QbzSession.tr("Library Explorer", QbzSession.trRev), "count": root.view.genreNames.length },
|
||||||
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 },
|
"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)
|
if (root.view.ephemeralActive)
|
||||||
t.push({ "id": "ephemeral",
|
t.push({ "id": "ephemeral",
|
||||||
"label": root.view.ephemeralLabel,
|
"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,
|
qualityRow.implicitWidth,
|
||||||
formatRow1.implicitWidth,
|
formatRow1.implicitWidth,
|
||||||
formatRow2.implicitWidth,
|
formatRow2.implicitWidth,
|
||||||
|
favoriteRow.visible ? favoriteRow.implicitWidth : 0,
|
||||||
sourceRow.implicitWidth)
|
sourceRow.implicitWidth)
|
||||||
width: Math.min(Math.max(372, card.contentWidth + 32),
|
width: Math.min(Math.max(372, card.contentWidth + 32),
|
||||||
Math.max(372, root.width - 72))
|
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 ----
|
// ---- Quality ----
|
||||||
Text {
|
Text {
|
||||||
text: QbzSession.tr("Quality", QbzSession.trRev)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ Row {
|
|||||||
id: albumsSearchBox
|
id: albumsSearchBox
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||||
|
text: root.view ? root.view.albumsSearch : ""
|
||||||
// DEBOUNCED, like the Tracks box below — which had it and this one
|
// DEBOUNCED, like the Tracks box below — which had it and this one
|
||||||
// did not, in the same file.
|
// did not, in the same file.
|
||||||
//
|
//
|
||||||
@@ -102,7 +103,7 @@ Row {
|
|||||||
//
|
//
|
||||||
// The box keeps showing what was typed immediately; only the
|
// The box keeps showing what was typed immediately; only the
|
||||||
// filter waits.
|
// filter waits.
|
||||||
property string pending: ""
|
property string pending: root.view ? root.view.albumsSearch : ""
|
||||||
onEdited: function (v) {
|
onEdited: function (v) {
|
||||||
albumsSearchBox.pending = v
|
albumsSearchBox.pending = v
|
||||||
albumsSearchDebounce.restart()
|
albumsSearchDebounce.restart()
|
||||||
@@ -149,76 +150,10 @@ Row {
|
|||||||
QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder")
|
QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Quality/format/source filter popup trigger + active-count badge.
|
LocalFilterButton {
|
||||||
Item {
|
|
||||||
width: 34
|
|
||||||
height: 30
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
Rectangle {
|
view: root.view
|
||||||
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
|
|
||||||
ownerKey: "local-albums-filter"
|
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 {
|
QbzSegToggle {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -247,6 +182,7 @@ Row {
|
|||||||
LocalSearchBox {
|
LocalSearchBox {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||||
|
text: root.view ? root.view.tracksSearch : ""
|
||||||
onEdited: function (v) {
|
onEdited: function (v) {
|
||||||
root.view.tracksSearch = v
|
root.view.tracksSearch = v
|
||||||
tracksSearchDebounce.restart()
|
tracksSearchDebounce.restart()
|
||||||
@@ -282,6 +218,11 @@ Row {
|
|||||||
// global across every keyset page.
|
// global across every keyset page.
|
||||||
onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) }
|
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
|
// Multi-select LAST — never to the LEFT of a search box. The expandable
|
||||||
// field keeps a 30px footprint and grows its inner box LEFT
|
// field keeps a 30px footprint and grows its inner box LEFT
|
||||||
// (QbzLineEdit.qml:157-161, `x: root.width - width`), so anything
|
// (QbzLineEdit.qml:157-161, `x: root.width - width`), so anything
|
||||||
@@ -310,6 +251,7 @@ Row {
|
|||||||
LocalSearchBox {
|
LocalSearchBox {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||||
|
text: root.view ? root.view.foldersSearch : ""
|
||||||
onEdited: function (v) { root.view.foldersSearch = v }
|
onEdited: function (v) { root.view.foldersSearch = v }
|
||||||
}
|
}
|
||||||
LocalIconSelect {
|
LocalIconSelect {
|
||||||
@@ -353,16 +295,20 @@ Row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================ ARTISTS ================================
|
// ============================ ARTISTS ================================
|
||||||
LocalSearchBox {
|
Row {
|
||||||
visible: root.view && root.view.activeTab === "artists"
|
visible: root.view && root.view.activeTab === "artists"
|
||||||
&& ((QbzLocal.localArtistsNativeActive
|
&& ((QbzLocal.localArtistsNativeActive
|
||||||
? QbzLocal.localArtistsNativeTotal > 0
|
? QbzLocal.localArtistsNativeTotal > 0
|
||||||
: root.view.artists.length > 0)
|
: root.view.artists.length > 0)
|
||||||
|| root.view.artistsSearch !== ""
|
|| root.view.artistsSearch !== ""
|
||||||
|| QbzLocal.localArtistsLoading)
|
|| QbzLocal.localArtistsLoading)
|
||||||
|
spacing: 8
|
||||||
|
height: parent.height
|
||||||
|
LocalSearchBox {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
|
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) {
|
onEdited: function (v) {
|
||||||
pending = v
|
pending = v
|
||||||
artistsSearchDebounce.restart()
|
artistsSearchDebounce.restart()
|
||||||
@@ -373,4 +319,64 @@ Row {
|
|||||||
onTriggered: root.view.artistsSearch = parent.pending
|
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 === ""
|
readonly property var rowItem: root.artSource === ""
|
||||||
? root.item
|
? root.item
|
||||||
: Object.assign({}, root.item, { "artPath": root.artSource })
|
: 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
|
// 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.
|
// 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.
|
// the sidebar drop handler forwards it as a Qobuz catalog id.
|
||||||
draggable: false
|
draggable: false
|
||||||
qualityStyle: "icon"
|
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,
|
// 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
|
// the port of TrackRow.slint:58 `multi-select-mode`). This file used
|
||||||
// to overlay its own SelectCheck on the number cell because the
|
// to overlay its own SelectCheck on the number cell because the
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ Rectangle {
|
|||||||
/// [{ version, trackCount, quality, source }]
|
/// [{ version, trackCount, quality, source }]
|
||||||
property var versions: []
|
property var versions: []
|
||||||
property int current: 0
|
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)
|
signal picked(int index)
|
||||||
|
|
||||||
QbzTheme { id: theme }
|
QbzTheme { id: theme }
|
||||||
@@ -36,12 +40,14 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
height: 30
|
height: 30
|
||||||
width: row.width
|
width: compact ? 28 : row.width
|
||||||
radius: 6
|
radius: 6
|
||||||
color: pickArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
color: pickArea.containsMouse && versions.length > 1
|
||||||
|
? theme.surfaceHover : (compact ? "transparent" : theme.surfaceElevated)
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: row
|
id: row
|
||||||
|
visible: !root.compact
|
||||||
height: parent.height
|
height: parent.height
|
||||||
leftPadding: 10
|
leftPadding: 10
|
||||||
rightPadding: 8
|
rightPadding: 8
|
||||||
@@ -71,62 +77,101 @@ Rectangle {
|
|||||||
tintName: "muted"
|
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 {
|
MouseArea {
|
||||||
id: pickArea
|
id: pickArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
enabled: root.versions.length > 1
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: menu.openBelowRight(pickArea)
|
onClicked: {
|
||||||
|
versionMenuLoader.active = true
|
||||||
|
versionMenuLoader.item.openBelowRight(pickArea)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QbzContextMenu {
|
// The popup and every option delegate are cold until the user asks for
|
||||||
id: menu
|
// them. Genres Details mounts a picker per visible album, while AlbumView
|
||||||
menuWidth: 380
|
// mounts one; neither should pay for an invisible Controls.Popup tree.
|
||||||
Repeater {
|
Loader {
|
||||||
model: root.versions
|
id: versionMenuLoader
|
||||||
delegate: Rectangle {
|
active: false
|
||||||
id: opt
|
sourceComponent: QbzContextMenu {
|
||||||
required property var modelData
|
id: versionMenu
|
||||||
required property int index
|
menuWidth: 380
|
||||||
width: parent ? parent.width : 0
|
Repeater {
|
||||||
height: 30
|
model: root.versions
|
||||||
radius: 6
|
delegate: Rectangle {
|
||||||
color: index === root.current ? theme.surfaceElevated
|
id: opt
|
||||||
: optArea.containsMouse ? theme.surfaceHover : "transparent"
|
required property var modelData
|
||||||
Row {
|
required property int index
|
||||||
anchors.fill: parent
|
width: parent ? parent.width : 0
|
||||||
anchors.leftMargin: 8
|
height: 30
|
||||||
anchors.rightMargin: 8
|
radius: 6
|
||||||
spacing: 8
|
color: index === root.current ? theme.surfaceElevated
|
||||||
SourceIcon {
|
: optArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
Row {
|
||||||
kind: opt.modelData.source || "local"
|
anchors.fill: parent
|
||||||
mono: true
|
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 {
|
MouseArea {
|
||||||
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
|
id: optArea
|
||||||
height: parent.height
|
anchors.fill: parent
|
||||||
text: root.optionText(opt.modelData)
|
hoverEnabled: true
|
||||||
color: theme.textPrimary
|
cursorShape: Qt.PointingHandCursor
|
||||||
font.pixelSize: theme.fontLegal
|
onClicked: {
|
||||||
verticalAlignment: Text.AlignVCenter
|
versionMenu.close()
|
||||||
elide: Text.ElideRight
|
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ pub mod qbz_bridge {
|
|||||||
// Blacklist. This is Slint's `SettingsState.section`, which is a global
|
// Blacklist. This is Slint's `SettingsState.section`, which is a global
|
||||||
// for the same reason (SettingsView.slint:121-200).
|
// for the same reason (SettingsView.slint:121-200).
|
||||||
#[qproperty(i32, settings_section)]
|
#[qproperty(i32, settings_section)]
|
||||||
|
|
||||||
// --- Search: MOVED (cortinilla-parity contract C0) -----------------
|
// --- Search: MOVED (cortinilla-parity contract C0) -----------------
|
||||||
// cortinilla_* + search_json + intelligent_search -> QbzSearch
|
// cortinilla_* + search_json + intelligent_search -> QbzSearch
|
||||||
// (search_bridge.rs). `selected_index` was RENAMED on the way out to
|
// (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
|
// ONE JSON document (playlist_qt.rs PlaylistDoc: header + track
|
||||||
// rows + ownership/follow/pin/sort/search state).
|
// rows + ownership/follow/pin/sort/search state).
|
||||||
#[qproperty(QString, playlist_json)]
|
#[qproperty(QString, playlist_json)]
|
||||||
|
|
||||||
// --- Filter by genre (shared, per context) -------------------------
|
// --- Filter by genre (shared, per context) -------------------------
|
||||||
// ONE JSON document (genre_filter_qt.rs FilterDoc): the popup model
|
// ONE JSON document (genre_filter_qt.rs FilterDoc): the popup model
|
||||||
// (chips / tree / remember / advanced / loading) PLUS the per-context
|
// (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
|
// "discover", server-side via get_discover_index) and Library > All
|
||||||
// (context "library-all", client-side over the feed).
|
// (context "library-all", client-side over the feed).
|
||||||
#[qproperty(QString, genre_filter_json)]
|
#[qproperty(QString, genre_filter_json)]
|
||||||
|
|
||||||
// --- Discover section configurator (the toolbar gear) --------------
|
// --- Discover section configurator (the toolbar gear) --------------
|
||||||
// ONE JSON document (discover_config_qt.rs ConfigDoc: the active
|
// ONE JSON document (discover_config_qt.rs ConfigDoc: the active
|
||||||
// tab's ordered rows + enabled/total counts).
|
// tab's ordered rows + enabled/total counts).
|
||||||
#[qproperty(QString, discover_config_json)]
|
#[qproperty(QString, discover_config_json)]
|
||||||
|
|
||||||
type QbzBridge = super::QbzBridgeRust;
|
type QbzBridge = super::QbzBridgeRust;
|
||||||
|
|
||||||
/// TEMP (phase 23 split): registers the QbzBridge Qt-thread
|
/// TEMP (phase 23 split): registers the QbzBridge Qt-thread
|
||||||
@@ -244,16 +240,18 @@ pub struct QbzBridgeRust {
|
|||||||
impl Default for QbzBridgeRust {
|
impl Default for QbzBridgeRust {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
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.
|
// 0 = Audio, the section Settings opens on.
|
||||||
settings_section: 0,
|
settings_section: 0,
|
||||||
playlist_json: QString::from("{}"),
|
playlist_json: QString::from("{}"),
|
||||||
// Seeded from the PERSISTED selection (no network): a remembered
|
// Seeded from the PERSISTED selection (no network): a remembered
|
||||||
// filter colors both genre buttons and narrows the Library feed
|
// filter colors both genre buttons and narrows the Library feed
|
||||||
// before the popup has ever been opened.
|
// before the popup has ever been opened.
|
||||||
genre_filter_json: QString::from(
|
genre_filter_json: QString::from(crate::genre_filter_qt::current_json().as_str()),
|
||||||
crate::genre_filter_qt::current_json().as_str(),
|
|
||||||
),
|
|
||||||
// Seeded for the tab Discover mounts on.
|
// Seeded for the tab Discover mounts on.
|
||||||
discover_config_json: QString::from(
|
discover_config_json: QString::from(
|
||||||
crate::discover_config_qt::rows_json("home").as_str(),
|
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(), §ion_id.to_string());
|
crate::discover_config_qt::toggle_section(&tab.to_string(), §ion_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(), §ion_id.to_string(), dir);
|
crate::discover_config_qt::move_section(&tab.to_string(), §ion_id.to_string(), dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,11 @@
|
|||||||
//! selected version rather than the logical collection.
|
//! selected version rather than the logical collection.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cxx_qt_lib::QString;
|
use cxx_qt_lib::QString;
|
||||||
|
use qbz_app::settings::local_favorites::LocalFavItem;
|
||||||
use qbz_app::shell::AppRuntime;
|
use qbz_app::shell::AppRuntime;
|
||||||
use qbz_core::LoggingAdapter;
|
use qbz_core::LoggingAdapter;
|
||||||
use qbz_library::{AudioFormat, LocalTrack};
|
use qbz_library::{AudioFormat, LocalTrack};
|
||||||
@@ -33,10 +35,10 @@ use qbz_models::QueueTrack;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::local_bridge::ui;
|
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::{
|
use crate::local_rows::{
|
||||||
album_key, badge_source, badge_source_raw, map_track, tier_of, to_json, total_duration,
|
album_favorite_source, album_key, badge_source, badge_source_raw, map_track, tier_of, to_json,
|
||||||
AlbumRow, TrackRow,
|
total_duration, AlbumRow, TrackRow,
|
||||||
};
|
};
|
||||||
use crate::local_state::{state, with_art};
|
use crate::local_state::{state, with_art};
|
||||||
|
|
||||||
@@ -51,6 +53,10 @@ type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
|||||||
/// `qml/views/local/VersionPicker.qml`.
|
/// `qml/views/local/VersionPicker.qml`.
|
||||||
#[derive(Clone, Default, Serialize)]
|
#[derive(Clone, Default, Serialize)]
|
||||||
pub struct AlbumVersion {
|
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.
|
/// "Remastered", "Deluxe Edition", … when metadata makes it inferable.
|
||||||
pub version: String,
|
pub version: String,
|
||||||
#[serde(rename = "trackCount")]
|
#[serde(rename = "trackCount")]
|
||||||
@@ -102,7 +108,6 @@ pub struct DiscRow {
|
|||||||
pub cover: String,
|
pub cover: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// `{album:{…}, tracks:[…], discs:[…]}` — the `localAlbumJson` document.
|
/// `{album:{…}, tracks:[…], discs:[…]}` — the `localAlbumJson` document.
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
pub struct AlbumDetailDoc {
|
pub struct AlbumDetailDoc {
|
||||||
@@ -113,6 +118,23 @@ pub struct AlbumDetailDoc {
|
|||||||
pub discs: Vec<DiscRow>,
|
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)
|
// 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 sample_rate = t.sample_rate.max(0.0) as u64;
|
||||||
let depth = t.bit_depth.unwrap_or(0);
|
let depth = t.bit_depth.unwrap_or(0);
|
||||||
let tier = if t.format == AudioFormat::Dsd
|
let tier = if t.format == AudioFormat::Dsd || (lossless && (depth > 16 || sample_rate > 48_000))
|
||||||
|| (lossless && (depth > 16 || sample_rate > 48_000))
|
|
||||||
{
|
{
|
||||||
3
|
3
|
||||||
} else if lossless {
|
} else if lossless {
|
||||||
@@ -180,11 +201,21 @@ pub fn split_versions(tracks: Vec<LocalTrack>) -> Vec<(String, Vec<LocalTrack>)>
|
|||||||
fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
|
fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
|
||||||
match tracks.iter().max_by_key(|track| version_rank(track)) {
|
match tracks.iter().max_by_key(|track| version_rank(track)) {
|
||||||
Some(t) => {
|
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 =
|
let detail =
|
||||||
crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
|
crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
|
||||||
let fmt = t.format.to_string();
|
let fmt = t.format.to_string();
|
||||||
let raw_source = badge_source_raw(t.source.as_deref());
|
let raw_source = badge_source_raw(t.source.as_deref());
|
||||||
AlbumVersion {
|
AlbumVersion {
|
||||||
|
key: format!("{:016x}", identity.finish()),
|
||||||
version: crate::local_albums::edition_descriptor(&t.album_group_title),
|
version: crate::local_albums::edition_descriptor(&t.album_group_title),
|
||||||
track_count: tracks.len() as u32,
|
track_count: tracks.len() as u32,
|
||||||
quality: if detail.is_empty() {
|
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
|
/// Open `id`: cache its versions, select the best-quality one and return its
|
||||||
/// document. Called by `local_albums::load_album_detail_blocking` (BLOCKING
|
/// document. Called by `local_albums::load_album_detail_blocking` (BLOCKING
|
||||||
/// context — no Qt, no await).
|
/// 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
|
// 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`).
|
// 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| {
|
state(|s| {
|
||||||
s.album_id = id.to_string();
|
s.album_id = id.to_string();
|
||||||
s.album_versions = versions;
|
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
|
/// Build the document for version `index` of the OPEN album. Reads the cached
|
||||||
/// split — no DB round-trip (the Slint's `apply_album_version`).
|
/// split — no DB round-trip (the Slint's `apply_album_version`).
|
||||||
pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
|
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
|
let infos: Vec<AlbumVersion> = s
|
||||||
.album_versions
|
.album_versions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -237,25 +314,28 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
|
|||||||
.get(index)
|
.get(index)
|
||||||
.map(|(_, v)| v.clone())
|
.map(|(_, v)| v.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(infos, tracks)
|
let artwork = version_artwork_ref(&tracks, &s.album_versions);
|
||||||
|
(infos, tracks, artwork)
|
||||||
});
|
});
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
return None;
|
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
|
// Register the album cover + the per-row sources in the art index (the
|
||||||
// windowed artwork channel resolves them; nothing rides the document).
|
// windowed artwork channel resolves them; nothing rides the document).
|
||||||
let rows = with_art(|art| {
|
let rows = with_art(|art| {
|
||||||
if let Some(p) = tracks
|
if let Some((source, path)) = artwork.as_ref() {
|
||||||
.iter()
|
if let Some(token) = crate::local_rows::art_token(source.as_deref(), path) {
|
||||||
.find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty()))
|
art.insert(row.art_key.clone(), token);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
} 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);
|
let discs = disc_rows(&tracks, &row.title);
|
||||||
Some(AlbumDetailDoc {
|
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.
|
/// 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.
|
/// 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;
|
use std::collections::BTreeMap;
|
||||||
let mut first: BTreeMap<u32, &LocalTrack> = BTreeMap::new();
|
let mut first: BTreeMap<u32, &LocalTrack> = BTreeMap::new();
|
||||||
for t in tracks {
|
for t in tracks {
|
||||||
@@ -315,6 +395,66 @@ fn disc_rows(tracks: &[LocalTrack], group_title: &str) -> Vec<DiscRow> {
|
|||||||
.collect()
|
.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
|
/// The header for ONE version — recomputed per version (the Slint recomputes
|
||||||
/// title / artist / info-line / quality in `apply_album_version`, because two
|
/// title / artist / info-line / quality in `apply_album_version`, because two
|
||||||
/// copies of the same album differ exactly in those). `directoryPath` and
|
/// 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 artist_of = |t: &LocalTrack| t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
|
||||||
let lead = artist_of(first);
|
let lead = artist_of(first);
|
||||||
let artist = if tracks.iter().all(|t| artist_of(t) == lead) {
|
let artist = if tracks.iter().all(|t| artist_of(t) == lead) {
|
||||||
lead
|
lead.clone()
|
||||||
} else {
|
} else {
|
||||||
qbz_i18n::t("Various Artists")
|
qbz_i18n::t("Various Artists")
|
||||||
};
|
};
|
||||||
@@ -350,11 +490,37 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
|
|||||||
.find(|a| a.id == id)
|
.find(|a| a.id == id)
|
||||||
.cloned()
|
.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 {
|
AlbumRow {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: first.album_group_title.clone(),
|
title: first.album_group_title.clone(),
|
||||||
artist,
|
artist,
|
||||||
all_artists: all_artists.join(", "),
|
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(),
|
year: first.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||||
track_count: tracks.len() as u32,
|
track_count: tracks.len() as u32,
|
||||||
duration: total_duration(tracks.iter().map(|t| t.duration_secs).sum()),
|
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,
|
best.sample_rate,
|
||||||
),
|
),
|
||||||
format: best.format.to_string(),
|
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),
|
art_key: album_key(id),
|
||||||
source: badge_source(first.source.as_deref()),
|
source: badge_source(first.source.as_deref()),
|
||||||
sources: card.as_ref().map(|c| c.sources.clone()).unwrap_or_else(|| {
|
sources,
|
||||||
let mut values = Vec::new();
|
source_raw: badge_source_raw(first.source.as_deref()),
|
||||||
for track in state(|s| {
|
directory_path: card
|
||||||
s.album_versions
|
.as_ref()
|
||||||
.iter()
|
.map(|c| c.directory_path.clone())
|
||||||
.filter_map(|(_, tracks)| tracks.first().cloned())
|
.unwrap_or_default(),
|
||||||
.collect::<Vec<_>>()
|
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
|
||||||
}) {
|
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", id),
|
||||||
let raw = badge_source_raw(track.source.as_deref());
|
favoriteable,
|
||||||
let source = if raw.is_empty() {
|
}
|
||||||
badge_source(track.source.as_deref())
|
}
|
||||||
} else {
|
|
||||||
raw
|
/// 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
|
||||||
if !values.contains(&source) {
|
/// source guard made explicit so a remote-only row cannot write a bogus local
|
||||||
values.push(source);
|
/// 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
|
});
|
||||||
}),
|
ui(move |mut bridge| {
|
||||||
source_raw: badge_source_raw(first.source.as_deref()),
|
bridge
|
||||||
directory_path: card.as_ref().map(|c| c.directory_path.clone()).unwrap_or_default(),
|
.as_mut()
|
||||||
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
|
.local_album_favorite_changed(QString::from(id.as_str()), favorite);
|
||||||
}
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -427,6 +650,21 @@ fn current_disc_tracks(disc: i32) -> Vec<LocalTrack> {
|
|||||||
.collect()
|
.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
|
// Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -454,17 +692,92 @@ pub fn select_version(index: i32) {
|
|||||||
/// ("play" | "next" | "later" | "queue") over THIS disc's tracks only, reusing
|
/// ("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.
|
/// the same queue ops as the header play button and the per-row menu.
|
||||||
pub fn disc_action(disc: i32, action: String) {
|
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() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let runtime = crate::app();
|
let runtime = crate::app();
|
||||||
crate::spawn(async move {
|
crate::spawn(async move {
|
||||||
match action.as_str() {
|
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,
|
"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
|
/// "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.
|
/// 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
|
/// local-only batch is never Qobuz-castable, so the predicate is always false
|
||||||
/// (Slint local_album_actions.rs:486-494 is likewise unhooked).
|
/// (Slint local_album_actions.rs:486-494 is likewise unhooked).
|
||||||
async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: &str) {
|
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();
|
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -718,4 +1019,205 @@ mod version_tests {
|
|||||||
assert_eq!(info.source, "subsonic");
|
assert_eq!(info.source, "subsonic");
|
||||||
assert!(info.quality.contains("FLAC"));
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,12 @@ use qbz_library::{AlbumTrackEvidence, AudioFormat, LocalAlbum, LocalTrack};
|
|||||||
|
|
||||||
use crate::local_album_actions::AlbumDetailDoc;
|
use crate::local_album_actions::AlbumDetailDoc;
|
||||||
use crate::local_artist_match::{
|
use crate::local_artist_match::{
|
||||||
album_matches_artist_with_aliases, build_artist_family_aliases, merge_artists,
|
album_credit_names, album_matches_artist_with_aliases, build_artist_family_aliases,
|
||||||
normalize_artist, AlbumCredit, ArtistInput,
|
merge_artists, normalize_artist, AlbumCredit, ArtistInput,
|
||||||
};
|
};
|
||||||
use crate::local_rows::{
|
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::{
|
use crate::local_state::{
|
||||||
commit_tracks_page, group_mode, state, tracks_generation, with_art, with_db,
|
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 total = albums.len() as u64;
|
||||||
let n = albums.len();
|
let n = albums.len();
|
||||||
let t1 = std::time::Instant::now();
|
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| {
|
let rows = with_art(|art| {
|
||||||
albums
|
albums
|
||||||
.into_iter()
|
.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>>()
|
.collect::<Vec<AlbumRow>>()
|
||||||
});
|
});
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -159,7 +171,53 @@ fn coalesce_album_versions(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|album| album.id.clone())
|
.map(|album| album.id.clone())
|
||||||
.collect::<Vec<_>>();
|
.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);
|
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() {
|
if !group.is_empty() {
|
||||||
let logical_id = logical_album_id(&ids);
|
let logical_id = logical_album_id(&ids);
|
||||||
let mut source_words = std::iter::once(&representative)
|
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> {
|
pub fn load_folders_blocking() -> Result<Vec<AlbumRow>, String> {
|
||||||
let albums = with_db(|db| {
|
let albums = with_db(|db| {
|
||||||
db.get_albums_with_full_filter(
|
db.get_albums_with_full_filter(
|
||||||
/* include_hidden */ false,
|
/* include_hidden */ false, /* include_qobuz_downloads */ true,
|
||||||
/* include_qobuz_downloads */ true,
|
|
||||||
/* exclude_network_folders */ false,
|
/* 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.
|
// so an artist's album count matches the grid the user sees.
|
||||||
let (artists, albums, custom) = with_db(|db| {
|
let (artists, albums, custom) = with_db(|db| {
|
||||||
let artists = db.get_artists_with_filter(
|
let artists = db.get_artists_with_filter(
|
||||||
/* include_qobuz_downloads */ true,
|
/* include_qobuz_downloads */ true, /* exclude_network_folders */ false,
|
||||||
/* exclude_network_folders */ false,
|
|
||||||
)?;
|
)?;
|
||||||
let albums = if plex_on || remote_on {
|
let albums = if plex_on || remote_on {
|
||||||
db.get_albums_metadata_page(
|
db.get_albums_metadata_page(
|
||||||
@@ -424,8 +480,7 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
|
|||||||
.albums
|
.albums
|
||||||
} else {
|
} else {
|
||||||
db.get_albums_with_full_filter(
|
db.get_albums_with_full_filter(
|
||||||
/* include_hidden */ false,
|
/* include_hidden */ false, /* include_qobuz_downloads */ true,
|
||||||
/* include_qobuz_downloads */ true,
|
|
||||||
/* exclude_network_folders */ false,
|
/* exclude_network_folders */ false,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
@@ -495,6 +550,19 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
|
|||||||
plex_on || remote_on,
|
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
|
// The artwork index is keyed on the DISPLAY name (`artist:{name}`), so the
|
||||||
// portrait is registered under the CANONICAL spelling the row carries —
|
// portrait is registered under the CANONICAL spelling the row carries —
|
||||||
// registering it under the Plex spelling is what left a merged row with a
|
// 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);
|
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 {
|
ArtistRow {
|
||||||
art_key: key,
|
art_key: key,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
album_count: m.album_count,
|
album_count: m.album_count,
|
||||||
track_count: m.track_count,
|
track_count: m.track_count,
|
||||||
source: m.source,
|
source: m.source,
|
||||||
|
sources,
|
||||||
|
formats,
|
||||||
|
quality_tiers,
|
||||||
|
year: years.last().map(u32::to_string).unwrap_or_default(),
|
||||||
|
years,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -637,26 +755,38 @@ pub fn load_tracks_page_blocking(
|
|||||||
let effective_sort = effective_tracks_sort(&request.sort, &request.group);
|
let effective_sort = effective_tracks_sort(&request.sort, &request.group);
|
||||||
let query_started = std::time::Instant::now();
|
let query_started = std::time::Instant::now();
|
||||||
// No library.db is valid for a remote-only installation.
|
// No library.db is valid for a remote-only installation.
|
||||||
let local = with_db(|db| {
|
let local =
|
||||||
db.search_with_filter_page(
|
if request.filter.source_enabled("local") || request.filter.source_enabled("offline") {
|
||||||
request.query.trim(),
|
with_db(|db| {
|
||||||
request.offsets.local,
|
db.search_with_filter_page_faceted(
|
||||||
candidate_limit,
|
request.query.trim(),
|
||||||
true,
|
request.offsets.local,
|
||||||
false,
|
candidate_limit,
|
||||||
effective_sort,
|
true,
|
||||||
)
|
false,
|
||||||
})
|
effective_sort,
|
||||||
.unwrap_or_default();
|
&request.filter.formats,
|
||||||
|
request.filter.other_formats,
|
||||||
|
&request.filter.qualities,
|
||||||
|
&request.filter.sources,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
if tracks_generation() != request.generation {
|
if tracks_generation() != request.generation {
|
||||||
return Ok(None);
|
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(
|
crate::local_plex::search_tracks_page(
|
||||||
&request.query,
|
&request.query,
|
||||||
request.offsets.plex,
|
request.offsets.plex,
|
||||||
candidate_limit,
|
candidate_limit,
|
||||||
effective_sort,
|
effective_sort,
|
||||||
|
&request.filter.formats,
|
||||||
|
request.filter.other_formats,
|
||||||
|
&request.filter.qualities,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -664,23 +794,37 @@ pub fn load_tracks_page_blocking(
|
|||||||
if tracks_generation() != request.generation {
|
if tracks_generation() != request.generation {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let jellyfin = crate::media_servers_qt::search_tracks_page(
|
let jellyfin = if request.filter.source_enabled("jellyfin") {
|
||||||
"jellyfin",
|
crate::media_servers_qt::search_tracks_page(
|
||||||
&request.query,
|
"jellyfin",
|
||||||
request.offsets.jellyfin,
|
&request.query,
|
||||||
candidate_limit,
|
request.offsets.jellyfin,
|
||||||
effective_sort,
|
candidate_limit,
|
||||||
);
|
effective_sort,
|
||||||
|
&request.filter.formats,
|
||||||
|
request.filter.other_formats,
|
||||||
|
&request.filter.qualities,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
if tracks_generation() != request.generation {
|
if tracks_generation() != request.generation {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let subsonic = crate::media_servers_qt::search_tracks_page(
|
let subsonic = if request.filter.source_enabled("subsonic") {
|
||||||
"subsonic",
|
crate::media_servers_qt::search_tracks_page(
|
||||||
&request.query,
|
"subsonic",
|
||||||
request.offsets.subsonic,
|
&request.query,
|
||||||
candidate_limit,
|
request.offsets.subsonic,
|
||||||
effective_sort,
|
candidate_limit,
|
||||||
);
|
effective_sort,
|
||||||
|
&request.filter.formats,
|
||||||
|
request.filter.other_formats,
|
||||||
|
&request.filter.qualities,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
let query_time = query_started.elapsed();
|
let query_time = query_started.elapsed();
|
||||||
let candidates = TrackSourceCounts {
|
let candidates = TrackSourceCounts {
|
||||||
local: local.len(),
|
local: local.len(),
|
||||||
@@ -695,10 +839,22 @@ pub fn load_tracks_page_blocking(
|
|||||||
let merge_started = std::time::Instant::now();
|
let merge_started = std::time::Instant::now();
|
||||||
let merged = merge_track_pages(
|
let merged = merge_track_pages(
|
||||||
vec![
|
vec![
|
||||||
CandidatePage { source: TrackSourcePage::Local, rows: local },
|
CandidatePage {
|
||||||
CandidatePage { source: TrackSourcePage::Plex, rows: plex },
|
source: TrackSourcePage::Local,
|
||||||
CandidatePage { source: TrackSourcePage::Jellyfin, rows: jellyfin },
|
rows: local,
|
||||||
CandidatePage { source: TrackSourcePage::Subsonic, rows: subsonic },
|
},
|
||||||
|
CandidatePage {
|
||||||
|
source: TrackSourcePage::Plex,
|
||||||
|
rows: plex,
|
||||||
|
},
|
||||||
|
CandidatePage {
|
||||||
|
source: TrackSourcePage::Jellyfin,
|
||||||
|
rows: jellyfin,
|
||||||
|
},
|
||||||
|
CandidatePage {
|
||||||
|
source: TrackSourcePage::Subsonic,
|
||||||
|
rows: subsonic,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
effective_sort,
|
effective_sort,
|
||||||
TRACKS_PAGE as usize,
|
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);
|
source.bump_count(&mut published);
|
||||||
rows.push(row);
|
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.
|
/// 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
|
/// 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
|
/// `local_album_actions`, which is also what the version picker and the
|
||||||
/// per-disc menu read back.
|
/// 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);
|
let mut tracks = fetch_album_tracks_blocking(id);
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
return None;
|
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
|
// rows AND everything that later reads `detail_raw` (the per-disc menu, the
|
||||||
// bulk bar, `find_track_blocking`) carry the cover the folder has.
|
// bulk bar, `find_track_blocking`) carry the cover the folder has.
|
||||||
crate::local_playback::fill_missing_covers(&mut tracks);
|
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)
|
crate::local_album_actions::open_versions(id, tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,11 +1122,7 @@ mod phase_a_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn native_tie(
|
fn native_tie(source: TrackSourcePage, a: &LocalTrack, b: &LocalTrack) -> std::cmp::Ordering {
|
||||||
source: TrackSourcePage,
|
|
||||||
a: &LocalTrack,
|
|
||||||
b: &LocalTrack,
|
|
||||||
) -> std::cmp::Ordering {
|
|
||||||
match source {
|
match source {
|
||||||
TrackSourcePage::Plex => a.file_path.cmp(&b.file_path),
|
TrackSourcePage::Plex => a.file_path.cmp(&b.file_path),
|
||||||
_ => a.id.cmp(&b.id),
|
_ => a.id.cmp(&b.id),
|
||||||
@@ -1295,7 +1460,9 @@ mod phase_a_tests {
|
|||||||
all_artists: "Talk Talk".to_string(),
|
all_artists: "Talk Talk".to_string(),
|
||||||
year: Some(1988),
|
year: Some(1988),
|
||||||
catalog_number: None,
|
catalog_number: None,
|
||||||
|
genres: Vec::new(),
|
||||||
artwork_path: None,
|
artwork_path: None,
|
||||||
|
artwork_source: None,
|
||||||
track_count: tracks.len() as u32,
|
track_count: tracks.len() as u32,
|
||||||
total_duration_secs: tracks.iter().map(|(_, duration)| duration).sum(),
|
total_duration_secs: tracks.iter().map(|(_, duration)| duration).sum(),
|
||||||
format: AudioFormat::Flac,
|
format: AudioFormat::Flac,
|
||||||
@@ -1357,6 +1524,38 @@ mod phase_a_tests {
|
|||||||
assert_eq!(rows.len(), 2, "title and artist alone must never merge");
|
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]
|
#[test]
|
||||||
fn album_copy_matching_allows_bonus_tracks_but_not_one_shared_compilation_track() {
|
fn album_copy_matching_allows_bonus_tracks_but_not_one_shared_compilation_track() {
|
||||||
let base = [
|
let base = [
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use qbz_source::SourceId;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::local_bridge::ui;
|
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 PAGE_ENTRIES: usize = 100;
|
||||||
const FLAT_QUERY_ROWS: usize = 500;
|
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 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 {
|
NativeAlbum {
|
||||||
row: AlbumRow {
|
row: AlbumRow {
|
||||||
|
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", &id),
|
||||||
|
favoriteable,
|
||||||
id,
|
id,
|
||||||
title: record.title.clone(),
|
title: record.title.clone(),
|
||||||
artist: record.artist.clone(),
|
artist: record.artist.clone(),
|
||||||
all_artists: record.all_artists.clone(),
|
all_artists: record.all_artists.clone(),
|
||||||
|
artists,
|
||||||
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
||||||
track_count: record.track_count,
|
track_count: record.track_count,
|
||||||
duration: total_duration(record.total_duration_ms / 1_000),
|
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,
|
record.sample_rate_hz.unwrap_or(0) as f64,
|
||||||
),
|
),
|
||||||
format: record.format.to_ascii_uppercase(),
|
format: record.format.to_ascii_uppercase(),
|
||||||
|
genres: Vec::new(),
|
||||||
art_key,
|
art_key,
|
||||||
source: badge_source(Some(record.source_raw_or_kind())),
|
source,
|
||||||
sources: vec![badge_source(Some(record.source_raw_or_kind()))],
|
sources,
|
||||||
source_raw: badge_source_raw(Some(record.source_raw_or_kind())),
|
source_raw,
|
||||||
directory_path: record.directory_path.clone(),
|
directory_path: record.directory_path.clone(),
|
||||||
folder_count: record.folder_count,
|
folder_count: record.folder_count,
|
||||||
},
|
},
|
||||||
@@ -1170,9 +1194,7 @@ fn clear_selection_generation(generation: u64) {
|
|||||||
fn set_loading() {
|
fn set_loading() {
|
||||||
ui(|mut bridge| {
|
ui(|mut bridge| {
|
||||||
bridge.as_mut().set_local_albums_loading(true);
|
bridge.as_mut().set_local_albums_loading(true);
|
||||||
bridge
|
bridge.as_mut().set_local_albums_error(QString::default());
|
||||||
.as_mut()
|
|
||||||
.set_local_albums_error(QString::default());
|
|
||||||
bridge
|
bridge
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.set_local_albums_native_error(QString::default());
|
.set_local_albums_native_error(QString::default());
|
||||||
|
|||||||
@@ -284,10 +284,17 @@ pub(crate) fn requested() -> bool {
|
|||||||
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
|
.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() {
|
if !requested() {
|
||||||
return false;
|
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
|
*LAST_SEARCH
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner()) = Some(search.clone());
|
.unwrap_or_else(|error| error.into_inner()) = Some(search.clone());
|
||||||
@@ -325,7 +332,7 @@ pub(crate) fn retry_last() -> bool {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
.clone();
|
.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) {
|
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);
|
cpp_artist_reset(generation, total, artist_total);
|
||||||
let bytes = publish_artist_page_now(generation, 0, &wire);
|
let bytes = publish_artist_page_now(generation, 0, &wire);
|
||||||
bridge.as_mut().set_local_artists_native_active(true);
|
bridge.as_mut().set_local_artists_native_active(true);
|
||||||
bridge
|
bridge.as_mut().set_local_artists_json(QString::from("[]"));
|
||||||
.as_mut()
|
|
||||||
.set_local_artists_json(QString::from("[]"));
|
|
||||||
bridge
|
bridge
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.set_local_artists_native_total(artist_total.min(i64::MAX as u64) as i64);
|
.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,
|
track_count: record.track_count,
|
||||||
art_key,
|
art_key,
|
||||||
source: record.source.clone(),
|
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(),
|
artist_key: record.artist_key.clone(),
|
||||||
art_path: String::new(),
|
art_path: String::new(),
|
||||||
@@ -1344,12 +1354,33 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let format = audio_format(&record.format);
|
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 {
|
NativeAlbum {
|
||||||
row: AlbumRow {
|
row: AlbumRow {
|
||||||
|
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", &id),
|
||||||
|
favoriteable,
|
||||||
id,
|
id,
|
||||||
title: record.title.clone(),
|
title: record.title.clone(),
|
||||||
artist: record.artist.clone(),
|
artist: record.artist.clone(),
|
||||||
all_artists: record.all_artists.clone(),
|
all_artists: record.all_artists.clone(),
|
||||||
|
artists,
|
||||||
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
||||||
track_count: record.track_count,
|
track_count: record.track_count,
|
||||||
duration: total_duration(record.total_duration_ms / 1_000),
|
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,
|
record.sample_rate_hz.unwrap_or(0) as f64,
|
||||||
),
|
),
|
||||||
format: record.format.to_ascii_uppercase(),
|
format: record.format.to_ascii_uppercase(),
|
||||||
|
genres: Vec::new(),
|
||||||
art_key,
|
art_key,
|
||||||
source: badge_source(Some(source_word(record))),
|
source,
|
||||||
sources: vec![badge_source(Some(source_word(record)))],
|
sources,
|
||||||
source_raw: badge_source_raw(Some(source_word(record))),
|
source_raw,
|
||||||
directory_path: record.directory_path.clone(),
|
directory_path: record.directory_path.clone(),
|
||||||
folder_count: record.folder_count,
|
folder_count: record.folder_count,
|
||||||
},
|
},
|
||||||
@@ -1597,6 +1629,11 @@ mod tests {
|
|||||||
track_count: 1,
|
track_count: 1,
|
||||||
art_key: format!("artist:{index}"),
|
art_key: format!("artist:{index}"),
|
||||||
source: "local".to_string(),
|
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}"),
|
artist_key: format!("artist {index}"),
|
||||||
art_path: String::new(),
|
art_path: String::new(),
|
||||||
@@ -1656,6 +1693,11 @@ mod tests {
|
|||||||
_ => "subsonic",
|
_ => "subsonic",
|
||||||
}
|
}
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
formats: Vec::new(),
|
||||||
|
quality_tiers: Vec::new(),
|
||||||
|
years: Vec::new(),
|
||||||
|
year: String::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let legacy_bytes = serde_json::to_vec(&legacy).unwrap().len();
|
let legacy_bytes = serde_json::to_vec(&legacy).unwrap().len();
|
||||||
|
|||||||
@@ -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
|
// `src` is a 500px thumb — its hash is one-way, so the original is
|
||||||
// re-derived from the track row.
|
// re-derived from the track row.
|
||||||
let Some(track) = track_id.and_then(|id| {
|
let Some(track) =
|
||||||
crate::local_state::with_db(|db| db.get_track(id as i64)).flatten()
|
track_id.and_then(|id| crate::local_state::with_db(|db| db.get_track(id as i64)).flatten())
|
||||||
}) else {
|
else {
|
||||||
return Some(path.to_path_buf());
|
return Some(path.to_path_buf());
|
||||||
};
|
};
|
||||||
// Serialize per source (the module-docs race): the large tier shares the
|
// 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
|
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> {
|
fn large_art_for_track(track: &qbz_library::LocalTrack) -> Option<PathBuf> {
|
||||||
let file = Path::new(&track.file_path);
|
let file = Path::new(&track.file_path);
|
||||||
if let Some(bytes) = qbz_library::MetadataExtractor::extract_artwork_bytes(file) {
|
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);
|
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))?;
|
let art = qbz_library::MetadataExtractor::find_folder_artwork(file, Some(&track.album))?;
|
||||||
qbz_library::get_or_generate_large_thumbnail(Path::new(&art)).ok()
|
qbz_library::get_or_generate_large_thumbnail(Path::new(&art)).ok()
|
||||||
}
|
}
|
||||||
+541
-105
@@ -51,7 +51,6 @@ pub mod qbz_local {
|
|||||||
/// Album identity: "folder" | "metadata" (persisted; shared with
|
/// Album identity: "folder" | "metadata" (persisted; shared with
|
||||||
/// the Slint frontend's locallibrary_ui.json).
|
/// the Slint frontend's locallibrary_ui.json).
|
||||||
#[qproperty(QString, local_album_mode)]
|
#[qproperty(QString, local_album_mode)]
|
||||||
|
|
||||||
// --- Albums tab ---------------------------------------------------
|
// --- Albums tab ---------------------------------------------------
|
||||||
#[qproperty(bool, local_albums_loading)]
|
#[qproperty(bool, local_albums_loading)]
|
||||||
#[qproperty(QString, local_albums_error)]
|
#[qproperty(QString, local_albums_error)]
|
||||||
@@ -64,7 +63,6 @@ pub mod qbz_local {
|
|||||||
/// Bounded A-Z jump metadata (`[{letter,index}]`), never album rows.
|
/// Bounded A-Z jump metadata (`[{letter,index}]`), never album rows.
|
||||||
#[qproperty(QString, local_albums_native_jumps_json)]
|
#[qproperty(QString, local_albums_native_jumps_json)]
|
||||||
#[qproperty(QString, local_albums_native_error)]
|
#[qproperty(QString, local_albums_native_error)]
|
||||||
|
|
||||||
// --- Artists tab --------------------------------------------------
|
// --- Artists tab --------------------------------------------------
|
||||||
#[qproperty(bool, local_artists_loading)]
|
#[qproperty(bool, local_artists_loading)]
|
||||||
#[qproperty(QString, local_artists_json)]
|
#[qproperty(QString, local_artists_json)]
|
||||||
@@ -75,11 +73,9 @@ pub mod qbz_local {
|
|||||||
#[qproperty(bool, local_artist_albums_loading)]
|
#[qproperty(bool, local_artist_albums_loading)]
|
||||||
#[qproperty(QString, local_artists_native_jumps_json)]
|
#[qproperty(QString, local_artists_native_jumps_json)]
|
||||||
#[qproperty(QString, local_artists_native_error)]
|
#[qproperty(QString, local_artists_native_error)]
|
||||||
|
|
||||||
// --- Folders tab, FLAT mode ---------------------------------------
|
// --- Folders tab, FLAT mode ---------------------------------------
|
||||||
#[qproperty(bool, local_folders_loading)]
|
#[qproperty(bool, local_folders_loading)]
|
||||||
#[qproperty(QString, local_folders_json)]
|
#[qproperty(QString, local_folders_json)]
|
||||||
|
|
||||||
// --- Folders tab, TREE mode ---------------------------------------
|
// --- Folders tab, TREE mode ---------------------------------------
|
||||||
#[qproperty(bool, local_tree_loading)]
|
#[qproperty(bool, local_tree_loading)]
|
||||||
/// The FLATTENED, search-filtered visible tree — a plain array the
|
/// The FLATTENED, search-filtered visible tree — a plain array the
|
||||||
@@ -91,12 +87,13 @@ pub mod qbz_local {
|
|||||||
#[qproperty(bool, local_detail_loading)]
|
#[qproperty(bool, local_detail_loading)]
|
||||||
/// {path, name, trackCount, subfolders[], tracks[]}
|
/// {path, name, trackCount, subfolders[], tracks[]}
|
||||||
#[qproperty(QString, local_detail_json)]
|
#[qproperty(QString, local_detail_json)]
|
||||||
|
|
||||||
// --- Tracks tab (server-paginated) --------------------------------
|
// --- Tracks tab (server-paginated) --------------------------------
|
||||||
#[qproperty(bool, local_tracks_loading)]
|
#[qproperty(bool, local_tracks_loading)]
|
||||||
#[qproperty(bool, local_tracks_loading_more)]
|
#[qproperty(bool, local_tracks_loading_more)]
|
||||||
#[qproperty(bool, local_tracks_has_more)]
|
#[qproperty(bool, local_tracks_has_more)]
|
||||||
#[qproperty(QString, local_tracks_sort)]
|
#[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
|
/// Tracks-tab grouping: "off" | "album" | "artist" | "name". A
|
||||||
/// CLIENT-side visual reorder (unlike the sort, which is the SQL
|
/// CLIENT-side visual reorder (unlike the sort, which is the SQL
|
||||||
/// ORDER BY), but persisted in the SAME locallibrary_ui.json key the
|
/// 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.
|
/// Bounded A-Z jump metadata (`[{letter,index}]`), never track rows.
|
||||||
#[qproperty(QString, local_tracks_native_jumps_json)]
|
#[qproperty(QString, local_tracks_native_jumps_json)]
|
||||||
#[qproperty(QString, local_tracks_native_error)]
|
#[qproperty(QString, local_tracks_native_error)]
|
||||||
|
|
||||||
// --- Local album detail (the album pane) ---------------------------
|
// --- Local album detail (the album pane) ---------------------------
|
||||||
#[qproperty(bool, local_album_loading)]
|
#[qproperty(bool, local_album_loading)]
|
||||||
/// {album:{...}, tracks:[...]} — "" while nothing is open.
|
/// {album:{...}, tracks:[...]} — "" while nothing is open.
|
||||||
@@ -188,7 +184,6 @@ pub mod qbz_local {
|
|||||||
/// destination, index, count, fraction, overall, tracks}`. Rate-limited
|
/// destination, index, count, fraction, overall, tracks}`. Rate-limited
|
||||||
/// to ~10 Hz at the source — the rip callback fires per chunk.
|
/// to ~10 Hz at the source — the rip callback fires per chunk.
|
||||||
#[qproperty(QString, local_rip_status)]
|
#[qproperty(QString, local_rip_status)]
|
||||||
|
|
||||||
// --- Plex ----------------------------------------------------------
|
// --- Plex ----------------------------------------------------------
|
||||||
/// Master toggle (Settings > Local Library > Plex). Drives whether
|
/// Master toggle (Settings > Local Library > Plex). Drives whether
|
||||||
/// the browse union includes Plex at all + the source filter chip.
|
/// the browse union includes Plex at all + the source filter chip.
|
||||||
@@ -241,7 +236,6 @@ pub mod qbz_local {
|
|||||||
#[qproperty(QString, pin_auth_url)]
|
#[qproperty(QString, pin_auth_url)]
|
||||||
/// The pin/start request is in flight ("Working...").
|
/// The pin/start request is in flight ("Working...").
|
||||||
#[qproperty(bool, pin_busy)]
|
#[qproperty(bool, pin_busy)]
|
||||||
|
|
||||||
type QbzLocal = super::QbzLocalRust;
|
type QbzLocal = super::QbzLocalRust;
|
||||||
|
|
||||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
|
||||||
@@ -272,17 +266,9 @@ pub mod qbz_local {
|
|||||||
columns: i32,
|
columns: i32,
|
||||||
);
|
);
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn albums_native_page_miss(
|
fn albums_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
page: i32,
|
|
||||||
generation: i32,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn albums_native_toggle_select(
|
fn albums_native_toggle_select(self: Pin<&mut QbzLocal>, index: i64, shift: bool);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
index: i64,
|
|
||||||
shift: bool,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn albums_native_select_all(self: Pin<&mut QbzLocal>);
|
fn albums_native_select_all(self: Pin<&mut QbzLocal>);
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
@@ -292,25 +278,18 @@ pub mod qbz_local {
|
|||||||
|
|
||||||
// --- Artists tab (native paged models) ---------------------------
|
// --- Artists tab (native paged models) ---------------------------
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn artists_native_reset(self: Pin<&mut QbzLocal>, search: QString);
|
fn artists_native_reset(
|
||||||
#[qinvokable]
|
|
||||||
fn artists_native_page_miss(
|
|
||||||
self: Pin<&mut QbzLocal>,
|
self: Pin<&mut QbzLocal>,
|
||||||
page: i32,
|
search: QString,
|
||||||
generation: i32,
|
sort: QString,
|
||||||
|
filter_json: QString,
|
||||||
);
|
);
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn artists_native_select(
|
fn artists_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
artist: QString,
|
|
||||||
columns: i32,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn artist_albums_native_page_miss(
|
fn artists_native_select(self: Pin<&mut QbzLocal>, artist: QString, columns: i32);
|
||||||
self: Pin<&mut QbzLocal>,
|
#[qinvokable]
|
||||||
page: i32,
|
fn artist_albums_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
|
||||||
generation: i32,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Tracks tab ----------------------------------------------------
|
// --- Tracks tab ----------------------------------------------------
|
||||||
/// Toolbar search (server-side; resets to page 1).
|
/// 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).
|
/// Toolbar sort (SQL ORDER BY; resets to page 1).
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_set_sort(self: Pin<&mut QbzLocal>, sort: QString);
|
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 +
|
/// Toolbar grouping ("off" | "album" | "artist" | "name"): persist +
|
||||||
/// republish. NO re-query — the group modes reorder what is already
|
/// republish. NO re-query — the group modes reorder what is already
|
||||||
/// loaded, on top of the SQL sort.
|
/// loaded, on top of the SQL sort.
|
||||||
@@ -329,17 +310,9 @@ pub mod qbz_local {
|
|||||||
fn tracks_load_more(self: Pin<&mut QbzLocal>);
|
fn tracks_load_more(self: Pin<&mut QbzLocal>);
|
||||||
/// QAbstractListModel page miss -> async catalog keyset page.
|
/// QAbstractListModel page miss -> async catalog keyset page.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_page_miss(
|
fn tracks_native_page_miss(self: Pin<&mut QbzLocal>, page: i32, generation: i32);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
page: i32,
|
|
||||||
generation: i32,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_toggle_select(
|
fn tracks_native_toggle_select(self: Pin<&mut QbzLocal>, index: i64, shift: bool);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
index: i64,
|
|
||||||
shift: bool,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_select_all(self: Pin<&mut QbzLocal>);
|
fn tracks_native_select_all(self: Pin<&mut QbzLocal>);
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
@@ -349,17 +322,9 @@ pub mod qbz_local {
|
|||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_play(self: Pin<&mut QbzLocal>, index: i64);
|
fn tracks_native_play(self: Pin<&mut QbzLocal>, index: i64);
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_enqueue(
|
fn tracks_native_enqueue(self: Pin<&mut QbzLocal>, index: i64, mode: QString);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
index: i64,
|
|
||||||
mode: QString,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_row_action(
|
fn tracks_native_row_action(self: Pin<&mut QbzLocal>, index: i64, action: QString);
|
||||||
self: Pin<&mut QbzLocal>,
|
|
||||||
index: i64,
|
|
||||||
action: QString,
|
|
||||||
);
|
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tracks_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
|
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);
|
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
|
||||||
/// Albums-grid / Tracks-table bulk bar. scope = "album" | "track".
|
/// Albums-grid / Tracks-table bulk bar. scope = "album" | "track".
|
||||||
#[qinvokable]
|
#[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.
|
/// Row body: select a folder and load its detail pane.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn select_folder(self: Pin<&mut QbzLocal>, path: QString);
|
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.
|
/// tracks). A `plex:<hash>` id is served from the Plex cache.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn open_album(self: Pin<&mut QbzLocal>, id: QString);
|
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).
|
/// Close the album pane (back to the grid).
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn close_album(self: Pin<&mut QbzLocal>);
|
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.
|
/// QML uses it to filter the very array it renders.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn artist_album_ids(self: &QbzLocal, artist: QString) -> QString;
|
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).
|
/// Album header pencil — LOGGED SEAM (no tag-editor modal yet).
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn album_edit_tags(self: Pin<&mut QbzLocal>, id: QString);
|
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".
|
/// Per-disc menu: "play" | "next" | "later" | "queue".
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn album_disc_action(self: Pin<&mut QbzLocal>, disc: i32, action: QString);
|
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 ------------------------------------------------
|
// --- Ephemeral folder ------------------------------------------------
|
||||||
/// Pick a folder OUTSIDE the library, scan it, show the pane.
|
/// Pick a folder OUTSIDE the library, scan it, show the pane.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
@@ -493,6 +510,14 @@ pub mod qbz_local {
|
|||||||
/// Album card / detail header Play (optionally shuffled).
|
/// Album card / detail header Play (optionally shuffled).
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn play_album(self: Pin<&mut QbzLocal>, id: QString, shuffle: bool);
|
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.
|
/// Album-detail row click: play the album from that track.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn play_album_track(self: Pin<&mut QbzLocal>, id: QString, track_id: QString);
|
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".
|
/// mode = "next" | "later" | "queue".
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn enqueue(self: Pin<&mut QbzLocal>, kind: QString, id: QString, mode: QString);
|
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 ------------------------------------------------------------
|
// --- Plex ------------------------------------------------------------
|
||||||
/// Header Sync button (#573): re-fetch the Plex sections + tracks
|
/// Header Sync button (#573): re-fetch the Plex sections + tracks
|
||||||
@@ -620,6 +664,27 @@ pub mod qbz_local {
|
|||||||
/// wrong row.
|
/// wrong row.
|
||||||
#[qsignal]
|
#[qsignal]
|
||||||
fn local_artwork_ready(self: Pin<&mut QbzLocal>, key: QString, path: QString);
|
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 {}
|
impl cxx_qt::Threading for QbzLocal {}
|
||||||
@@ -659,6 +724,7 @@ pub struct QbzLocalRust {
|
|||||||
local_tracks_loading_more: bool,
|
local_tracks_loading_more: bool,
|
||||||
local_tracks_has_more: bool,
|
local_tracks_has_more: bool,
|
||||||
local_tracks_sort: QString,
|
local_tracks_sort: QString,
|
||||||
|
local_tracks_filter: QString,
|
||||||
local_tracks_group: QString,
|
local_tracks_group: QString,
|
||||||
local_tracks_json: QString,
|
local_tracks_json: QString,
|
||||||
local_tracks_native_active: bool,
|
local_tracks_native_active: bool,
|
||||||
@@ -732,6 +798,7 @@ impl Default for QbzLocalRust {
|
|||||||
local_tracks_loading_more: false,
|
local_tracks_loading_more: false,
|
||||||
local_tracks_has_more: false,
|
local_tracks_has_more: false,
|
||||||
local_tracks_sort: QString::from("default"),
|
local_tracks_sort: QString::from("default"),
|
||||||
|
local_tracks_filter: QString::default(),
|
||||||
local_tracks_group: QString::from("off"),
|
local_tracks_group: QString::from("off"),
|
||||||
local_tracks_json: QString::from("[]"),
|
local_tracks_json: QString::from("[]"),
|
||||||
local_tracks_native_active: false,
|
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 {
|
impl qbz_local::QbzLocal {
|
||||||
pub fn boot(self: Pin<&mut Self>) {
|
pub fn boot(self: Pin<&mut Self>) {
|
||||||
@@ -802,6 +910,7 @@ impl qbz_local::QbzLocal {
|
|||||||
let mode = lib::album_mode();
|
let mode = lib::album_mode();
|
||||||
let sort = lib::tracks_sort();
|
let sort = lib::tracks_sort();
|
||||||
let group = lib::tracks_group();
|
let group = lib::tracks_group();
|
||||||
|
let tracks_filter = lib::tracks_filter();
|
||||||
crate::spawn(async move {
|
crate::spawn(async move {
|
||||||
let available = tokio::task::spawn_blocking(lib::has_library)
|
let available = tokio::task::spawn_blocking(lib::has_library)
|
||||||
.await
|
.await
|
||||||
@@ -814,6 +923,8 @@ impl qbz_local::QbzLocal {
|
|||||||
.set_local_tracks_sort(QString::from(sort.as_str()));
|
.set_local_tracks_sort(QString::from(sort.as_str()));
|
||||||
b.as_mut()
|
b.as_mut()
|
||||||
.set_local_tracks_group(QString::from(group.as_str()));
|
.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();
|
publish_plex_state();
|
||||||
@@ -867,19 +978,11 @@ impl qbz_local::QbzLocal {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn albums_native_page_miss(
|
pub fn albums_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
page: i32,
|
|
||||||
generation: i32,
|
|
||||||
) {
|
|
||||||
crate::local_albums_model_qt::request_page(page, generation);
|
crate::local_albums_model_qt::request_page(page, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn albums_native_toggle_select(
|
pub fn albums_native_toggle_select(self: Pin<&mut Self>, index: i64, shift: bool) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
index: i64,
|
|
||||||
shift: bool,
|
|
||||||
) {
|
|
||||||
crate::local_albums_model_qt::toggle_selection(index, shift);
|
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());
|
crate::local_albums_model_qt::bulk_action(action.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn artists_native_reset(self: Pin<&mut Self>, search: QString) {
|
pub fn artists_native_reset(
|
||||||
crate::local_artists_model_qt::reset(search.to_string());
|
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(
|
pub fn artists_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
page: i32,
|
|
||||||
generation: i32,
|
|
||||||
) {
|
|
||||||
crate::local_artists_model_qt::request_page(page, generation);
|
crate::local_artists_model_qt::request_page(page, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn artists_native_select(
|
pub fn artists_native_select(self: Pin<&mut Self>, artist: QString, columns: i32) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
artist: QString,
|
|
||||||
columns: i32,
|
|
||||||
) {
|
|
||||||
crate::local_artists_model_qt::select_artist(artist.to_string(), columns);
|
crate::local_artists_model_qt::select_artist(artist.to_string(), columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn artist_albums_native_page_miss(
|
pub fn artist_albums_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
page: i32,
|
|
||||||
generation: i32,
|
|
||||||
) {
|
|
||||||
crate::local_artists_model_qt::request_detail_page(page, generation);
|
crate::local_artists_model_qt::request_detail_page(page, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,6 +1040,16 @@ impl qbz_local::QbzLocal {
|
|||||||
load_tracks(true);
|
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) {
|
pub fn tracks_set_group(self: Pin<&mut Self>, mode: QString) {
|
||||||
let mode = mode.to_string();
|
let mode = mode.to_string();
|
||||||
lib::set_tracks_group(&mode);
|
lib::set_tracks_group(&mode);
|
||||||
@@ -963,11 +1075,7 @@ impl qbz_local::QbzLocal {
|
|||||||
crate::local_tracks_model_qt::request_page(page, generation);
|
crate::local_tracks_model_qt::request_page(page, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tracks_native_toggle_select(
|
pub fn tracks_native_toggle_select(self: Pin<&mut Self>, index: i64, shift: bool) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
index: i64,
|
|
||||||
shift: bool,
|
|
||||||
) {
|
|
||||||
crate::local_tracks_model_qt::toggle_selection(index, shift);
|
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());
|
crate::local_tracks_model_qt::enqueue(index, mode.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tracks_native_row_action(
|
pub fn tracks_native_row_action(self: Pin<&mut Self>, index: i64, action: QString) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
index: i64,
|
|
||||||
action: QString,
|
|
||||||
) {
|
|
||||||
crate::local_tracks_model_qt::row_action(index, action.to_string());
|
crate::local_tracks_model_qt::row_action(index, action.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,9 +1130,10 @@ impl qbz_local::QbzLocal {
|
|||||||
let path = path.to_string();
|
let path = path.to_string();
|
||||||
ui(|mut b| b.as_mut().set_local_detail_loading(true));
|
ui(|mut b| b.as_mut().set_local_detail_loading(true));
|
||||||
crate::spawn(async move {
|
crate::spawn(async move {
|
||||||
let detail = tokio::task::spawn_blocking(move || lib::load_folder_detail_blocking(&path))
|
let detail =
|
||||||
.await
|
tokio::task::spawn_blocking(move || lib::load_folder_detail_blocking(&path))
|
||||||
.ok();
|
.await
|
||||||
|
.ok();
|
||||||
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
|
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
|
||||||
ui(move |mut b| {
|
ui(move |mut b| {
|
||||||
b.as_mut()
|
b.as_mut()
|
||||||
@@ -1042,6 +1147,14 @@ impl qbz_local::QbzLocal {
|
|||||||
open_album_by_id(id.to_string());
|
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>) {
|
pub fn close_album(self: Pin<&mut Self>) {
|
||||||
ui(|mut b| {
|
ui(|mut b| {
|
||||||
b.as_mut().set_local_album_json(QString::from(""));
|
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) {
|
pub fn play_album_track(self: Pin<&mut Self>, id: QString, track_id: QString) {
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
let row = track_id.to_string().parse::<i64>().ok();
|
let row = track_id.to_string().parse::<i64>().ok();
|
||||||
@@ -1085,11 +1211,7 @@ impl qbz_local::QbzLocal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_tracks_visible(
|
pub fn play_tracks_visible(self: Pin<&mut Self>, visible_ids_json: QString, track_id: QString) {
|
||||||
self: Pin<&mut Self>,
|
|
||||||
visible_ids_json: QString,
|
|
||||||
track_id: QString,
|
|
||||||
) {
|
|
||||||
let Ok(row) = track_id.to_string().parse::<i64>() else {
|
let Ok(row) = track_id.to_string().parse::<i64>() else {
|
||||||
return;
|
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 --------------------------------------------------------------
|
// --- Plex --------------------------------------------------------------
|
||||||
|
|
||||||
pub fn sync_plex(self: Pin<&mut Self>) {
|
pub fn sync_plex(self: Pin<&mut Self>) {
|
||||||
@@ -1123,7 +1279,8 @@ impl qbz_local::QbzLocal {
|
|||||||
let text = json.to_string();
|
let text = json.to_string();
|
||||||
// The qproperty is what the view re-seeds from on its next mount, so
|
// The qproperty is what the view re-seeds from on its next mount, so
|
||||||
// it is written on EVERY toggle, not only on close.
|
// 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);
|
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())
|
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) {
|
pub fn album_edit_tags(self: Pin<&mut Self>, id: QString) {
|
||||||
crate::local_album_actions::edit_tags(id.to_string());
|
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());
|
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 ----------------------------------------------------
|
// --- Ephemeral folder ----------------------------------------------------
|
||||||
|
|
||||||
pub fn ephemeral_open(self: Pin<&mut Self>) {
|
pub fn ephemeral_open(self: Pin<&mut Self>) {
|
||||||
@@ -1520,7 +1951,6 @@ impl qbz_local::QbzLocal {
|
|||||||
crate::local_ephemeral::play_track(&runtime, row).await;
|
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
|
/// 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.
|
/// main.rs can route to it when a local id reaches the catalog path.
|
||||||
pub(crate) fn open_album_by_id(id: String) {
|
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| {
|
ui(|mut b| {
|
||||||
// Clear the previous local album with the loading flag — the same
|
// Clear the previous local album with the loading flag — the same
|
||||||
// stale-render the catalog album view had.
|
// 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);
|
b.as_mut().set_local_album_loading(true);
|
||||||
});
|
});
|
||||||
crate::spawn(async move {
|
crate::spawn(async move {
|
||||||
let detail = tokio::task::spawn_blocking(move || lib::load_album_detail_blocking(&id))
|
let detail = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
lib::load_album_detail_filtered_blocking(&id, &filter_json)
|
||||||
.ok()
|
})
|
||||||
.flatten();
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
|
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
|
||||||
ui(move |mut b| {
|
ui(move |mut b| {
|
||||||
b.as_mut()
|
b.as_mut()
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ pub(crate) fn publish_media_gates() {
|
|||||||
ui(move |mut b| {
|
ui(move |mut b| {
|
||||||
b.as_mut().set_media_has_jellyfin(jf);
|
b.as_mut().set_media_has_jellyfin(jf);
|
||||||
b.as_mut().set_media_has_subsonic(sub);
|
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() {
|
let value = if json.trim().is_empty() {
|
||||||
serde_json::Value::Object(serde_json::Map::new())
|
serde_json::Value::Object(serde_json::Map::new())
|
||||||
} else {
|
} 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);
|
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
|
/// ever match zero rows, and the chip that would let the user untick it is
|
||||||
/// hidden by the same gate.
|
/// hidden by the same gate.
|
||||||
fn pruned_albums_filter(has_jellyfin: bool, has_subsonic: bool) -> String {
|
fn pruned_albums_filter(has_jellyfin: bool, has_subsonic: bool) -> String {
|
||||||
let Some(serde_json::Value::Object(mut map)) =
|
let Some(serde_json::Value::Object(mut map)) = crate::settings_qt::read_pref(ALBUMS_FILTER_KEY)
|
||||||
crate::settings_qt::read_pref(ALBUMS_FILTER_KEY)
|
|
||||||
else {
|
else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
@@ -148,6 +149,21 @@ pub(crate) fn reload_browse() {
|
|||||||
// wrong. This is the single chokepoint for local-library mutations, which
|
// wrong. This is the single chokepoint for local-library mutations, which
|
||||||
// is why the invalidation lives here rather than at each call site.
|
// is why the invalidation lives here rather than at each call site.
|
||||||
crate::search_cache_qt::invalidate();
|
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_albums();
|
||||||
load_artists();
|
load_artists();
|
||||||
load_tracks(true);
|
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.
|
// commit, and it still derives album credits from that document.
|
||||||
"albums-legacy" => load_albums_legacy(),
|
"albums-legacy" => load_albums_legacy(),
|
||||||
"artists" => load_artists(),
|
"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(),
|
"folders" => load_folders(),
|
||||||
"tracks" => load_tracks(true),
|
"tracks" => load_tracks(true),
|
||||||
// The ephemeral session lives in memory and is published the moment it
|
// The ephemeral session lives in memory and is published the moment it
|
||||||
@@ -286,8 +305,7 @@ pub(crate) fn load_albums_legacy() {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[qbz-qt] local albums load failed: {e}");
|
log::warn!("[qbz-qt] local albums load failed: {e}");
|
||||||
ui(move |mut b| {
|
ui(move |mut b| {
|
||||||
b.as_mut()
|
b.as_mut().set_local_albums_error(QString::from(e.as_str()));
|
||||||
.set_local_albums_error(QString::from(e.as_str()));
|
|
||||||
b.as_mut().set_local_albums_loading(false);
|
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");
|
log_surface_route("tracks", "catalog", "feature-enabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log_surface_route(
|
log_surface_route("tracks", "legacy", "feature-disabled-or-session-failed");
|
||||||
"tracks",
|
|
||||||
"legacy",
|
|
||||||
"feature-disabled-or-session-failed",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
log_surface_route("tracks", "legacy", "legacy-pagination");
|
log_surface_route("tracks", "legacy", "legacy-pagination");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,87 @@ use qbz_app::shell::AppRuntime;
|
|||||||
use qbz_core::LoggingAdapter;
|
use qbz_core::LoggingAdapter;
|
||||||
use qbz_library::LocalTrack;
|
use qbz_library::LocalTrack;
|
||||||
use qbz_models::QueueTrack;
|
use qbz_models::QueueTrack;
|
||||||
|
use qbz_source::PlaybackTicket;
|
||||||
|
|
||||||
use crate::local_albums::fetch_album_tracks_blocking;
|
use crate::local_albums::fetch_album_tracks_blocking;
|
||||||
use crate::local_state::{state, with_db};
|
use crate::local_state::{state, with_db};
|
||||||
|
|
||||||
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
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` +
|
// On-disk cover backfill (playback.rs `fill_missing_covers` +
|
||||||
// local_library.rs `find_folder_cover`, both 1:1)
|
// 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())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill `artwork_path` for the rows that lack one, from a cover image sitting
|
/// Fill `artwork_path` from a cover image sitting in the track's own folder —
|
||||||
/// in the track's folder — the port of `playback.rs:1490 fill_missing_covers`,
|
/// 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
|
/// 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,
|
/// 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
|
/// 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;
|
use std::collections::HashMap;
|
||||||
let mut memo: HashMap<String, Option<String>> = HashMap::new();
|
let mut memo: HashMap<String, Option<String>> = HashMap::new();
|
||||||
for t in tracks.iter_mut() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let p = Path::new(&t.file_path);
|
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 key = folder.to_string_lossy().into_owned();
|
||||||
let cover = memo
|
let folder_cover = memo
|
||||||
.entry(key)
|
.entry(key)
|
||||||
.or_insert_with(|| find_folder_cover(&folder))
|
.or_insert_with(|| find_folder_cover(&folder))
|
||||||
.clone();
|
.clone();
|
||||||
if cover.is_some() {
|
let may_override = t.source.as_deref() != Some("qobuz_download");
|
||||||
t.artwork_path = cover;
|
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
|
// 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.
|
// so the now-playing bar / queue resolve it from current creds.
|
||||||
// `file://`-prefixing it poisons it into a local-read miss.
|
// `file://`-prefixing it poisons it into a local-read miss.
|
||||||
let artwork_url = t.artwork_path.as_ref().map(|p| {
|
let artwork_url = t
|
||||||
if is_plex || p.starts_with("file://") {
|
.artwork_path
|
||||||
p.clone()
|
.as_ref()
|
||||||
} else if is_media {
|
.filter(|path| !path.is_empty())
|
||||||
// SOURCE-TAGGED, because a media server's token is meaningless on
|
.or_else(|| {
|
||||||
// its own: a Jellyfin image tag and a Subsonic coverArt id are
|
t.collection_artwork_path
|
||||||
// both opaque, and the now-playing bar / queue / MPRIS resolve an
|
.as_ref()
|
||||||
// `artwork_url` through the shared taxonomy with no row in hand.
|
.filter(|path| !path.is_empty())
|
||||||
// The tag is what lets that taxonomy hand it back to the source
|
})
|
||||||
// that issued it instead of guessing (`artwork_qt::classify`).
|
.map(|p| {
|
||||||
format!("{src}:{p}")
|
if is_plex || p.starts_with("file://") {
|
||||||
} else {
|
p.clone()
|
||||||
format!("file://{p}")
|
} 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 {
|
let sample_rate_khz = if t.sample_rate >= 1000.0 {
|
||||||
t.sample_rate / 1000.0
|
t.sample_rate / 1000.0
|
||||||
} else {
|
} 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.
|
/// 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
|
/// 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() {
|
if tracks.is_empty() {
|
||||||
return;
|
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
|
// Folder-cover backfill BEFORE the mapping (the reference runs it at every
|
||||||
// local play site: playback.rs:1094 / :1322 / :1349 / :1382). This is the
|
// local play site: playback.rs:1094 / :1322 / :1349 / :1382). This is the
|
||||||
// funnel for album / folder / folder-track / Tracks-tab play, so one call
|
// 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() {
|
if tracks.is_empty() {
|
||||||
return;
|
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
|
// 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
|
// 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
|
// 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
|
// 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).
|
// the order is mixed, so it is dropped (`play_track_list_in` does the same).
|
||||||
let start = if shuffle {
|
let start = if shuffle { 0 } else { start };
|
||||||
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();
|
|
||||||
// Through the SHARED seam (not `core().set_queue`) so the origin is derived
|
// 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 ->
|
// 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.
|
// 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
|
// the anchor always comes back and always names `first`. Answered anyway
|
||||||
// rather than discarded with a `let _`: the day a Qobuz row reaches this
|
// 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.
|
// 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)
|
let Some(prepared) = crate::playback_qt::prepare_queue_stamped(queue, Some(start), None) else {
|
||||||
.await
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
log::warn!("[qbz-qt] local play: the queue was filtered to nothing");
|
log::warn!("[qbz-qt] local play: the queue was filtered to nothing");
|
||||||
return;
|
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;
|
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;
|
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,16 +637,37 @@ pub async fn play_album(
|
|||||||
shuffle: bool,
|
shuffle: bool,
|
||||||
) {
|
) {
|
||||||
let key = album_id.clone();
|
let key = album_id.clone();
|
||||||
let tracks =
|
let tracks = tokio::task::spawn_blocking(move || fetch_album_tracks_blocking(&key))
|
||||||
tokio::task::spawn_blocking(move || fetch_album_tracks_blocking(&key))
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let start = start_track_id
|
let start = start_track_id
|
||||||
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
|
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
play_rows(runtime, tracks, start, shuffle).await;
|
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.
|
/// Play everything under a folder, recursively, in path order.
|
||||||
pub async fn play_folder(runtime: &Runtime, path: String) {
|
pub async fn play_folder(runtime: &Runtime, path: String) {
|
||||||
let tracks = tokio::task::spawn_blocking(move || {
|
let tracks = tokio::task::spawn_blocking(move || {
|
||||||
@@ -579,6 +755,7 @@ fn find_track_blocking(row_id: i64) -> Option<LocalTrack> {
|
|||||||
s.tracks_raw
|
s.tracks_raw
|
||||||
.iter()
|
.iter()
|
||||||
.chain(s.detail_raw.iter())
|
.chain(s.detail_raw.iter())
|
||||||
|
.chain(s.genre_detail_raw.values().flatten())
|
||||||
.find(|t| t.id == row_id)
|
.find(|t| t.id == row_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
});
|
});
|
||||||
@@ -617,15 +794,39 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String)
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.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() {
|
if tracks.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Same stamping seam the Qobuz enqueue paths use, so an appended local
|
// Same stamping seam the Qobuz enqueue paths use, so an appended local
|
||||||
// block carries its own origin instead of inheriting whatever is playing.
|
// block carries its own origin instead of inheriting whatever is playing.
|
||||||
let queue = crate::playback_qt::stamped(
|
let queue = crate::playback_qt::stamped(tracks.iter().map(local_queue_track).collect(), None);
|
||||||
tracks.iter().map(local_queue_track).collect(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
|
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
|
||||||
// (reversed so a multi-track insert keeps its order), "later" appends to
|
// (reversed so a multi-track insert keeps its order), "later" appends to
|
||||||
// the block tail, anything else appends.
|
// 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 {
|
let track = match source {
|
||||||
// A library row: the DB has everything, and `local_queue_track` builds
|
// A library row: the DB has everything, and `local_queue_track` builds
|
||||||
// the QueueTrack the audible step expects.
|
// the QueueTrack the audible step expects.
|
||||||
"local" | "qobuz_download" => {
|
"local" | "qobuz_download" => tokio::task::spawn_blocking(move || {
|
||||||
tokio::task::spawn_blocking(move || with_db(|db| db.get_track(track_id as i64)).flatten())
|
with_db(|db| db.get_track(track_id as i64)).flatten()
|
||||||
.await
|
})
|
||||||
.ok()
|
.await
|
||||||
.flatten()
|
.ok()
|
||||||
}
|
.flatten(),
|
||||||
// A Plex row is SYNTHETIC — `local_plex::map_cached_to_local_track`
|
// 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.
|
// 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
|
// 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 {
|
return match qbz_source::registry().tracks_of(&raw).await {
|
||||||
Ok(tracks) if !tracks.is_empty() => {
|
Ok(tracks) if !tracks.is_empty() => {
|
||||||
let first = tracks[0].clone();
|
let Some(prepared) =
|
||||||
if crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None)
|
crate::playback_qt::prepare_queue_stamped(tracks, Some(0), None)
|
||||||
.await
|
else {
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
log::warn!("[qbz-qt] {source} play: the queue was filtered to nothing");
|
log::warn!("[qbz-qt] {source} play: the queue was filtered to nothing");
|
||||||
return false;
|
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;
|
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;
|
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||||
played
|
played
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct AlbumRow {
|
|||||||
pub artist: String,
|
pub artist: String,
|
||||||
#[serde(rename = "allArtists")]
|
#[serde(rename = "allArtists")]
|
||||||
pub all_artists: String,
|
pub all_artists: String,
|
||||||
|
/// Canonical individual contributors used by chained artist facets.
|
||||||
|
pub artists: Vec<String>,
|
||||||
pub year: String,
|
pub year: String,
|
||||||
#[serde(rename = "trackCount")]
|
#[serde(rename = "trackCount")]
|
||||||
pub track_count: u32,
|
pub track_count: u32,
|
||||||
@@ -49,6 +51,8 @@ pub struct AlbumRow {
|
|||||||
#[serde(rename = "qualityDetail")]
|
#[serde(rename = "qualityDetail")]
|
||||||
pub quality_detail: String,
|
pub quality_detail: String,
|
||||||
pub format: String,
|
pub format: String,
|
||||||
|
/// Every genre represented by this logical album, across all versions.
|
||||||
|
pub genres: Vec<String>,
|
||||||
#[serde(rename = "artKey")]
|
#[serde(rename = "artKey")]
|
||||||
pub art_key: String,
|
pub art_key: String,
|
||||||
/// "local" | "offline" | "plex" (the source badge).
|
/// "local" | "offline" | "plex" (the source badge).
|
||||||
@@ -67,6 +71,13 @@ pub struct AlbumRow {
|
|||||||
/// the "album spans N folders" tooltip case).
|
/// the "album spans N folders" tooltip case).
|
||||||
#[serde(rename = "folderCount")]
|
#[serde(rename = "folderCount")]
|
||||||
pub folder_count: u32,
|
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).
|
/// One track row (Tracks tab, album detail, folder detail).
|
||||||
@@ -93,6 +104,7 @@ pub struct TrackRow {
|
|||||||
#[serde(rename = "qualityDetail")]
|
#[serde(rename = "qualityDetail")]
|
||||||
pub quality_detail: String,
|
pub quality_detail: String,
|
||||||
pub format: String,
|
pub format: String,
|
||||||
|
pub genres: Vec<String>,
|
||||||
pub year: String,
|
pub year: String,
|
||||||
#[serde(rename = "artKey")]
|
#[serde(rename = "artKey")]
|
||||||
pub art_key: String,
|
pub art_key: String,
|
||||||
@@ -121,6 +133,18 @@ pub struct ArtistRow {
|
|||||||
pub art_key: String,
|
pub art_key: String,
|
||||||
/// "local" | "plex" | "mixed" — the rail's provenance hint.
|
/// "local" | "plex" | "mixed" — the rail's provenance hint.
|
||||||
pub source: String,
|
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
|
/// 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.
|
/// 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
|
/// This direct URL remains the local-file fallback beside the track's id-keyed
|
||||||
/// album ROOT a +5 bonus against a strict `>`, so `Box/cover.jpg` beats
|
/// `artwork_path`. Current scans preserve embedded/disc/collection precedence;
|
||||||
/// `Box/Disc 01 - X/cover.jpg` on every track of every disc and a per-disc
|
/// older database rows can still carry one album-root image for every disc.
|
||||||
/// thumbnail drawn from it would be N copies of one image.
|
|
||||||
///
|
///
|
||||||
/// Encoded through `artwork_qt::file_url`, which is not optional: these folder
|
/// 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
|
/// 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 {
|
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);
|
let key = album_key(&a.id);
|
||||||
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) {
|
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);
|
art.insert(key.clone(), t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,10 +476,13 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
sources.dedup();
|
sources.dedup();
|
||||||
|
let favoriteable = album_favorite_source(&sources).is_some();
|
||||||
|
let is_favorite = favoriteable && crate::library_qt::is_local_favorite("album", &a.id);
|
||||||
AlbumRow {
|
AlbumRow {
|
||||||
quality_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(),
|
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),
|
quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate),
|
||||||
format: a.format.to_string(),
|
format: a.format.to_string(),
|
||||||
|
genres: a.genres,
|
||||||
year: a.year.map(|y| y.to_string()).unwrap_or_default(),
|
year: a.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||||
duration: total_duration(a.total_duration_secs),
|
duration: total_duration(a.total_duration_secs),
|
||||||
track_count: a.track_count,
|
track_count: a.track_count,
|
||||||
@@ -450,16 +492,47 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
|
|||||||
source_raw,
|
source_raw,
|
||||||
directory_path: a.directory_path,
|
directory_path: a.directory_path,
|
||||||
all_artists: a.all_artists,
|
all_artists: a.all_artists,
|
||||||
|
artists,
|
||||||
folder_count,
|
folder_count,
|
||||||
|
is_favorite,
|
||||||
|
favoriteable,
|
||||||
id: a.id,
|
id: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
artist: a.artist,
|
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 {
|
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, (SourceId, String)>) -> TrackRow {
|
||||||
let key = track_key(t.id);
|
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) {
|
if let Some(tok) = art_token(t.source.as_deref(), p) {
|
||||||
art.insert(key.clone(), tok);
|
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_tier: tier_of(&t.format, t.bit_depth, t.sample_rate).into(),
|
||||||
quality_detail: detail_of(&t.format, t.bit_depth, t.sample_rate),
|
quality_detail: detail_of(&t.format, t.bit_depth, t.sample_rate),
|
||||||
format: t.format.to_string(),
|
format: t.format.to_string(),
|
||||||
|
genres: t.genres.clone(),
|
||||||
year: t.year.map(|y| y.to_string()).unwrap_or_default(),
|
year: t.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||||
art_key: key,
|
art_key: key,
|
||||||
art_path: String::new(),
|
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 {
|
pub fn to_json<T: Serialize>(value: &T) -> String {
|
||||||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use qbz_app::user_data::UserDataPaths;
|
use qbz_app::user_data::UserDataPaths;
|
||||||
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
|
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
|
||||||
@@ -45,6 +45,7 @@ pub struct TracksLoadRequest {
|
|||||||
pub query: String,
|
pub query: String,
|
||||||
pub sort: String,
|
pub sort: String,
|
||||||
pub group: 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
|
/// rationale as `tracks_raw`: a context-menu enqueue on a Plex detail row
|
||||||
/// has no `local_tracks` id to re-query with.
|
/// has no `local_tracks` id to re-query with.
|
||||||
pub detail_raw: Vec<LocalTrack>,
|
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,
|
pub tracks_offsets: TrackSourceOffsets,
|
||||||
/// Search/sort generation. An older worker may not mutate or publish.
|
/// Search/sort generation. An older worker may not mutate or publish.
|
||||||
pub tracks_generation: u64,
|
pub tracks_generation: u64,
|
||||||
@@ -122,6 +153,7 @@ pub struct LocalState {
|
|||||||
/// (locallibrary_ui `tracks_group`). It is part of the immutable paged
|
/// (locallibrary_ui `tracks_group`). It is part of the immutable paged
|
||||||
/// query: otherwise a later page can sort ahead of the visible prefix.
|
/// query: otherwise a later page can sort ahead of the visible prefix.
|
||||||
pub tracks_group: String,
|
pub tracks_group: String,
|
||||||
|
pub tracks_filter: String,
|
||||||
pub tracks_has_more: bool,
|
pub tracks_has_more: bool,
|
||||||
/// The FULL flattened tree (visible derivation applies the rail search).
|
/// The FULL flattened tree (visible derivation applies the rail search).
|
||||||
pub tree: Vec<TreeNode>,
|
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,
|
album_mode: prefs.albums_id_mode,
|
||||||
tracks_sort: prefs.tracks_sort,
|
tracks_sort: prefs.tracks_sort,
|
||||||
tracks_group: prefs.tracks_group,
|
tracks_group: prefs.tracks_group,
|
||||||
|
tracks_filter: prefs.tracks_filter,
|
||||||
..LocalState::default()
|
..LocalState::default()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -200,6 +233,8 @@ pub struct Prefs {
|
|||||||
pub tracks_group: String,
|
pub tracks_group: String,
|
||||||
#[serde(default = "d_default")]
|
#[serde(default = "d_default")]
|
||||||
pub tracks_sort: String,
|
pub tracks_sort: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tracks_filter: String,
|
||||||
#[serde(default = "d_folder")]
|
#[serde(default = "d_folder")]
|
||||||
pub albums_id_mode: String,
|
pub albums_id_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -221,6 +256,7 @@ impl Default for Prefs {
|
|||||||
Self {
|
Self {
|
||||||
tracks_group: d_off(),
|
tracks_group: d_off(),
|
||||||
tracks_sort: d_default(),
|
tracks_sort: d_default(),
|
||||||
|
tracks_filter: String::new(),
|
||||||
albums_id_mode: d_folder(),
|
albums_id_mode: d_folder(),
|
||||||
ephemeral_folder: None,
|
ephemeral_folder: None,
|
||||||
}
|
}
|
||||||
@@ -314,7 +350,11 @@ pub fn group_mode() -> qbz_library::album_grouping::AlbumGroupMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_album_mode(mode: &str) {
|
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());
|
state(|s| s.album_mode = mode.to_string());
|
||||||
update_prefs(|p| p.albums_id_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
|
// 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())
|
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 {
|
pub fn begin_tracks_load(reset: bool) -> TracksLoadRequest {
|
||||||
state(|s| {
|
state(|s| {
|
||||||
if reset {
|
if reset {
|
||||||
@@ -380,6 +429,7 @@ pub fn begin_tracks_load(reset: bool) -> TracksLoadRequest {
|
|||||||
query: s.tracks_query.clone(),
|
query: s.tracks_query.clone(),
|
||||||
sort: s.tracks_sort.clone(),
|
sort: s.tracks_sort.clone(),
|
||||||
group: s.tracks_group.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 std::sync::{mpsc, Arc};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
library_sources_available, tracks_request_is_current, TrackSourceOffsets,
|
library_sources_available, tracks_request_is_current, TrackSourceOffsets, TracksLoadRequest,
|
||||||
TracksLoadRequest,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -481,6 +530,7 @@ mod phase_a_tests {
|
|||||||
query: "old".to_string(),
|
query: "old".to_string(),
|
||||||
sort: "title-asc".to_string(),
|
sort: "title-asc".to_string(),
|
||||||
group: "off".to_string(),
|
group: "off".to_string(),
|
||||||
|
filter: Default::default(),
|
||||||
};
|
};
|
||||||
let old = std::thread::spawn(move || {
|
let old = std::thread::spawn(move || {
|
||||||
old_started_tx.send(()).unwrap();
|
old_started_tx.send(()).unwrap();
|
||||||
@@ -500,6 +550,7 @@ mod phase_a_tests {
|
|||||||
query: "new".to_string(),
|
query: "new".to_string(),
|
||||||
sort: "artist-asc".to_string(),
|
sort: "artist-asc".to_string(),
|
||||||
group: "off".to_string(),
|
group: "off".to_string(),
|
||||||
|
filter: Default::default(),
|
||||||
};
|
};
|
||||||
allow_old_finish_tx.send(()).unwrap();
|
allow_old_finish_tx.send(()).unwrap();
|
||||||
|
|
||||||
@@ -561,6 +612,75 @@ mod phase_a_tests {
|
|||||||
assert!(!view_qml.contains("readonly property var tracksVisible: {"));
|
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]
|
#[test]
|
||||||
fn explicit_remote_resync_is_authoritative() {
|
fn explicit_remote_resync_is_authoritative() {
|
||||||
// Jellyfin deltas cannot enumerate deleted/reminted provider ids. The
|
// 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(\"jellyfin\", false)"));
|
||||||
assert!(!chrome.contains("QbzLocal.mediaSync(\"subsonic\", 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -224,10 +224,15 @@ pub(crate) fn reset() -> bool {
|
|||||||
let query = crate::local_state::tracks_query();
|
let query = crate::local_state::tracks_query();
|
||||||
let sort = parse_sort(&crate::local_state::tracks_sort());
|
let sort = parse_sort(&crate::local_state::tracks_sort());
|
||||||
let group = parse_group(&crate::local_state::tracks_group());
|
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()
|
let descriptor = QueryDescriptor::tracks()
|
||||||
.with_search(query.clone())
|
.with_search(query.clone())
|
||||||
.with_sort(sort)
|
.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;
|
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None;
|
||||||
if !query.is_empty() && query.chars().count() < 3 {
|
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,
|
record.sample_rate_hz.unwrap_or(0) as f64,
|
||||||
),
|
),
|
||||||
format: record.format.to_ascii_uppercase(),
|
format: record.format.to_ascii_uppercase(),
|
||||||
|
genres: Vec::new(),
|
||||||
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
|
||||||
art_key,
|
art_key,
|
||||||
art_path: String::new(),
|
art_path: String::new(),
|
||||||
|
|||||||
+161
-104
@@ -20,11 +20,11 @@ mod auth_qt;
|
|||||||
// object's Qt event loop (no-op pre-registration).
|
// object's Qt event loop (no-op pre-registration).
|
||||||
// All singletons live in the SAME QML module (com.blitzfc.qbz); the
|
// All singletons live in the SAME QML module (com.blitzfc.qbz); the
|
||||||
// invokables stay one-line forwards into the domain controllers below.
|
// invokables stay one-line forwards into the domain controllers below.
|
||||||
mod session_bridge;
|
mod home_bridge;
|
||||||
mod shell_bridge;
|
|
||||||
mod player_bridge;
|
mod player_bridge;
|
||||||
mod queue_bridge;
|
mod queue_bridge;
|
||||||
mod home_bridge;
|
mod session_bridge;
|
||||||
|
mod shell_bridge;
|
||||||
mod viz_bridge;
|
mod viz_bridge;
|
||||||
// Immersive mode (2026-08-02 immersive-port contract, block B1): the
|
// Immersive mode (2026-08-02 immersive-port contract, block B1): the
|
||||||
// QbzImmersive singleton (§3) — open funnel + view persistence + the §3.4
|
// 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.
|
// + blocking mappers shared by the desktop and immersive dropdowns.
|
||||||
mod search_local;
|
mod search_local;
|
||||||
// Instant cached paint for the cortinilla (contract C11, rulings R1+R6).
|
// 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 album_bridge;
|
||||||
mod artist_bridge;
|
mod artist_bridge;
|
||||||
|
mod cast_bridge;
|
||||||
|
mod library_bridge;
|
||||||
|
mod local_bridge;
|
||||||
mod musician_bridge;
|
mod musician_bridge;
|
||||||
mod scene_bridge;
|
mod scene_bridge;
|
||||||
mod cast_bridge;
|
mod search_cache_qt;
|
||||||
// MyQBZ splits across THREE singletons rather than one, because the two
|
// MyQBZ splits across THREE singletons rather than one, because the two
|
||||||
// modals are global overlays with their own lifetime: QbzMyQbz carries the
|
// modals are global overlays with their own lifetime: QbzMyQbz carries the
|
||||||
// two grids + the detail page + the edit/mix modals + the branding,
|
// two grids + the detail page + the edit/mix modals + the branding,
|
||||||
// QbzMyQbzAdd carries only the app-wide "Add to Mixtape/Collection" picker
|
// QbzMyQbzAdd carries only the app-wide "Add to Mixtape/Collection" picker
|
||||||
// (mounted in AppShell, reachable from any view's row menu), and QbzDisco
|
// (mounted in AppShell, reachable from any view's row menu), and QbzDisco
|
||||||
// carries the Artist-Collection builder.
|
// carries the Artist-Collection builder.
|
||||||
mod myqbz_bridge;
|
|
||||||
mod myqbz_add_bridge;
|
|
||||||
mod disco_bridge;
|
|
||||||
mod blacklist_bridge;
|
mod blacklist_bridge;
|
||||||
|
mod disco_bridge;
|
||||||
|
mod myqbz_add_bridge;
|
||||||
|
mod myqbz_bridge;
|
||||||
mod playlist_picker_bridge;
|
mod playlist_picker_bridge;
|
||||||
// Playlist Manager. THREE singletons, not one (contract D2): the manager
|
// Playlist Manager. THREE singletons, not one (contract D2): the manager
|
||||||
// document + folder list + organisation writes here, the folder modals on
|
// document + folder list + organisation writes here, the folder modals on
|
||||||
// QbzFolderEdit and the shared playlist editor on QbzPlaylistEdit — so a
|
// QbzFolderEdit and the shared playlist editor on QbzPlaylistEdit — so a
|
||||||
// folder save cannot perturb an open playlist editor.
|
// folder save cannot perturb an open playlist editor.
|
||||||
mod playlist_manager_bridge;
|
|
||||||
mod folder_edit_bridge;
|
mod folder_edit_bridge;
|
||||||
mod playlist_edit_bridge;
|
mod playlist_edit_bridge;
|
||||||
|
mod playlist_manager_bridge;
|
||||||
// Purchases (2026-08-15 purchases contract §G.1): the QbzPurchases singleton —
|
// Purchases (2026-08-15 purchases contract §G.1): the QbzPurchases singleton —
|
||||||
// two documents (list + album detail) and a publish counter. Its controller
|
// two documents (list + album detail) and a publish counter. Its controller
|
||||||
// half is `purchases_qt` below, a PLAIN module. This file DOES belong in
|
// 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
|
// System tray (the same contract, block B5): the QbzTray singleton — the
|
||||||
// window verbs' Qt-thread hop. Its controllers are `tray_qt` (portable) and
|
// window verbs' Qt-thread hop. Its controllers are `tray_qt` (portable) and
|
||||||
// `tray_linux` (the ksni item), both PLAIN modules below.
|
// `tray_linux` (the ksni item), both PLAIN modules below.
|
||||||
mod tray_bridge;
|
|
||||||
mod kiosk_nav_qt;
|
mod kiosk_nav_qt;
|
||||||
|
mod tray_bridge;
|
||||||
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
|
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
|
||||||
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
|
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
|
||||||
mod kiosk_profile_qt;
|
|
||||||
mod artwork_qt;
|
mod artwork_qt;
|
||||||
mod atmosphere_qt;
|
mod atmosphere_qt;
|
||||||
|
mod kiosk_profile_qt;
|
||||||
// THE audible step (design 02 §9 stage 3): one exhaustive match over
|
// THE audible step (design 02 §9 stage 3): one exhaustive match over
|
||||||
// `qbz_source::PlaybackTicket`, replacing the three drifted copies of
|
// `qbz_source::PlaybackTicket`, replacing the three drifted copies of
|
||||||
// `play_audible` that each matched on `track.source` by hand.
|
// `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
|
// 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
|
// 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.
|
// SOURCE owns, so sweep and reads can never point at different users.
|
||||||
mod media_sync_qt;
|
|
||||||
mod custom_theme_qt;
|
mod custom_theme_qt;
|
||||||
mod diagnostics_qt;
|
mod diagnostics_qt;
|
||||||
mod discover_config_qt;
|
mod discover_config_qt;
|
||||||
mod fav_cache_qt;
|
mod fav_cache_qt;
|
||||||
|
mod media_sync_qt;
|
||||||
// The folder-modal controller. A plain module — it declares no
|
// The folder-modal controller. A plain module — it declares no
|
||||||
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||||
mod folder_edit_qt;
|
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
|
// 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
|
// `use_gpu_renderer`). A plain module: it declares no #[cxx_qt::bridge], so it
|
||||||
// must NOT appear in build.rs's rust_files.
|
// must NOT appear in build.rs's rust_files.
|
||||||
mod renderer_qt;
|
|
||||||
mod foryou_qt;
|
mod foryou_qt;
|
||||||
mod genre_filter_qt;
|
mod genre_filter_qt;
|
||||||
mod home_qt;
|
mod home_qt;
|
||||||
mod icon_tint_qt;
|
mod icon_tint_qt;
|
||||||
mod recently_qt;
|
mod recently_qt;
|
||||||
|
mod renderer_qt;
|
||||||
// The in-app log viewer over `qbz_log::ring` — the read surface that turns
|
// 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
|
// "Share logs" from an `open::that(path)` handoff into a filterable view with
|
||||||
// copy / bundle / upload.
|
// copy / bundle / upload.
|
||||||
@@ -169,18 +169,19 @@ mod log_viewer_qt;
|
|||||||
// other two are plain controller modules and must NOT be.
|
// other two are plain controller modules and must NOT be.
|
||||||
mod about_bridge;
|
mod about_bridge;
|
||||||
mod about_qt;
|
mod about_qt;
|
||||||
mod whats_new_qt;
|
|
||||||
mod recommendations_qt;
|
|
||||||
mod library_db_qt;
|
|
||||||
mod library_qt;
|
|
||||||
mod library_bulk;
|
mod library_bulk;
|
||||||
|
mod library_db_qt;
|
||||||
mod library_prefs;
|
mod library_prefs;
|
||||||
|
mod library_qt;
|
||||||
mod library_sidepanel;
|
mod library_sidepanel;
|
||||||
mod local_library_qt;
|
|
||||||
mod local_artist_match;
|
mod local_artist_match;
|
||||||
|
mod local_catalog_qt;
|
||||||
|
mod local_filter;
|
||||||
|
mod local_library_qt;
|
||||||
mod local_rows;
|
mod local_rows;
|
||||||
mod local_state;
|
mod local_state;
|
||||||
mod local_catalog_qt;
|
mod recommendations_qt;
|
||||||
|
mod whats_new_qt;
|
||||||
// Watcher hints + periodic root reconciliation for the incremental scanner.
|
// Watcher hints + periodic root reconciliation for the incremental scanner.
|
||||||
mod local_scan_maintenance_qt;
|
mod local_scan_maintenance_qt;
|
||||||
// Native bounded Tracks QAbstractListModel + catalog query controller (phase E).
|
// 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
|
// 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
|
// ping calls — the protocol was always in the shared crate, only the glue was
|
||||||
// missing.
|
// missing.
|
||||||
mod plex_pin_qt;
|
mod local_album_actions;
|
||||||
mod local_albums;
|
mod local_albums;
|
||||||
mod local_tree;
|
|
||||||
mod local_artwork;
|
mod local_artwork;
|
||||||
mod local_playback;
|
|
||||||
mod local_playlist_qt;
|
|
||||||
mod local_bridge_ops;
|
mod local_bridge_ops;
|
||||||
mod local_bulk;
|
mod local_bulk;
|
||||||
mod local_ephemeral;
|
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
|
// MyQBZ domain controllers. One module per concern, all driven by the three
|
||||||
// bridges above: the grids + Create modal (myqbz_qt), the per-user branding
|
// bridges above: the grids + Create modal (myqbz_qt), the per-user branding
|
||||||
// and per-collection view prefs (myqbz_prefs_qt), the detail page
|
// and per-collection view prefs (myqbz_prefs_qt), the detail page
|
||||||
// (myqbz_detail_qt) and its playback / edit / cover / DJ-mix arms, the
|
// (myqbz_detail_qt) and its playback / edit / cover / DJ-mix arms, the
|
||||||
// app-wide Add picker (myqbz_add_qt) and the Artist-Collection builder
|
// app-wide Add picker (myqbz_add_qt) and the Artist-Collection builder
|
||||||
// (myqbz_builder_qt + its Qobuz/local/Plex fetchers).
|
// (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_add_qt;
|
||||||
mod myqbz_builder_qt;
|
|
||||||
mod myqbz_builder_fetch_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
|
// Blacklist: the per-user store (artist_blacklist), the Recommendations
|
||||||
// dismissal store (reco_dismiss_qt) and the manager view's controller.
|
// dismissal store (reco_dismiss_qt) and the manager view's controller.
|
||||||
mod artist_blacklist;
|
mod artist_blacklist;
|
||||||
mod reco_dismiss_qt;
|
|
||||||
mod blacklist_qt;
|
mod blacklist_qt;
|
||||||
|
mod reco_dismiss_qt;
|
||||||
// Artist page per-section release sort, persisted by release_type. Co-owns
|
// 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
|
// `<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.
|
// 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).
|
// 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
|
// A plain module, NOT a bridge — the artist header's ⋯ → Share reaches it
|
||||||
// through QbzArtist.share (artist_bridge.rs).
|
// through QbzArtist.share (artist_bridge.rs).
|
||||||
mod share_qt;
|
|
||||||
mod nav_qt;
|
|
||||||
mod browse_qt;
|
mod browse_qt;
|
||||||
mod cast_qt;
|
mod cast_qt;
|
||||||
mod cdda_qt;
|
mod cdda_qt;
|
||||||
mod sacd_qt;
|
|
||||||
mod disc_identity;
|
mod disc_identity;
|
||||||
mod disc_meta_bridge;
|
mod disc_meta_bridge;
|
||||||
mod disc_meta_qt;
|
mod disc_meta_qt;
|
||||||
mod tag_editor_bridge;
|
|
||||||
mod tag_editor_qt;
|
|
||||||
mod rip_qt;
|
|
||||||
mod rip_wizard_qt;
|
|
||||||
mod label_qt;
|
mod label_qt;
|
||||||
|
mod nav_qt;
|
||||||
mod now_playing;
|
mod now_playing;
|
||||||
|
mod offline_fwd;
|
||||||
mod output_labels;
|
mod output_labels;
|
||||||
mod quality_qt;
|
mod quality_qt;
|
||||||
mod quality_state;
|
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
|
// Offline cache (downloads tier): state activation on login + the action
|
||||||
// set. offline_fwd.rs is the offline MODE (connectivity); these two are the
|
// set. offline_fwd.rs is the offline MODE (connectivity); these two are the
|
||||||
// user-managed download cache (see AGENTS.md's caching-model note).
|
// user-managed download cache (see AGENTS.md's caching-model note).
|
||||||
mod offline_qt;
|
|
||||||
mod offline_cache_qt;
|
mod offline_cache_qt;
|
||||||
mod offline_manager_qt;
|
|
||||||
mod offline_manager_bridge;
|
mod offline_manager_bridge;
|
||||||
|
mod offline_manager_qt;
|
||||||
|
mod offline_qt;
|
||||||
// Shared multi-select bulk actions for Qobuz track listings (playlist,
|
// Shared multi-select bulk actions for Qobuz track listings (playlist,
|
||||||
// artist, label — the album page has its own album-ordered variant).
|
// artist, label — the album page has its own album-ordered variant).
|
||||||
mod bulk_tracks_qt;
|
|
||||||
mod album_qt;
|
mod album_qt;
|
||||||
mod award_qt;
|
mod award_qt;
|
||||||
|
mod bulk_tracks_qt;
|
||||||
mod track_info_qt;
|
mod track_info_qt;
|
||||||
// Album Info (Credits/Review) modal controller — info_modals.rs port.
|
// Album Info (Credits/Review) modal controller — info_modals.rs port.
|
||||||
mod album_info_qt;
|
mod album_info_qt;
|
||||||
// Album custom covers + cover file actions — the album half of the Slint
|
// Album custom covers + cover file actions — the album half of the Slint
|
||||||
// `custom_artwork.rs` store (SAME json file, shared between both apps).
|
// `custom_artwork.rs` store (SAME json file, shared between both apps).
|
||||||
|
mod ambient_qt;
|
||||||
mod cover_artwork_qt;
|
mod cover_artwork_qt;
|
||||||
mod external_reco_qt;
|
mod external_reco_qt;
|
||||||
mod ambient_qt;
|
|
||||||
// Tunnel Flow scene (B1, 2026-08-15 immersive-completion contract): the
|
// Tunnel Flow scene (B1, 2026-08-15 immersive-completion contract): the
|
||||||
// Tauri line-palette extraction, published on QbzShaderScene per track.
|
// Tauri line-palette extraction, published on QbzShaderScene per track.
|
||||||
mod tunnelflow_qt;
|
|
||||||
mod artist_qt;
|
mod artist_qt;
|
||||||
mod lyrics_qt;
|
mod lyrics_qt;
|
||||||
mod playback_qt;
|
mod playback_qt;
|
||||||
mod playlist_picker_qt;
|
mod playlist_picker_qt;
|
||||||
|
mod tunnelflow_qt;
|
||||||
// Playlist Importer controller (the `qbz-playlist-import` crate's frontend
|
// Playlist Importer controller (the `qbz-playlist-import` crate's frontend
|
||||||
// half). Plain module — it declares no #[cxx_qt::bridge], so it must NOT
|
// half). Plain module — it declares no #[cxx_qt::bridge], so it must NOT
|
||||||
// appear in build.rs's rust_files.
|
// 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.
|
// #[cxx_qt::bridge], so they must NOT appear in build.rs's rust_files.
|
||||||
mod playlist_manager_ops;
|
mod playlist_manager_ops;
|
||||||
mod playlist_manager_qt;
|
mod playlist_manager_qt;
|
||||||
mod playlist_snapshot_qt;
|
|
||||||
mod playlist_manager_rows;
|
mod playlist_manager_rows;
|
||||||
|
mod playlist_snapshot_qt;
|
||||||
// The SHARED playlist editor's controller (rename · description ·
|
// The SHARED playlist editor's controller (rename · description ·
|
||||||
// offline-only · delete), driven from the manager's three delegates, the
|
// offline-only · delete), driven from the manager's three delegates, the
|
||||||
// sidebar row menu and the playlist detail header. Plain module — it declares
|
// 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 —
|
// 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.
|
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||||
mod hotkeys_qt;
|
mod hotkeys_qt;
|
||||||
|
mod integrations_qt;
|
||||||
mod settings_qt;
|
mod settings_qt;
|
||||||
mod sidebar_qt;
|
mod sidebar_qt;
|
||||||
mod sleep_timer_qt;
|
mod sleep_timer_qt;
|
||||||
mod theme_qt;
|
mod theme_qt;
|
||||||
mod integrations_qt;
|
|
||||||
mod viz_qt;
|
mod viz_qt;
|
||||||
// Qobuz Connect port (2026-08-01 contract), blocks B1/B2: transport config +
|
// Qobuz Connect port (2026-08-01 contract), blocks B1/B2: transport config +
|
||||||
// credential discovery + device identity, and the renderer engine over
|
// 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
|
// 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
|
// 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.
|
// keeps macOS and Windows out — neither has a tray in either frontend.
|
||||||
mod tray_qt;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod tray_linux;
|
mod tray_linux;
|
||||||
|
mod tray_qt;
|
||||||
// macOS menu-bar tray (NSStatusItem), ported 2026-08-05. The K2 ruling that
|
// 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
|
// kept macOS out was reasoned from a premise that reads the reference
|
||||||
// backwards — see the module header.
|
// 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).
|
// ignored, which is what we want (the runtime is process-global anyway).
|
||||||
qbz_app::session_persist::bind_exit_ctx(
|
qbz_app::session_persist::bind_exit_ctx(
|
||||||
app(),
|
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,
|
// 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
|
// 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(Some(session)) => enter_shell(session),
|
||||||
Ok(None) => session_bridge::ui(|mut b| b.as_mut().set_screen(QString::from("login"))),
|
Ok(None) => session_bridge::ui(|mut b| b.as_mut().set_screen(QString::from("login"))),
|
||||||
Err(e) => session_bridge::ui(move |mut b| {
|
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"));
|
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
|
// 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
|
// fed by a Rust document that only `navigate_to` ever asked for, and a
|
||||||
// restore does not go through `navigate_to`.
|
// 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);
|
nav_qt::record(&entry_view);
|
||||||
hydrate_view(&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();
|
now_playing::publish_current();
|
||||||
// Phase 3: fetch Discover > Home (online sessions only — the offline
|
// Phase 3: fetch Discover > Home (online sessions only — the offline
|
||||||
// engine gates Qobuz calls anyway, and the view shows 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
|
// 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
|
// (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.
|
// 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();
|
let rt = app();
|
||||||
spawn(async move { playback_qt::set_volume(&rt, restored).await });
|
spawn(async move { playback_qt::set_volume(&rt, restored).await });
|
||||||
// ...and seed the UI model with the SAME value, or the engine and the
|
// ...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) {
|
fn enter_shell(session: auth_qt::SessionInfo) {
|
||||||
on_session_entered();
|
on_session_entered();
|
||||||
session_bridge::ui(move |mut b| {
|
session_bridge::ui(move |mut b| {
|
||||||
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
|
b.as_mut()
|
||||||
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
|
.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_has_previous_session(true);
|
||||||
b.as_mut().set_login_error(QString::from(""));
|
b.as_mut().set_login_error(QString::from(""));
|
||||||
b.as_mut().set_restore_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| {
|
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);
|
b.as_mut().set_login_phase(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -824,7 +852,7 @@ pub(crate) fn start_login() {
|
|||||||
match result {
|
match result {
|
||||||
Ok(session) => enter_shell(session),
|
Ok(session) => enter_shell(session),
|
||||||
Err(e) => session_bridge::ui(move |mut b| {
|
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()));
|
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.
|
// uses it — NOT the two-line subset this used to run.
|
||||||
on_session_entered();
|
on_session_entered();
|
||||||
session_bridge::ui(move |mut b| {
|
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_session_subscription(QString::from(""));
|
||||||
b.as_mut().set_login_error(QString::from(""));
|
b.as_mut().set_login_error(QString::from(""));
|
||||||
b.as_mut().set_restore_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() {
|
pub(crate) fn publish_sidebar() {
|
||||||
let entries = sidebar_qt::rebuild();
|
let entries = sidebar_qt::rebuild();
|
||||||
let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into());
|
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();
|
let (sort_by, sort_asc) = sidebar_qt::sort_state();
|
||||||
shell_bridge::ui(move |mut b| {
|
shell_bridge::ui(move |mut b| {
|
||||||
b.as_mut().set_sidebar_json(QString::from(json.as_str()));
|
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);
|
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();
|
let json = doc.to_string();
|
||||||
log::debug!("[qbz-qt] sidebar folder popup: {folder_id} ({count} rows)");
|
log::debug!("[qbz-qt] sidebar folder popup: {folder_id} ({count} rows)");
|
||||||
shell_bridge::ui(move |mut b| {
|
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 {
|
spawn(async move {
|
||||||
match album_qt::load_album_view(&runtime, &album_id).await {
|
match album_qt::load_album_view(&runtime, &album_id).await {
|
||||||
Ok(json) => {
|
Ok(json) => album_bridge::ui(move |mut b| {
|
||||||
album_bridge::ui(move |mut b| {
|
b.as_mut().set_album_json(QString::from(json.as_str()));
|
||||||
b.as_mut().set_album_json(QString::from(json.as_str()));
|
b.as_mut().set_album_loading(false);
|
||||||
b.as_mut().set_album_loading(false);
|
}),
|
||||||
})
|
|
||||||
},
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[qbz-qt] album view load failed: {e}");
|
log::warn!("[qbz-qt] album view load failed: {e}");
|
||||||
album_bridge::ui(move |mut b| b.as_mut().set_album_loading(false));
|
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) {
|
pub(crate) fn load_release_section(artist_id: String, release_type: String, offset: i32) {
|
||||||
let runtime = app();
|
let runtime = app();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match artist_qt::load_release_page(&runtime, &artist_id, &release_type, offset.max(0) as u32)
|
match artist_qt::load_release_page(
|
||||||
.await
|
&runtime,
|
||||||
|
&artist_id,
|
||||||
|
&release_type,
|
||||||
|
offset.max(0) as u32,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok((cards, has_more)) => {
|
Ok((cards, has_more)) => {
|
||||||
// The user may have opened ANOTHER artist while this page was
|
// 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
|
/// 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
|
/// this encodes: a per-click mutation must never republish a document. See
|
||||||
/// `home_qt::apply_pin_change`'s docs for what republishing cost.
|
/// `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) {
|
if let Some(value) = sidebar_qt::toggle_pin(&kind, &id, &title, &subtitle, &artwork_url) {
|
||||||
let key = format!("{kind}:{id}");
|
let key = format!("{kind}:{id}");
|
||||||
library_bridge::ui(move |mut b| {
|
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);
|
static LYRICS_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
pub(crate) fn toggle_lyrics() {
|
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| {
|
shell_bridge::ui(move |mut b| {
|
||||||
b.as_mut().set_lyrics_open(open);
|
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(
|
pub(crate) fn queue_extended_drop(phase: String, index: i32, expected_id: String, slot: i32) {
|
||||||
phase: String,
|
|
||||||
index: i32,
|
|
||||||
expected_id: String,
|
|
||||||
slot: i32,
|
|
||||||
) {
|
|
||||||
let Ok(track_id) = expected_id.parse::<u64>() else {
|
let Ok(track_id) = expected_id.parse::<u64>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1419,7 +1461,9 @@ pub(crate) fn queue_remove_all_after(index: i32) {
|
|||||||
|
|
||||||
pub(crate) fn queue_move_track(from: i32, to: i32) {
|
pub(crate) fn queue_move_track(from: i32, to: i32) {
|
||||||
let runtime = app();
|
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) {
|
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) {
|
pub(crate) fn enqueue_album_track(album_id: String, track_id: u64, mode: String) {
|
||||||
let runtime = app();
|
let runtime = app();
|
||||||
spawn(async move {
|
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}");
|
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.
|
/// Track-row click (Library tracks): one-track queue through the core.
|
||||||
pub(crate) fn play_track(track_id: u64) {
|
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();
|
now_playing::begin_loading();
|
||||||
let runtime = app();
|
let runtime = app();
|
||||||
spawn(async move {
|
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
|
// Learn from results-page interactions too, not only from the
|
||||||
// cortinilla. Self-gated on the Search view being current, so every other
|
// cortinilla. Self-gated on the Search view being current, so every other
|
||||||
// caller of this router is unaffected.
|
// 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`
|
// 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
|
// sets it authoritatively at the end, but between here and there sits a
|
||||||
// network fetch, and a remove clicked inside that window would otherwise
|
// 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
|
// A sidecar row of a MIXED Qobuz playlist: it comes out of
|
||||||
// `library.db`, not out of the Qobuz membership. Returns false when the
|
// `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.
|
// row turns out to be an ordinary Qobuz one, so this falls through.
|
||||||
if playlist_qt::is_mixed()
|
if playlist_qt::is_mixed() && playlist_qt::remove_sidecar_row(&runtime, &row_id).await {
|
||||||
&& playlist_qt::remove_sidecar_row(&runtime, &row_id).await
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(playlist_track_id) = row_id.parse::<u64>() else {
|
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| {
|
shell_bridge::ui(move |mut b| {
|
||||||
b.as_mut().set_drag_count(if id > 0 { 1 } else { 0 });
|
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_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_x(x);
|
||||||
b.as_mut().set_drag_y(y);
|
b.as_mut().set_drag_y(y);
|
||||||
b.as_mut().set_drag_over_playlist_id(QString::default());
|
b.as_mut().set_drag_over_playlist_id(QString::default());
|
||||||
@@ -2345,7 +2397,9 @@ fn republish_artist(artist_id: String) {
|
|||||||
let runtime = app();
|
let runtime = app();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
if let Ok(json) = artist_qt::load_artist_view(&runtime, &artist_id).await {
|
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) {
|
pub(crate) fn settings_select(key: String, index: i32) {
|
||||||
let runtime = app();
|
let runtime = app();
|
||||||
spawn(async move {
|
spawn(async move { settings_qt::settings_select(&runtime, &key, index.max(0) as usize).await });
|
||||||
settings_qt::settings_select(&runtime, &key, index.max(0) as usize).await
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn settings_slider(key: String, value: i32) {
|
pub(crate) fn settings_slider(key: String, value: i32) {
|
||||||
@@ -2556,7 +2608,8 @@ pub(crate) fn reload_library() {
|
|||||||
t.elapsed(),
|
t.elapsed(),
|
||||||
);
|
);
|
||||||
library_bridge::ui(move |mut b| {
|
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()
|
b.as_mut()
|
||||||
.set_library_counts_json(QString::from(counts_json.as_str()));
|
.set_library_counts_json(QString::from(counts_json.as_str()));
|
||||||
b.as_mut().set_library_loading(false);
|
b.as_mut().set_library_loading(false);
|
||||||
@@ -2599,7 +2652,8 @@ pub(crate) fn publish_library_document() {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
library_bridge::ui(move |mut b| {
|
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()
|
b.as_mut()
|
||||||
.set_library_counts_json(QString::from(counts_json.as_str()));
|
.set_library_counts_json(QString::from(counts_json.as_str()));
|
||||||
});
|
});
|
||||||
@@ -2819,7 +2873,11 @@ pub(crate) fn reload_home() {
|
|||||||
missing.dedup();
|
missing.dedup();
|
||||||
let count: usize = sections.home.iter().map(|s| s.items.len()).sum::<usize>()
|
let count: usize = sections.home.iter().map(|s| s.items.len()).sum::<usize>()
|
||||||
+ sections.editor.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(§ions);
|
publish_home_sections(§ions);
|
||||||
// LIVENESS. The shell has a mounted view with real content, so
|
// 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) {
|
fn publish_home_sections(sections: &home_qt::DiscoverSections) {
|
||||||
let home_json = serde_json::to_string(§ions.home).unwrap_or_else(|_| "[]".to_string());
|
let home_json = serde_json::to_string(§ions.home).unwrap_or_else(|_| "[]".to_string());
|
||||||
let editor_json = serde_json::to_string(§ions.editor).unwrap_or_else(|_| "[]".to_string());
|
let editor_json = serde_json::to_string(§ions.editor).unwrap_or_else(|_| "[]".to_string());
|
||||||
let for_you_json = serde_json::to_string(§ions.for_you).unwrap_or_else(|_| "[]".to_string());
|
let for_you_json =
|
||||||
|
serde_json::to_string(§ions.for_you).unwrap_or_else(|_| "[]".to_string());
|
||||||
home_bridge::ui(move |mut b| {
|
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()
|
b.as_mut()
|
||||||
.set_editor_sections_json(QString::from(editor_json.as_str()));
|
.set_editor_sections_json(QString::from(editor_json.as_str()));
|
||||||
b.as_mut()
|
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
|
/// The Settings > Appearance RENDERER row, consumed at startup (PARITY-DEBT
|
||||||
/// #104 — the row rendered and persisted into the SAME ui_prefs the Slint
|
/// #104 — the row rendered and persisted into the SAME ui_prefs the Slint
|
||||||
/// reads, but nothing consumed it, so it lied).
|
/// 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) {
|
if HARD_EXIT_ARMED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
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()
|
let _ = std::thread::Builder::new()
|
||||||
.name("qbz-exit-watchdog".into())
|
.name("qbz-exit-watchdog".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -3108,9 +3169,7 @@ fn main() {
|
|||||||
// so this costs nothing until the Large dock's band is shown. It is a
|
// 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
|
// read-only copy downstream of the bit-perfect stream — no device/stream
|
||||||
// init is touched.
|
// init is touched.
|
||||||
let runtime = Arc::new(AppRuntime::with_visualizer(LoggingAdapter::new(
|
let runtime = Arc::new(AppRuntime::with_visualizer(LoggingAdapter::new("[qbz-qt]")));
|
||||||
"[qbz-qt]",
|
|
||||||
)));
|
|
||||||
if let Some(tap) = runtime.visualizer_tap().cloned() {
|
if let Some(tap) = runtime.visualizer_tap().cloned() {
|
||||||
viz_qt::install(tap);
|
viz_qt::install(tap);
|
||||||
viz_qt::set_mode(settings_qt::large_spectrum_mode());
|
viz_qt::set_mode(settings_qt::large_spectrum_mode());
|
||||||
@@ -3197,9 +3256,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(engine) = engine.as_mut() {
|
if let Some(engine) = engine.as_mut() {
|
||||||
engine.load(&QUrl::from(
|
engine.load(&QUrl::from("qrc:/qt/qml/com/blitzfc/qbz/qml/Main.qml"));
|
||||||
"qrc:/qt/qml/com/blitzfc/qbz/qml/Main.qml",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(app) = app.as_mut() {
|
if let Some(app) = app.as_mut() {
|
||||||
|
|||||||
@@ -72,8 +72,13 @@ fn playback() -> &'static PlaybackPreferencesState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn with_audio<T>(f: impl FnOnce(&AudioSettingsStore) -> Result<T, String>) -> Result<T, String> {
|
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 guard = audio()
|
||||||
let store = guard.as_ref().ok_or_else(|| "audio settings store not open".to_string())?;
|
.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)
|
f(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +437,10 @@ pub fn streaming_quality() -> String {
|
|||||||
std::fs::read_to_string(path)
|
std::fs::read_to_string(path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).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())
|
.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
|
/// the pane, and because a SCROLLING list invalidates it every frame — it is
|
||||||
/// here to be measured, not assumed.
|
/// here to be measured, not assumed.
|
||||||
pub fn pane_layer() -> bool {
|
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.
|
/// 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)
|
let key = std::fs::read_to_string(path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).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());
|
.unwrap_or_else(|| "new".to_string());
|
||||||
match key.as_str() {
|
match key.as_str() {
|
||||||
"classic" => 1,
|
"classic" => 1,
|
||||||
@@ -1158,6 +1172,94 @@ pub fn pref_json(key: &str) -> Option<serde_json::Value> {
|
|||||||
.and_then(|v| v.get(key).cloned())
|
.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
|
/// f32 reader WITH a default — same additive single-key discipline as
|
||||||
/// `pref_bool`/`pref_str`/`pref_i32`. Added for the miniplayer geometry keys
|
/// `pref_bool`/`pref_str`/`pref_i32`. Added for the miniplayer geometry keys
|
||||||
/// (`mini_width`/`mini_height`), which the Slint app declares as f32 and
|
/// (`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)",
|
"Native DSD (kernel support required)",
|
||||||
];
|
];
|
||||||
const DSD_MODE_VALUES: &[&str] = &["convert", "dop", "native"];
|
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 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 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_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"];
|
||||||
const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"];
|
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_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"];
|
||||||
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
|
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
|
||||||
const LANGUAGE_LABELS: &[&str] = &[
|
const LANGUAGE_LABELS: &[&str] = &[
|
||||||
"Auto", "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語",
|
"Auto",
|
||||||
|
"English",
|
||||||
|
"Español",
|
||||||
|
"Français",
|
||||||
|
"Deutsch",
|
||||||
|
"Português",
|
||||||
|
"Русский",
|
||||||
|
"日本語",
|
||||||
"Nederlands",
|
"Nederlands",
|
||||||
];
|
];
|
||||||
const LANGUAGE_VALUES: &[&str] = &["auto", "en", "es", "fr", "de", "pt", "ru", "ja", "nl"];
|
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(&"")
|
.unwrap_or(&"")
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
const IMMERSIVE_SEARCH_LABELS: &[&str] =
|
const IMMERSIVE_SEARCH_LABELS: &[&str] = &[
|
||||||
&["Disabled", "Replace current queue", "Play next", "Add to queue"];
|
"Disabled",
|
||||||
|
"Replace current queue",
|
||||||
|
"Play next",
|
||||||
|
"Add to queue",
|
||||||
|
];
|
||||||
const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"];
|
const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"];
|
||||||
const IMMERSIVE_VIEW_LABELS: &[&str] =
|
const IMMERSIVE_VIEW_LABELS: &[&str] = &[
|
||||||
&["Remember last", "Album Reactive", "Static", "Coverflow", "Spectrum", "Lyrics", "Queue"];
|
"Remember last",
|
||||||
const IMMERSIVE_VIEW_VALUES: &[&str] =
|
"Album Reactive",
|
||||||
&["remember", "reactive", "static", "coverflow", "spectrum", "lyrics", "queue"];
|
"Static",
|
||||||
const MINI_VIEW_LABELS: &[&str] =
|
"Coverflow",
|
||||||
&["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"];
|
"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 MINI_VIEW_VALUES: &[&str] = &["remember", "micro", "compact", "artwork", "queue", "lyrics"];
|
||||||
const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"];
|
const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"];
|
||||||
const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"];
|
const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"];
|
||||||
const WC_POSITION_LABELS: &[&str] = &["Left", "Right"];
|
const WC_POSITION_LABELS: &[&str] = &["Left", "Right"];
|
||||||
const WC_POSITION_VALUES: &[&str] = &["left", "right"];
|
const WC_POSITION_VALUES: &[&str] = &["left", "right"];
|
||||||
const RENDERER_LABELS: &[&str] =
|
const RENDERER_LABELS: &[&str] = &[
|
||||||
&["Auto (recommended)", "GPU", "GPU (compatibility)", "Software"];
|
"Auto (recommended)",
|
||||||
|
"GPU",
|
||||||
|
"GPU (compatibility)",
|
||||||
|
"Software",
|
||||||
|
];
|
||||||
const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"];
|
const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"];
|
||||||
const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"];
|
const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"];
|
||||||
const TRAY_ICON_VALUES: &[&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.
|
/// the section's flyout and the user picks a tab from it.
|
||||||
#[serde(rename = "navClickFirstTab")]
|
#[serde(rename = "navClickFirstTab")]
|
||||||
pub nav_click_first_tab: bool,
|
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>,
|
pub renderers: Vec<String>,
|
||||||
#[serde(rename = "rendererIndex")]
|
#[serde(rename = "rendererIndex")]
|
||||||
pub renderer_index: i32,
|
pub renderer_index: i32,
|
||||||
@@ -1924,10 +2072,7 @@ pub async fn publish_snapshot() {
|
|||||||
backend_is_jack: active_backend == AudioBackendType::Jack,
|
backend_is_jack: active_backend == AudioBackendType::Jack,
|
||||||
devices,
|
devices,
|
||||||
device_index: device_index as i32,
|
device_index: device_index as i32,
|
||||||
alsa_plugins: ALSA_PLUGIN_LABELS
|
alsa_plugins: ALSA_PLUGIN_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|l| qbz_i18n::t(l))
|
|
||||||
.collect(),
|
|
||||||
alsa_plugin_index: alsa_plugin_index as i32,
|
alsa_plugin_index: alsa_plugin_index as i32,
|
||||||
alsa_plugin_is_hw: alsa_plugin == AlsaPlugin::Hw,
|
alsa_plugin_is_hw: alsa_plugin == AlsaPlugin::Hw,
|
||||||
alsa_hardware_volume: audio_settings.alsa_hardware_volume,
|
alsa_hardware_volume: audio_settings.alsa_hardware_volume,
|
||||||
@@ -2002,7 +2147,13 @@ pub async fn publish_snapshot() {
|
|||||||
app_fonts: APP_FONT_LABELS
|
app_fonts: APP_FONT_LABELS
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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(),
|
.collect(),
|
||||||
app_font_index: app_font_index(),
|
app_font_index: app_font_index(),
|
||||||
immersive_search_actions: IMMERSIVE_SEARCH_LABELS
|
immersive_search_actions: IMMERSIVE_SEARCH_LABELS
|
||||||
@@ -2036,30 +2187,23 @@ pub async fn publish_snapshot() {
|
|||||||
window_title_show: pref_bool("window_title_show", false),
|
window_title_show: pref_bool("window_title_show", false),
|
||||||
use_system_title_bar: use_system_title_bar(),
|
use_system_title_bar: use_system_title_bar(),
|
||||||
hide_title_bar: pref_bool("hide_title_bar", false),
|
hide_title_bar: pref_bool("hide_title_bar", false),
|
||||||
wc_positions: WC_POSITION_LABELS
|
wc_positions: WC_POSITION_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|l| qbz_i18n::t(l))
|
|
||||||
.collect(),
|
|
||||||
wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1),
|
wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1),
|
||||||
show_window_controls: pref_bool("show_window_controls", true),
|
show_window_controls: pref_bool("show_window_controls", true),
|
||||||
show_volume_steppers: pref_bool("show_volume_steppers", false),
|
show_volume_steppers: pref_bool("show_volume_steppers", false),
|
||||||
mini_default_views: MINI_VIEW_LABELS
|
mini_default_views: MINI_VIEW_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|l| qbz_i18n::t(l))
|
|
||||||
.collect(),
|
|
||||||
mini_default_view_index: index_of(
|
mini_default_view_index: index_of(
|
||||||
MINI_VIEW_VALUES,
|
MINI_VIEW_VALUES,
|
||||||
&pref_str("mini_default_view", "remember"),
|
&pref_str("mini_default_view", "remember"),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
startup_pages: STARTUP_PAGE_LABELS
|
startup_pages: STARTUP_PAGE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|l| qbz_i18n::t(l))
|
|
||||||
.collect(),
|
|
||||||
startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0),
|
startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0),
|
||||||
show_purchases: pref_bool("show_purchases", false),
|
show_purchases: pref_bool("show_purchases", false),
|
||||||
nav_tb_purchases: pref_bool("nav_tb_purchases", false),
|
nav_tb_purchases: pref_bool("nav_tb_purchases", false),
|
||||||
nav_click_first_tab: pref_bool("nav_click_first_tab", 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(),
|
renderers: RENDERER_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0),
|
renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0),
|
||||||
gpu_powers: gpu_power_choice().0,
|
gpu_powers: gpu_power_choice().0,
|
||||||
@@ -2093,10 +2237,7 @@ pub async fn publish_snapshot() {
|
|||||||
.map(|t| t.mac_hide_dock)
|
.map(|t| t.mac_hide_dock)
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
is_macos: cfg!(target_os = "macos"),
|
is_macos: cfg!(target_os = "macos"),
|
||||||
tray_icon_themes: TRAY_ICON_LABELS
|
tray_icon_themes: TRAY_ICON_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|l| qbz_i18n::t(l))
|
|
||||||
.collect(),
|
|
||||||
tray_icon_theme_index: tray()
|
tray_icon_theme_index: tray()
|
||||||
.get_settings()
|
.get_settings()
|
||||||
.map(|t| index_of(TRAY_ICON_VALUES, &t.tray_icon_theme, 0))
|
.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" => {
|
"alsa-hardware-volume" => {
|
||||||
with_audio(|s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit)
|
with_audio(|s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit)
|
||||||
}
|
}
|
||||||
"exclusive-mode" => {
|
"exclusive-mode" => with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit),
|
||||||
with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit)
|
|
||||||
}
|
|
||||||
"reserve-dac" => {
|
"reserve-dac" => {
|
||||||
with_audio(|s| s.set_reserve_dac_while_running(value)).map(|_| Apply::Reload)
|
with_audio(|s| s.set_reserve_dac_while_running(value)).map(|_| Apply::Reload)
|
||||||
}
|
}
|
||||||
"dac-passthrough" => {
|
"dac-passthrough" => with_audio(|s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit),
|
||||||
with_audio(|s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit)
|
|
||||||
}
|
|
||||||
"pw-force-bitperfect" => {
|
"pw-force-bitperfect" => {
|
||||||
with_audio(|s| s.set_pw_force_bitperfect(value)).map(|_| Apply::Reload)
|
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" => {
|
"sync-audio-on-startup" => {
|
||||||
with_audio(|s| s.set_sync_audio_on_startup(value)).map(|_| Apply::Reload)
|
with_audio(|s| s.set_sync_audio_on_startup(value)).map(|_| Apply::Reload)
|
||||||
}
|
}
|
||||||
"skip-sink-switch" => {
|
"skip-sink-switch" => with_audio(|s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit),
|
||||||
with_audio(|s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit)
|
|
||||||
}
|
|
||||||
"gapless" => with_audio(|s| s.set_gapless_enabled(value)).map(|_| Apply::Reload),
|
"gapless" => with_audio(|s| s.set_gapless_enabled(value)).map(|_| Apply::Reload),
|
||||||
"normalization" => {
|
"normalization" => {
|
||||||
with_audio(|s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
|
with_audio(|s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
|
||||||
}
|
}
|
||||||
"stream-uncached" => {
|
"stream-uncached" => with_audio(|s| s.set_stream_first_track(value)).map(|_| Apply::Reload),
|
||||||
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),
|
||||||
}
|
|
||||||
"streaming-only" => {
|
|
||||||
with_audio(|s| s.set_streaming_only(value)).map(|_| Apply::Reload)
|
|
||||||
}
|
|
||||||
"continue-playback" => {
|
"continue-playback" => {
|
||||||
let mode = if value {
|
let mode = if value {
|
||||||
AutoplayMode::ContinueWithinSource
|
AutoplayMode::ContinueWithinSource
|
||||||
@@ -2447,15 +2578,11 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
|
|||||||
"scrobble-collapse" => {
|
"scrobble-collapse" => {
|
||||||
crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
|
crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
|
||||||
}
|
}
|
||||||
"lastfm-enable" => {
|
"lastfm-enable" => crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None),
|
||||||
crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None)
|
|
||||||
}
|
|
||||||
"listenbrainz-enable" => {
|
"listenbrainz-enable" => {
|
||||||
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
|
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
|
||||||
}
|
}
|
||||||
"discord-rpc" => {
|
"discord-rpc" => crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None),
|
||||||
crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None)
|
|
||||||
}
|
|
||||||
// --- Offline -------------------------------------------------------
|
// --- Offline -------------------------------------------------------
|
||||||
// Induced offline. The engine takes the #279 stream-first snapshot,
|
// Induced offline. The engine takes the #279 stream-first snapshot,
|
||||||
// so the audio settings can change under us -> Reload the player.
|
// 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 {
|
let Some(id) = id else {
|
||||||
return;
|
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)) {
|
if let Err(e) = with_audio(|s| s.set_output_device(device_opt)) {
|
||||||
log::error!("[qbz-qt] persist output device failed: {e}");
|
log::error!("[qbz-qt] persist output device failed: {e}");
|
||||||
return;
|
return;
|
||||||
@@ -2720,6 +2851,12 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
|
|||||||
};
|
};
|
||||||
save_pref("startup_page", serde_json::json!(v));
|
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" => {
|
"renderer" => {
|
||||||
let Some(v) = RENDERER_VALUES.get(index) else {
|
let Some(v) = RENDERER_VALUES.get(index) else {
|
||||||
return;
|
return;
|
||||||
@@ -2818,6 +2955,15 @@ pub async fn settings_string(key: &str, value: String) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// --- Local Library -------------------------------------------------
|
// --- 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,
|
"library-add-folder" => library::add_folder(value).await,
|
||||||
// --- Per-folder settings modal (LibFolderEditModal) ------------------
|
// --- Per-folder settings modal (LibFolderEditModal) ------------------
|
||||||
"library-folder-edit-open" => {
|
"library-folder-edit-open" => {
|
||||||
@@ -3095,3 +3241,48 @@ fn qconnect_default_name() -> String {
|
|||||||
format!("Qbz - {host}")
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user