feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. cc
This commit is contained in:
47 files changed
+8116
-2968
No files matched your search
Generated
+1
@@ -6632,6 +6632,7 @@ dependencies = [
|
||||
"qbz-lyrics",
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-plex",
|
||||
"qbz-qobuz",
|
||||
"qbz-text-utils",
|
||||
"qbz-theme",
|
||||
|
||||
@@ -32,6 +32,7 @@ qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-offline-cache = { path = "../qbz-offline-cache" }
|
||||
qbz-library = { path = "../qbz-library" }
|
||||
qbz-plex = { path = "../qbz-plex" }
|
||||
qbz-lyrics = { path = "../qbz-lyrics" }
|
||||
qbz-text-utils = { path = "../qbz-text-utils" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
|
||||
@@ -102,6 +102,33 @@ fn main() {
|
||||
"qml/shell/SongCardStamp.qml",
|
||||
"qml/shell/SpectrumBand.qml",
|
||||
"qml/shell/VizSettle.qml",
|
||||
"qml/views/LocalAlbumView.qml",
|
||||
"qml/views/local/AlphaStrip.qml",
|
||||
"qml/views/local/FilterChip.qml",
|
||||
"qml/views/local/FolderSubcard.qml",
|
||||
"qml/views/local/LocalAlbumCollection.qml",
|
||||
"qml/views/local/LocalAlbumHeader.qml",
|
||||
"qml/views/local/LocalAlbumRow.qml",
|
||||
"qml/views/local/LocalAlbumsTab.qml",
|
||||
"qml/views/local/LocalArtistRow.qml",
|
||||
"qml/views/local/LocalArtistsTab.qml",
|
||||
"qml/views/local/LocalChrome.qml",
|
||||
"qml/views/local/LocalEphemeralPane.qml",
|
||||
"qml/views/local/LocalFilterPopup.qml",
|
||||
"qml/views/local/LocalFolderDetail.qml",
|
||||
"qml/views/local/LocalFoldersTab.qml",
|
||||
"qml/views/local/LocalMultiSelectBar.qml",
|
||||
"qml/views/local/LocalNote.qml",
|
||||
"qml/views/local/LocalSearchBox.qml",
|
||||
"qml/views/local/LocalSegToggle.qml",
|
||||
"qml/views/local/LocalToolbar.qml",
|
||||
"qml/views/local/LocalTrackRow.qml",
|
||||
"qml/views/local/LocalTracksTab.qml",
|
||||
"qml/views/local/LocalTreeRail.qml",
|
||||
"qml/views/local/SelectCheck.qml",
|
||||
"qml/views/local/SourceIcon.qml",
|
||||
"qml/views/local/TreeRow.qml",
|
||||
"qml/views/local/VersionPicker.qml",
|
||||
],
|
||||
qrc_files: &qrc_refs,
|
||||
..Default::default()
|
||||
|
||||
@@ -146,6 +146,7 @@ Rectangle {
|
||||
source: QbzShell.currentView === "home" ? "../views/HomeView.qml"
|
||||
: QbzShell.currentView === "library" ? "../views/LibraryView.qml"
|
||||
: QbzShell.currentView === "local" ? "../views/LocalLibraryView.qml"
|
||||
: QbzShell.currentView === "localalbum" ? "../views/LocalAlbumView.qml"
|
||||
: QbzShell.currentView === "album" ? "../views/AlbumView.qml"
|
||||
: QbzShell.currentView === "artist" ? "../views/ArtistView.qml"
|
||||
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
// Local album detail — QML port of album/LocalAlbumView.slint, a ROUTED
|
||||
// PAGE (not a pane inside the Local Library view, which is how this port
|
||||
// used to render it).
|
||||
//
|
||||
// Homologated 1:1 with the Qobuz AlbumView: whole-page Flickable, the same
|
||||
// header proportions and action row, the toolbar (quality badge + track
|
||||
// search), the column header, and source-aware track rows. The intentional
|
||||
// differences vs the Qobuz page are the Slint's own: no label/awards
|
||||
// sidebar, no Qobuz context menus, and the local-only version picker.
|
||||
//
|
||||
// Local actions ONLY: play all / shuffle / edit tags / add to playlist /
|
||||
// add to Mixtape. A multi-artist album gets the "+N more artists" expander;
|
||||
// a multi-disc album gets the disc dividers with their per-disc ⋯ menu.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
import "local"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// ---------------------------- document -------------------------------
|
||||
function parseDoc(json, fallback) {
|
||||
if (json === "") return fallback
|
||||
try { return JSON.parse(json) } catch (e) { return fallback }
|
||||
}
|
||||
readonly property var doc: parseDoc(QbzLocal.localAlbumJson, null)
|
||||
readonly property var album: doc ? doc.album : null
|
||||
readonly property var tracks: doc ? doc.tracks : []
|
||||
readonly property var versions: album && album.versions ? album.versions : []
|
||||
readonly property var allArtists: {
|
||||
if (!album) return []
|
||||
var raw = (album.allArtists || "").split(",")
|
||||
var out = []
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
var n = raw[i].trim()
|
||||
if (n !== "" && out.indexOf(n) < 0) out.push(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// The Slint's `info-line` — built in Rust there, derived here from the
|
||||
// fields the album row already carries.
|
||||
readonly property string infoLine: {
|
||||
if (!album) return ""
|
||||
var parts = []
|
||||
if ((album.year || "") !== "") parts.push(album.year)
|
||||
parts.push((album.trackCount || 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev))
|
||||
if ((album.duration || "") !== "") parts.push(album.duration)
|
||||
if ((album.format || "") !== "") parts.push(album.format.toUpperCase())
|
||||
return parts.join(" • ")
|
||||
}
|
||||
|
||||
property string trackQuery: ""
|
||||
// Client-side track search: an album's track list is bounded, so the
|
||||
// Slint's LocalAlbumActions.search is a pure view filter here.
|
||||
readonly property var visibleTracks: {
|
||||
if (trackQuery === "") return tracks
|
||||
var q = trackQuery.toLowerCase()
|
||||
var out = []
|
||||
for (var i = 0; i < tracks.length; i++) {
|
||||
if ((tracks[i].title || "").toLowerCase().indexOf(q) >= 0) out.push(tracks[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Disc divider before the first row of each disc on a multi-disc album
|
||||
// (0 = flat list, as the Slint's disc-header-number).
|
||||
function discHeader(i) {
|
||||
var t = visibleTracks[i]
|
||||
if (!t) return 0
|
||||
var multi = false
|
||||
for (var j = 0; j < visibleTracks.length; j++) {
|
||||
if ((visibleTracks[j].disc || 1) > 1) { multi = true; break }
|
||||
}
|
||||
if (!multi) return 0
|
||||
if (i === 0) return t.disc || 1
|
||||
return (visibleTracks[i - 1].disc || 1) !== (t.disc || 1) ? (t.disc || 1) : 0
|
||||
}
|
||||
|
||||
// Cover — the same id-keyed artwork channel every local surface uses.
|
||||
property var artMap: ({})
|
||||
Connections {
|
||||
target: QbzLocal
|
||||
function onLocalArtworkReady(key, path) {
|
||||
var m = root.artMap
|
||||
m[key] = path
|
||||
root.artMap = Object.assign({}, m)
|
||||
}
|
||||
}
|
||||
onAlbumChanged: {
|
||||
if (album && album.artKey) {
|
||||
QbzLocal.artworkWindow(JSON.stringify([album.artKey]))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ page ===================================
|
||||
// Neutral header band (local albums have no artwork-derived tint yet).
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: 0
|
||||
width: parent.width
|
||||
height: 340
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#181820" }
|
||||
GradientStop { position: 0.16; color: "#181820" }
|
||||
GradientStop { position: 1.0; color: "#00181820" }
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: page.height + 100
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: page
|
||||
x: 32
|
||||
y: 11
|
||||
width: parent.width - 64
|
||||
spacing: 0
|
||||
|
||||
// ---- History navigation ----
|
||||
Row {
|
||||
spacing: 6
|
||||
QbzNavButton {
|
||||
name: "chevron-left"
|
||||
btnEnabled: QbzShell.canBack
|
||||
onClicked: QbzShell.navigateBack()
|
||||
}
|
||||
QbzNavButton {
|
||||
name: "chevron-right"
|
||||
btnEnabled: QbzShell.canForward
|
||||
onClicked: QbzShell.navigateForward()
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 22 }
|
||||
|
||||
// ---- Album header ----
|
||||
LocalAlbumHeader {
|
||||
width: parent.width
|
||||
album: root.album
|
||||
allArtists: root.allArtists
|
||||
infoLine: root.infoLine
|
||||
versions: root.versions
|
||||
coverSource: root.album ? (root.artMap[root.album.artKey] || "") : ""
|
||||
onOpenArtist: function (name) { root.openArtist(name) }
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
Item { width: 1; height: 8 }
|
||||
|
||||
// ---- Loading ----
|
||||
Item {
|
||||
visible: QbzLocal.localAlbumLoading && root.tracks.length === 0
|
||||
width: parent.width
|
||||
height: visible ? 280 : 0
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 36
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Toolbar: quality badge + track search ----
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 52
|
||||
QualityBadgeFull {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tier: root.album ? (root.album.qualityTier || "") : ""
|
||||
detail: root.album ? (root.album.qualityDetail || "") : ""
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 280
|
||||
height: 34
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: theme.surfaceElevated
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
Item {
|
||||
width: parent.width - 21
|
||||
height: parent.height
|
||||
clip: true
|
||||
TextInput {
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
selectByMouse: true
|
||||
onTextEdited: root.trackQuery = text
|
||||
}
|
||||
Text {
|
||||
visible: searchInput.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Column header ----
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 40
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
spacing: 16
|
||||
Text {
|
||||
width: 32
|
||||
height: parent.height
|
||||
text: "#"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.letterSpacing: 0.5
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 24 - 32 - 80 - 80 - 28 - 32 - 5 * 16
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Title", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.letterSpacing: 0.5
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
width: 80
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Duration", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.letterSpacing: 0.5
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
width: 80
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Quality", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.letterSpacing: 0.5
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
// Source column + the per-row more-button slot.
|
||||
Item { width: 28; height: parent.height }
|
||||
Item { width: 32; height: parent.height }
|
||||
}
|
||||
|
||||
// ---- Track list ----
|
||||
// An album's track count is bounded (the windowed views are for
|
||||
// the library-scale surfaces), so this mounts like the Slint.
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: root.visibleTracks
|
||||
delegate: Column {
|
||||
id: trackBlock
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: page.width
|
||||
spacing: 0
|
||||
|
||||
// Disc divider + its per-disc ⋯ menu.
|
||||
Item {
|
||||
visible: root.discHeader(trackBlock.index) > 0
|
||||
width: parent.width
|
||||
height: visible ? 40 : 0
|
||||
Text {
|
||||
x: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Disc", QbzSession.trRev) + " "
|
||||
+ root.discHeader(trackBlock.index)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width - 44
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: discArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
QbzIcon {
|
||||
name: "ellipsis"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: discArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: discArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
discMenu.openAtCursor(discArea, mouse.x, mouse.y)
|
||||
}
|
||||
}
|
||||
CardMenu {
|
||||
id: discMenu
|
||||
menuWidth: 200
|
||||
entries: [
|
||||
{ "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) {
|
||||
QbzLocal.albumDiscAction(root.discHeader(trackBlock.index), a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalTrackRow {
|
||||
width: page.width
|
||||
item: trackBlock.modelData
|
||||
number: trackBlock.modelData.number > 0
|
||||
? trackBlock.modelData.number : trackBlock.index + 1
|
||||
showAlbum: false
|
||||
showArtwork: false
|
||||
onPlayRequested: QbzLocal.playAlbumTrack(
|
||||
root.album.id, trackBlock.modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: flick
|
||||
visible: flick.contentHeight > flick.height
|
||||
}
|
||||
|
||||
// Local/Plex artists have no catalog id, so "go to artist" is a NAME
|
||||
// route into the Local Library Artists tab (the Slint's source-aware
|
||||
// open-artist).
|
||||
function openArtist(name) {
|
||||
QbzLocal.openArtistByName(name)
|
||||
QbzShell.navigateTo("local")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
// Vertical A-Z jump strip (LocalLibraryView.slint:577 AlphaStrip, itself a
|
||||
// 1:1 lift of FavoritesView's). 18px gutter, 15px rows, 9px semibold muted
|
||||
// letters that go accent on hover. Emits jump(ordinal, index); the host
|
||||
// scrolls its own view proportionally — exactly like the Slint (:1298).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// [{ letter, index }] — index = the row ordinal in the flat model.
|
||||
property var jumps: []
|
||||
signal jump(int ordinal, int index)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 18
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: root.jumps
|
||||
delegate: Item {
|
||||
id: cell
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: 18
|
||||
height: 15
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: cell.modelData.letter
|
||||
color: cellArea.containsMouse ? theme.accent : theme.textMuted
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
MouseArea {
|
||||
id: cellArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.jump(cell.index, cell.modelData.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// One chip in the Albums quality/format/source filter popup
|
||||
// (LocalLibraryView.slint:676 FilterChip). 30px tall, label width + 22px,
|
||||
// radius 6 (ADR-008 — NOT a pill), accent fill + accent-text label when
|
||||
// active, 1px border that follows the same state.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal toggled()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 30
|
||||
width: lbl.implicitWidth + 22
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: active ? theme.accent : theme.borderSubtle
|
||||
color: active ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
|
||||
Text {
|
||||
id: lbl
|
||||
anchors.centerIn: parent
|
||||
text: root.label
|
||||
color: root.active ? theme.accentText : theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: root.active ? theme.weightSemibold : theme.weightRegular
|
||||
}
|
||||
MouseArea {
|
||||
id: chipArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Subfolder cover card for the tree-mode folder-detail pane
|
||||
// (LocalLibraryView.slint:57 FolderSubcard). Bordered card (radius 6, accent
|
||||
// border on hover), square cover = card width - 16 with a folder placeholder,
|
||||
// name 13px/medium, recursive track count 11px/muted. Click drills in.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var item: ({})
|
||||
/// Resolved cover for item.artKey (the host's artMap lookup).
|
||||
property string artSource: ""
|
||||
signal opened()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: cardArea.containsMouse ? theme.accent : theme.borderSubtle
|
||||
color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 6
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.width
|
||||
radius: 4
|
||||
color: theme.surfaceMain
|
||||
clip: true
|
||||
QbzIcon {
|
||||
name: "folder"
|
||||
width: 32
|
||||
height: 32
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.item.name || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: (root.item.trackCount || 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: cardArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.opened()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// The Local Library album collection — the Qt equivalent of
|
||||
// discover/AlbumCollectionView.slint as LocalLibraryView.slint mounts it
|
||||
// (:1255 Albums, :1601 Folders-flat, :2409 Artists detail): grid or list,
|
||||
// grouped or flat, with the source badge and multi-select.
|
||||
//
|
||||
// WHY A CHUNK MODEL AND NOT A GridView + Repeater
|
||||
// The Slint calls this a "chunked grid" and it is the one surface with the
|
||||
// documented 16k-row freeze. Here EVERY arm is a single windowing ListView
|
||||
// over a flat "entry" array:
|
||||
// { t: 0, label } -> group header (34px)
|
||||
// { t: 1, items: [...], base: n } -> one visual row of cards, or one
|
||||
// list row; `base` is the row's
|
||||
// first index in the flat album array
|
||||
// so grouping costs one O(n) rebuild when the inputs change (never per
|
||||
// frame) and the mounted delegate count stays proportional to the viewport,
|
||||
// not to the library. Artwork is reported from the mounted entry range only,
|
||||
// via the host's windowing helpers.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../cards"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// The LocalLibraryView root — artMap + queueWindowReport live there.
|
||||
property var view: null
|
||||
/// Flat album rows (already searched/sorted/filtered by the host).
|
||||
property var rows: []
|
||||
/// [{ letter, items: [...] }] — only read when `grouped`.
|
||||
property var groups: []
|
||||
property bool grouped: false
|
||||
property string viewMode: "grid" // "grid" | "list"
|
||||
property bool showSource: true
|
||||
property bool selectMode: false
|
||||
/// { albumId: true } — the host owns the selection set.
|
||||
property var selected: ({})
|
||||
|
||||
signal openRequested(string id)
|
||||
signal playRequested(string id)
|
||||
signal enqueueRequested(string id, string mode)
|
||||
signal toggleSelect(string id)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property int cellW: 220
|
||||
readonly property int cellH: 266
|
||||
readonly property int headerH: 34
|
||||
readonly property int listRowH: 56
|
||||
readonly property int cols: viewMode === "grid"
|
||||
? Math.max(1, Math.floor(width / cellW)) : 1
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Chunk model
|
||||
// ---------------------------------------------------------------------
|
||||
// `flat` is the album array in display order (the window report indexes
|
||||
// into it); `entries` is what the ListView actually mounts.
|
||||
property var flat: []
|
||||
property var entries: []
|
||||
/// [{ letter, index }] with `index` = the ENTRY index of that letter's
|
||||
/// header — what AlphaStrip needs to scroll here.
|
||||
property var alphaJumps: []
|
||||
|
||||
function rebuild() {
|
||||
var out = []
|
||||
var flatOut = []
|
||||
var jumps = []
|
||||
var per = cols
|
||||
var i, j
|
||||
function pushChunk(items) {
|
||||
for (j = 0; j < items.length; j += per) {
|
||||
out.push({ "t": 1, "base": flatOut.length + j,
|
||||
"items": items.slice(j, j + per) })
|
||||
}
|
||||
for (j = 0; j < items.length; j++) flatOut.push(items[j])
|
||||
}
|
||||
if (grouped) {
|
||||
for (i = 0; i < groups.length; i++) {
|
||||
var g = groups[i]
|
||||
if (!g.items || g.items.length === 0) continue
|
||||
jumps.push({ "letter": g.letter, "index": out.length })
|
||||
out.push({ "t": 0, "label": g.letter })
|
||||
pushChunk(g.items)
|
||||
}
|
||||
} else {
|
||||
pushChunk(rows)
|
||||
}
|
||||
flat = flatOut
|
||||
entries = out
|
||||
alphaJumps = jumps
|
||||
report()
|
||||
}
|
||||
onRowsChanged: rebuild()
|
||||
onGroupsChanged: rebuild()
|
||||
onGroupedChanged: rebuild()
|
||||
onViewModeChanged: rebuild()
|
||||
onColsChanged: rebuild()
|
||||
Component.onCompleted: rebuild()
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Window report — the MOUNTED entry band, mapped back to flat indices.
|
||||
// ---------------------------------------------------------------------
|
||||
function report() {
|
||||
if (!view || !list || !visible || entries.length === 0) return
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = Math.min(entries.length - 1, first + 8)
|
||||
var lo = entries[first]
|
||||
var hi = entries[last]
|
||||
var loIdx = lo ? (lo.t === 1 ? lo.base : 0) : 0
|
||||
var hiIdx = hi ? (hi.t === 1 ? hi.base + hi.items.length - 1 : loIdx) : loIdx
|
||||
view.queueWindowReport(flat, loIdx, hiIdx)
|
||||
}
|
||||
|
||||
function jumpToEntry(entryIndex) {
|
||||
list.positionViewAtIndex(entryIndex, ListView.Beginning)
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
cacheBuffer: root.viewMode === "grid" ? root.cellH * 2 : root.listRowH * 10
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: list.width
|
||||
height: modelData.t === 0 ? root.headerH
|
||||
: root.viewMode === "grid" ? root.cellH : root.listRowH
|
||||
sourceComponent: modelData.t === 0 ? headerComp
|
||||
: root.viewMode === "grid" ? cardRowComp : listRowComp
|
||||
|
||||
Component {
|
||||
id: headerComp
|
||||
Item {
|
||||
Text {
|
||||
x: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One visual row of album cards.
|
||||
Component {
|
||||
id: cardRowComp
|
||||
Row {
|
||||
spacing: root.cellW - 200
|
||||
Repeater {
|
||||
model: modelData.items
|
||||
delegate: Item {
|
||||
id: cardCell
|
||||
required property var modelData
|
||||
width: 200
|
||||
height: 246
|
||||
AlbumCard {
|
||||
localMode: true
|
||||
albumId: cardCell.modelData.id
|
||||
title: cardCell.modelData.title
|
||||
artist: cardCell.modelData.artist
|
||||
artistId: ""
|
||||
genre: ""
|
||||
year: cardCell.modelData.year
|
||||
qualityTier: cardCell.modelData.qualityTier
|
||||
artSource: root.view
|
||||
? (root.view.artMap[cardCell.modelData.artKey] || "") : ""
|
||||
source: root.showSource ? cardCell.modelData.source : ""
|
||||
onOpenRequested: root.selectMode
|
||||
? root.toggleSelect(cardCell.modelData.id)
|
||||
: root.openRequested(cardCell.modelData.id)
|
||||
onPlayRequested: root.playRequested(cardCell.modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
root.enqueueRequested(cardCell.modelData.id, m)
|
||||
}
|
||||
}
|
||||
// Multi-select tick — the collection view's
|
||||
// select-mode overlay (top-left of the artwork).
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: 8
|
||||
y: 8
|
||||
width: 22
|
||||
height: 22
|
||||
radius: 11
|
||||
color: "#b3000000"
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.selected[cardCell.modelData.id] === true
|
||||
onToggled: root.toggleSelect(cardCell.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One list row.
|
||||
Component {
|
||||
id: listRowComp
|
||||
LocalAlbumRow {
|
||||
width: list.width
|
||||
item: modelData.items[0]
|
||||
artSource: root.view
|
||||
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
|
||||
showSource: root.showSource
|
||||
selectMode: root.selectMode
|
||||
checked: root.selected[modelData.items[0].id] === true
|
||||
onOpened: root.openRequested(modelData.items[0].id)
|
||||
onPlayRequested: root.playRequested(modelData.items[0].id)
|
||||
onToggleSelect: root.toggleSelect(modelData.items[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: list
|
||||
visible: list.contentHeight > list.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Local album header block (album/LocalAlbumView.slint:213-404): the 224px
|
||||
// cover, the title / artist line with the "+N more artists" expander and its
|
||||
// expanded list, the info line, the local action row (play / shuffle / edit
|
||||
// tags / add to playlist / add to Mixtape) and the version picker.
|
||||
//
|
||||
// `artistsExpanded` is view-local and resets when another album opens — the
|
||||
// Slint watches its own mirror property for exactly this (:156).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Row {
|
||||
id: root
|
||||
|
||||
property var album: null
|
||||
/// De-duplicated contributor list (>1 turns the expander on).
|
||||
property var allArtists: []
|
||||
property string infoLine: ""
|
||||
property var versions: []
|
||||
property string coverSource: ""
|
||||
signal openArtist(string name)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
property bool artistsExpanded: false
|
||||
onAlbumChanged: artistsExpanded = false
|
||||
|
||||
spacing: 32
|
||||
|
||||
Rectangle {
|
||||
width: 224
|
||||
height: 224
|
||||
radius: 12
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverSource
|
||||
radius: 12
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 224 - 32
|
||||
topPadding: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.album ? (root.album.title || "") : ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
Row {
|
||||
spacing: 8
|
||||
Text {
|
||||
text: root.album ? (root.album.artist || "") : ""
|
||||
color: artistArea.containsMouse ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
id: artistArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.openArtist(root.album.artist)
|
||||
}
|
||||
}
|
||||
// Multi-artist album (compilations): "+N more artists" expands
|
||||
// the full track-artist list below.
|
||||
Text {
|
||||
visible: root.allArtists.length > 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.artistsExpanded
|
||||
? QbzSession.tr("Show less", QbzSession.trRev)
|
||||
: "+" + (root.allArtists.length - 1) + " "
|
||||
+ QbzSession.tr("more artists", QbzSession.trRev)
|
||||
color: moreArea.containsMouse ? theme.accent : theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
MouseArea {
|
||||
id: moreArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.artistsExpanded = !root.artistsExpanded
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expanded track-artist list — each entry routes to the Local
|
||||
// Library Artists tab (name-based).
|
||||
Column {
|
||||
visible: root.artistsExpanded && root.allArtists.length > 1
|
||||
width: parent.width
|
||||
topPadding: 6
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: root.allArtists
|
||||
delegate: Text {
|
||||
id: nameRow
|
||||
required property string modelData
|
||||
height: 24
|
||||
text: modelData
|
||||
color: nameArea.containsMouse ? theme.accent : theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
MouseArea {
|
||||
id: nameArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.openArtist(nameRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 10 }
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.infoLine
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { width: 1; height: 20 }
|
||||
|
||||
// ---- Local action row ----
|
||||
Row {
|
||||
spacing: 12
|
||||
QbzCircleAction {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
onClicked: QbzLocal.playAlbum(root.album.id, false)
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "shuffle"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.playAlbum(root.album.id, true)
|
||||
}
|
||||
// ASSET GAP: Slint uses `pencil`; the Qt set ships pen-line.
|
||||
QbzCircleAction {
|
||||
name: "pen-line"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.albumEditTags(root.album.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "list-plus"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.albumAddToPlaylist(root.album.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "cassette-tape"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.albumAddToMixtape(root.album.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Version picker (multiple physical copies only) ----
|
||||
Item {
|
||||
visible: root.versions.length > 1
|
||||
width: 1
|
||||
height: visible ? 16 : 0
|
||||
}
|
||||
Row {
|
||||
visible: root.versions.length > 1
|
||||
spacing: 8
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.versions.length + " "
|
||||
+ QbzSession.tr("versions", QbzSession.trRev) + ":"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
VersionPicker {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
versions: root.versions
|
||||
current: root.album ? (root.album.versionIndex || 0) : 0
|
||||
onPicked: function (i) { QbzLocal.albumSelectVersion(i) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Compact album row for the LIST arm of the Albums / Folders-flat / Artists
|
||||
// surfaces (AlbumCollectionView's list mode, mounted by
|
||||
// LocalLibraryView.slint:1255 with show-source / show-source-badge on).
|
||||
//
|
||||
// 56px: cover, title + artist, year, track count, quality mark and the
|
||||
// source column. Multi-select puts the shared checkbox in front of the
|
||||
// cover, as the collection view does.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var item: ({})
|
||||
property string artSource: ""
|
||||
property bool showSource: true
|
||||
property bool selectMode: false
|
||||
property bool checked: false
|
||||
signal opened()
|
||||
signal playRequested()
|
||||
signal toggleSelect()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 56
|
||||
radius: 6
|
||||
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.selectMode ? root.toggleSelect() : root.opened()
|
||||
onDoubleClicked: if (!root.selectMode) root.playRequested()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Item {
|
||||
visible: root.selectMode
|
||||
width: visible ? 16 : 0
|
||||
height: parent.height
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.checked
|
||||
onToggled: root.toggleSelect()
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 40
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 4
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 40 - 70 - 90 - 92 - (root.showSource ? 34 : 0)
|
||||
- (root.selectMode ? 28 : 0) - 5 * 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
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.textMuted
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 70
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.item.year || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Text {
|
||||
width: 90
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: (root.item.trackCount || 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Item {
|
||||
width: 92
|
||||
height: parent.height
|
||||
QualityMini {
|
||||
tier: root.item.qualityTier || ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
Item {
|
||||
visible: root.showSource
|
||||
width: visible ? 34 : 0
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
visible: root.item.source === "local" || root.item.source === "plex"
|
||||
|| root.item.source === "offline"
|
||||
name: root.item.source === "offline" ? "cloud-download" : "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: root.item.source === "plex" ? "accent" : "muted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Albums tab body (LocalLibraryView.slint:1133).
|
||||
//
|
||||
// States, in the Slint's own order: initial spinner, load error + Retry,
|
||||
// "nothing indexed yet", "nothing matches" (search OR the quality/format/
|
||||
// source filter emptied the visible set), then the chunked collection with
|
||||
// the bulk bar and the A-Z jump strip.
|
||||
//
|
||||
// Deviation: the Slint scrolls its MultiSelectBar away with the grid (it
|
||||
// lives inside the scrolling page); here the bar is pinned above the
|
||||
// collection, because the collection IS the scrolling view (one windowing
|
||||
// ListView) rather than a page inside a Flickable.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 36
|
||||
visible: QbzLocal.localAlbumsLoading
|
||||
}
|
||||
|
||||
// Load error + retry.
|
||||
Column {
|
||||
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError !== ""
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Couldn't load your local albums.", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
SettingsButton {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Retry", QbzSession.trRev)
|
||||
onClicked: QbzLocal.loadTab("albums")
|
||||
}
|
||||
}
|
||||
|
||||
LocalNote {
|
||||
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
|
||||
&& root.view.albums.length === 0 && root.view.albumsSearch === ""
|
||||
text: QbzSession.tr("No albums in your local library yet.", QbzSession.trRev)
|
||||
}
|
||||
LocalNote {
|
||||
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
|
||||
&& root.view.albums.length > 0 && root.view.albumsVisible.length === 0
|
||||
text: QbzSession.tr("No albums match your search.", QbzSession.trRev)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 16
|
||||
spacing: 8
|
||||
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
|
||||
&& root.view.albumsVisible.length > 0
|
||||
|
||||
LocalMultiSelectBar {
|
||||
visible: root.view.albumsMultiSelect
|
||||
width: parent.width
|
||||
selectedCount: root.view.albumsSelectedCount
|
||||
actions: [
|
||||
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
||||
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
|
||||
]
|
||||
onAction: function (id) { root.view.albumsBulkAction(id) }
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - (root.view.albumsMultiSelect ? 52 : 0)
|
||||
|
||||
LocalAlbumCollection {
|
||||
id: collection
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.view.albumsGroup === "alpha" ? 20 : 0
|
||||
view: root.view
|
||||
rows: root.view.albumsVisible
|
||||
groups: root.view.albumsGrouped
|
||||
grouped: root.view.albumsGroup !== "off"
|
||||
viewMode: root.view.albumsView
|
||||
showSource: true
|
||||
selectMode: root.view.albumsMultiSelect
|
||||
selected: root.view.albumsSelected
|
||||
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
||||
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
|
||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
||||
onToggleSelect: function (id) { root.view.toggleAlbumSelected(id) }
|
||||
}
|
||||
AlphaStrip {
|
||||
visible: root.view.albumsGroup === "alpha"
|
||||
&& collection.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
jumps: collection.alphaJumps
|
||||
onJump: function (ordinal, index) { collection.jumpToEntry(index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// One row of the Artists master rail (LocalLibraryView.slint:609
|
||||
// LocalArtistRow). 60px tall, radius 8, 48px ROUND avatar (cached photo or a
|
||||
// user glyph), display name + "N albums · M tracks". Selected = accent fill
|
||||
// with accent-text labels (that is the Slint, :615-660 — the accent fill is
|
||||
// the master-list selection, not a pill).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var item: ({})
|
||||
property bool selected: false
|
||||
property string artSource: ""
|
||||
signal picked(string name)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 60
|
||||
radius: 8
|
||||
color: selected ? theme.accent
|
||||
: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 12
|
||||
Rectangle {
|
||||
width: 48
|
||||
height: 48
|
||||
radius: 24
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
QbzIcon {
|
||||
visible: root.artSource === ""
|
||||
name: "user"
|
||||
width: 22
|
||||
height: 22
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.artSource
|
||||
radius: 24
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 60
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.item.displayName || root.item.name || ""
|
||||
color: root.selected ? theme.accentText : theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: root.selected ? theme.weightSemibold : theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: (root.item.albumCount || 0) + " "
|
||||
+ QbzSession.tr("albums", QbzSession.trRev) + " · "
|
||||
+ (root.item.trackCount || 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev)
|
||||
color: root.selected ? theme.accentText : theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.picked(root.item.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Artists tab body (LocalLibraryView.slint:2213) — two-column
|
||||
// master/detail: the merged, A-Z grouped artist rail on the left (280px,
|
||||
// with its own AlphaStrip), that artist's albums on the right.
|
||||
//
|
||||
// The Slint rail is a plain Flickable with a nested for-per-section; here
|
||||
// the sections are flattened into one entry array so the rail is a WINDOWING
|
||||
// ListView — a 4k-artist library mounts a viewport, not 4k rows.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property int headerH: 22
|
||||
readonly property int rowH: 62
|
||||
|
||||
// --------------------------- entry model ------------------------------
|
||||
property var entries: []
|
||||
property var alphaJumps: []
|
||||
function rebuild() {
|
||||
var groups = view ? view.artistsGrouped : []
|
||||
var out = []
|
||||
var jumps = []
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
var g = groups[i]
|
||||
if (!g.items || g.items.length === 0) continue
|
||||
jumps.push({ "letter": g.letter, "index": out.length })
|
||||
out.push({ "t": 0, "label": g.letter })
|
||||
for (var j = 0; j < g.items.length; j++) out.push({ "t": 1, "item": g.items[j] })
|
||||
}
|
||||
entries = out
|
||||
alphaJumps = jumps
|
||||
}
|
||||
Component.onCompleted: rebuild()
|
||||
Connections {
|
||||
target: root.view
|
||||
function onArtistsGroupedChanged() { root.rebuild() }
|
||||
}
|
||||
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 36
|
||||
visible: QbzLocal.localArtistsLoading
|
||||
}
|
||||
// Empty library — icon + line (Slint :2222).
|
||||
Column {
|
||||
visible: !QbzLocal.localArtistsLoading && root.view.artists.length === 0
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
QbzIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "user"
|
||||
width: 44
|
||||
height: 44
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("No artists in your local library yet.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
visible: !QbzLocal.localArtistsLoading && root.view.artists.length > 0
|
||||
spacing: 0
|
||||
|
||||
// ---------------------- master rail ------------------------------
|
||||
Item {
|
||||
width: 280
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 24
|
||||
anchors.topMargin: 12
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.view.artistsVisibleCount + " "
|
||||
+ QbzSession.tr("artists", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Text {
|
||||
visible: root.view.artistsVisibleCount === 0
|
||||
text: QbzSession.tr("No artists match your search.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
Item {
|
||||
visible: root.view.artistsVisibleCount > 0
|
||||
width: parent.width
|
||||
height: parent.height - 30
|
||||
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 22
|
||||
clip: true
|
||||
cacheBuffer: 62 * 8
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: rail.width
|
||||
height: modelData.t === 0 ? root.headerH : root.rowH
|
||||
sourceComponent: modelData.t === 0 ? letterComp : artistComp
|
||||
|
||||
Component {
|
||||
id: letterComp
|
||||
Item {
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: artistComp
|
||||
LocalArtistRow {
|
||||
width: rail.width
|
||||
item: modelData.item
|
||||
artSource: root.view.artMap[modelData.item.artKey] || ""
|
||||
selected: root.view.selectedArtist === modelData.item.name
|
||||
onPicked: function (name) { root.view.selectArtist(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AlphaStrip {
|
||||
visible: root.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
jumps: root.alphaJumps
|
||||
onJump: function (ordinal, index) {
|
||||
rail.positionViewAtIndex(index, ListView.Beginning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// ---------------------- detail pane ------------------------------
|
||||
Item {
|
||||
width: parent.width - 281
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
visible: root.view.selectedArtist === ""
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
QbzIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "user"
|
||||
width: 44
|
||||
height: 44
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Select an artist to view their albums", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 28
|
||||
anchors.rightMargin: 8
|
||||
anchors.topMargin: 14
|
||||
spacing: 12
|
||||
visible: root.view.selectedArtist !== ""
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.view.selectedArtist
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
text: root.view.artistAlbums.length + " "
|
||||
+ QbzSession.tr("albums", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: root.view.artistAlbums.length === 0
|
||||
width: parent.width
|
||||
topPadding: 24
|
||||
text: QbzSession.tr("No albums found", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
LocalAlbumCollection {
|
||||
visible: root.view.artistAlbums.length > 0
|
||||
width: parent.width
|
||||
height: parent.height - 60
|
||||
view: root.view
|
||||
rows: root.view.artistAlbums
|
||||
grouped: false
|
||||
viewMode: "grid"
|
||||
showSource: true
|
||||
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
||||
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
|
||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Artist avatars ride the same id-keyed artwork window as every other
|
||||
// cover; the rail reports its mounted band.
|
||||
function report() {
|
||||
if (!view || !rail) return
|
||||
var first = rail.indexAt(4, rail.contentY + 1)
|
||||
var last = rail.indexAt(4, rail.contentY + Math.max(1, rail.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = first + 10
|
||||
view.queueWindowReport(view.artistsVisible, Math.max(0, first - 4), last + 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Local Library FIXED CHROME (LocalLibraryView.slint:710-1125) — the
|
||||
// mandatory two-row header this app's list views all share:
|
||||
//
|
||||
// row 1 (46px) "Local Library" + the settings gear + the Plex re-sync
|
||||
// button (#573, only when Plex is enabled + authenticated)
|
||||
// row 2 (44px) the segmented tab bar with count badges on the left, the
|
||||
// per-tab toolbar floated right
|
||||
// then the 1px divider.
|
||||
//
|
||||
// Total height 91px — the content area subtracts exactly this.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 91
|
||||
|
||||
// ---- Row 1: title + gear + Plex sync ----
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 46
|
||||
Row {
|
||||
x: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Local Library", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
// Folder management, scan and maintenance do NOT live in this
|
||||
// view (Slint header note) — the gear routes out.
|
||||
QbzNavButton {
|
||||
name: "settings-2"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzShell.navigateTo("settings")
|
||||
}
|
||||
Item {
|
||||
visible: QbzLocal.plexAvailable
|
||||
width: visible ? 28 : 0
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzNavButton {
|
||||
anchors.fill: parent
|
||||
name: "refresh-cw"
|
||||
btnEnabled: !QbzLocal.plexSyncing
|
||||
onClicked: QbzLocal.syncPlex()
|
||||
}
|
||||
QbzSpinner {
|
||||
visible: QbzLocal.plexSyncing
|
||||
anchors.centerIn: parent
|
||||
size: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Row 2: tabs (left) + per-tab toolbar (right) ----
|
||||
Item {
|
||||
y: 46
|
||||
width: parent.width
|
||||
height: 44
|
||||
|
||||
QbzTabBar {
|
||||
x: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
counts: true
|
||||
underline: true
|
||||
activeId: root.view.activeTab
|
||||
// NOTE the `root.view.` prefix: QbzTabBar has its OWN `counts`
|
||||
// property (the badge arm), so an unqualified `counts` here
|
||||
// would resolve to that bool, not to the view's document.
|
||||
tabs: [
|
||||
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
|
||||
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
|
||||
{ "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
|
||||
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 },
|
||||
]
|
||||
onSelected: function (id) { root.view.activeTab = id }
|
||||
}
|
||||
|
||||
LocalToolbar {
|
||||
x: parent.width - width - 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
view: root.view
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: 90
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Ephemeral-folder pane (LocalLibraryView.slint:114 EphemeralPane) — shown
|
||||
// in the Folders tab while an ad-hoc folder is open. Album-grouped (a folder
|
||||
// can hold several albums): each block is a 56px cover + title/artist/meta,
|
||||
// with a per-album play button ONLY for multi-album sessions, then the
|
||||
// block's tracks.
|
||||
//
|
||||
// Metadata-bound actions (favorite / playlist / album+artist links) are off:
|
||||
// ephemeral tracks have no DB row. The CUE badge marks a single-file rip.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../rows"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var eph: view ? view.ephemeral : null
|
||||
readonly property var albums: eph && eph.albums ? eph.albums : []
|
||||
|
||||
Flickable {
|
||||
id: pane
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: page.height + 100
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: page
|
||||
x: 32
|
||||
y: 16
|
||||
width: parent.width - 64
|
||||
spacing: 14
|
||||
|
||||
// ---- Header: name + count · path, Play all, Clear ----
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Column {
|
||||
width: parent.width - 2 * 40 - 2 * 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.eph ? (root.eph.name || "") : ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: (root.eph ? (root.eph.trackCount || 0) : 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev) + " · "
|
||||
+ (root.eph ? (root.eph.path || "") : "")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ephemeralPlayAll()
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "x"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.ephemeralClear()
|
||||
}
|
||||
}
|
||||
|
||||
// Scanning.
|
||||
QbzSpinner {
|
||||
visible: QbzLocal.localEphemeralLoading
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
size: 28
|
||||
}
|
||||
|
||||
// Empty (no playable tracks).
|
||||
Text {
|
||||
visible: !QbzLocal.localEphemeralLoading && root.albums.length === 0
|
||||
width: parent.width
|
||||
topPadding: 24
|
||||
text: QbzSession.tr("No playable tracks in this folder.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
// ---- Album blocks ----
|
||||
Column {
|
||||
visible: !QbzLocal.localEphemeralLoading
|
||||
width: parent.width
|
||||
spacing: 22
|
||||
Repeater {
|
||||
model: root.albums
|
||||
delegate: Column {
|
||||
id: block
|
||||
required property var modelData
|
||||
width: page.width
|
||||
spacing: 6
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Rectangle {
|
||||
width: 56
|
||||
height: 56
|
||||
radius: 6
|
||||
color: theme.surfaceHover
|
||||
clip: true
|
||||
QbzIcon {
|
||||
name: "disc-3"
|
||||
width: 24
|
||||
height: 24
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.view.artMap[block.modelData.artKey] || ""
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 56 - 12
|
||||
- (root.eph && root.eph.multiAlbum ? 46 : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
Text {
|
||||
width: parent.width - (block.modelData.isCue ? 44 : 0)
|
||||
text: block.modelData.title || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// CUE badge (single-file rip) — a 16px
|
||||
// rounded rect, not a pill (ADR-008).
|
||||
Rectangle {
|
||||
visible: block.modelData.isCue === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: cueText.implicitWidth + 12
|
||||
height: 16
|
||||
radius: 3
|
||||
color: theme.surfaceElevated
|
||||
Text {
|
||||
id: cueText
|
||||
anchors.centerIn: parent
|
||||
text: "CUE"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: block.modelData.artist || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
visible: (block.modelData.meta || "") !== ""
|
||||
width: parent.width
|
||||
text: block.modelData.meta || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
QbzCircleAction {
|
||||
visible: root.eph && root.eph.multiAlbum === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "play-fill"
|
||||
onClicked: QbzLocal.ephemeralPlayAlbum(block.modelData.groupKey)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: block.modelData.tracks || []
|
||||
delegate: TrackRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: page.width
|
||||
item: modelData
|
||||
number: index + 1
|
||||
showArtwork: false
|
||||
showAlbum: false
|
||||
showFavorite: false
|
||||
showDownload: false
|
||||
showMenu: false
|
||||
draggable: false
|
||||
onPlayRequested: QbzLocal.ephemeralPlayTrack(modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: pane
|
||||
visible: pane.contentHeight > pane.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Albums quality/format/source filter popup (LocalLibraryView.slint:2470 —
|
||||
// the component at the end of the file). Floats OVER the content: a
|
||||
// click-out backdrop plus a 372px card pinned 36px from the right edge,
|
||||
// 92px down, radius 10, surface-card, 1px subtle border.
|
||||
//
|
||||
// Three sections of FilterChips: Quality (Hi-Res / CD / Lossy), Format
|
||||
// (FLAC ALAC APE WAV, then MP3 AAC Other), Source (Local / Offline cache /
|
||||
// Plex). "Clear" appears in the title row only when something is active.
|
||||
//
|
||||
// The Slint card carries a 28px drop shadow; QML shadows need the graphical
|
||||
// effects path, which renders nothing on this port's software renderer
|
||||
// (QbzIcon.qml documents the same trade), so the border carries the edge.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Click-out backdrop.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.view.filterOpen = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: parent.width - width - 36
|
||||
y: 92
|
||||
width: 372
|
||||
height: col.height + 32
|
||||
color: theme.surfaceCard
|
||||
radius: 10
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
// Swallow clicks so the backdrop does not close the card.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
id: col
|
||||
x: 16
|
||||
y: 16
|
||||
width: parent.width - 32
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 28
|
||||
Text {
|
||||
width: parent.width - (root.view.filterCount > 0 ? clearBtn.width : 0)
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Filter", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
id: clearBtn
|
||||
visible: root.view.filterCount > 0
|
||||
width: clearText.implicitWidth + 18
|
||||
height: 28
|
||||
radius: 6
|
||||
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Text {
|
||||
id: clearText
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Clear", QbzSession.trRev)
|
||||
color: theme.accent
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.clearFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Quality ----
|
||||
Text {
|
||||
text: QbzSession.tr("Quality", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
FilterChip {
|
||||
label: QbzSession.tr("Hi-Res", QbzSession.trRev)
|
||||
active: root.view.filter.hires === true
|
||||
onToggled: root.view.toggleFilter("hires")
|
||||
}
|
||||
FilterChip {
|
||||
label: QbzSession.tr("CD", QbzSession.trRev)
|
||||
active: root.view.filter.cd === true
|
||||
onToggled: root.view.toggleFilter("cd")
|
||||
}
|
||||
FilterChip {
|
||||
label: QbzSession.tr("Lossy", QbzSession.trRev)
|
||||
active: root.view.filter.lossy === true
|
||||
onToggled: root.view.toggleFilter("lossy")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Format ----
|
||||
Text {
|
||||
text: QbzSession.tr("Format", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
FilterChip { label: "FLAC"; active: root.view.filter.flac === true; onToggled: root.view.toggleFilter("flac") }
|
||||
FilterChip { label: "ALAC"; active: root.view.filter.alac === true; onToggled: root.view.toggleFilter("alac") }
|
||||
FilterChip { label: "APE"; active: root.view.filter.ape === true; onToggled: root.view.toggleFilter("ape") }
|
||||
FilterChip { label: "WAV"; active: root.view.filter.wav === true; onToggled: root.view.toggleFilter("wav") }
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
FilterChip { label: "MP3"; active: root.view.filter.mp3 === true; onToggled: root.view.toggleFilter("mp3") }
|
||||
FilterChip { label: "AAC"; active: root.view.filter.aac === true; onToggled: root.view.toggleFilter("aac") }
|
||||
FilterChip {
|
||||
label: QbzSession.tr("Other", QbzSession.trRev)
|
||||
active: root.view.filter.other === true
|
||||
onToggled: root.view.toggleFilter("other")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Source ----
|
||||
Text {
|
||||
text: QbzSession.tr("Source", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
FilterChip {
|
||||
label: QbzSession.tr("Local", QbzSession.trRev)
|
||||
active: root.view.filter.local === true
|
||||
onToggled: root.view.toggleFilter("local")
|
||||
}
|
||||
FilterChip {
|
||||
label: QbzSession.tr("Offline cache", QbzSession.trRev)
|
||||
active: root.view.filter.offline === true
|
||||
onToggled: root.view.toggleFilter("offline")
|
||||
}
|
||||
FilterChip {
|
||||
label: "Plex"
|
||||
active: root.view.filter.plex === true
|
||||
onToggled: root.view.toggleFilter("plex")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Folders tree-mode RIGHT PANE — the selected folder's detail
|
||||
// (LocalLibraryView.slint:1918).
|
||||
//
|
||||
// Header: circular Play + folder name + recursive track count + the
|
||||
// persistent subfolder search + the grid/list toggle. Then the subfolder
|
||||
// section (cover cards on a 140px-minimum column grid, or compact 34px
|
||||
// rows), then the folder's DIRECT tracks, then the "no tracks" note.
|
||||
//
|
||||
// Subfolder/track counts are per-folder and bounded, so this pane mounts its
|
||||
// children like the Slint does (one Flickable page) — the windowing views
|
||||
// are for the library-scale surfaces.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var detail: view ? view.folderDetail : null
|
||||
|
||||
LocalNote {
|
||||
visible: root.view.selectedFolder === ""
|
||||
text: QbzSession.tr("Select a folder to see its contents.", QbzSession.trRev)
|
||||
}
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 28
|
||||
visible: QbzLocal.localDetailLoading
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: pane
|
||||
anchors.fill: parent
|
||||
visible: root.view.selectedFolder !== "" && !QbzLocal.localDetailLoading
|
||||
&& root.detail !== null
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: page.height + 100
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: page
|
||||
x: 28
|
||||
y: 16
|
||||
width: parent.width - 56
|
||||
spacing: 14
|
||||
|
||||
// ---- Header ----
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
QbzCircleAction {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzLocal.playFolder(root.view.selectedFolder)
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 40 - 200 - 60 - 3 * 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.detail ? root.detail.name : ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: (root.detail ? root.detail.trackCount : 0) + " "
|
||||
+ QbzSession.tr("tracks", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
LocalSearchBox {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
boxWidth: 200
|
||||
iconSize: 14
|
||||
leftPad: 10
|
||||
placeholder: QbzSession.tr("Search albums...", QbzSession.trRev)
|
||||
onEdited: function (v) { root.view.folderDetailSearch = v }
|
||||
}
|
||||
LocalSegToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
segments: [{ "id": "grid", "icon": "layout-grid" },
|
||||
{ "id": "list", "icon": "list" }]
|
||||
mode: root.view.folderDetailView
|
||||
onSetMode: function (v) { root.view.folderDetailView = v }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Subfolders ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
visible: root.detail && root.detail.subfolders.length > 0
|
||||
Text {
|
||||
text: QbzSession.tr("Folders", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Text {
|
||||
visible: root.view.detailSubfolders.length === 0
|
||||
width: parent.width
|
||||
topPadding: 8
|
||||
text: QbzSession.tr("No folders match your search.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
// Grid — the Slint's `subgrid`: 140px minimum column, 12px
|
||||
// gap, card height = column width + 42.
|
||||
Item {
|
||||
id: subgrid
|
||||
visible: root.view.folderDetailView === "grid"
|
||||
&& root.view.detailSubfolders.length > 0
|
||||
readonly property int gap: 12
|
||||
readonly property int minCw: 140
|
||||
readonly property int cols: Math.max(1, Math.floor((width + gap) / (minCw + gap)))
|
||||
readonly property real cw: (width - (cols - 1) * gap) / cols
|
||||
readonly property real ch: cw + 42
|
||||
readonly property int rws: Math.ceil(root.view.detailSubfolders.length / cols)
|
||||
width: parent.width
|
||||
height: visible && rws > 0 ? rws * ch + (rws - 1) * gap : 0
|
||||
Repeater {
|
||||
model: root.view.detailSubfolders
|
||||
delegate: FolderSubcard {
|
||||
required property var modelData
|
||||
required property int index
|
||||
x: (index % subgrid.cols) * (subgrid.cw + subgrid.gap)
|
||||
y: Math.floor(index / subgrid.cols) * (subgrid.ch + subgrid.gap)
|
||||
width: subgrid.cw
|
||||
height: subgrid.ch
|
||||
item: modelData
|
||||
artSource: root.view.artMap[modelData.artKey] || ""
|
||||
onOpened: root.view.selectFolder(modelData.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// List — compact 34px rows.
|
||||
Column {
|
||||
visible: root.view.folderDetailView === "list"
|
||||
&& root.view.detailSubfolders.length > 0
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.view.detailSubfolders
|
||||
delegate: Rectangle {
|
||||
id: subRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
height: 34
|
||||
radius: 6
|
||||
color: subArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "folder"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 60
|
||||
height: parent.height
|
||||
text: subRow.modelData.name
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
height: parent.height
|
||||
text: subRow.modelData.trackCount
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: subArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.selectFolder(subRow.modelData.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The folder's DIRECT tracks ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.detail && root.detail.tracks.length > 0
|
||||
Text {
|
||||
text: QbzSession.tr("Tracks", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Repeater {
|
||||
model: root.detail ? root.detail.tracks : []
|
||||
delegate: LocalTrackRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: page.width
|
||||
item: modelData
|
||||
number: modelData.number > 0 ? modelData.number : index + 1
|
||||
showArtwork: root.view.trackArtwork
|
||||
onPlayRequested: QbzLocal.playFolderTrack(
|
||||
root.view.selectedFolder, modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", modelData.id, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder with neither tracks nor subfolders.
|
||||
Text {
|
||||
visible: root.detail && root.detail.tracks.length === 0
|
||||
&& root.detail.subfolders.length === 0
|
||||
width: parent.width
|
||||
topPadding: 24
|
||||
text: QbzSession.tr("This folder has no tracks.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: pane
|
||||
visible: pane.visible && pane.contentHeight > pane.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Folders tab body (LocalLibraryView.slint:1531) — three mutually exclusive
|
||||
// arms: the ephemeral pane (an ad-hoc folder is open), FLAT mode (the album
|
||||
// collection grouped by directory) and TREE mode (the two-pane filesystem
|
||||
// browser).
|
||||
//
|
||||
// The tree divider is also the drag handle that resizes the rail: min 136px
|
||||
// (half the 272px default), max half the content area — the Slint's own
|
||||
// clamp at :1907.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// ---- Ephemeral ----
|
||||
LocalEphemeralPane {
|
||||
anchors.fill: parent
|
||||
visible: root.view.ephemeralActive
|
||||
view: root.view
|
||||
}
|
||||
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 36
|
||||
visible: QbzLocal.localFoldersLoading && !root.view.ephemeralActive
|
||||
}
|
||||
LocalNote {
|
||||
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
|
||||
&& root.view.folders.length === 0 && root.view.foldersSearch === ""
|
||||
text: QbzSession.tr("No folders in your local library yet.", QbzSession.trRev)
|
||||
}
|
||||
|
||||
// ---------------------------- FLAT MODE ------------------------------
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
|
||||
&& root.view.folders.length > 0 && root.view.foldersMode === "flat"
|
||||
|
||||
LocalNote {
|
||||
visible: root.view.foldersVisible.length === 0
|
||||
text: QbzSession.tr("No folders match your search.", QbzSession.trRev)
|
||||
}
|
||||
LocalAlbumCollection {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 16
|
||||
visible: root.view.foldersVisible.length > 0
|
||||
view: root.view
|
||||
rows: root.view.foldersVisible
|
||||
groups: root.view.foldersGrouped
|
||||
grouped: root.view.foldersGroup !== "off"
|
||||
viewMode: root.view.foldersGridView
|
||||
// Slint mounts the flat folders collection with show-source
|
||||
// false but show-source-badge true (:1606) — the badge on the
|
||||
// card, no source COLUMN in the list arm.
|
||||
showSource: true
|
||||
onOpenRequested: function (id) { root.view.openAlbum(id) }
|
||||
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
|
||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- TREE MODE ------------------------------
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
|
||||
&& root.view.folders.length > 0 && root.view.foldersMode === "tree"
|
||||
|
||||
LocalTreeRail {
|
||||
id: rail
|
||||
x: 0
|
||||
width: root.view.treeRailWidth
|
||||
height: parent.height
|
||||
view: root.view
|
||||
}
|
||||
|
||||
// Divider + drag handle (7px grab area, 1px visible line).
|
||||
Item {
|
||||
id: handle
|
||||
x: rail.width
|
||||
width: 7
|
||||
height: parent.height
|
||||
Rectangle {
|
||||
x: 3
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: dragArea.containsMouse || dragArea.pressed
|
||||
? theme.accent : theme.borderSubtle
|
||||
}
|
||||
MouseArea {
|
||||
id: dragArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.SizeHorCursor
|
||||
property real startW: 272
|
||||
property real startX: 0
|
||||
onPressed: function (mouse) {
|
||||
startW = root.view.treeRailWidth
|
||||
startX = mapToItem(root, mouse.x, mouse.y).x
|
||||
}
|
||||
onPositionChanged: function (mouse) {
|
||||
if (!pressed) return
|
||||
var dx = mapToItem(root, mouse.x, mouse.y).x - startX
|
||||
root.view.treeRailWidth = Math.max(136,
|
||||
Math.min(root.width / 2, startW + dx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalFolderDetail {
|
||||
x: rail.width + 7
|
||||
width: parent.width - rail.width - 7
|
||||
height: parent.height
|
||||
view: root.view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Bulk action bar (primitives/MultiSelectBar.slint, mounted by
|
||||
// LocalLibraryView.slint:1239 for Albums and :1406 for Tracks).
|
||||
//
|
||||
// 44px tall, radius 8, surface-elevated: "N selected" on the left, then one
|
||||
// 34x30 icon button per action — dimmed to 0.4 and disarmed while the action
|
||||
// needs a selection and there is none, red-bordered when it is a danger
|
||||
// action. ADR-008: bordered/hover buttons, not pills.
|
||||
//
|
||||
// The Slint puts the action label in the shared tooltip bubble; the Qt port
|
||||
// has no tooltip channel yet, so the label rides Qt's own ToolTip.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// [{ id, label, icon, danger, needsSelection }]
|
||||
property var actions: []
|
||||
property int selectedCount: 0
|
||||
signal action(string id)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 44
|
||||
radius: 8
|
||||
color: theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 10
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
width: parent.width - actionRow.width - 6
|
||||
height: parent.height
|
||||
text: root.selectedCount + " "
|
||||
+ QbzSession.tr("selected", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Row {
|
||||
id: actionRow
|
||||
height: parent.height
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: root.actions
|
||||
delegate: Rectangle {
|
||||
id: btn
|
||||
required property var modelData
|
||||
readonly property bool armed: !modelData.needsSelection
|
||||
|| root.selectedCount > 0
|
||||
width: 34
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
opacity: armed ? 1.0 : 0.4
|
||||
color: modelData.danger
|
||||
? (btnArea.containsMouse ? theme.dangerHover : "transparent")
|
||||
: (btnArea.containsMouse ? theme.surfaceHover : "transparent")
|
||||
border.width: modelData.danger ? 1 : 0
|
||||
border.color: theme.dangerBorder
|
||||
QbzIcon {
|
||||
name: btn.modelData.icon
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: btn.modelData.danger ? "warning"
|
||||
: btnArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: btnArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: btn.armed ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (btn.armed) root.action(btn.modelData.id)
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.text: btn.modelData.label
|
||||
ToolTip.delay: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Centered muted note — the empty / no-match / nothing-selected body every
|
||||
// tab in LocalLibraryView.slint uses (a centered VerticalLayout with one
|
||||
// text-muted Typography.body line).
|
||||
|
||||
import QtQuick
|
||||
import "../../theme"
|
||||
|
||||
Text {
|
||||
QbzTheme { id: theme }
|
||||
anchors.centerIn: parent
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Local Library search input — the ONE box the whole surface uses.
|
||||
//
|
||||
// Slint has three near-identical inputs on this view (LocalLibraryView.slint:
|
||||
// the toolbar ExpandableSearch `sm`, the tree-rail header input at :1724 and
|
||||
// the folder-detail input at :1984). They differ only in width, left padding
|
||||
// and glyph size, so this is one component with those three as properties
|
||||
// instead of three copies.
|
||||
//
|
||||
// Numbers: 30px tall, radius 6, surface-card fill, 1px border that turns
|
||||
// accent on focus, 12px text, muted placeholder (Slint :1726-1771).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string placeholder: ""
|
||||
property alias text: input.text
|
||||
property int boxWidth: 200
|
||||
property int iconSize: 13
|
||||
property int leftPad: 9
|
||||
property int rightPad: 8
|
||||
/// Elevated fill for the toolbar variant; the rail/detail inputs use
|
||||
/// surface-card (Slint :1728).
|
||||
property bool elevated: false
|
||||
signal edited(string value)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: boxWidth
|
||||
height: 30
|
||||
radius: 6
|
||||
color: elevated ? theme.surfaceElevated : theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.leftPad
|
||||
anchors.rightMargin: root.rightPad
|
||||
spacing: 6
|
||||
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: root.iconSize
|
||||
height: root.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
Item {
|
||||
width: parent.width - root.iconSize - 6
|
||||
height: parent.height
|
||||
clip: true
|
||||
TextInput {
|
||||
id: input
|
||||
anchors.fill: parent
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
selectByMouse: true
|
||||
onTextEdited: root.edited(text)
|
||||
}
|
||||
Text {
|
||||
visible: input.text === ""
|
||||
anchors.fill: parent
|
||||
text: root.placeholder
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Two-segment icon toggle — ONE component for both ViewToggle (grid/list,
|
||||
// LocalLibraryView.slint:358) and ModeToggle (flat/tree, :384). Slint keeps
|
||||
// them as two components because its ToggleButton cannot take a model; both
|
||||
// bodies are otherwise byte-identical (60x30, radius 6, surface-elevated,
|
||||
// two ToggleButton `sm` children), so here they are one component driven by
|
||||
// a `segments` model.
|
||||
//
|
||||
// ADR-008: small-radius rectangle, never a pill.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// [{ id, icon }] — exactly two entries on every current call site.
|
||||
property var segments: []
|
||||
property string mode: ""
|
||||
signal setMode(string value)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 60
|
||||
height: 30
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.segments
|
||||
delegate: Rectangle {
|
||||
id: seg
|
||||
required property var modelData
|
||||
readonly property bool active: modelData.id === root.mode
|
||||
width: 26
|
||||
height: 24
|
||||
radius: 4
|
||||
color: active ? theme.surfaceMain
|
||||
: segArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: seg.modelData.icon
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
tintName: seg.active ? "accent" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: segArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.setMode(seg.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Row-2 right-hand toolbar — the four per-tab control groups from
|
||||
// LocalLibraryView.slint:774 (Albums), :924 (Tracks), :1009 (Folders) and
|
||||
// :1103 (Artists). One file because they are one row: exactly one group is
|
||||
// visible at a time and they share the search box, the select chrome and
|
||||
// the 8px rhythm.
|
||||
//
|
||||
// Every Slint gate is kept: the Albums/Tracks groups only appear once the
|
||||
// tab has rows OR a live search; the Folders flat-only block hides in tree
|
||||
// mode while the Flat/Tree toggle stays; Artists shows the search only when
|
||||
// there are artists.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Row {
|
||||
id: root
|
||||
|
||||
/// The LocalLibraryView root (state + actions).
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var sortIds: ["artist-asc", "artist-desc", "title-asc",
|
||||
"title-desc", "year-desc", "year-asc"]
|
||||
readonly property var sortLabels: [
|
||||
QbzSession.tr("Artist A-Z", QbzSession.trRev),
|
||||
QbzSession.tr("Artist Z-A", QbzSession.trRev),
|
||||
QbzSession.tr("Title A-Z", QbzSession.trRev),
|
||||
QbzSession.tr("Title Z-A", QbzSession.trRev),
|
||||
QbzSession.tr("Year (newest)", QbzSession.trRev),
|
||||
QbzSession.tr("Year (oldest)", QbzSession.trRev),
|
||||
]
|
||||
readonly property var groupIds: ["off", "alpha", "artist"]
|
||||
readonly property var groupLabels: [
|
||||
QbzSession.tr("No grouping", QbzSession.trRev),
|
||||
QbzSession.tr("Group by letter", QbzSession.trRev),
|
||||
QbzSession.tr("Group by artist", QbzSession.trRev),
|
||||
]
|
||||
|
||||
height: 30
|
||||
spacing: 8
|
||||
|
||||
// ============================ ALBUMS =================================
|
||||
Row {
|
||||
visible: root.view && root.view.activeTab === "albums"
|
||||
&& (root.view.albums.length > 0 || root.view.albumsSearch !== "")
|
||||
spacing: 8
|
||||
height: parent.height
|
||||
|
||||
LocalSearchBox {
|
||||
elevated: true
|
||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||
onEdited: function (v) { root.view.albumsSearch = v }
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 180
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: root.sortLabels
|
||||
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.albumsSort))
|
||||
onSelected: function (i) { root.view.albumsSort = root.sortIds[i] }
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 160
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: root.groupLabels
|
||||
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.albumsGroup))
|
||||
onSelected: function (i) { root.view.albumsGroup = root.groupIds[i] }
|
||||
}
|
||||
// Album identity ("Albums by") — what one album IS. A QUERY change
|
||||
// (the grouping happens in SQL), so it reloads the set; persisted,
|
||||
// shared with the Slint app.
|
||||
QbzSelect {
|
||||
menuWidth: 190
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: [
|
||||
QbzSession.tr("Albums by folder", QbzSession.trRev),
|
||||
QbzSession.tr("Albums by metadata", QbzSession.trRev),
|
||||
]
|
||||
currentIndex: QbzLocal.localAlbumMode === "metadata" ? 1 : 0
|
||||
onSelected: function (i) {
|
||||
QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder")
|
||||
}
|
||||
}
|
||||
// Quality/format/source filter popup trigger + active-count badge.
|
||||
Item {
|
||||
width: 34
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: root.view.filterCount > 0 ? theme.accent : theme.borderSubtle
|
||||
color: filtArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 15
|
||||
height: 15
|
||||
anchors.centerIn: parent
|
||||
tintName: root.view.filterCount > 0 ? "accent" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: filtArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.filterOpen = !root.view.filterOpen
|
||||
}
|
||||
}
|
||||
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
|
||||
color: theme.accentText
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalSegToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
segments: [{ "id": "grid", "icon": "layout-grid" },
|
||||
{ "id": "list", "icon": "list" }]
|
||||
mode: root.view.albumsView
|
||||
onSetMode: function (v) { root.view.albumsView = v }
|
||||
}
|
||||
QbzNavButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "square-check-big"
|
||||
onClicked: root.view.toggleAlbumsMultiSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ TRACKS =================================
|
||||
Row {
|
||||
visible: root.view && root.view.activeTab === "tracks"
|
||||
&& (root.view.tracks.length > 0 || root.view.tracksSearch !== "")
|
||||
spacing: 8
|
||||
height: parent.height
|
||||
|
||||
QbzNavButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "square-check-big"
|
||||
onClicked: root.view.toggleTracksMultiSelect()
|
||||
}
|
||||
LocalSearchBox {
|
||||
elevated: true
|
||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||
onEdited: function (v) {
|
||||
root.view.tracksSearch = v
|
||||
tracksSearchDebounce.restart()
|
||||
}
|
||||
// Server-side search: debounce so a keystroke is not a query.
|
||||
Timer {
|
||||
id: tracksSearchDebounce
|
||||
interval: 250
|
||||
onTriggered: QbzLocal.tracksSearch(root.view.tracksSearch)
|
||||
}
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 180
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: [
|
||||
QbzSession.tr("Default", QbzSession.trRev),
|
||||
QbzSession.tr("Artist A-Z", QbzSession.trRev),
|
||||
QbzSession.tr("Artist Z-A", QbzSession.trRev),
|
||||
QbzSession.tr("Title A-Z", QbzSession.trRev),
|
||||
QbzSession.tr("Title Z-A", QbzSession.trRev),
|
||||
QbzSession.tr("Year (newest)", QbzSession.trRev),
|
||||
QbzSession.tr("Year (oldest)", QbzSession.trRev),
|
||||
QbzSession.tr("Date added", QbzSession.trRev),
|
||||
]
|
||||
currentIndex: Math.max(0, ["default", "artist-asc", "artist-desc",
|
||||
"title-asc", "title-desc", "year-desc", "year-asc",
|
||||
"added-desc"].indexOf(QbzLocal.localTracksSort))
|
||||
onSelected: function (i) {
|
||||
QbzLocal.tracksSetSort(["default", "artist-asc", "artist-desc",
|
||||
"title-asc", "title-desc", "year-desc", "year-asc",
|
||||
"added-desc"][i])
|
||||
}
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 160
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: [
|
||||
QbzSession.tr("No grouping", QbzSession.trRev),
|
||||
QbzSession.tr("By album", QbzSession.trRev),
|
||||
QbzSession.tr("By artist", QbzSession.trRev),
|
||||
QbzSession.tr("By name", QbzSession.trRev),
|
||||
]
|
||||
currentIndex: Math.max(0, ["off", "album", "artist", "name"]
|
||||
.indexOf(root.view.tracksGroup))
|
||||
onSelected: function (i) {
|
||||
root.view.tracksGroup = ["off", "album", "artist", "name"][i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ FOLDERS ================================
|
||||
Row {
|
||||
visible: root.view && root.view.activeTab === "folders"
|
||||
&& !root.view.ephemeralActive
|
||||
spacing: 8
|
||||
height: parent.height
|
||||
|
||||
// Flat-only controls — the tree rail carries its own search.
|
||||
Row {
|
||||
visible: root.view.foldersMode === "flat"
|
||||
&& (root.view.folders.length > 0 || root.view.foldersSearch !== "")
|
||||
spacing: 8
|
||||
height: parent.height
|
||||
LocalSearchBox {
|
||||
elevated: true
|
||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||
onEdited: function (v) { root.view.foldersSearch = v }
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 150
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: root.sortLabels
|
||||
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.foldersSort))
|
||||
onSelected: function (i) { root.view.foldersSort = root.sortIds[i] }
|
||||
}
|
||||
QbzSelect {
|
||||
menuWidth: 150
|
||||
height: 30
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
options: root.groupLabels
|
||||
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.foldersGroup))
|
||||
onSelected: function (i) { root.view.foldersGroup = root.groupIds[i] }
|
||||
}
|
||||
LocalSegToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
segments: [{ "id": "grid", "icon": "layout-grid" },
|
||||
{ "id": "list", "icon": "list" }]
|
||||
mode: root.view.foldersGridView
|
||||
onSetMode: function (v) { root.view.foldersGridView = v }
|
||||
}
|
||||
}
|
||||
// Flat / Tree — always visible on the Folders tab.
|
||||
// ASSET GAP: Slint uses disc-album / folder-tree; neither glyph is
|
||||
// baked in the Qt icon set yet (see GLUE), so this uses the closest
|
||||
// shipped pair.
|
||||
LocalSegToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
segments: [{ "id": "flat", "icon": "disc" },
|
||||
{ "id": "tree", "icon": "folder-open" }]
|
||||
mode: root.view.foldersMode
|
||||
onSetMode: function (v) { root.view.foldersMode = v }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ ARTISTS ================================
|
||||
LocalSearchBox {
|
||||
visible: root.view && root.view.activeTab === "artists"
|
||||
&& root.view.artists.length > 0
|
||||
elevated: true
|
||||
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
|
||||
onEdited: function (v) { root.view.artistsSearch = v }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Local track row — the SHARED rows/TrackRow.qml with the local arms set,
|
||||
// plus the two things the shared row has no arm for:
|
||||
//
|
||||
// * the always-visible SOURCE glyph the Slint renders from
|
||||
// `show-source: true` (LocalLibraryView.slint:1489) — drawn OUTSIDE the
|
||||
// shared row so TrackRow keeps one column layout for every surface;
|
||||
// * the multi-select checkbox (Slint TrackRow's `multi-select-mode`
|
||||
// swaps the number cell for it) — overlaid on the number cell here for
|
||||
// the same reason: no fork of the shared row, no shared-file edit.
|
||||
//
|
||||
// Artwork follows the Slint gate (AppearanceState.local-library-track-
|
||||
// artwork, default OFF) — decoding a cover per row is exactly what froze
|
||||
// this surface at 16k tracks.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../rows"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var item: ({})
|
||||
property int number: 0
|
||||
property bool showAlbum: true
|
||||
property bool showArtwork: false
|
||||
property string artSource: ""
|
||||
property bool selectMode: false
|
||||
property bool checked: false
|
||||
signal playRequested()
|
||||
signal enqueueRequested(string mode)
|
||||
signal toggleSelect()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 50
|
||||
|
||||
TrackRow {
|
||||
id: sharedRow
|
||||
width: root.width - 26
|
||||
item: root.item
|
||||
number: root.number
|
||||
showArtwork: root.showArtwork
|
||||
showAlbum: root.showAlbum
|
||||
showFavorite: false
|
||||
showDownload: false
|
||||
menuShowGoTo: false
|
||||
menuShowFavorite: false
|
||||
// Local rows are NOT drag sources: item.id is a local DB row id and
|
||||
// the sidebar drop handler forwards it as a Qobuz catalog id.
|
||||
draggable: false
|
||||
qualityStyle: "icon"
|
||||
onPlayRequested: root.playRequested()
|
||||
onEnqueueRequested: function (m) { root.enqueueRequested(m) }
|
||||
}
|
||||
|
||||
// Multi-select checkbox over the number cell (x 12..44 in TrackRow).
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: 12
|
||||
width: 32
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceMain
|
||||
radius: 4
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.checked
|
||||
onToggled: root.toggleSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// Source glyph (local / offline cache / Plex).
|
||||
QbzIcon {
|
||||
visible: root.item.source === "local" || root.item.source === "plex"
|
||||
|| root.item.source === "offline"
|
||||
name: root.item.source === "offline" ? "cloud-download" : "hard-drive"
|
||||
width: 14
|
||||
height: 14
|
||||
x: root.width - 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: root.item.source === "plex" ? "accent" : "muted"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Tracks tab body (LocalLibraryView.slint:1317) — the SERVER-PAGINATED flat
|
||||
// list. This is the surface that froze the Slint build at 16k tracks: the
|
||||
// model is never the whole table (500 rows per page, appended within 600px
|
||||
// of the tail — the Slint's own trigger distance at :1442) and the ListView
|
||||
// only ever instantiates its window.
|
||||
//
|
||||
// Grouping (off / album / artist / name) inserts 32px header rows, so the
|
||||
// model is an entry array — { t: 0, label } | { t: 1, row, n } — exactly as
|
||||
// the collection does it, keeping ONE windowing ListView with variable row
|
||||
// heights instead of a Repeater per group.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property int rowH: 50
|
||||
readonly property int headerH: 32
|
||||
|
||||
// --------------------------- entry model ------------------------------
|
||||
property var entries: []
|
||||
property var alphaJumps: []
|
||||
function rebuild() {
|
||||
var rows = view ? view.tracksVisible : []
|
||||
var mode = view ? view.tracksGroup : "off"
|
||||
var out = []
|
||||
var jumps = []
|
||||
var prev = null
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var t = rows[i]
|
||||
var key = mode === "album" ? (t.album || "")
|
||||
: mode === "artist" ? (t.artist || "")
|
||||
: mode === "name" ? (t.title || "").slice(0, 1).toUpperCase()
|
||||
: ""
|
||||
if (mode !== "off" && key !== "" && key !== prev) {
|
||||
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
|
||||
out.push({ "t": 0, "label": key })
|
||||
prev = key
|
||||
}
|
||||
out.push({ "t": 1, "row": t, "n": i + 1 })
|
||||
}
|
||||
entries = out
|
||||
alphaJumps = jumps
|
||||
}
|
||||
Component.onCompleted: rebuild()
|
||||
Connections {
|
||||
target: root.view
|
||||
function onTracksVisibleChanged() { root.rebuild() }
|
||||
function onTracksGroupChanged() { root.rebuild() }
|
||||
}
|
||||
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 36
|
||||
visible: QbzLocal.localTracksLoading
|
||||
}
|
||||
LocalNote {
|
||||
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
|
||||
&& root.view.tracksSearch === ""
|
||||
text: QbzSession.tr("No tracks in your local library yet.", QbzSession.trRev)
|
||||
}
|
||||
LocalNote {
|
||||
visible: !QbzLocal.localTracksLoading
|
||||
&& (root.view.tracks.length === 0 || root.view.tracksVisible.length === 0)
|
||||
&& root.view.tracksSearch !== ""
|
||||
text: QbzSession.tr("No tracks match your search.", QbzSession.trRev)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 32
|
||||
anchors.topMargin: 12
|
||||
spacing: 8
|
||||
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
|
||||
|
||||
LocalMultiSelectBar {
|
||||
visible: root.view.tracksMultiSelect
|
||||
width: parent.width
|
||||
selectedCount: root.view.tracksSelectedCount
|
||||
actions: [
|
||||
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
||||
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
||||
{ "id": "remove-favorites", "label": QbzSession.tr("Remove from favorites", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true },
|
||||
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
|
||||
]
|
||||
onAction: function (id) { root.view.tracksBulkAction(id) }
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - (root.view.tracksMultiSelect ? 52 : 0)
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.view.tracksGroup === "name" ? 20 : 0
|
||||
clip: true
|
||||
cacheBuffer: 50 * 12
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.entries
|
||||
|
||||
onContentYChanged: {
|
||||
root.report()
|
||||
// Infinite scroll: 600px of runway (Slint :1442).
|
||||
if (contentY + height >= contentHeight - 600
|
||||
&& QbzLocal.localTracksHasMore
|
||||
&& !QbzLocal.localTracksLoadingMore
|
||||
&& !QbzLocal.localTracksLoading) {
|
||||
QbzLocal.tracksLoadMore()
|
||||
}
|
||||
}
|
||||
onModelChanged: root.report()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: list.width
|
||||
height: modelData.t === 0 ? root.headerH : root.rowH
|
||||
sourceComponent: modelData.t === 0 ? headerComp : rowComp
|
||||
|
||||
Component {
|
||||
id: headerComp
|
||||
Item {
|
||||
Text {
|
||||
x: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 4
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: rowComp
|
||||
LocalTrackRow {
|
||||
width: list.width
|
||||
item: modelData.row
|
||||
number: modelData.n
|
||||
// Album column hides when the list is already
|
||||
// grouped by album (Slint :1486).
|
||||
showAlbum: root.view.tracksGroup !== "album"
|
||||
showArtwork: root.view.trackArtwork
|
||||
selectMode: root.view.tracksMultiSelect
|
||||
checked: root.view.tracksSelected[modelData.row.id] === true
|
||||
onPlayRequested: QbzLocal.playTrack(modelData.row.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", modelData.row.id, m)
|
||||
}
|
||||
onToggleSelect: root.view.toggleTrackSelected(modelData.row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: Item {
|
||||
width: list.width
|
||||
height: QbzLocal.localTracksLoadingMore ? 48 : 0
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 22
|
||||
visible: QbzLocal.localTracksLoadingMore
|
||||
}
|
||||
}
|
||||
}
|
||||
AlphaStrip {
|
||||
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
jumps: root.alphaJumps
|
||||
onJump: function (ordinal, index) {
|
||||
list.positionViewAtIndex(index, ListView.Beginning)
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.view.tracksGroup === "name" ? 22 : 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: list
|
||||
visible: list.contentHeight > list.height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function report() {
|
||||
if (!view || !list) return
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = first + 12
|
||||
// Entry indices are >= row indices (headers inflate them), so the
|
||||
// clamp inside queueWindowReport keeps this safe.
|
||||
view.queueWindowReport(view.tracksVisible, Math.max(0, first - 4), last + 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Folders tree-mode LEFT RAIL (LocalLibraryView.slint:1641).
|
||||
//
|
||||
// Header toolbar: select-mode, collapse-all, open-ephemeral-folder, then the
|
||||
// search box (stretches). Below it the compact bulk bar (only in select mode
|
||||
// with a selection — icon buttons, because the rail is narrow), then the
|
||||
// tree itself.
|
||||
//
|
||||
// The tree is a FLAT array windowed by a ListView — the recursive component
|
||||
// is deliberately not reproduced, and levels are fetched lazily on expand.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var view: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 8
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 8
|
||||
spacing: 8
|
||||
|
||||
// ---- Header toolbar ----
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 30
|
||||
spacing: 4
|
||||
QbzIconButton {
|
||||
name: "square-check-big"
|
||||
btnSize: 30
|
||||
iconSize: 15
|
||||
active: root.view.treeSelectMode
|
||||
activeBackground: true
|
||||
onClicked: root.view.toggleTreeSelectMode()
|
||||
}
|
||||
QbzIconButton {
|
||||
name: "chevron-up"
|
||||
btnSize: 30
|
||||
iconSize: 15
|
||||
onClicked: QbzLocal.treeCollapseAll()
|
||||
}
|
||||
QbzIconButton {
|
||||
name: "folder-open"
|
||||
btnSize: 30
|
||||
iconSize: 15
|
||||
onClicked: QbzLocal.ephemeralOpen()
|
||||
}
|
||||
LocalSearchBox {
|
||||
boxWidth: parent.width - 3 * 34
|
||||
leftPad: 8
|
||||
rightPad: 6
|
||||
placeholder: QbzSession.tr("Search folders", QbzSession.trRev)
|
||||
onEdited: function (v) {
|
||||
root.view.treeSearch = v
|
||||
treeSearchDebounce.restart()
|
||||
}
|
||||
Timer {
|
||||
id: treeSearchDebounce
|
||||
interval: 180
|
||||
onTriggered: QbzLocal.treeSearch(root.view.treeSearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Compact bulk bar (select mode + a selection) ----
|
||||
Rectangle {
|
||||
visible: root.view.treeSelectMode && QbzLocal.localTreeSelectedCount > 0
|
||||
width: parent.width
|
||||
height: visible ? 36 : 0
|
||||
radius: 8
|
||||
color: theme.surfaceElevated
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 6
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width - 7 * 30
|
||||
height: parent.height
|
||||
text: QbzLocal.localTreeSelectedCount + " "
|
||||
+ QbzSession.tr("sel", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Repeater {
|
||||
// ASSET GAP: Slint's "Check all" uses folder-check, which
|
||||
// is not baked in the Qt icon set (see GLUE).
|
||||
model: [
|
||||
{ "icon": "square-check-big", "action": "select-all", "tip": QbzSession.tr("Check all", QbzSession.trRev) },
|
||||
{ "icon": "list-start", "action": "play-next", "tip": QbzSession.tr("Play next", QbzSession.trRev) },
|
||||
{ "icon": "list-plus", "action": "play-later", "tip": QbzSession.tr("Play later", QbzSession.trRev) },
|
||||
{ "icon": "list-end", "action": "queue", "tip": QbzSession.tr("Add to queue", QbzSession.trRev) },
|
||||
{ "icon": "list-music", "action": "add-to-playlist", "tip": QbzSession.tr("Add to playlist", QbzSession.trRev) },
|
||||
{ "icon": "cassette-tape", "action": "add-to-mixtape", "tip": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev) },
|
||||
{ "icon": "x", "action": "clear", "tip": QbzSession.tr("Clear", QbzSession.trRev) },
|
||||
]
|
||||
delegate: QbzIconButton {
|
||||
required property var modelData
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: modelData.icon
|
||||
btnSize: 28
|
||||
iconSize: 15
|
||||
onClicked: QbzLocal.foldersBulkAction(modelData.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The tree ----
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 38
|
||||
- (root.view.treeSelectMode && QbzLocal.localTreeSelectedCount > 0 ? 44 : 0)
|
||||
|
||||
QbzSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 28
|
||||
visible: QbzLocal.localTreeLoading
|
||||
}
|
||||
ListView {
|
||||
id: treeList
|
||||
anchors.fill: parent
|
||||
visible: !QbzLocal.localTreeLoading
|
||||
clip: true
|
||||
cacheBuffer: 26 * 20
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.view.tree
|
||||
delegate: TreeRow {
|
||||
required property var modelData
|
||||
width: treeList.width
|
||||
node: modelData
|
||||
selected: modelData.path === root.view.selectedFolder
|
||||
selectMode: root.view.treeSelectMode
|
||||
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
|
||||
onActivated: root.view.selectFolder(modelData.path)
|
||||
onToggleSelect: {
|
||||
if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path)
|
||||
else QbzLocal.treeToggleTrackSelect(modelData.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: treeList
|
||||
visible: treeList.contentHeight > treeList.height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The app-wide selection checkbox (LocalLibraryView.slint:486-515): a 13px
|
||||
// circle, accent-filled when on, 1.5px muted ring when off, 8px white check
|
||||
// glyph — or a minus glyph for the folder tri-state "partial".
|
||||
//
|
||||
// Slint declares this inline inside TreeRow; the Qt port also needs it on
|
||||
// the album cards and the track rows in multi-select, so it is its own file
|
||||
// (three call sites, one shape).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool on: false
|
||||
property bool partial: false
|
||||
property int diameter: 13
|
||||
signal toggled()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: diameter
|
||||
height: diameter
|
||||
radius: diameter / 2
|
||||
color: (on || partial) ? theme.accent : "transparent"
|
||||
border.width: (on || partial) ? 0 : 1.5
|
||||
border.color: checkArea.containsMouse ? theme.textPrimary : theme.textMuted
|
||||
|
||||
QbzIcon {
|
||||
visible: root.on || root.partial
|
||||
name: root.partial ? "minus" : "check"
|
||||
width: Math.round(root.diameter * 0.62)
|
||||
height: Math.round(root.diameter * 0.62)
|
||||
anchors.centerIn: parent
|
||||
tintName: "primary"
|
||||
}
|
||||
MouseArea {
|
||||
id: checkArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Per-version source glyph (album/LocalAlbumView.slint:23 SourceIcon):
|
||||
// hard-drive for a local file, the Qobuz mark for an offline/purchased copy
|
||||
// (gold when purchased), the Plex mark for Plex.
|
||||
//
|
||||
// ASSET GAP: the Qt icon set has no qobuz-logo-filled / plex-logo variant
|
||||
// yet (see GLUE), so the offline copy reuses cloud-download and Plex reuses
|
||||
// hard-drive in the accent tint — the same substitution the Local Library
|
||||
// cards already ship.
|
||||
|
||||
import QtQuick
|
||||
import "../../theme"
|
||||
|
||||
QbzIcon {
|
||||
id: root
|
||||
|
||||
/// "local" | "qobuz_download" | "qobuz_purchase" | "offline" | "plex"
|
||||
property string kind: "local"
|
||||
|
||||
width: 14
|
||||
height: 14
|
||||
name: (kind === "qobuz_download" || kind === "qobuz_purchase" || kind === "offline")
|
||||
? "cloud-download" : "hard-drive"
|
||||
tintName: kind === "qobuz_purchase" ? "warning"
|
||||
: kind === "plex" ? "accent" : "secondary"
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// One row of the FLATTENED folder tree (LocalLibraryView.slint:444 TreeRow).
|
||||
//
|
||||
// 26px, radius 6, depth indent 8 + depth*16, select-mode checkbox (folder =
|
||||
// tri-state from node.selectState 0/1/2, track = node.selected), an expand
|
||||
// chevron with its OWN hit target (so toggling never selects), the folder /
|
||||
// music glyph and the segment name. The selection cue is the 3px accent bar
|
||||
// on the left edge — ADR-008: never a pill.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var node: ({})
|
||||
property bool selected: false
|
||||
property bool selectMode: false
|
||||
signal toggled()
|
||||
signal activated()
|
||||
signal toggleSelect()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
height: 26
|
||||
radius: 6
|
||||
color: selected ? theme.surfaceElevated
|
||||
: rowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
// Row body — declared FIRST so the chevron / checkbox win their clicks.
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.node.isFolder ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.node.isFolder) root.activated()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.selected
|
||||
x: 0
|
||||
y: 4
|
||||
width: 3
|
||||
height: parent.height - 8
|
||||
radius: 1.5
|
||||
color: theme.accent
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8 + (root.node.depth || 0) * 16
|
||||
anchors.rightMargin: 10
|
||||
spacing: 6
|
||||
|
||||
// Select-mode checkbox (folder tri-state / track boolean).
|
||||
Item {
|
||||
visible: root.selectMode
|
||||
width: visible ? 13 : 0
|
||||
height: parent.height
|
||||
SelectCheck {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
on: root.node.isFolder ? root.node.selectState === 2
|
||||
: root.node.selected === true
|
||||
partial: root.node.isFolder === true && root.node.selectState === 1
|
||||
onToggled: root.toggleSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// Expand chevron — its own hit target.
|
||||
Item {
|
||||
width: 18
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
visible: root.node.canExpand === true
|
||||
name: root.node.expanded ? "chevron-down" : "chevron-right"
|
||||
width: 11
|
||||
height: 11
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.node.canExpand === true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.toggled()
|
||||
}
|
||||
}
|
||||
|
||||
QbzIcon {
|
||||
name: root.node.isFolder
|
||||
? (root.node.expanded ? "folder-open" : "folder") : "music"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: root.node.isFolder ? "accent" : "muted"
|
||||
}
|
||||
Text {
|
||||
width: Math.max(0, parent.width - 50 - (root.node.depth || 0) * 16
|
||||
- (root.selectMode ? 19 : 0))
|
||||
height: parent.height
|
||||
text: root.node.segment || ""
|
||||
color: root.selected ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: root.selected ? theme.weightSemibold : theme.weightRegular
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Local album version picker (album/LocalAlbumView.slint:41 VersionPicker) —
|
||||
// shown only when an album has several PHYSICAL copies (distinct source
|
||||
// folders), so two copies never merge into a duplicated track list.
|
||||
//
|
||||
// Sized like QbzSelect `sm` (30px, radius 6, 10px left pad) so it reads as
|
||||
// one of the small selects rather than towering over them: source icon +
|
||||
// label + chevron, with a 280px dropdown that repeats the icon and ticks the
|
||||
// current entry.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// [{ label, source }]
|
||||
property var versions: []
|
||||
property int current: 0
|
||||
signal picked(int index)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var currentVersion: versions[current] || ({})
|
||||
|
||||
height: 30
|
||||
width: row.width
|
||||
radius: 6
|
||||
color: pickArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
id: row
|
||||
height: parent.height
|
||||
leftPadding: 10
|
||||
rightPadding: 8
|
||||
spacing: 7
|
||||
SourceIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
kind: root.currentVersion.source || "local"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.currentVersion.label || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
QbzIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "chevron-down"
|
||||
width: 12
|
||||
height: 12
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: pickArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: menu.openBelowRight(pickArea)
|
||||
}
|
||||
|
||||
QbzContextMenu {
|
||||
id: menu
|
||||
menuWidth: 280
|
||||
Repeater {
|
||||
model: root.versions
|
||||
delegate: Rectangle {
|
||||
id: opt
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
height: 30
|
||||
radius: 6
|
||||
color: index === root.current ? theme.surfaceElevated
|
||||
: optArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
SourceIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
kind: opt.modelData.source || "local"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
|
||||
height: parent.height
|
||||
text: opt.modelData.label || ""
|
||||
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"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: optArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: { menu.close(); root.picked(opt.index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,9 @@ where
|
||||
// recovery: a successful login ends any unauthenticated offline session.
|
||||
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
|
||||
crate::offline_fwd::init_for_user(&dir);
|
||||
// Plex settings live per-user (plex_settings.db); bind the store to
|
||||
// this session so the first Local Library read does not have to.
|
||||
crate::local_plex::init_for_user(&dir);
|
||||
// Phase 5: local-favorites store (Library show-local + hearts).
|
||||
crate::library_qt::init_local_favorites(&dir);
|
||||
// Phase 7: pinned-items store (AlbumCard pin badges).
|
||||
@@ -251,6 +254,10 @@ where
|
||||
// skipped (see module docs).
|
||||
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
|
||||
crate::offline_fwd::init_for_user(&dir);
|
||||
crate::local_plex::init_for_user(&dir);
|
||||
// Plex settings live per-user (plex_settings.db); bind the store to
|
||||
// this session so the first Local Library read does not have to.
|
||||
crate::local_plex::init_for_user(&dir);
|
||||
crate::library_qt::init_local_favorites(&dir);
|
||||
crate::sidebar_qt::init_pinned(&dir);
|
||||
// Phase 15: intelligent-search service (cortinilla cache+ranking).
|
||||
@@ -317,6 +324,9 @@ where
|
||||
// unauthenticated offline session.
|
||||
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
|
||||
crate::offline_fwd::init_for_user(&dir);
|
||||
// Plex settings live per-user (plex_settings.db); bind the store to
|
||||
// this session so the first Local Library read does not have to.
|
||||
crate::local_plex::init_for_user(&dir);
|
||||
// Phase 5: local-favorites store (Library show-local + hearts).
|
||||
crate::library_qt::init_local_favorites(&dir);
|
||||
// Phase 7: pinned-items store (AlbumCard pin badges).
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Local album ACTIONS — everything the routed local album page
|
||||
//! (`qml/views/LocalAlbumView.qml` + `qml/views/local/LocalAlbumHeader.qml`)
|
||||
//! drives that is not a plain query: the VERSION picker, the per-disc "Disc N"
|
||||
//! menu, the artist-NAME route into the Local Library Artists tab, the
|
||||
//! per-row-artwork appearance mirror, and the three album-level actions whose
|
||||
//! surfaces the Qt port has not grown yet.
|
||||
//!
|
||||
//! Ported 1:1 from `album/LocalAlbumView.slint` (617 lines) plus its Rust glue
|
||||
//! (`crates/qbz/src/local_library.rs`: `open_local_album`,
|
||||
//! `apply_album_version`, `album_version_dir`, `current_album_version_tracks`,
|
||||
//! `current_album_disc_tracks`; `crates/qbz/src/main.rs`: the
|
||||
//! `LocalAlbumActions` handlers).
|
||||
//!
|
||||
//! VERSIONS: a "version" is a distinct PHYSICAL copy of the album — a distinct
|
||||
//! source directory (`LocalTrack.album_group_key`). Metadata identity mode can
|
||||
//! fold several directories into one album card, and merging their tracks
|
||||
//! would render a duplicated track list; splitting by directory is what stops
|
||||
//! that. The split is cached in `LocalState` so the picker switches with NO DB
|
||||
//! round-trip (the Slint's `ALBUM_VERSIONS` static, 1:1).
|
||||
//!
|
||||
//! NOT WIRED (deliberate, reported): `album_edit_tags`, `album_add_to_playlist`
|
||||
//! and `album_add_to_mixtape` are LOGGED SEAMS. The Qt port has no tag-editor
|
||||
//! modal, no playlist picker and no Mixtape/Collection store, and a menu item
|
||||
//! must never write tags to disk with no UI in front of it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_library::LocalTrack;
|
||||
use qbz_models::QueueTrack;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::local_bridge::ui;
|
||||
use crate::local_playback::{local_queue_track, play_local_file, play_plex_track};
|
||||
use crate::local_rows::{
|
||||
album_key, badge_source, map_track, tier_of, to_json, total_duration, AlbumRow, TrackRow,
|
||||
};
|
||||
use crate::local_state::{state, with_art};
|
||||
|
||||
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The routed page's document (the QML contract)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One selectable physical copy — the Slint's `LocalAlbumVersion`
|
||||
/// (album/LocalAlbumView.slint:42), consumed by
|
||||
/// `qml/views/local/VersionPicker.qml` as `{ label, source }`.
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct AlbumVersion {
|
||||
/// "24-bit / 96 kHz · FLAC" (quality + container).
|
||||
pub label: String,
|
||||
/// RAW `local_tracks.source` ("" | "qobuz_download" | "qobuz_purchase" |
|
||||
/// "plex") — `SourceIcon.qml` keys its glyph/tint off exactly these.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// The album-detail HEADER: the shared `AlbumRow` plus the two fields only the
|
||||
/// routed page uses. Flattened so QML keeps reading `album.title`,
|
||||
/// `album.artKey`, … unchanged.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct AlbumHeaderDoc {
|
||||
#[serde(flatten)]
|
||||
pub row: AlbumRow,
|
||||
pub versions: Vec<AlbumVersion>,
|
||||
#[serde(rename = "versionIndex")]
|
||||
pub version_index: i32,
|
||||
}
|
||||
|
||||
/// `{album:{…}, tracks:[…]}` — the `localAlbumJson` document.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct AlbumDetailDoc {
|
||||
pub album: AlbumHeaderDoc,
|
||||
pub tracks: Vec<TrackRow>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version splitting (local_library.rs `open_local_album` 1:1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Quality rank for ordering versions (hi-res first).
|
||||
fn version_rank(t: &LocalTrack) -> (u32, u64) {
|
||||
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
|
||||
}
|
||||
|
||||
/// Group the album's tracks by SOURCE DIRECTORY, sort each copy by
|
||||
/// (disc, track) and put the best-quality copy first, so the default
|
||||
/// selection is the highest-res one.
|
||||
pub fn split_versions(tracks: Vec<LocalTrack>) -> Vec<(String, Vec<LocalTrack>)> {
|
||||
let mut groups: HashMap<String, Vec<LocalTrack>> = HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for t in tracks {
|
||||
let key = t.album_group_key.clone();
|
||||
if !groups.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
groups.entry(key).or_default().push(t);
|
||||
}
|
||||
let mut versions: Vec<(String, Vec<LocalTrack>)> = order
|
||||
.into_iter()
|
||||
.filter_map(|k| {
|
||||
groups.remove(&k).map(|mut v| {
|
||||
v.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(0)));
|
||||
(k, v)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
versions.sort_by(|a, b| {
|
||||
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
||||
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
||||
qb.cmp(&qa)
|
||||
});
|
||||
versions
|
||||
}
|
||||
|
||||
/// A version's picker entry (`version_label` + `version_source` 1:1).
|
||||
fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
|
||||
match tracks.first() {
|
||||
Some(t) => {
|
||||
let detail = crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
|
||||
let fmt = t.format.to_string();
|
||||
AlbumVersion {
|
||||
label: if detail.is_empty() {
|
||||
fmt
|
||||
} else {
|
||||
format!("{detail} · {fmt}")
|
||||
},
|
||||
source: t.source.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
None => AlbumVersion::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `id`: cache its versions, select the best-quality one and return its
|
||||
/// document. Called by `local_albums::load_album_detail_blocking` (BLOCKING
|
||||
/// context — no Qt, no await).
|
||||
pub fn open_versions(id: &str, tracks: Vec<LocalTrack>) -> Option<AlbumDetailDoc> {
|
||||
let versions = split_versions(tracks);
|
||||
if versions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Keep the RAW rows of EVERY version so a context-menu enqueue on a Plex
|
||||
// detail row resolves without a DB id (`local_playback::find_track_blocking`).
|
||||
let all: Vec<LocalTrack> = versions.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
state(|s| {
|
||||
s.album_id = id.to_string();
|
||||
s.album_versions = versions;
|
||||
s.album_version_index = 0;
|
||||
s.detail_raw = all;
|
||||
});
|
||||
version_doc(id, 0)
|
||||
}
|
||||
|
||||
/// Build the document for version `index` of the OPEN album. Reads the cached
|
||||
/// split — no DB round-trip (the Slint's `apply_album_version`).
|
||||
pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
|
||||
let (infos, tracks) = state(|s| {
|
||||
let infos: Vec<AlbumVersion> = s
|
||||
.album_versions
|
||||
.iter()
|
||||
.map(|(_, v)| version_info(v))
|
||||
.collect();
|
||||
let tracks = s
|
||||
.album_versions
|
||||
.get(index)
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_default();
|
||||
(infos, tracks)
|
||||
});
|
||||
if tracks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let row = album_header(id, &tracks);
|
||||
// Register the album cover + the per-row sources in the art index (the
|
||||
// windowed artwork channel resolves them; nothing rides the document).
|
||||
let rows = with_art(|art| {
|
||||
if let Some(p) = tracks
|
||||
.iter()
|
||||
.find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty()))
|
||||
{
|
||||
art.entry(row.art_key.clone()).or_insert_with(|| p.clone());
|
||||
}
|
||||
tracks.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>()
|
||||
});
|
||||
Some(AlbumDetailDoc {
|
||||
album: AlbumHeaderDoc {
|
||||
row,
|
||||
versions: infos,
|
||||
version_index: index as i32,
|
||||
},
|
||||
tracks: rows,
|
||||
})
|
||||
}
|
||||
|
||||
/// The header for ONE version — recomputed per version (the Slint recomputes
|
||||
/// title / artist / info-line / quality in `apply_album_version`, because two
|
||||
/// copies of the same album differ exactly in those). `directoryPath` and
|
||||
/// `folderCount` come from the loaded card when it is in a mounted page; a
|
||||
/// deep link leaves them empty, as the grid does.
|
||||
fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
|
||||
let first = &tracks[0];
|
||||
let artist_of = |t: &LocalTrack| t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
|
||||
let lead = artist_of(first);
|
||||
let artist = if tracks.iter().all(|t| artist_of(t) == lead) {
|
||||
lead
|
||||
} else {
|
||||
qbz_i18n::t("Various Artists")
|
||||
};
|
||||
// Distinct TRACK artists, first-appearance order — the "+N more artists"
|
||||
// expander (spec §B2). Raw `artist`, not `album_artist`: a compilation's
|
||||
// track artists are the list's whole point. QML splits this on ",".
|
||||
let mut all_artists: Vec<&str> = Vec::new();
|
||||
for t in tracks {
|
||||
let a = t.artist.trim();
|
||||
if !a.is_empty() && !all_artists.contains(&a) {
|
||||
all_artists.push(a);
|
||||
}
|
||||
}
|
||||
let best = tracks
|
||||
.iter()
|
||||
.max_by_key(|t| t.bit_depth.unwrap_or(0))
|
||||
.unwrap_or(first);
|
||||
let card = state(|s| {
|
||||
s.albums
|
||||
.iter()
|
||||
.chain(s.folders.iter())
|
||||
.find(|a| a.id == id)
|
||||
.cloned()
|
||||
});
|
||||
AlbumRow {
|
||||
id: id.to_string(),
|
||||
title: first.album_group_title.clone(),
|
||||
artist,
|
||||
all_artists: all_artists.join(", "),
|
||||
year: first.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||
track_count: tracks.len() as u32,
|
||||
duration: total_duration(tracks.iter().map(|t| t.duration_secs).sum()),
|
||||
quality_tier: tier_of(&best.format, best.bit_depth, best.sample_rate).into(),
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(
|
||||
best.bit_depth,
|
||||
Some(best.sample_rate),
|
||||
),
|
||||
format: best.format.to_string(),
|
||||
art_key: album_key(id),
|
||||
source: badge_source(first.source.as_deref()),
|
||||
directory_path: card.as_ref().map(|c| c.directory_path.clone()).unwrap_or_default(),
|
||||
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The OPEN album's track cache (the Slint's `current_album_version_tracks` /
|
||||
// `current_album_disc_tracks` / `album_version_dir`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The selected version's tracks (play / enqueue / the unwired actions).
|
||||
pub fn current_version_tracks() -> Vec<LocalTrack> {
|
||||
state(|s| {
|
||||
s.album_versions
|
||||
.get(s.album_version_index)
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The selected version's SOURCE DIRECTORY key — what a tag editor would edit.
|
||||
pub fn current_version_dir() -> String {
|
||||
state(|s| {
|
||||
s.album_versions
|
||||
.get(s.album_version_index)
|
||||
.map(|(dir, _)| dir.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// One disc of the selected version, in the upstream (disc, track) order.
|
||||
/// `disc_number` defaults to 1 — exactly how the header number is stamped.
|
||||
fn current_disc_tracks(disc: i32) -> Vec<LocalTrack> {
|
||||
current_version_tracks()
|
||||
.into_iter()
|
||||
.filter(|t| t.disc_number.unwrap_or(1) as i32 == disc)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Version picker: switch the shown copy in place (no DB round-trip).
|
||||
pub fn select_version(index: i32) {
|
||||
if index < 0 {
|
||||
return;
|
||||
}
|
||||
let index = index as usize;
|
||||
let id = state(|s| {
|
||||
if index >= s.album_versions.len() || index == s.album_version_index {
|
||||
return None;
|
||||
}
|
||||
s.album_version_index = index;
|
||||
Some(s.album_id.clone())
|
||||
});
|
||||
let Some(id) = id.filter(|id| !id.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
publish_doc(version_doc(&id, index));
|
||||
}
|
||||
|
||||
/// Per-disc "Disc N" header menu. `action` is the QML CardMenu's own vocabulary
|
||||
/// ("play" | "next" | "later" | "queue") over THIS disc's tracks only, reusing
|
||||
/// the same queue ops as the header play button and the per-row menu.
|
||||
pub fn disc_action(disc: i32, action: String) {
|
||||
let tracks = current_disc_tracks(disc);
|
||||
if tracks.is_empty() {
|
||||
log::debug!("[qbz-qt] local album: disc {disc} has no tracks for '{action}'");
|
||||
return;
|
||||
}
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
match action.as_str() {
|
||||
"play" => play_rows(&runtime, tracks).await,
|
||||
"next" | "later" | "queue" => enqueue_rows(&runtime, tracks, &action).await,
|
||||
other => log::debug!("[qbz-qt] local album: unhandled disc action '{other}'"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "Go to artist" on a local/Plex album: those artists have no catalog id, so
|
||||
/// the route is a NAME the Local Library view consumes on its Artists tab
|
||||
/// (`consumePendingArtist`). Cleared by `clear_pending_artist` once applied.
|
||||
pub fn open_artist_by_name(name: String) {
|
||||
if name.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_pending_artist(QString::from(name.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// The view consumed the pending name — release it so the same artist can be
|
||||
/// routed to twice in a row (the property change IS the trigger).
|
||||
pub fn clear_pending_artist() {
|
||||
ui(|mut b| b.as_mut().set_local_pending_artist(QString::from("")));
|
||||
}
|
||||
|
||||
/// Mirror `AppearanceState.local-library-track-artwork` (ui_prefs.json,
|
||||
/// default OFF — per-row covers on a 16K-track list are the freeze surface)
|
||||
/// onto the bridge so the Local Library view can gate its track artwork.
|
||||
pub fn publish_track_artwork() {
|
||||
let on = crate::settings_qt::pref_bool("local_library_track_artwork", false);
|
||||
ui(move |mut b| b.as_mut().set_local_track_artwork(on));
|
||||
}
|
||||
|
||||
// --- Not wired yet (LOGGED SEAMS — see the module header) ------------------
|
||||
|
||||
/// Album header ✏️: the Slint opens `LocalLibraryTagEditorModal` on the
|
||||
/// selected version's directory (`tag_writer.rs` / `tag_sidecar.rs` do the
|
||||
/// write). The Qt port has NO tag-editor modal, so this stays a seam: it
|
||||
/// resolves and logs the directory a future modal would receive and writes
|
||||
/// NOTHING. Writing tags from a menu item with no UI in front of it is not a
|
||||
/// port, it is data loss.
|
||||
pub fn edit_tags(id: String) {
|
||||
let dir = current_version_dir();
|
||||
log::info!(
|
||||
"[qbz-qt] local album edit-tags: no tag-editor modal in the Qt port yet \
|
||||
(album '{id}', version dir '{dir}') — seam only, no tags were written"
|
||||
);
|
||||
}
|
||||
|
||||
/// Album header +: the Slint opens the playlist picker over the version's
|
||||
/// tracks. The Qt port has no picker modal and `playlist_qt::add_tracks` only
|
||||
/// speaks Qobuz track ids, so this is a logged seam.
|
||||
pub fn add_to_playlist(id: String) {
|
||||
let n = current_version_tracks().len();
|
||||
log::info!(
|
||||
"[qbz-qt] local album add-to-playlist: no playlist picker in the Qt port yet \
|
||||
(album '{id}', {n} tracks) — seam only"
|
||||
);
|
||||
}
|
||||
|
||||
/// Album header 📼: the Slint builds a MyQBZ `AddItem` and opens the
|
||||
/// Mixtape/Collection picker. Neither the store nor the modal exists in the Qt
|
||||
/// port, so this is a logged seam.
|
||||
pub fn add_to_mixtape(id: String) {
|
||||
let n = current_version_tracks().len();
|
||||
log::info!(
|
||||
"[qbz-qt] local album add-to-mixtape: no Mixtape/Collection store in the Qt port yet \
|
||||
(album '{id}', {n} tracks) — seam only"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish + queue helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn publish_doc(doc: Option<AlbumDetailDoc>) {
|
||||
let json = doc.map(|d| to_json(&d)).unwrap_or_default();
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_album_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_album_loading(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Route ONE queue track to its audible step. Mirrors
|
||||
/// `local_playback::play_audible` (private there) over the SAME public seams —
|
||||
/// the PROTECTED audio path is entered, never modified.
|
||||
async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool {
|
||||
match track.source.as_deref() {
|
||||
Some("plex") => match track.source_item_id_hint.clone() {
|
||||
Some(rating_key) => play_plex_track(runtime, rating_key, track.id).await,
|
||||
None => {
|
||||
log::error!(
|
||||
"[qbz-qt] plex play: queue track {} has no rating key",
|
||||
track.id
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
_ => play_local_file(runtime, track.id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
play_audible(runtime, &first).await;
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
}
|
||||
|
||||
/// "Play next" / "Play later" / "Add to queue" over a track SUBSET — the same
|
||||
/// core helpers `local_playback::enqueue` uses, just without the id round-trip.
|
||||
async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: &str) {
|
||||
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||
if queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
match mode {
|
||||
// Reversed so a multi-track insert at the cursor keeps its order.
|
||||
"next" => {
|
||||
for t in queue.into_iter().rev() {
|
||||
runtime.core().add_track_next(t).await;
|
||||
}
|
||||
}
|
||||
"later" => {
|
||||
for t in queue {
|
||||
runtime.core().add_track_later(t).await;
|
||||
}
|
||||
}
|
||||
_ => runtime.core().add_tracks(queue).await,
|
||||
}
|
||||
crate::playback_qt::publish_queue(runtime).await;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Local Library queries: albums, folder-albums, artists, the paginated
|
||||
//! Tracks tab, the tab badges and the album-detail pane.
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization). Every query
|
||||
//! is a BLOCKING body (the bridge wraps it in `spawn_blocking`) and every one
|
||||
//! is Plex-aware:
|
||||
//!
|
||||
//! - Albums / badges: `get_albums_metadata_page(..., plex_cache_path, ...)`
|
||||
//! ATTACHes the Plex cache DB, so the returned page is the local+Plex
|
||||
//! UNION (sort, search and pagination apply to the union as one set).
|
||||
//! - Tracks: `search_with_filter_page` is `local_tracks`-only, so the FULL
|
||||
//! Plex search set is merged ONCE on page 1 and later pages stay pure
|
||||
//! local — the local LIMIT/OFFSET path is untouched and `has_more` is
|
||||
//! driven by the LOCAL page only, so pagination never over-reports.
|
||||
//! - Album detail: a `plex:`-prefixed group key is served from the Plex
|
||||
//! cache instead of `library.db`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use qbz_library::album_grouping::AlbumGroupMode;
|
||||
use qbz_library::LocalTrack;
|
||||
|
||||
use crate::local_album_actions::AlbumDetailDoc;
|
||||
use crate::local_rows::{
|
||||
artist_key, map_album, map_track, AlbumRow, ArtistRow, LocalCounts, TrackRow,
|
||||
};
|
||||
use crate::local_state::{group_mode, state, with_art, with_db, TRACKS_PAGE};
|
||||
|
||||
/// Albums tab: the FULL metadata/folder-grouped set (local + Plex) in one
|
||||
/// page. Search, sort and grouping derive QML-side over the parsed array.
|
||||
pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
|
||||
let mode = group_mode();
|
||||
let plex_path = crate::local_plex::cache_db_path();
|
||||
let page = with_db(|db| {
|
||||
db.get_albums_metadata_page(
|
||||
0,
|
||||
100_000,
|
||||
None,
|
||||
"artist",
|
||||
"asc",
|
||||
/* include_qobuz_downloads */ true,
|
||||
/* exclude_network_folders */ false,
|
||||
plex_path.as_deref(),
|
||||
mode,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "local library not available".to_string())?;
|
||||
let total = page.total;
|
||||
let albums = page.albums;
|
||||
let rows = with_art(|art| {
|
||||
albums
|
||||
.into_iter()
|
||||
.map(|a| map_album(a, art))
|
||||
.collect::<Vec<AlbumRow>>()
|
||||
});
|
||||
state(|s| {
|
||||
s.counts.albums = total as i64;
|
||||
s.albums = rows.clone();
|
||||
});
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Folders tab, FLAT mode: the album grid grouped by DIRECTORY. Local-only
|
||||
/// by definition — Plex rows have no filesystem folder.
|
||||
pub fn load_folders_blocking() -> Result<Vec<AlbumRow>, String> {
|
||||
let albums = with_db(|db| {
|
||||
db.get_albums_with_full_filter(
|
||||
/* include_hidden */ false,
|
||||
/* include_qobuz_downloads */ true,
|
||||
/* exclude_network_folders */ false,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "local library not available".to_string())?;
|
||||
let rows = with_art(|art| {
|
||||
albums
|
||||
.into_iter()
|
||||
.map(|a| map_album(a, art))
|
||||
.collect::<Vec<AlbumRow>>()
|
||||
});
|
||||
state(|s| {
|
||||
s.counts.folders = rows.len() as i64;
|
||||
s.folders = rows.clone();
|
||||
});
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Artists tab: the on-disk artists, with the aggregated Plex artists folded
|
||||
/// in by case-insensitive name (a local + Plex artist of the same name counts
|
||||
/// ONCE, with summed album/track counts — the Slint's `merge_artists` rule).
|
||||
pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
|
||||
let artists = with_db(|db| {
|
||||
db.get_artists_with_filter(
|
||||
/* include_qobuz_downloads */ true,
|
||||
/* exclude_network_folders */ false,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "local library not available".to_string())?;
|
||||
|
||||
let mut rows: Vec<ArtistRow> = artists
|
||||
.into_iter()
|
||||
.map(|a| ArtistRow {
|
||||
art_key: artist_key(&a.name),
|
||||
name: a.name,
|
||||
album_count: a.album_count,
|
||||
track_count: a.track_count,
|
||||
source: "local".into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if crate::local_plex::is_enabled() {
|
||||
let mut index: HashMap<String, usize> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| (r.name.trim().to_lowercase(), i))
|
||||
.collect();
|
||||
let plex_artists = crate::local_plex::cached_artists();
|
||||
with_art(|art| {
|
||||
for pa in plex_artists {
|
||||
let key = pa.name.trim().to_lowercase();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let art_key = artist_key(&pa.name);
|
||||
if let Some(thumb) = pa.artwork_path.as_ref().filter(|p| !p.is_empty()) {
|
||||
art.entry(art_key.clone()).or_insert_with(|| thumb.clone());
|
||||
}
|
||||
match index.get(&key) {
|
||||
Some(&i) => {
|
||||
rows[i].album_count += pa.album_count;
|
||||
rows[i].track_count += pa.track_count;
|
||||
rows[i].source = "mixed".into();
|
||||
}
|
||||
None => {
|
||||
index.insert(key, rows.len());
|
||||
rows.push(ArtistRow {
|
||||
art_key,
|
||||
name: pa.name,
|
||||
album_count: pa.album_count,
|
||||
track_count: pa.track_count,
|
||||
source: "plex".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
state(|s| {
|
||||
s.counts.artists = rows.len() as i64;
|
||||
s.artists = rows.clone();
|
||||
});
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Tracks tab, ONE page. `reset` clears the accumulator (a new search or
|
||||
/// sort); otherwise the page is appended (load-more on scroll).
|
||||
pub fn load_tracks_page_blocking(reset: bool) -> Result<(Vec<TrackRow>, bool), String> {
|
||||
let (offset, query, sort) = state(|s| {
|
||||
if reset {
|
||||
s.tracks.clear();
|
||||
s.tracks_raw.clear();
|
||||
s.tracks_offset = 0;
|
||||
}
|
||||
(s.tracks_offset, s.tracks_query.clone(), s.tracks_sort.clone())
|
||||
});
|
||||
let mut page = with_db(|db| {
|
||||
db.search_with_filter_page(
|
||||
query.trim(),
|
||||
offset,
|
||||
TRACKS_PAGE,
|
||||
/* include_qobuz_downloads */ true,
|
||||
/* exclude_network_folders */ false,
|
||||
&sort,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "local library not available".to_string())?;
|
||||
// `has_more` follows the LOCAL page only — merged Plex rows must never
|
||||
// make pagination over-report.
|
||||
let has_more = page.len() as u64 == TRACKS_PAGE;
|
||||
|
||||
if crate::local_plex::is_enabled() && offset == 0 {
|
||||
let mut merged = crate::local_plex::search_tracks(&query);
|
||||
if !merged.is_empty() {
|
||||
merged.append(&mut page);
|
||||
if sort != "default" {
|
||||
sort_tracks_like_sql(&mut merged, &sort);
|
||||
}
|
||||
page = merged;
|
||||
}
|
||||
}
|
||||
|
||||
let rows = with_art(|art| page.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>());
|
||||
let all = state(|s| {
|
||||
// The offset only ever advances by the LOCAL page length.
|
||||
s.tracks_offset += page
|
||||
.iter()
|
||||
.filter(|t| t.source.as_deref() != Some("plex"))
|
||||
.count() as u64;
|
||||
s.tracks.extend(rows);
|
||||
s.tracks_raw.extend(page);
|
||||
s.tracks_has_more = has_more;
|
||||
s.tracks.clone()
|
||||
});
|
||||
Ok((all, has_more))
|
||||
}
|
||||
|
||||
/// The tab badges. Cheap: the Tracks count never materializes the table.
|
||||
pub fn load_counts_blocking() -> LocalCounts {
|
||||
let local_tracks = with_db(|db| db.count_all_local_tracks()).unwrap_or(0) as i64;
|
||||
let plex_tracks = if crate::local_plex::is_enabled() {
|
||||
crate::local_plex::cached_track_count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
state(|s| {
|
||||
s.counts.tracks = local_tracks + plex_tracks;
|
||||
s.counts.plex_tracks = plex_tracks;
|
||||
s.counts.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// An album's tracks by group key: the Plex cache for a `plex:` key, else
|
||||
/// the ACTIVE identity mode's query against `library.db`.
|
||||
pub fn fetch_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
|
||||
if id.starts_with("plex:") {
|
||||
return crate::local_plex::album_tracks(id);
|
||||
}
|
||||
let mode = group_mode();
|
||||
with_db(|db| match mode {
|
||||
AlbumGroupMode::Metadata => {
|
||||
let meta = db.get_album_tracks_metadata(id)?;
|
||||
if meta.is_empty() {
|
||||
db.get_album_tracks(id)
|
||||
} else {
|
||||
Ok(meta)
|
||||
}
|
||||
}
|
||||
AlbumGroupMode::Folder => db.get_album_tracks(id),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The local album-detail document (header + versions + track list). `id` is
|
||||
/// the group key of the ACTIVE identity mode, or a Plex `plex:<hash>` key.
|
||||
///
|
||||
/// The tracks are split into VERSIONS by source directory before the header is
|
||||
/// built — two physical copies of the same album must not merge into one
|
||||
/// duplicated track list (album/LocalAlbumView.slint `open_local_album`). The
|
||||
/// header, the picker entries and the raw-row cache are all owned by
|
||||
/// `local_album_actions`, which is also what the version picker and the
|
||||
/// per-disc menu read back.
|
||||
pub fn load_album_detail_blocking(id: &str) -> Option<AlbumDetailDoc> {
|
||||
let tracks = fetch_album_tracks_blocking(id);
|
||||
if tracks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
crate::local_album_actions::open_versions(id, tracks)
|
||||
}
|
||||
|
||||
/// Client-side comparator mirroring `search_with_filter_page`'s ORDER BY over
|
||||
/// `LocalTrack` — only used to re-sort the Plex-merged page 1. NULL years
|
||||
/// sort last in both directions (SQL's `year IS NULL` prefix); `sort_by` is
|
||||
/// stable, like SQLite pagination over the same ORDER BY.
|
||||
fn sort_tracks_like_sql(rows: &mut [LocalTrack], sort: &str) {
|
||||
let lc = |s: &str| s.to_lowercase();
|
||||
let artist_key = |t: &LocalTrack| lc(t.album_artist.as_deref().unwrap_or(&t.artist));
|
||||
let album_tail = |a: &LocalTrack, b: &LocalTrack| {
|
||||
lc(&a.album)
|
||||
.cmp(&lc(&b.album))
|
||||
.then(a.disc_number.cmp(&b.disc_number))
|
||||
.then(a.track_number.cmp(&b.track_number))
|
||||
};
|
||||
let year_cmp = |a: &LocalTrack, b: &LocalTrack, desc: bool| match (a.year, b.year) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(ya), Some(yb)) => {
|
||||
if desc {
|
||||
yb.cmp(&ya)
|
||||
} else {
|
||||
ya.cmp(&yb)
|
||||
}
|
||||
}
|
||||
};
|
||||
match sort {
|
||||
"title-asc" => rows.sort_by(|a, b| {
|
||||
lc(&a.title)
|
||||
.cmp(&lc(&b.title))
|
||||
.then(lc(&a.artist).cmp(&lc(&b.artist)))
|
||||
}),
|
||||
"title-desc" => rows.sort_by(|a, b| {
|
||||
lc(&b.title)
|
||||
.cmp(&lc(&a.title))
|
||||
.then(lc(&a.artist).cmp(&lc(&b.artist)))
|
||||
}),
|
||||
"artist-asc" => rows.sort_by(|a, b| artist_key(a).cmp(&artist_key(b)).then(album_tail(a, b))),
|
||||
"artist-desc" => rows.sort_by(|a, b| artist_key(b).cmp(&artist_key(a)).then(album_tail(a, b))),
|
||||
"year-desc" => rows.sort_by(|a, b| year_cmp(a, b, true).then(album_tail(a, b))),
|
||||
"year-asc" => rows.sort_by(|a, b| year_cmp(a, b, false).then(album_tail(a, b))),
|
||||
"added-desc" => rows.sort_by(|a, b| b.indexed_at.cmp(&a.indexed_at).then(album_tail(a, b))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Windowed, id-keyed artwork for the Local Library (the perf contract:
|
||||
//! a 16K-track library must never decode a cover it does not show).
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization) and made
|
||||
//! source-aware:
|
||||
//!
|
||||
//! - a LOCAL cover path resolves to the `qbz-library` 256px THUMBNAIL
|
||||
//! (generated once, cached on disk);
|
||||
//! - a PLEX thumb (`/library/...` / `/photo/...`) resolves to the tokenized
|
||||
//! server-side transcode URL and is served through the SAME shared image
|
||||
//! cache the Home/queue covers use (`artwork_qt`), so a Plex cover is
|
||||
//! downloaded once per size and then read from disk like any other.
|
||||
//!
|
||||
//! Both arms end at a `file://` path — QML `Image` decodes it natively and
|
||||
//! asynchronously, and no token ever reaches QML.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::local_state::state;
|
||||
|
||||
/// Decode size for browse thumbnails (matches the `qbz-library` thumbnail
|
||||
/// size, so the local and Plex arms produce visually identical cards).
|
||||
const THUMB_PX: u32 = 256;
|
||||
|
||||
/// The result of one window pass.
|
||||
pub struct ArtworkWindow {
|
||||
/// (artKey, "file://…") — ready to emit immediately.
|
||||
pub hits: Vec<(String, String)>,
|
||||
/// (artKey, tokenized Plex URL) — not on disk yet; the bridge downloads
|
||||
/// these off the Qt thread and re-resolves them to `file://`.
|
||||
pub plex_misses: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Resolve the mounted window's artKeys. BLOCKING (image decode + disk) —
|
||||
/// the bridge runs it on `spawn_blocking`. Keys with no cover are dropped, so
|
||||
/// the QML map only ever grows with real hits.
|
||||
pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow {
|
||||
let sources: Vec<(String, String)> = state(|s| {
|
||||
keys.iter()
|
||||
.filter_map(|k| s.art_index.get(k).map(|p| (k.clone(), p.clone())))
|
||||
.collect()
|
||||
});
|
||||
let mut hits = Vec::with_capacity(sources.len());
|
||||
let mut plex_misses = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for (key, src) in sources {
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
// --- Plex arm: a server-relative thumb path, never a file ---------
|
||||
if crate::local_plex::is_thumb_path(&src) {
|
||||
let url = crate::local_plex::thumb_url(&src, Some(THUMB_PX));
|
||||
if url.is_empty() {
|
||||
continue; // no creds — nothing to fetch.
|
||||
}
|
||||
let cached = crate::artwork_qt::cached_path(&url);
|
||||
if cached.is_empty() {
|
||||
plex_misses.push((key, url));
|
||||
} else {
|
||||
hits.push((key, cached));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// --- Local arm: thumbnail the on-disk cover -----------------------
|
||||
let path = Path::new(&src);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
match qbz_library::get_or_generate_thumbnail(path) {
|
||||
Ok(thumb) => hits.push((key, format!("file://{}", thumb.display()))),
|
||||
// No thumbnail (unsupported source): fall back to the original
|
||||
// file so the card is not blank.
|
||||
Err(_) => hits.push((key, format!("file://{src}"))),
|
||||
}
|
||||
}
|
||||
ArtworkWindow { hits, plex_misses }
|
||||
}
|
||||
|
||||
/// Download the Plex misses into the shared image cache and return the
|
||||
/// resolved `(key, file://…)` pairs. Async (network) — the bridge awaits it
|
||||
/// on the tokio runtime, never on the Qt thread.
|
||||
pub async fn fetch_plex_misses(misses: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
if misses.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let urls: Vec<String> = misses.iter().map(|(_, u)| u.clone()).collect();
|
||||
crate::artwork_qt::download_missing(urls).await;
|
||||
misses
|
||||
.into_iter()
|
||||
.filter_map(|(key, url)| {
|
||||
let path = crate::artwork_qt::cached_path(&url);
|
||||
(!path.is_empty()).then_some((key, path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
+324
-150
@@ -10,9 +10,10 @@
|
||||
//! evicted QML-side, which is what keeps a 16K-track library from decoding
|
||||
//! covers it never shows.
|
||||
//!
|
||||
//! The invokables stay one-line forwards into `local_library_qt.rs`; the
|
||||
//! The invokables stay one-line forwards into the `local_*` modules; the
|
||||
//! blocking DB work runs on `spawn_blocking` so the Qt event loop is never
|
||||
//! touched by a rusqlite call.
|
||||
//! touched by a rusqlite call, and the Plex network work runs on the tokio
|
||||
//! runtime.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
@@ -21,7 +22,12 @@ use cxx_qt::CxxQtThread;
|
||||
use cxx_qt::Threading as _;
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
use crate::local_bridge_ops::{
|
||||
emit_artwork, load_tab_impl, load_tracks, publish_plex_state, publish_tree, reload_browse,
|
||||
run_sync,
|
||||
};
|
||||
use crate::local_library_qt as lib;
|
||||
use crate::local_plex as plex;
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qbz_local {
|
||||
@@ -36,11 +42,11 @@ pub mod qbz_local {
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
// --- Availability / chrome ----------------------------------------
|
||||
/// False when there is no per-user library.db or no registered
|
||||
/// folder — the view then shows the "nothing indexed yet" state
|
||||
/// with the route into Settings > Local Library.
|
||||
/// False when there is no per-user library.db, no registered folder
|
||||
/// AND no cached Plex content — the view then shows the "nothing
|
||||
/// indexed yet" state with the route into Settings > Local Library.
|
||||
#[qproperty(bool, local_available)]
|
||||
/// {albums, artists, folders, tracks} — the tab badges.
|
||||
/// {albums, artists, folders, tracks, plexTracks} — the tab badges.
|
||||
#[qproperty(QString, local_counts_json)]
|
||||
/// Album identity: "folder" | "metadata" (persisted; shared with
|
||||
/// the Slint frontend's locallibrary_ui.json).
|
||||
@@ -64,6 +70,9 @@ pub mod qbz_local {
|
||||
/// The FLATTENED, search-filtered visible tree — a plain array the
|
||||
/// rail windows with a ListView (never a recursive component).
|
||||
#[qproperty(QString, local_tree_json)]
|
||||
/// Tracks selected in the tree rail — the bulk bar's counter and its
|
||||
/// visibility gate.
|
||||
#[qproperty(i32, local_tree_selected_count)]
|
||||
#[qproperty(bool, local_detail_loading)]
|
||||
/// {path, name, trackCount, subfolders[], tracks[]}
|
||||
#[qproperty(QString, local_detail_json)]
|
||||
@@ -79,6 +88,39 @@ pub mod qbz_local {
|
||||
#[qproperty(bool, local_album_loading)]
|
||||
/// {album:{...}, tracks:[...]} — "" while nothing is open.
|
||||
#[qproperty(QString, local_album_json)]
|
||||
/// Mirror of AppearanceState.local-library-track-artwork (ui_prefs,
|
||||
/// default OFF). OFF is the 16k-row freeze guard — keep the default.
|
||||
#[qproperty(bool, local_track_artwork)]
|
||||
/// Artist NAME route from the routed local album page into the Artists
|
||||
/// tab (local/Plex artists carry no catalog id). "" once consumed.
|
||||
#[qproperty(QString, local_pending_artist)]
|
||||
// --- Ephemeral folder (an ad-hoc folder outside the index) ---------
|
||||
/// A session is open: the Folders tab shows the ephemeral pane.
|
||||
#[qproperty(bool, local_ephemeral_active)]
|
||||
/// The folder is being scanned (metadata + CUE + artwork).
|
||||
#[qproperty(bool, local_ephemeral_loading)]
|
||||
/// {name, path, trackCount, multiAlbum, albums:[…]} — "" while closed.
|
||||
#[qproperty(QString, local_ephemeral_json)]
|
||||
|
||||
// --- Plex ----------------------------------------------------------
|
||||
/// Master toggle (Settings > Local Library > Plex). Drives whether
|
||||
/// the browse union includes Plex at all + the source filter chip.
|
||||
#[qproperty(bool, plex_enabled)]
|
||||
/// enabled + LAN address + resolved base url + token — the Slint's
|
||||
/// `plex-available`: gates the header Sync button and every request
|
||||
/// that leaves the process.
|
||||
#[qproperty(bool, plex_available)]
|
||||
/// A manual sync is in flight (`plex-syncing`) — the Sync button's
|
||||
/// busy state.
|
||||
#[qproperty(bool, plex_syncing)]
|
||||
/// Tracks written by the last sync; -1 = never synced this session.
|
||||
#[qproperty(i32, plex_last_sync_tracks)]
|
||||
/// Raw error text from the last Plex operation ("" when fine). QML
|
||||
/// wraps it in its own translated frame.
|
||||
#[qproperty(QString, plex_error)]
|
||||
/// [{key, title, selected}] — the cached library sections + the
|
||||
/// user's selection (Settings > Local Library > Plex).
|
||||
#[qproperty(QString, plex_sections_json)]
|
||||
|
||||
type QbzLocal = super::QbzLocalRust;
|
||||
|
||||
@@ -119,17 +161,74 @@ pub mod qbz_local {
|
||||
/// Rail search box (filters the visible set, keeps ancestors).
|
||||
#[qinvokable]
|
||||
fn tree_search(self: Pin<&mut QbzLocal>, query: QString);
|
||||
/// Rail header: enter / leave multi-select. Leaving DROPS the selection.
|
||||
#[qinvokable]
|
||||
fn tree_set_select_mode(self: Pin<&mut QbzLocal>, on: bool);
|
||||
/// Folder checkbox: toggle every track under it, recursively.
|
||||
#[qinvokable]
|
||||
fn tree_toggle_folder_select(self: Pin<&mut QbzLocal>, path: QString);
|
||||
/// Track checkbox: toggle one row by file path.
|
||||
#[qinvokable]
|
||||
fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString);
|
||||
/// Tree-rail bulk bar.
|
||||
#[qinvokable]
|
||||
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
|
||||
/// Albums-grid / Tracks-table bulk bar. scope = "album" | "track".
|
||||
#[qinvokable]
|
||||
fn bulk_action(self: Pin<&mut QbzLocal>, scope: QString, ids_json: QString, action: QString);
|
||||
/// Row body: select a folder and load its detail pane.
|
||||
#[qinvokable]
|
||||
fn select_folder(self: Pin<&mut QbzLocal>, path: QString);
|
||||
|
||||
// --- Album detail ---------------------------------------------------
|
||||
/// Album card click: load the local album detail (header + tracks).
|
||||
/// Album card click: load the local/Plex album detail (header +
|
||||
/// tracks). A `plex:<hash>` id is served from the Plex cache.
|
||||
#[qinvokable]
|
||||
fn open_album(self: Pin<&mut QbzLocal>, id: QString);
|
||||
/// Close the album pane (back to the grid).
|
||||
#[qinvokable]
|
||||
fn close_album(self: Pin<&mut QbzLocal>);
|
||||
// --- Local album actions -------------------------------------------
|
||||
/// The view consumed the pending artist-name route.
|
||||
#[qinvokable]
|
||||
fn clear_pending_artist(self: Pin<&mut QbzLocal>);
|
||||
/// "Go to artist" on a local/Plex album — a NAME route, not an id.
|
||||
#[qinvokable]
|
||||
fn open_artist_by_name(self: Pin<&mut QbzLocal>, name: QString);
|
||||
/// Album header pencil — LOGGED SEAM (no tag-editor modal yet).
|
||||
#[qinvokable]
|
||||
fn album_edit_tags(self: Pin<&mut QbzLocal>, id: QString);
|
||||
/// Album header list-plus — LOGGED SEAM (no playlist picker yet).
|
||||
#[qinvokable]
|
||||
fn album_add_to_playlist(self: Pin<&mut QbzLocal>, id: QString);
|
||||
/// Album header cassette — LOGGED SEAM (no Mixtape store yet).
|
||||
#[qinvokable]
|
||||
fn album_add_to_mixtape(self: Pin<&mut QbzLocal>, id: QString);
|
||||
/// Version picker: switch the shown physical copy.
|
||||
#[qinvokable]
|
||||
fn album_select_version(self: Pin<&mut QbzLocal>, index: i32);
|
||||
/// Per-disc menu: "play" | "next" | "later" | "queue".
|
||||
#[qinvokable]
|
||||
fn album_disc_action(self: Pin<&mut QbzLocal>, disc: i32, action: QString);
|
||||
// --- Ephemeral folder ------------------------------------------------
|
||||
/// Pick a folder OUTSIDE the library, scan it, show the pane.
|
||||
#[qinvokable]
|
||||
fn ephemeral_open(self: Pin<&mut QbzLocal>);
|
||||
/// Same, for a KNOWN path (no picker).
|
||||
#[qinvokable]
|
||||
fn ephemeral_open_path(self: Pin<&mut QbzLocal>, path: QString);
|
||||
/// Close the session (stops playback if it came from the session).
|
||||
#[qinvokable]
|
||||
fn ephemeral_clear(self: Pin<&mut QbzLocal>);
|
||||
/// Header Play: the whole folder becomes the queue.
|
||||
#[qinvokable]
|
||||
fn ephemeral_play_all(self: Pin<&mut QbzLocal>);
|
||||
/// Per-album Play (multi-album sessions only).
|
||||
#[qinvokable]
|
||||
fn ephemeral_play_album(self: Pin<&mut QbzLocal>, group_key: QString);
|
||||
/// Track row click: the track's album block becomes the queue.
|
||||
#[qinvokable]
|
||||
fn ephemeral_play_track(self: Pin<&mut QbzLocal>, id: QString);
|
||||
|
||||
// --- Playback --------------------------------------------------------
|
||||
/// Album card / detail header Play (optionally shuffled).
|
||||
@@ -153,9 +252,37 @@ pub mod qbz_local {
|
||||
#[qinvokable]
|
||||
fn enqueue(self: Pin<&mut QbzLocal>, kind: QString, id: QString, mode: QString);
|
||||
|
||||
// --- Plex ------------------------------------------------------------
|
||||
/// Header Sync button (#573): re-fetch the Plex sections + tracks
|
||||
/// into the shared cache DB, then reload the browse documents in
|
||||
/// place. No-op when `plexAvailable` is false.
|
||||
#[qinvokable]
|
||||
fn sync_plex(self: Pin<&mut QbzLocal>);
|
||||
/// Master toggle. Turning it OFF collapses the browse union back to
|
||||
/// local-only immediately (the cache DB is kept).
|
||||
#[qinvokable]
|
||||
fn plex_set_enabled(self: Pin<&mut QbzLocal>, enabled: bool);
|
||||
/// Manual connect: resolve + persist `proto://host:32400` + token,
|
||||
/// then run a first sync. LAN addresses only.
|
||||
#[qinvokable]
|
||||
fn plex_connect(self: Pin<&mut QbzLocal>, server_url: QString, token: QString);
|
||||
/// Sign out of Plex: clear creds/sections and purge the cache DB,
|
||||
/// then reload the (now local-only) browse documents.
|
||||
#[qinvokable]
|
||||
fn plex_disconnect(self: Pin<&mut QbzLocal>);
|
||||
/// Persist the selected library section keys (JSON array of strings)
|
||||
/// and re-sync so the cache matches the selection.
|
||||
#[qinvokable]
|
||||
fn set_plex_sections(self: Pin<&mut QbzLocal>, keys_json: QString);
|
||||
/// Re-read the Plex gates + sections from the store (call after the
|
||||
/// settings panel changed something out of band).
|
||||
#[qinvokable]
|
||||
fn refresh_plex(self: Pin<&mut QbzLocal>);
|
||||
|
||||
// --- Windowed artwork -------------------------------------------------
|
||||
/// The mounted window reports its artKeys; Rust resolves each to a
|
||||
/// 256px thumbnail and emits `localArtworkReady` per hit.
|
||||
/// 256px thumbnail (local cover) or a disk-cached Plex thumb and
|
||||
/// emits `localArtworkReady` per hit.
|
||||
#[qinvokable]
|
||||
fn artwork_window(self: Pin<&mut QbzLocal>, keys_json: QString);
|
||||
/// (key, "file://…") — id-keyed so a cover can never land on the
|
||||
@@ -183,6 +310,7 @@ pub struct QbzLocalRust {
|
||||
local_folders_json: QString,
|
||||
local_tree_loading: bool,
|
||||
local_tree_json: QString,
|
||||
local_tree_selected_count: i32,
|
||||
local_detail_loading: bool,
|
||||
local_detail_json: QString,
|
||||
local_tracks_loading: bool,
|
||||
@@ -192,6 +320,17 @@ pub struct QbzLocalRust {
|
||||
local_tracks_json: QString,
|
||||
local_album_loading: bool,
|
||||
local_album_json: QString,
|
||||
local_track_artwork: bool,
|
||||
local_pending_artist: QString,
|
||||
local_ephemeral_active: bool,
|
||||
local_ephemeral_loading: bool,
|
||||
local_ephemeral_json: QString,
|
||||
plex_enabled: bool,
|
||||
plex_available: bool,
|
||||
plex_syncing: bool,
|
||||
plex_last_sync_tracks: i32,
|
||||
plex_error: QString,
|
||||
plex_sections_json: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzLocalRust {
|
||||
@@ -209,6 +348,7 @@ impl Default for QbzLocalRust {
|
||||
local_folders_json: QString::from("[]"),
|
||||
local_tree_loading: false,
|
||||
local_tree_json: QString::from("[]"),
|
||||
local_tree_selected_count: 0,
|
||||
local_detail_loading: false,
|
||||
local_detail_json: QString::from(""),
|
||||
local_tracks_loading: false,
|
||||
@@ -218,6 +358,17 @@ impl Default for QbzLocalRust {
|
||||
local_tracks_json: QString::from("[]"),
|
||||
local_album_loading: false,
|
||||
local_album_json: QString::from(""),
|
||||
local_track_artwork: false,
|
||||
local_pending_artist: QString::default(),
|
||||
local_ephemeral_active: false,
|
||||
local_ephemeral_loading: false,
|
||||
local_ephemeral_json: QString::from(""),
|
||||
plex_enabled: false,
|
||||
plex_available: false,
|
||||
plex_syncing: false,
|
||||
plex_last_sync_tracks: -1,
|
||||
plex_error: QString::default(),
|
||||
plex_sections_json: QString::from("[]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,22 +387,6 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLocal>) + Send + 'static) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Republish the tab badges + availability after any load.
|
||||
fn publish_counts() {
|
||||
let counts = lib::to_json(&lib::counts());
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_counts_json(QString::from(counts.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_tree(nodes_json: String) {
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tree_json(QString::from(nodes_json.as_str()));
|
||||
b.as_mut().set_local_tree_loading(false);
|
||||
});
|
||||
}
|
||||
|
||||
impl qbz_local::QbzLocal {
|
||||
pub fn boot(self: Pin<&mut Self>) {
|
||||
@@ -274,6 +409,10 @@ impl qbz_local::QbzLocal {
|
||||
.set_local_tracks_sort(QString::from(sort.as_str()));
|
||||
});
|
||||
});
|
||||
publish_plex_state();
|
||||
crate::local_album_actions::publish_track_artwork();
|
||||
// Slint parity: re-open the persisted ad-hoc folder at startup.
|
||||
crate::local_ephemeral::rehydrate();
|
||||
}
|
||||
|
||||
pub fn load_tab(self: Pin<&mut Self>, tab: QString) {
|
||||
@@ -431,147 +570,182 @@ impl qbz_local::QbzLocal {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Plex --------------------------------------------------------------
|
||||
|
||||
pub fn sync_plex(self: Pin<&mut Self>) {
|
||||
if plex::is_syncing() {
|
||||
return;
|
||||
}
|
||||
run_sync();
|
||||
}
|
||||
|
||||
pub fn plex_set_enabled(self: Pin<&mut Self>, enabled: bool) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || plex::set_enabled(enabled)).await;
|
||||
publish_plex_state();
|
||||
// The union IS the query — the grid/tracks/badges must re-run.
|
||||
reload_browse();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn plex_connect(self: Pin<&mut Self>, server_url: QString, token: QString) {
|
||||
let (url, token) = (server_url.to_string(), token.to_string());
|
||||
crate::spawn(async move {
|
||||
let base = tokio::task::spawn_blocking(move || {
|
||||
plex::set_enabled(true);
|
||||
plex::connect_manual(&url, &token)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if base.is_empty() {
|
||||
// Translated Rust-side (qbz_i18n is the same catalog the
|
||||
// Slint build ships); QML shows `plexError` verbatim.
|
||||
let msg = qbz_i18n::t("Plex server must be a local network address");
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_plex_error(QString::from(msg.as_str()));
|
||||
});
|
||||
return;
|
||||
}
|
||||
publish_plex_state();
|
||||
run_sync();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn plex_disconnect(self: Pin<&mut Self>) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(plex::disconnect).await;
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_plex_last_sync_tracks(-1);
|
||||
b.as_mut().set_plex_error(QString::from(""));
|
||||
});
|
||||
publish_plex_state();
|
||||
reload_browse();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_plex_sections(self: Pin<&mut Self>, keys_json: QString) {
|
||||
let keys: Vec<String> = serde_json::from_str(&keys_json.to_string()).unwrap_or_default();
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || plex::set_selected_sections(&keys)).await;
|
||||
publish_plex_state();
|
||||
run_sync();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_plex(self: Pin<&mut Self>) {
|
||||
publish_plex_state();
|
||||
}
|
||||
|
||||
// --- Artwork -----------------------------------------------------------
|
||||
|
||||
pub fn artwork_window(self: Pin<&mut Self>, keys_json: QString) {
|
||||
let keys: Vec<String> = serde_json::from_str(&keys_json.to_string()).unwrap_or_default();
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let hits = tokio::task::spawn_blocking(move || lib::artwork_window_blocking(keys))
|
||||
let window = tokio::task::spawn_blocking(move || lib::resolve_window_blocking(keys))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (key, path) in hits {
|
||||
ui(move |mut b| {
|
||||
b.as_mut().local_artwork_ready(
|
||||
QString::from(key.as_str()),
|
||||
QString::from(path.as_str()),
|
||||
);
|
||||
});
|
||||
}
|
||||
.ok();
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
};
|
||||
emit_artwork(window.hits);
|
||||
// Plex thumbs that were not on disk: fetch them (network), then
|
||||
// emit whatever landed.
|
||||
let fetched = lib::fetch_plex_misses(window.plex_misses).await;
|
||||
emit_artwork(fetched);
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- Tree selection + bulk actions --------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loaders (each publishes its own tab's document + the shared badges)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn load_tab_impl(tab: String) {
|
||||
match tab.as_str() {
|
||||
"albums" => load_albums(),
|
||||
"artists" => load_artists(),
|
||||
"folders" => load_folders(),
|
||||
"tracks" => load_tracks(true),
|
||||
_ => log::warn!("[qbz-qt] local: unknown tab {tab}"),
|
||||
pub fn tree_set_select_mode(self: Pin<&mut Self>, on: bool) {
|
||||
crate::local_bulk::set_select_mode(on);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_albums() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_albums_loading(true);
|
||||
b.as_mut().set_local_albums_error(QString::from(""));
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(lib::load_albums_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
let _ = tokio::task::spawn_blocking(lib::load_counts_blocking).await;
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let json = lib::to_json(&rows);
|
||||
log::info!("[qbz-qt] local albums published: {} ({} bytes)", rows.len(), json.len());
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_albums_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_albums_loading(false);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] local albums load failed: {e}");
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_albums_error(QString::from(e.as_str()));
|
||||
b.as_mut().set_local_albums_loading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
pub fn tree_toggle_folder_select(self: Pin<&mut Self>, path: QString) {
|
||||
crate::local_bulk::toggle_folder_select(path.to_string());
|
||||
}
|
||||
|
||||
fn load_artists() {
|
||||
ui(|mut b| b.as_mut().set_local_artists_loading(true));
|
||||
crate::spawn(async move {
|
||||
let rows = tokio::task::spawn_blocking(lib::load_artists_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_artists_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_artists_loading(false);
|
||||
pub fn tree_toggle_track_select(self: Pin<&mut Self>, path: QString) {
|
||||
crate::local_bulk::toggle_track_select(path.to_string());
|
||||
}
|
||||
|
||||
pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) {
|
||||
crate::local_bulk::folders_bulk_action(action.to_string());
|
||||
}
|
||||
|
||||
pub fn bulk_action(self: Pin<&mut Self>, scope: QString, ids_json: QString, action: QString) {
|
||||
crate::local_bulk::bulk_action(scope.to_string(), ids_json.to_string(), action.to_string());
|
||||
}
|
||||
|
||||
// --- Local album actions -------------------------------------------------
|
||||
|
||||
pub fn clear_pending_artist(self: Pin<&mut Self>) {
|
||||
crate::local_album_actions::clear_pending_artist();
|
||||
}
|
||||
|
||||
pub fn open_artist_by_name(self: Pin<&mut Self>, name: QString) {
|
||||
crate::local_album_actions::open_artist_by_name(name.to_string());
|
||||
}
|
||||
|
||||
pub fn album_edit_tags(self: Pin<&mut Self>, id: QString) {
|
||||
crate::local_album_actions::edit_tags(id.to_string());
|
||||
}
|
||||
|
||||
pub fn album_add_to_playlist(self: Pin<&mut Self>, id: QString) {
|
||||
crate::local_album_actions::add_to_playlist(id.to_string());
|
||||
}
|
||||
|
||||
pub fn album_add_to_mixtape(self: Pin<&mut Self>, id: QString) {
|
||||
crate::local_album_actions::add_to_mixtape(id.to_string());
|
||||
}
|
||||
|
||||
pub fn album_select_version(self: Pin<&mut Self>, index: i32) {
|
||||
crate::local_album_actions::select_version(index);
|
||||
}
|
||||
|
||||
pub fn album_disc_action(self: Pin<&mut Self>, disc: i32, action: QString) {
|
||||
crate::local_album_actions::disc_action(disc, action.to_string());
|
||||
}
|
||||
|
||||
// --- Ephemeral folder ----------------------------------------------------
|
||||
|
||||
pub fn ephemeral_open(self: Pin<&mut Self>) {
|
||||
crate::local_ephemeral::open();
|
||||
}
|
||||
|
||||
pub fn ephemeral_open_path(self: Pin<&mut Self>, path: QString) {
|
||||
crate::local_ephemeral::open_path(path.to_string());
|
||||
}
|
||||
|
||||
pub fn ephemeral_clear(self: Pin<&mut Self>) {
|
||||
crate::local_ephemeral::clear();
|
||||
}
|
||||
|
||||
pub fn ephemeral_play_all(self: Pin<&mut Self>) {
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
crate::local_ephemeral::play_all(&runtime).await;
|
||||
});
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn load_folders() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_folders_loading(true);
|
||||
b.as_mut().set_local_tree_loading(true);
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let rows = tokio::task::spawn_blocking(lib::load_folders_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_folders_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_folders_loading(false);
|
||||
pub fn ephemeral_play_album(self: Pin<&mut Self>, group_key: QString) {
|
||||
let key = group_key.to_string();
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
crate::local_ephemeral::play_album(&runtime, key).await;
|
||||
});
|
||||
// The tree rail seeds from the registered library folders; the
|
||||
// levels below it are fetched on expand.
|
||||
let _ = tokio::task::spawn_blocking(lib::load_tree_roots_blocking).await;
|
||||
publish_tree(lib::to_json(&lib::tree_visible()));
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ephemeral_play_track(self: Pin<&mut Self>, id: QString) {
|
||||
let Ok(row) = id.to_string().parse::<i64>() else {
|
||||
return;
|
||||
};
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
crate::local_ephemeral::play_track(&runtime, row).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn load_tracks(reset: bool) {
|
||||
ui(move |mut b| {
|
||||
if reset {
|
||||
b.as_mut().set_local_tracks_loading(true);
|
||||
} else {
|
||||
b.as_mut().set_local_tracks_loading_more(true);
|
||||
}
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || lib::load_tracks_page_blocking(reset))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
let _ = tokio::task::spawn_blocking(lib::load_counts_blocking).await;
|
||||
match result {
|
||||
Ok((rows, has_more)) => {
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tracks_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_tracks_has_more(has_more);
|
||||
b.as_mut().set_local_tracks_loading(false);
|
||||
b.as_mut().set_local_tracks_loading_more(false);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] local tracks load failed: {e}");
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_tracks_loading(false);
|
||||
b.as_mut().set_local_tracks_loading_more(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! QbzLocal bridge OPS — the publish + load orchestration behind the
|
||||
//! `local_bridge.rs` invokables.
|
||||
//!
|
||||
//! Split out of `local_bridge.rs` so the bridge file stays what it is meant
|
||||
//! to be: ONE `#[cxx_qt::bridge]` declaration plus the thin invokable
|
||||
//! forwards (cxx-qt requires the bridge module to live alone in a FLAT
|
||||
//! `src/` file — QTBUG-93443). Everything here is a plain free function
|
||||
//! that hops to the Qt thread through `local_bridge::ui`, so it carries no
|
||||
//! cxx-qt constraints.
|
||||
//!
|
||||
//! Every loader publishes its own tab's document + the shared badges; the
|
||||
//! Plex helpers publish the gates and drive the manual sync.
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
use crate::local_bridge::ui;
|
||||
use crate::local_library_qt as lib;
|
||||
use crate::local_plex as plex;
|
||||
|
||||
/// Republish the tab badges + availability after any load.
|
||||
pub(crate) fn publish_counts() {
|
||||
let counts = lib::to_json(&lib::counts());
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_counts_json(QString::from(counts.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn publish_tree(nodes_json: String) {
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tree_json(QString::from(nodes_json.as_str()));
|
||||
b.as_mut().set_local_tree_loading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plex helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct PlexSectionRow {
|
||||
key: String,
|
||||
title: String,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
/// Read the Plex gates + cached sections off the Qt thread and publish them.
|
||||
pub(crate) fn publish_plex_state() {
|
||||
crate::spawn(async move {
|
||||
let snapshot = tokio::task::spawn_blocking(|| {
|
||||
let (sections, selected) = plex::cached_sections();
|
||||
let rows: Vec<PlexSectionRow> = sections
|
||||
.into_iter()
|
||||
.map(|s| PlexSectionRow {
|
||||
selected: selected.iter().any(|k| *k == s.key),
|
||||
key: s.key,
|
||||
title: s.title,
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
plex::is_enabled(),
|
||||
plex::is_configured(),
|
||||
serde_json::to_string(&rows).unwrap_or_else(|_| "[]".to_string()),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let Ok((enabled, available, sections_json)) = snapshot else {
|
||||
return;
|
||||
};
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_plex_enabled(enabled);
|
||||
b.as_mut().set_plex_available(available);
|
||||
b.as_mut()
|
||||
.set_plex_sections_json(QString::from(sections_json.as_str()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Reload every browse document in place (after a sync / connect / toggle).
|
||||
pub(crate) fn reload_browse() {
|
||||
load_albums();
|
||||
load_artists();
|
||||
load_tracks(true);
|
||||
}
|
||||
|
||||
/// The sync body shared by the header button, `plex_connect` and
|
||||
/// `set_plex_sections`.
|
||||
pub(crate) fn run_sync() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_plex_syncing(true);
|
||||
b.as_mut().set_plex_error(QString::from(""));
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let result = plex::sync_now().await;
|
||||
match result {
|
||||
Ok(total) => {
|
||||
log::info!("[qbz-qt] plex sync finished: {total} tracks");
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_plex_last_sync_tracks(total as i32);
|
||||
b.as_mut().set_plex_syncing(false);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] plex sync failed: {e}");
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_plex_error(QString::from(e.as_str()));
|
||||
b.as_mut().set_plex_syncing(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
publish_plex_state();
|
||||
reload_browse();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn emit_artwork(pairs: Vec<(String, String)>) {
|
||||
for (key, path) in pairs {
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.local_artwork_ready(QString::from(key.as_str()), QString::from(path.as_str()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loaders (each publishes its own tab's document + the shared badges)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn load_tab_impl(tab: String) {
|
||||
match tab.as_str() {
|
||||
"albums" => load_albums(),
|
||||
"artists" => load_artists(),
|
||||
"folders" => load_folders(),
|
||||
"tracks" => load_tracks(true),
|
||||
_ => log::warn!("[qbz-qt] local: unknown tab {tab}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_albums() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_albums_loading(true);
|
||||
b.as_mut().set_local_albums_error(QString::from(""));
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(lib::load_albums_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
let _ = tokio::task::spawn_blocking(lib::load_counts_blocking).await;
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let json = lib::to_json(&rows);
|
||||
log::info!("[qbz-qt] local albums published: {} ({} bytes)", rows.len(), json.len());
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_albums_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_albums_loading(false);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] local albums load failed: {e}");
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_albums_error(QString::from(e.as_str()));
|
||||
b.as_mut().set_local_albums_loading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn load_artists() {
|
||||
ui(|mut b| b.as_mut().set_local_artists_loading(true));
|
||||
crate::spawn(async move {
|
||||
let rows = tokio::task::spawn_blocking(lib::load_artists_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_artists_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_artists_loading(false);
|
||||
});
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn load_folders() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_folders_loading(true);
|
||||
b.as_mut().set_local_tree_loading(true);
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let rows = tokio::task::spawn_blocking(lib::load_folders_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_folders_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_folders_loading(false);
|
||||
});
|
||||
// The tree rail seeds from the registered library folders; the
|
||||
// levels below it are fetched on expand.
|
||||
let _ = tokio::task::spawn_blocking(lib::load_tree_roots_blocking).await;
|
||||
publish_tree(lib::to_json(&lib::tree_visible()));
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn load_tracks(reset: bool) {
|
||||
ui(move |mut b| {
|
||||
if reset {
|
||||
b.as_mut().set_local_tracks_loading(true);
|
||||
} else {
|
||||
b.as_mut().set_local_tracks_loading_more(true);
|
||||
}
|
||||
});
|
||||
crate::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || lib::load_tracks_page_blocking(reset))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
let _ = tokio::task::spawn_blocking(lib::load_counts_blocking).await;
|
||||
match result {
|
||||
Ok((rows, has_more)) => {
|
||||
let json = lib::to_json(&rows);
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tracks_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_tracks_has_more(has_more);
|
||||
b.as_mut().set_local_tracks_loading(false);
|
||||
b.as_mut().set_local_tracks_loading_more(false);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] local tracks load failed: {e}");
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_tracks_loading(false);
|
||||
b.as_mut().set_local_tracks_loading_more(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
publish_counts();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Local Library BULK actions — the three multi-select bars.
|
||||
//!
|
||||
//! Three surfaces, ONE action vocabulary (LocalLibraryView.slint: the tree
|
||||
//! rail's compact bar at :1779, the albums grid bar at :1251, the tracks
|
||||
//! table bar at :1419):
|
||||
//!
|
||||
//! | surface | selection lives in | entry point |
|
||||
//! |---------------|--------------------|------------------------|
|
||||
//! | tree rail | Rust (`local_tree`)| `folders_bulk_action` |
|
||||
//! | albums grid | QML | `bulk_action("album")` |
|
||||
//! | tracks table | QML | `bulk_action("track")` |
|
||||
//!
|
||||
//! The split is not arbitrary: a tree selection is a set of FILE PATHS that
|
||||
//! only Rust can expand (a folder check means "every track under me,
|
||||
//! recursively" — a query), while the grid/table select rows the QML already
|
||||
//! holds, so it keeps the ids and hands them over per action. That is exactly
|
||||
//! how the Slint does it, and why `select-all` / `clear` never reach Rust for
|
||||
//! the grid and table but DO for the tree.
|
||||
//!
|
||||
//! Once resolved, every surface funnels into the same `apply` — the same
|
||||
//! queue seam a single-row context menu takes (`local_playback::
|
||||
//! local_queue_track` + the core queue helpers), just with N rows.
|
||||
//!
|
||||
//! This file is the Qt-facing half of the tree selection; the state and the
|
||||
//! blocking mutators live in `local_tree.rs`, next to the tree they annotate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_library::LocalTrack;
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::local_albums::fetch_album_tracks_blocking;
|
||||
use crate::local_bridge::ui;
|
||||
use crate::local_library_qt as lib;
|
||||
use crate::local_playback::local_queue_track;
|
||||
use crate::local_state::{state, with_db};
|
||||
use crate::local_tree as tree;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Republish the tree document + the bulk bar's counter after a selection
|
||||
/// mutation.
|
||||
///
|
||||
/// Deliberately NOT `local_bridge_ops::publish_tree`: that one is the LOAD
|
||||
/// publish and also clears `localTreeLoading`, which a selection change must
|
||||
/// leave alone.
|
||||
fn publish_selection() {
|
||||
let json = lib::to_json(&tree::tree_visible());
|
||||
let count = tree::tree_selected_count();
|
||||
ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_local_tree_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_tree_selected_count(count);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree rail: select mode + the two checkboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rail header toggle. Leaving select mode drops the selection, so the
|
||||
/// republish is what hollows every checkbox again.
|
||||
pub fn set_select_mode(on: bool) {
|
||||
tree::set_tree_select_mode(on);
|
||||
publish_selection();
|
||||
}
|
||||
|
||||
/// Folder checkbox: recursive, so it is a DB read on a blocking thread.
|
||||
pub fn toggle_folder_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
|
||||
/// Track checkbox: a deselect is pure state, a select resolves the record
|
||||
/// from the parent folder listing — blocking either way.
|
||||
pub fn toggle_track_select(path: String) {
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The bulk bars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tree-rail bulk bar. `select-all` and `clear` mutate the Rust-side
|
||||
/// selection; everything else acts on a snapshot of it.
|
||||
///
|
||||
/// The bar is only VISIBLE with a non-empty selection (LocalTreeRail.qml:75),
|
||||
/// so an empty selection can only be reached by a stale call — it no-ops
|
||||
/// rather than enqueuing nothing.
|
||||
pub fn folders_bulk_action(action: String) {
|
||||
match action.as_str() {
|
||||
"select-all" => {
|
||||
// Two-way: "check all" un-checks when everything already is.
|
||||
crate::spawn(async move {
|
||||
let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await;
|
||||
publish_selection();
|
||||
});
|
||||
}
|
||||
"clear" => {
|
||||
tree::tree_clear_selection();
|
||||
publish_selection();
|
||||
}
|
||||
_ => {
|
||||
let rows = tree::tree_selected_snapshot();
|
||||
crate::spawn(async move {
|
||||
if apply(rows, &action).await {
|
||||
tree::tree_clear_selection();
|
||||
publish_selection();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Albums-grid / Tracks-table bulk bar. `scope` = "album" | "track",
|
||||
/// `ids_json` = the JSON string array the QML built from its own selection
|
||||
/// map. `select-all` / `clear` never arrive here — the QML owns that
|
||||
/// selection and short-circuits them (LocalLibraryView.qml:405/425).
|
||||
pub fn bulk_action(scope: String, ids_json: String, action: String) {
|
||||
let ids: Vec<String> = serde_json::from_str(&ids_json).unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
log::debug!("[qbz-qt] local bulk {action}: empty {scope} selection, ignored");
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
let rows = tokio::task::spawn_blocking(move || resolve_blocking(&scope, &ids))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
apply(rows, &action).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution + the shared action body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Selected ids -> the raw rows an action operates on.
|
||||
///
|
||||
/// - `album`: group keys, expanded through the SAME query album detail and
|
||||
/// album playback use, so a `plex:<hash>` key resolves identically.
|
||||
/// - `track`: Tracks-table row ids. The LOADED page (`tracks_raw`, plus the
|
||||
/// open detail pane) is the model of truth — a merged PLEX row is not in
|
||||
/// `local_tracks` and could never be re-queried by id; the DB lookup is a
|
||||
/// last resort for a local id that scrolled out of the cached page.
|
||||
fn resolve_blocking(scope: &str, ids: &[String]) -> Vec<LocalTrack> {
|
||||
if scope == "album" {
|
||||
return ids.iter().flat_map(|key| fetch_album_tracks_blocking(key)).collect();
|
||||
}
|
||||
let cached: HashMap<i64, LocalTrack> = state(|s| {
|
||||
s.tracks_raw
|
||||
.iter()
|
||||
.chain(s.detail_raw.iter())
|
||||
.map(|t| (t.id, t.clone()))
|
||||
.collect()
|
||||
});
|
||||
ids.iter()
|
||||
.filter_map(|id| id.parse::<i64>().ok())
|
||||
.filter_map(|row| {
|
||||
if let Some(t) = cached.get(&row) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
if crate::local_plex::is_plex_track_id(row) {
|
||||
return None;
|
||||
}
|
||||
with_db(|db| db.get_track(row)).flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run one action over already-resolved rows. Returns whether the caller
|
||||
/// should DROP its selection afterwards — the Slint clears after an enqueue
|
||||
/// and keeps it while a picker is still open.
|
||||
async fn apply(rows: Vec<LocalTrack>, action: &str) -> bool {
|
||||
match action {
|
||||
"queue" | "play-next" | "play-later" => {
|
||||
if rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
enqueue_rows(rows, action).await;
|
||||
true
|
||||
}
|
||||
// GAP (not a half-path): this port has no playlist picker modal and
|
||||
// no mixtape store — the Slint routes these into
|
||||
// `playlist_picker::open_multi` / `myqbz_add`, neither of which was
|
||||
// ported. Same gap the PlayerBar / NowPlayingBarSmall menus document.
|
||||
"add-to-playlist" | "add-to-mixtape" => {
|
||||
log::warn!(
|
||||
"[qbz-qt] local bulk {action}: no picker seam in this port ({} row(s) dropped)",
|
||||
rows.len()
|
||||
);
|
||||
false
|
||||
}
|
||||
// GAP: local hearts are not wired in this port at all — `map_track`
|
||||
// publishes `isFavorite: false` unconditionally, and the local
|
||||
// favorites store is keyed by FILE PATH behind a private handle in
|
||||
// `library_qt.rs` (its public `toggle_favorite` would route a numeric
|
||||
// local id to the Qobuz API, which is worse than doing nothing).
|
||||
"remove-favorites" => {
|
||||
log::warn!(
|
||||
"[qbz-qt] local bulk remove-favorites: local favorites not wired ({} row(s) ignored)",
|
||||
rows.len()
|
||||
);
|
||||
false
|
||||
}
|
||||
other => {
|
||||
log::warn!("[qbz-qt] local bulk: unknown action {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bulk enqueue. Identical semantics to the single-row context menu
|
||||
/// (`local_playback::enqueue`): "play-next" inserts at the cursor REVERSED so
|
||||
/// a multi-row insert keeps its order, "play-later" appends to the manual
|
||||
/// block's tail, anything else appends.
|
||||
async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
|
||||
let runtime = crate::app();
|
||||
let queue: Vec<QueueTrack> = rows.iter().map(local_queue_track).collect();
|
||||
log::info!("[qbz-qt] local bulk {mode}: {} track(s)", queue.len());
|
||||
match mode {
|
||||
"play-next" => {
|
||||
for t in queue.into_iter().rev() {
|
||||
runtime.core().add_track_next(t).await;
|
||||
}
|
||||
}
|
||||
"play-later" => {
|
||||
for t in queue {
|
||||
runtime.core().add_track_later(t).await;
|
||||
}
|
||||
}
|
||||
_ => runtime.core().add_tracks(queue).await,
|
||||
}
|
||||
crate::playback_qt::publish_queue(&runtime).await;
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Ephemeral folders — open a folder OUTSIDE the indexed library, browse it
|
||||
//! and play it without a single row landing in `library.db`.
|
||||
//!
|
||||
//! Qt/QML port of the shipping Slint pair (`crates/qbz/src/ephemeral.rs` +
|
||||
//! the `EphemeralPane` arm of `crates/qbz/src/local_library.rs`). ADR-006:
|
||||
//! NOTHING here re-implements scanning, CUE explosion, artwork extraction or
|
||||
//! album grouping — all of that is `qbz_library::ephemeral`, the same
|
||||
//! frontend-agnostic module the Slint binary drives. This file owns three
|
||||
//! things only:
|
||||
//!
|
||||
//! 1. the process-global session handle (in-memory, dies with the process);
|
||||
//! 2. the ONE JSON document the QML pane parses
|
||||
//! (`{name, path, trackCount, multiAlbum, albums:[…]}`), built out of the
|
||||
//! SAME `local_rows::TrackRow` every other Local Library surface uses;
|
||||
//! 3. the play seam — the queue is mapped by `local_playback`'s
|
||||
//! `local_queue_track` and the audible step hands file bytes to the same
|
||||
//! `play_data` / `play_dsd_file` entry points. The PROTECTED audio path is
|
||||
//! entered, never modified.
|
||||
//!
|
||||
//! Ephemeral track ids are SYNTHETIC and high (`>= 2^48`, the shared
|
||||
//! `EPHEMERAL_ID_FLOOR`), so they can never collide with a `local_tracks` row
|
||||
//! id: `is_ephemeral_id` is what lets any playback caller route an id to the
|
||||
//! in-memory store instead of the DB.
|
||||
//!
|
||||
//! What SURVIVES a restart is the folder PATH only (`locallibrary_ui.json`'s
|
||||
//! `ephemeral_folder`, shared with the Slint frontend); `rehydrate()` re-scans
|
||||
//! it at boot.
|
||||
//!
|
||||
//! POC-NOTE (folder picker): this port has no `rfd` dependency and cxx-qt-lib
|
||||
//! 0.7 exposes no QFileDialog, so `open()` shells out to the desktop's own
|
||||
//! folder chooser (zenity / qarma / kdialog / yad — the first one present).
|
||||
//! Swapping in `rfd::AsyncFileDialog` (what the Slint uses) is a five-line
|
||||
//! change to `pick_folder_blocking` once the dependency exists.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_library::ephemeral::{EphemeralLibraryState, EPHEMERAL_ID_FLOOR};
|
||||
use qbz_library::LocalTrack;
|
||||
use qbz_models::QueueTrack;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::local_bridge::ui;
|
||||
use crate::local_rows::{album_key, map_track, to_json, TrackRow};
|
||||
use crate::local_state::{read_prefs, with_art, write_prefs};
|
||||
|
||||
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session store (the Slint `crates/qbz/src/ephemeral.rs` 1:1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every method on `EphemeralLibraryState` takes `&self` and guards its own
|
||||
// inner Mutex, so a bare LazyLock (no outer lock) is enough.
|
||||
static STATE: LazyLock<EphemeralLibraryState> = LazyLock::new(EphemeralLibraryState::new);
|
||||
|
||||
/// True when `id` is a synthetic ephemeral track id. DB row ids never reach
|
||||
/// the floor, so this check unambiguously routes a playback request to the
|
||||
/// in-memory store instead of `library.db`.
|
||||
pub fn is_ephemeral_id(id: i64) -> bool {
|
||||
id >= EPHEMERAL_ID_FLOOR
|
||||
}
|
||||
|
||||
/// Resolve a synthetic id to its cached row (None once the session is gone).
|
||||
pub fn get_track(id: i64) -> Option<LocalTrack> {
|
||||
STATE.get_track(id)
|
||||
}
|
||||
|
||||
/// Every track of the current session, in scan (= display) order.
|
||||
pub fn tracks_snapshot() -> Vec<LocalTrack> {
|
||||
STATE.tracks_snapshot()
|
||||
}
|
||||
|
||||
/// The album grouping key for one ephemeral row — `album_group_key` when set,
|
||||
/// else `album|||album_artist`. Mirrors the scanner's own fallback so the
|
||||
/// pane's grouping and the play-album lookup can never disagree.
|
||||
fn album_key_of(t: &LocalTrack) -> String {
|
||||
if !t.album_group_key.is_empty() {
|
||||
t.album_group_key.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{}|||{}",
|
||||
t.album,
|
||||
t.album_artist.as_deref().unwrap_or(&t.artist)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tracks of one album block, in scan order.
|
||||
fn album_tracks(group_key: &str) -> Vec<LocalTrack> {
|
||||
STATE
|
||||
.tracks_snapshot()
|
||||
.into_iter()
|
||||
.filter(|t| album_key_of(t) == group_key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The document (the QML contract)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `{name, path, trackCount, multiAlbum, albums:[…]}` — the whole pane in one
|
||||
/// publish, exactly like every other Local Library surface.
|
||||
#[derive(Serialize)]
|
||||
struct EphemeralDoc {
|
||||
name: String,
|
||||
path: String,
|
||||
#[serde(rename = "trackCount")]
|
||||
track_count: usize,
|
||||
/// > 1 album in the folder: the per-album play button appears only then.
|
||||
#[serde(rename = "multiAlbum")]
|
||||
multi_album: bool,
|
||||
albums: Vec<EphemeralAlbumBlock>,
|
||||
}
|
||||
|
||||
/// One album block: header (cover + title/artist/meta + CUE badge) + tracks.
|
||||
#[derive(Serialize)]
|
||||
struct EphemeralAlbumBlock {
|
||||
#[serde(rename = "groupKey")]
|
||||
group_key: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
/// "1998 · 12 tracks" (already formatted + translated).
|
||||
meta: String,
|
||||
/// Single-file CUE rip (one audio file, virtual tracks).
|
||||
#[serde(rename = "isCue")]
|
||||
is_cue: bool,
|
||||
#[serde(rename = "artKey")]
|
||||
art_key: String,
|
||||
tracks: Vec<TrackRow>,
|
||||
}
|
||||
|
||||
/// Last path segment (the header's folder name).
|
||||
fn folder_display_name(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Group the scanned rows into album blocks (sorted by title) and register
|
||||
/// every cover in the shared art index, so the pane's covers ride the SAME
|
||||
/// windowed `artworkWindow` -> `localArtworkReady` path as the grids.
|
||||
/// Cheap (no I/O) but it takes the local-state lock — never call it on the Qt
|
||||
/// thread.
|
||||
fn build_doc(name: &str, path: &str, tracks: &[LocalTrack]) -> EphemeralDoc {
|
||||
// BTreeMap keeps a deterministic key order; scan order is preserved
|
||||
// inside each group (the scanner already sorted album/disc/track/title).
|
||||
let mut groups: BTreeMap<String, Vec<&LocalTrack>> = BTreeMap::new();
|
||||
for t in tracks {
|
||||
groups.entry(album_key_of(t)).or_default().push(t);
|
||||
}
|
||||
let multi_album = groups.len() > 1;
|
||||
let mut albums: Vec<EphemeralAlbumBlock> = with_art(|art| {
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(key, group)| {
|
||||
let first = group[0];
|
||||
let title = if first.album_group_title.is_empty() {
|
||||
first.album.clone()
|
||||
} else {
|
||||
first.album_group_title.clone()
|
||||
};
|
||||
let artist = first
|
||||
.album_artist
|
||||
.clone()
|
||||
.unwrap_or_else(|| first.artist.clone());
|
||||
let count = group.len();
|
||||
let tracks_label =
|
||||
qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()]);
|
||||
let meta = match first.year {
|
||||
Some(y) if y > 0 => format!("{y} · {tracks_label}"),
|
||||
_ => tracks_label,
|
||||
};
|
||||
// Namespaced so an ephemeral block can never claim the art
|
||||
// key of an indexed album that happens to group the same.
|
||||
let art_key = album_key(&format!("eph:{key}"));
|
||||
if let Some(p) = first.artwork_path.as_ref().filter(|p| !p.is_empty()) {
|
||||
art.insert(art_key.clone(), p.clone());
|
||||
}
|
||||
let rows: Vec<TrackRow> = group.iter().map(|t| map_track(*t, art)).collect();
|
||||
EphemeralAlbumBlock {
|
||||
group_key: key,
|
||||
title,
|
||||
artist,
|
||||
meta,
|
||||
is_cue: first.cue_file_path.is_some() || first.cue_start_secs.is_some(),
|
||||
art_key,
|
||||
tracks: rows,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
albums.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
EphemeralDoc {
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
track_count: tracks.len(),
|
||||
multi_album,
|
||||
albums,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publishing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn publish_doc(doc: &EphemeralDoc, loading: bool) {
|
||||
let json = to_json(doc);
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_local_ephemeral_active(true);
|
||||
b.as_mut()
|
||||
.set_local_ephemeral_json(QString::from(json.as_str()));
|
||||
b.as_mut().set_local_ephemeral_loading(loading);
|
||||
});
|
||||
}
|
||||
|
||||
/// Back to the closed state (`""` parses to null QML-side, which collapses the
|
||||
/// pane and restores the normal flat/tree browse).
|
||||
fn publish_closed() {
|
||||
ui(|mut b| {
|
||||
b.as_mut().set_local_ephemeral_active(false);
|
||||
b.as_mut().set_local_ephemeral_loading(false);
|
||||
b.as_mut().set_local_ephemeral_json(QString::from(""));
|
||||
});
|
||||
}
|
||||
|
||||
/// Persist / forget the folder PATH (the session itself never persists). The
|
||||
/// same `locallibrary_ui.json` key the Slint frontend writes.
|
||||
fn save_path(path: Option<&str>) {
|
||||
let mut prefs = read_prefs();
|
||||
prefs.ephemeral_folder = path.map(|p| p.to_string());
|
||||
write_prefs(&prefs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open / clear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Toolbar button: native folder picker -> scan -> pane.
|
||||
pub fn open() {
|
||||
crate::spawn(async move {
|
||||
let picked = tokio::task::spawn_blocking(pick_folder_blocking)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(path) = picked else {
|
||||
return; // cancelled, or no picker on this desktop (logged below).
|
||||
};
|
||||
scan(Some(crate::app()), path).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a KNOWN path (no picker) — the seam a drag-drop or a CLI argument
|
||||
/// would use, and what `open()` degrades to on a desktop with no chooser.
|
||||
pub fn open_path(path: String) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async move {
|
||||
scan(Some(crate::app()), path).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Boot: re-open the persisted folder, if any. Silent — a folder that moved
|
||||
/// away just clears the stale pref.
|
||||
pub fn rehydrate() {
|
||||
let Some(path) = read_prefs().ephemeral_folder.filter(|p| !p.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
crate::spawn(async move {
|
||||
scan(None, path).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear button: drop the pane, the in-memory store and the persisted path.
|
||||
pub fn clear() {
|
||||
let runtime = crate::app();
|
||||
crate::spawn(async move {
|
||||
wipe_if_playing(&runtime).await;
|
||||
STATE.clear();
|
||||
save_path(None);
|
||||
publish_closed();
|
||||
});
|
||||
}
|
||||
|
||||
/// The scan body shared by the picker, an explicit path and the boot
|
||||
/// rehydrate. When a runtime is given (a user-driven open), a currently
|
||||
/// playing ephemeral track is wiped FIRST — the next session reuses its
|
||||
/// synthetic ids, so a survivor would false-highlight a row in the new folder.
|
||||
async fn scan(runtime: Option<Runtime>, path: String) {
|
||||
if let Some(rt) = &runtime {
|
||||
wipe_if_playing(rt).await;
|
||||
}
|
||||
let name = folder_display_name(&path);
|
||||
// Header + spinner immediately; the scan of a big folder is not instant.
|
||||
publish_doc(
|
||||
&EphemeralDoc {
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
track_count: 0,
|
||||
multi_album: false,
|
||||
albums: Vec::new(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
let scan_path = path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || STATE.open_folder(Path::new(&scan_path))).await;
|
||||
match result {
|
||||
Ok(Ok(res)) => {
|
||||
log::info!(
|
||||
"[qbz-qt] ephemeral opened {} ({} tracks, {} skipped)",
|
||||
path,
|
||||
res.tracks.len(),
|
||||
res.skipped_files
|
||||
);
|
||||
publish_doc(&build_doc(&name, &path, &res.tracks), false);
|
||||
save_path(Some(&path));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[qbz-qt] ephemeral open failed: {e}");
|
||||
STATE.clear();
|
||||
save_path(None);
|
||||
publish_closed();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] ephemeral scan task failed: {e}");
|
||||
publish_closed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop + drop the queue when what is playing came from the session being
|
||||
/// replaced or cleared (the Slint's `wipeEphemeralPlaybackArtifacts`).
|
||||
async fn wipe_if_playing(runtime: &Runtime) {
|
||||
let is_eph = runtime
|
||||
.core()
|
||||
.current_track()
|
||||
.await
|
||||
.map(|t| is_ephemeral_id(t.id as i64))
|
||||
.unwrap_or(false);
|
||||
if !is_eph {
|
||||
return;
|
||||
}
|
||||
// Drops everything INCLUDING the current track, stops, republishes the
|
||||
// queue and the now-playing chrome.
|
||||
crate::queue_qt::clear_queue(runtime).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folder picker (POC-NOTE at the top of the file)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ask the desktop for a folder. Returns None on cancel AND when no chooser is
|
||||
/// installed (logged, so a dead button is never silent). BLOCKING — the caller
|
||||
/// runs it on `spawn_blocking`.
|
||||
fn pick_folder_blocking() -> Option<String> {
|
||||
let title = qbz_i18n::t("Choose a folder to play");
|
||||
let start = dirs::audio_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let start = start.to_string_lossy().into_owned();
|
||||
// zenity/qarma go through the XDG portal on modern desktops; kdialog is
|
||||
// the KDE native; yad is the last resort.
|
||||
let candidates: [(&str, Vec<String>); 4] = [
|
||||
(
|
||||
"zenity",
|
||||
vec![
|
||||
"--file-selection".into(),
|
||||
"--directory".into(),
|
||||
format!("--title={title}"),
|
||||
format!("--filename={start}/"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"qarma",
|
||||
vec![
|
||||
"--file-selection".into(),
|
||||
"--directory".into(),
|
||||
format!("--title={title}"),
|
||||
format!("--filename={start}/"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"kdialog",
|
||||
vec![
|
||||
"--getexistingdirectory".into(),
|
||||
start.clone(),
|
||||
"--title".into(),
|
||||
title.clone(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"yad",
|
||||
vec![
|
||||
"--file".into(),
|
||||
"--directory".into(),
|
||||
format!("--title={title}"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (bin, args) in candidates {
|
||||
match std::process::Command::new(bin).args(&args).output() {
|
||||
// The chooser ran: a path on stdout, or an empty/failed exit =
|
||||
// the user cancelled. Either way we stop looking.
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let picked = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
return (!picked.is_empty()).then_some(picked);
|
||||
}
|
||||
// Not installed — try the next one.
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
log::error!(
|
||||
"[qbz-qt] ephemeral: no folder chooser found (zenity / qarma / kdialog / yad) — \
|
||||
call ephemeralOpenPath(path) with a known folder instead"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback (the local seam — synthetic ids resolve from the in-memory store)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Play-all header button: the whole folder becomes the queue, scan order.
|
||||
pub async fn play_all(runtime: &Runtime) {
|
||||
play_rows(runtime, tracks_snapshot(), 0).await;
|
||||
}
|
||||
|
||||
/// Per-album play (multi-album sessions only): that block becomes the queue.
|
||||
pub async fn play_album(runtime: &Runtime, group_key: String) {
|
||||
play_rows(runtime, album_tracks(&group_key), 0).await;
|
||||
}
|
||||
|
||||
/// Track row click: the track's ALBUM BLOCK becomes the queue, starting there.
|
||||
pub async fn play_track(runtime: &Runtime, track_id: i64) {
|
||||
let Some(track) = get_track(track_id) else {
|
||||
log::warn!("[qbz-qt] ephemeral play: track {track_id} is not in the session");
|
||||
return;
|
||||
};
|
||||
let tracks = album_tracks(&album_key_of(&track));
|
||||
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
|
||||
play_rows(runtime, tracks, start).await;
|
||||
}
|
||||
|
||||
/// `set_queue` then the AUDIBLE step — the same order `local_playback`'s own
|
||||
/// queue builders use, with the same `local_queue_track` mapping (so an
|
||||
/// ephemeral row reaches the queue panel / now-playing bar identical to any
|
||||
/// other local file).
|
||||
async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, start: usize) {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let queue: Vec<QueueTrack> = tracks
|
||||
.iter()
|
||||
.map(crate::local_playback::local_queue_track)
|
||||
.collect();
|
||||
let start = start.min(queue.len() - 1);
|
||||
let first_id = queue[start].id;
|
||||
runtime.core().set_queue(queue, Some(start)).await;
|
||||
crate::playback_qt::publish_queue(runtime).await;
|
||||
play_file(runtime, first_id).await;
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
}
|
||||
|
||||
/// The AUDIBLE step for one ephemeral id: resolve the path from the in-memory
|
||||
/// store (there is no DB row to read) and hand the file to the player.
|
||||
/// `local_playback::play_local_file` 1:1 apart from that lookup — DSD streams
|
||||
/// from disk, a CUE virtual track seeks inside its shared container, and the
|
||||
/// bytes go to `play_data` untouched (bit-perfect).
|
||||
///
|
||||
/// Returns false when the row is stale (session cleared / replaced), so a
|
||||
/// caller can fall through.
|
||||
pub async fn play_file(runtime: &Runtime, row_id: u64) -> bool {
|
||||
let Some(track) = get_track(row_id as i64) else {
|
||||
log::error!("[qbz-qt] ephemeral play: track {row_id} not in the session");
|
||||
return false;
|
||||
};
|
||||
let path = track.file_path;
|
||||
let cue = track.cue_start_secs;
|
||||
let lower = path.to_lowercase();
|
||||
if lower.ends_with(".dsf") || lower.ends_with(".dff") {
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.player()
|
||||
.play_dsd_file(PathBuf::from(&path), row_id)
|
||||
{
|
||||
log::error!("[qbz-qt] ephemeral play: play_dsd_file {row_id} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// CUE fast path: every virtual track of a CUE album shares ONE audio
|
||||
// file. If that container is already loaded, seek instead of re-reading.
|
||||
if let Some(start) = cue.filter(|s| *s > 0.0) {
|
||||
let loaded = runtime.core().player().state.current_track_id();
|
||||
if runtime.core().player().has_loaded_audio()
|
||||
&& get_track(loaded as i64).map(|t| t.file_path) == Some(path.clone())
|
||||
{
|
||||
let _ = runtime.core().player().seek(start as u64);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let read_path = path.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
if !Path::new(&read_path).exists() {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(&read_path).ok()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(bytes) = bytes else {
|
||||
log::error!("[qbz-qt] ephemeral play: file not available at {path} (drive unmounted?)");
|
||||
return false;
|
||||
};
|
||||
if let Err(e) = runtime.core().player().play_data(bytes, row_id) {
|
||||
log::error!("[qbz-qt] ephemeral play: play_data {row_id} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
if let Some(start) = cue.filter(|s| *s > 0.0) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
|
||||
let _ = runtime.core().player().seek(start as u64);
|
||||
}
|
||||
true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,370 @@
|
||||
//! Local Library playback: `LocalTrack` -> `QueueTrack` -> `core().set_queue`
|
||||
//! -> the player's audible seam.
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization) and made
|
||||
//! source-aware: a queue built here can mix LOCAL files, OFFLINE (Qobuz
|
||||
//! download) copies and PLEX rows, and the audible step routes per row —
|
||||
//! local/offline read from disk (`play_data` / `play_dsd_file`), Plex
|
||||
//! resolves its direct-play part from the server.
|
||||
//!
|
||||
//! The PROTECTED audio path is only ENTERED here, never modified: the file
|
||||
//! bytes are handed to the same `play_data` seam the Slint frontend uses, so
|
||||
//! sample rate / bit depth stay whatever the decoder found.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_library::LocalTrack;
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::local_albums::fetch_album_tracks_blocking;
|
||||
use crate::local_state::{state, with_db};
|
||||
|
||||
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue mapping (playback.rs `local_queue_track` 1:1, all three sources)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
|
||||
let src = match t.source.as_deref() {
|
||||
Some("qobuz_download") => "qobuz_download",
|
||||
Some("plex") => "plex",
|
||||
_ => "local",
|
||||
};
|
||||
let is_offline = src == "qobuz_download";
|
||||
let is_plex = src == "plex";
|
||||
// A Plex row carries a RAW server-relative thumb path; it must stay raw
|
||||
// so the now-playing bar / queue resolve it from current creds.
|
||||
// `file://`-prefixing it poisons it into a local-read miss.
|
||||
let artwork_url = t.artwork_path.as_ref().map(|p| {
|
||||
if is_plex || p.starts_with("file://") {
|
||||
p.clone()
|
||||
} else {
|
||||
format!("file://{p}")
|
||||
}
|
||||
});
|
||||
let sample_rate_khz = if t.sample_rate >= 1000.0 {
|
||||
t.sample_rate / 1000.0
|
||||
} else {
|
||||
t.sample_rate
|
||||
};
|
||||
QueueTrack {
|
||||
id: if is_offline {
|
||||
t.qobuz_track_id.unwrap_or(t.id) as u64
|
||||
} else {
|
||||
t.id as u64
|
||||
},
|
||||
title: t.title.clone(),
|
||||
version: None,
|
||||
artist: t.artist.clone(),
|
||||
album: t.album_group_title.clone(),
|
||||
album_version: None,
|
||||
duration_secs: t.duration_secs,
|
||||
artwork_url,
|
||||
hires: t.bit_depth.map(|d| d > 16).unwrap_or(false),
|
||||
bit_depth: t.bit_depth,
|
||||
sample_rate: Some(sample_rate_khz),
|
||||
is_local: true,
|
||||
// Navigation key. For Plex the track's `album_group_key` is the
|
||||
// per-edition split key, which the album cache is NOT keyed by —
|
||||
// recover the content-hash key so "go to album" resolves.
|
||||
album_id: Some(if is_plex {
|
||||
crate::local_plex::album_key_for(&t.artist, &t.album)
|
||||
} else {
|
||||
t.album_group_key.clone()
|
||||
}),
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: Some(src.to_string()),
|
||||
parental_warning: false,
|
||||
// Plex: the string rating_key the resolve needs (the numeric queue id
|
||||
// is a namespaced form). Offline: the local row id.
|
||||
source_item_id_hint: if is_plex || is_offline {
|
||||
Some(if is_plex {
|
||||
t.file_path.clone()
|
||||
} else {
|
||||
t.id.to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
context_kind: Some("local".to_string()),
|
||||
context_id: Some(t.album_group_key.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audible steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The audible step for a LOCAL/OFFLINE queue track: read the file and hand
|
||||
/// the bytes to the player. False when the row can't be resolved (missing db
|
||||
/// row, unmounted drive) so the caller can fall through.
|
||||
///
|
||||
/// DSD (.dsf/.dff) is streamed from disk by the player instead of slurped.
|
||||
pub async fn play_local_file(runtime: &Runtime, row_id: u64) -> bool {
|
||||
// An ephemeral row id has no library.db row (the session store owns it),
|
||||
// so route it before any DB lookup — this is the path auto-advance and
|
||||
// queue-row clicks take.
|
||||
if crate::local_ephemeral::is_ephemeral_id(row_id as i64) {
|
||||
return crate::local_ephemeral::play_file(runtime, row_id).await;
|
||||
}
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
with_db(|db| db.get_track(row_id as i64))
|
||||
.flatten()
|
||||
.map(|t| (t.file_path, t.cue_start_secs))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some((path, cue)) = info else {
|
||||
log::error!("[qbz-qt] local play: track {row_id} not found");
|
||||
return false;
|
||||
};
|
||||
let lower = path.to_lowercase();
|
||||
if lower.ends_with(".dsf") || lower.ends_with(".dff") {
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
.player()
|
||||
.play_dsd_file(PathBuf::from(&path), row_id)
|
||||
{
|
||||
log::error!("[qbz-qt] local play: play_dsd_file {row_id} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// CUE fast path: every virtual track of a CUE album shares ONE audio
|
||||
// file. If that container is already loaded, seek instead of re-reading.
|
||||
if let Some(start) = cue.filter(|s| *s > 0.0) {
|
||||
let loaded = runtime.core().player().state.current_track_id();
|
||||
if runtime.core().player().has_loaded_audio() && loaded == row_id {
|
||||
let _ = runtime.core().player().seek(start as u64);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let read_path = path.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
if !Path::new(&read_path).exists() {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(&read_path).ok()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(bytes) = bytes else {
|
||||
log::error!("[qbz-qt] local play: file not available at {path} (drive unmounted?)");
|
||||
return false;
|
||||
};
|
||||
if let Err(e) = runtime.core().player().play_data(bytes, row_id) {
|
||||
log::error!("[qbz-qt] local play: play_data {row_id} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
if let Some(start) = cue.filter(|s| *s > 0.0) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
|
||||
let _ = runtime.core().player().seek(start as u64);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The audible step for a PLEX queue track: resolve the direct-play part and
|
||||
/// hand the ORIGINAL bytes to the player (bit-perfect — no transcode is
|
||||
/// requested).
|
||||
///
|
||||
/// POC-NOTE: the Slint frontend Range-streams the part progressively
|
||||
/// (`remote_stream`, which lives inside the Slint binary) and only falls back
|
||||
/// to this whole-file download when streaming setup fails. The Qt port has no
|
||||
/// streaming feeder yet, so it takes the documented fallback path: correct
|
||||
/// audio, slower first-note on a large FLAC.
|
||||
pub async fn play_plex_track(runtime: &Runtime, rating_key: String, play_id: u64) -> bool {
|
||||
let cfg = crate::local_plex::settings();
|
||||
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||
log::error!("[qbz-qt] plex play: no Plex credentials configured");
|
||||
return false;
|
||||
}
|
||||
match qbz_plex::plex_resolve_track_media(cfg.base_url, cfg.token, rating_key.clone()).await {
|
||||
Ok(media) => {
|
||||
if let Err(e) = runtime.core().player().play_data(media.bytes, play_id) {
|
||||
log::error!("[qbz-qt] plex play: play_data {play_id} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] plex play: resolve {rating_key} failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route ONE queue track to its audible step.
|
||||
async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool {
|
||||
match track.source.as_deref() {
|
||||
Some("plex") => {
|
||||
let Some(rating_key) = track.source_item_id_hint.clone() else {
|
||||
log::error!("[qbz-qt] plex play: queue track {} has no rating key", track.id);
|
||||
return false;
|
||||
};
|
||||
play_plex_track(runtime, rating_key, track.id).await
|
||||
}
|
||||
_ => play_local_file(runtime, track.id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Source-aware AUDIBLE step for the shared poll-loop advance: when the
|
||||
/// CURRENT queue track is a local file or a Plex row, play it here and report
|
||||
/// true; otherwise report false so the Qobuz tier-walk runs unchanged.
|
||||
///
|
||||
/// Without this seam, auto-advance to the next local/Plex track goes down
|
||||
/// `play_track_resolved` and fails with "No Qobuz client available".
|
||||
pub async fn play_current_if_local(runtime: &Runtime, track_id: u64) -> bool {
|
||||
let Some(qt) = runtime.core().current_track().await else {
|
||||
return false;
|
||||
};
|
||||
if qt.id != track_id {
|
||||
return false;
|
||||
}
|
||||
match qt.source.as_deref() {
|
||||
Some("local") | Some("plex") => play_audible(runtime, &qt).await,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set the queue to `tracks` and start `start` (playback.rs
|
||||
/// `play_local_tracks_now`: `set_queue` then the AUDIBLE step).
|
||||
async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, start: usize, shuffle: bool) {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||
let start = start.min(queue.len() - 1);
|
||||
let first = queue[start].clone();
|
||||
if shuffle {
|
||||
runtime.core().set_shuffle(true).await;
|
||||
crate::now_playing::set_shuffle(true);
|
||||
}
|
||||
runtime.core().set_queue(queue, Some(start)).await;
|
||||
crate::playback_qt::publish_queue(runtime).await;
|
||||
play_audible(runtime, &first).await;
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
}
|
||||
|
||||
/// Play a whole album (its group key, local or `plex:<hash>`) from the top,
|
||||
/// or from `start_track_id` when a row was clicked.
|
||||
pub async fn play_album(
|
||||
runtime: &Runtime,
|
||||
album_id: String,
|
||||
start_track_id: Option<i64>,
|
||||
shuffle: bool,
|
||||
) {
|
||||
let key = album_id.clone();
|
||||
let tracks =
|
||||
tokio::task::spawn_blocking(move || fetch_album_tracks_blocking(&key))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let start = start_track_id
|
||||
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
|
||||
.unwrap_or(0);
|
||||
play_rows(runtime, tracks, start, shuffle).await;
|
||||
}
|
||||
|
||||
/// Play everything under a folder, recursively, in path order.
|
||||
pub async fn play_folder(runtime: &Runtime, path: String) {
|
||||
let tracks = tokio::task::spawn_blocking(move || {
|
||||
with_db(|db| db.list_folder_tracks_recursive(&path, false)).unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
play_rows(runtime, tracks, 0, false).await;
|
||||
}
|
||||
|
||||
/// Play a folder's DIRECT tracks starting at the clicked row.
|
||||
pub async fn play_folder_track(runtime: &Runtime, folder: String, row_id: i64) {
|
||||
let tracks = tokio::task::spawn_blocking(move || {
|
||||
with_db(|db| db.list_folder_tracks(&folder, false)).unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let start = tracks.iter().position(|t| t.id == row_id).unwrap_or(0);
|
||||
play_rows(runtime, tracks, start, false).await;
|
||||
}
|
||||
|
||||
/// Tracks tab row click: the ALREADY-LOADED page set becomes the queue so
|
||||
/// playback continues down the list. No DB re-query — the raw rows are kept
|
||||
/// by the loader, which is also what makes a merged PLEX row playable (it has
|
||||
/// no `local_tracks` row to re-query).
|
||||
pub async fn play_tracks_from(runtime: &Runtime, row_id: i64) {
|
||||
let rows: Vec<LocalTrack> = state(|s| s.tracks_raw.clone());
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = rows.iter().position(|t| t.id == row_id).unwrap_or(0);
|
||||
play_rows(runtime, rows, start, false).await;
|
||||
}
|
||||
|
||||
/// Look up ONE raw row by id: the loaded Tracks page first, then the open
|
||||
/// detail pane, then `library.db` (Plex ids never reach the DB — they are
|
||||
/// namespaced and only ever live in the cached documents).
|
||||
fn find_track_blocking(row_id: i64) -> Option<LocalTrack> {
|
||||
let cached = state(|s| {
|
||||
s.tracks_raw
|
||||
.iter()
|
||||
.chain(s.detail_raw.iter())
|
||||
.find(|t| t.id == row_id)
|
||||
.cloned()
|
||||
});
|
||||
if cached.is_some() {
|
||||
return cached;
|
||||
}
|
||||
if crate::local_plex::is_plex_track_id(row_id) {
|
||||
return None;
|
||||
}
|
||||
with_db(|db| db.get_track(row_id)).flatten()
|
||||
}
|
||||
|
||||
/// Row / card "Play next" / "Play later" / "Add to queue".
|
||||
/// `kind` = "track" | "album" | "folder"; `mode` = "next" | "later" | queue.
|
||||
pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String) {
|
||||
let k = kind.clone();
|
||||
let ident = id.clone();
|
||||
let tracks: Vec<LocalTrack> = tokio::task::spawn_blocking(move || match k.as_str() {
|
||||
"track" => ident
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.and_then(find_track_blocking)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
"folder" => with_db(|db| db.list_folder_tracks_recursive(&ident, false)).unwrap_or_default(),
|
||||
_ => fetch_album_tracks_blocking(&ident),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
|
||||
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
|
||||
// (reversed so a multi-track insert keeps its order), "later" appends to
|
||||
// the block tail, anything else appends.
|
||||
match mode.as_str() {
|
||||
"next" => {
|
||||
for t in queue.into_iter().rev() {
|
||||
runtime.core().add_track_next(t).await;
|
||||
}
|
||||
}
|
||||
"later" => {
|
||||
for t in queue {
|
||||
runtime.core().add_track_later(t).await;
|
||||
}
|
||||
}
|
||||
_ => runtime.core().add_tracks(queue).await,
|
||||
}
|
||||
crate::playback_qt::publish_queue(runtime).await;
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Plex glue for the Local Library (the Qt port of `plex_settings.rs` +
|
||||
//! the browse-facing half of `plex_auth.rs`).
|
||||
//!
|
||||
//! ADR-006: the persisted model + SQLite store live in the shared
|
||||
//! `qbz_app::settings::plex` module and the runtime lives in `qbz-plex`.
|
||||
//! This file only owns:
|
||||
//!
|
||||
//! 1. a process-global `PlexSettingsState` bound to the ACTIVE user
|
||||
//! (`<data_dir>/qbz/users/<uid>/plex_settings.db` — the same file the
|
||||
//! Slint frontend writes, so a user authenticates Plex once);
|
||||
//! 2. the gates the Local Library reads — `is_enabled` (master toggle),
|
||||
//! `is_configured` (`canUsePlexRequests`: enabled + LAN address +
|
||||
//! resolved base url + token) and `cache_db_path` (the `ATTACH` path
|
||||
//! that turns the album query into a local+Plex UNION);
|
||||
//! 3. Plex-cache reads mapped into the `qbz_library::LocalTrack` shape so
|
||||
//! Plex rows flow through the SAME mapping/playback pipeline as local
|
||||
//! files (`map_cached_to_local_track`, 1:1 with the Slint);
|
||||
//! 4. the manual sync (`sync_now`) the Local Library header button fires:
|
||||
//! sections fetch -> save -> default-select-ALL -> prune -> per-section
|
||||
//! track fetch + cache save.
|
||||
//!
|
||||
//! The PIN/auth flow (`plex_auth_pin_*`) belongs to Settings and is NOT
|
||||
//! here; `connect_manual` / `disconnect` are exposed so the settings panel
|
||||
//! can drive the store without duplicating it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use qbz_app::user_data::UserDataPaths;
|
||||
use qbz_library::LocalTrack;
|
||||
|
||||
pub use qbz_app::settings::plex::{PlexSettings, PlexSettingsState};
|
||||
|
||||
/// Namespace for Plex track ids so they can never collide with a real
|
||||
/// `local_tracks.id` (1:1 with the Slint's `PLEX_TRACK_ID_FLOOR`).
|
||||
pub const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40;
|
||||
|
||||
/// True when this row id belongs to the Plex namespace.
|
||||
pub fn is_plex_track_id(id: i64) -> bool {
|
||||
(id as u64) & PLEX_TRACK_ID_FLOOR != 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The per-user store handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static STATE: LazyLock<PlexSettingsState> = LazyLock::new(PlexSettingsState::new_empty);
|
||||
/// The user id the store is currently bound to (None = session-less).
|
||||
static BOUND: Mutex<Option<u64>> = Mutex::new(None);
|
||||
|
||||
/// Bind (or re-bind) the store to the ACTIVE user. Called lazily by every
|
||||
/// accessor, so no glue in the login path is required; `init_for_user` is
|
||||
/// provided for the shell to call explicitly on session activation.
|
||||
fn ensure_bound() {
|
||||
let Some(uid) = UserDataPaths::load_last_user_id() else {
|
||||
return;
|
||||
};
|
||||
let mut bound = BOUND.lock().unwrap();
|
||||
if *bound == Some(uid) {
|
||||
return;
|
||||
}
|
||||
let dir = crate::sidebar_qt::user_dir().unwrap_or_else(|| {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_default()
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string())
|
||||
});
|
||||
match STATE.init_at(&dir) {
|
||||
Ok(()) => {
|
||||
*bound = Some(uid);
|
||||
log::info!("[qbz-qt] plex settings bound to user {uid}");
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-qt] plex settings init failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit bind on session activation (optional — accessors self-bind).
|
||||
pub fn init_for_user(base_dir: &std::path::Path) {
|
||||
if let Err(e) = STATE.init_at(base_dir) {
|
||||
log::warn!("[qbz-qt] plex settings init failed: {e}");
|
||||
return;
|
||||
}
|
||||
*BOUND.lock().unwrap() = UserDataPaths::load_last_user_id();
|
||||
}
|
||||
|
||||
/// Forget the binding (logout) so the next read re-binds to the new user.
|
||||
pub fn reset() {
|
||||
*BOUND.lock().unwrap() = None;
|
||||
let _ = STATE.teardown();
|
||||
}
|
||||
|
||||
/// Current persisted settings (defaults when there is no session).
|
||||
pub fn settings() -> PlexSettings {
|
||||
ensure_bound();
|
||||
STATE.get_settings().unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers (`normalizePlexServerUrl` / `isLocalPlexAddress` /
|
||||
// `resolvePlexBaseUrl`, ported from plex_auth.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn normalize_server_url(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
/// (scheme, host-without-port, port) for `http(s)://host[:port][/...]`.
|
||||
fn parse_url(normalized: &str) -> Option<(String, String, Option<String>)> {
|
||||
let (scheme, rest) = if let Some(r) = normalized.strip_prefix("http://") {
|
||||
("http", r)
|
||||
} else if let Some(r) = normalized.strip_prefix("https://") {
|
||||
("https", r)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let authority_end = rest
|
||||
.find(|c| c == '/' || c == '?' || c == '#')
|
||||
.unwrap_or(rest.len());
|
||||
let authority = &rest[..authority_end];
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||
let (host, port) = match authority.rfind(':') {
|
||||
Some(idx)
|
||||
if !authority[idx + 1..].is_empty()
|
||||
&& authority[idx + 1..].chars().all(|c| c.is_ascii_digit()) =>
|
||||
{
|
||||
(authority[..idx].to_string(), Some(authority[idx + 1..].to_string()))
|
||||
}
|
||||
_ => (authority.to_string(), None),
|
||||
};
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((scheme.to_string(), host, port))
|
||||
}
|
||||
|
||||
fn is_private_ipv4(host: &str) -> bool {
|
||||
let octets: Vec<&str> = host.split('.').collect();
|
||||
if octets.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let parsed = octets
|
||||
.iter()
|
||||
.map(|x| x.parse::<u32>().ok().filter(|v| *v <= 255))
|
||||
.collect::<Option<Vec<u32>>>();
|
||||
let Some(o) = parsed else {
|
||||
return false;
|
||||
};
|
||||
o[0] == 10
|
||||
|| o[0] == 127
|
||||
|| (o[0] == 192 && o[1] == 168)
|
||||
|| (o[0] == 172 && (16..=31).contains(&o[1]))
|
||||
}
|
||||
|
||||
/// `isLocalPlexAddress` — QBZ only talks to a LAN Plex server.
|
||||
pub fn is_local_address(url_input: &str) -> bool {
|
||||
let normalized = normalize_server_url(url_input);
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((_, host, _)) = parse_url(&normalized) else {
|
||||
return false;
|
||||
};
|
||||
let host = host.to_ascii_lowercase();
|
||||
host == "localhost"
|
||||
|| host == "::1"
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".lan")
|
||||
|| !host.contains('.')
|
||||
|| is_private_ipv4(&host)
|
||||
}
|
||||
|
||||
/// `resolvePlexBaseUrl`: normalize + default port 32400 -> `proto://host:port`.
|
||||
pub fn resolve_base_url(server_url: &str) -> String {
|
||||
let normalized = normalize_server_url(server_url);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let Some((scheme, host, port)) = parse_url(&normalized) else {
|
||||
return String::new();
|
||||
};
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return String::new();
|
||||
}
|
||||
format!("{scheme}://{host}:{}", port.unwrap_or_else(|| "32400".into()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Master toggle only — what the browse UNION is keyed on (the Slint's
|
||||
/// `plex_cache_db_path` gate: cached content stays browsable even if the
|
||||
/// server is currently unreachable).
|
||||
pub fn is_enabled() -> bool {
|
||||
settings().enabled
|
||||
}
|
||||
|
||||
/// `canUsePlexRequests`: enabled && LAN && resolved base url && token. This
|
||||
/// is `plex-available` — it gates the header Sync button and every request
|
||||
/// that leaves the process.
|
||||
pub fn is_configured() -> bool {
|
||||
let cfg = settings();
|
||||
cfg.enabled
|
||||
&& is_local_address(&cfg.base_url)
|
||||
&& !resolve_base_url(&cfg.base_url).is_empty()
|
||||
&& !cfg.token.trim().is_empty()
|
||||
}
|
||||
|
||||
/// `<data_dir>/qbz/plex_cache.db`, gated on the master toggle. `None` means
|
||||
/// the album union degrades to local-only — identical behaviour to before
|
||||
/// Plex existed.
|
||||
pub fn cache_db_path() -> Option<PathBuf> {
|
||||
if !is_enabled() {
|
||||
return None;
|
||||
}
|
||||
let path = dirs::data_dir()?.join("qbz").join("plex_cache.db");
|
||||
path.exists().then_some(path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artwork
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A Plex thumb is a SERVER-RELATIVE path, never a filesystem path.
|
||||
pub fn is_thumb_path(path: &str) -> bool {
|
||||
path.starts_with("/library/") || path.starts_with("/photo/")
|
||||
}
|
||||
|
||||
/// Tokenized, size-transcoded thumb URL for `path` ("" when Plex is not
|
||||
/// usable). `qbz_models::plex_thumb_url` is the shared builder, so the Qt
|
||||
/// and Slint frontends hit the SAME cache keys.
|
||||
pub fn thumb_url(path: &str, size: Option<u32>) -> String {
|
||||
let cfg = settings();
|
||||
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
qbz_models::plex_thumb_url(&cfg.base_url, &cfg.token, path, size)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache reads, mapped into the local-library shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_audio_format(s: &str) -> qbz_library::AudioFormat {
|
||||
use qbz_library::AudioFormat;
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"flac" => AudioFormat::Flac,
|
||||
"alac" => AudioFormat::Alac,
|
||||
"wav" | "wave" => AudioFormat::Wav,
|
||||
"aiff" | "aif" => AudioFormat::Aiff,
|
||||
"ape" => AudioFormat::Ape,
|
||||
"mp3" => AudioFormat::Mp3,
|
||||
_ => AudioFormat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a Plex-cache row to `LocalTrack` (1:1 with the Slint's
|
||||
/// `map_plex_cached_to_local_track`): `file_path` carries the `rating_key`
|
||||
/// the playback resolve needs, `artwork_path` stays the RAW `/library/...`
|
||||
/// thumb path (tokenized at fetch time), `source` is `"plex"`, and the id is
|
||||
/// namespaced so it cannot collide with a local row.
|
||||
pub fn map_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> LocalTrack {
|
||||
LocalTrack {
|
||||
id: (PLEX_TRACK_ID_FLOOR | (t.id & (PLEX_TRACK_ID_FLOOR - 1))) as i64,
|
||||
file_path: t.rating_key,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album.clone(),
|
||||
// Per-EDITION key so two same-titled Plex albums don't interleave;
|
||||
// pre-resync rows without a parent key fall back to the album hash.
|
||||
album_group_key: t
|
||||
.parent_rating_key
|
||||
.as_deref()
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(|k| format!("plex:album:{k}"))
|
||||
.unwrap_or_else(|| t.album_key.clone()),
|
||||
album_group_title: t.album.clone(),
|
||||
track_number: t.track_number,
|
||||
disc_number: t.disc_number,
|
||||
duration_secs: t.duration_secs,
|
||||
format: parse_audio_format(&t.format),
|
||||
bit_depth: t.bit_depth,
|
||||
sample_rate: t.sample_rate as f64,
|
||||
artwork_path: t.artwork_path,
|
||||
source: Some("plex".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The FULL Plex track set matching `query` (cap 5000 — the Plex cache is a
|
||||
/// bounded set, not 16K-row scale), in the `LocalTrack` shape.
|
||||
pub fn search_tracks(query: &str) -> Vec<LocalTrack> {
|
||||
qbz_plex::plex_cache_search_tracks(query.trim().to_string(), None)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(map_cached_to_local_track)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One Plex album's tracks, by the content-hash key (`plex:<hash>`).
|
||||
pub fn album_tracks(album_key: &str) -> Vec<LocalTrack> {
|
||||
qbz_plex::plex_cache_get_album_tracks(album_key.to_string())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(map_cached_to_local_track)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The content-hash album key for a played Plex track (its
|
||||
/// `album_group_key` is the per-edition split key, which the album cache is
|
||||
/// NOT keyed by).
|
||||
pub fn album_key_for(artist: &str, album: &str) -> String {
|
||||
qbz_plex::plex_album_key(artist, album)
|
||||
}
|
||||
|
||||
pub fn cached_artists() -> Vec<qbz_plex::PlexCachedArtist> {
|
||||
qbz_plex::plex_cache_get_artists().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn cached_track_count() -> i64 {
|
||||
qbz_plex::plex_cache_count_tracks().unwrap_or(0) as i64
|
||||
}
|
||||
|
||||
/// Cached library sections + which of them are selected (Settings panel).
|
||||
pub fn cached_sections() -> (Vec<qbz_plex::PlexMusicSection>, Vec<String>) {
|
||||
(
|
||||
qbz_plex::plex_cache_get_sections().unwrap_or_default(),
|
||||
settings().selected_section_keys,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations (the settings panel drives the SAME store)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn set_enabled(value: bool) {
|
||||
ensure_bound();
|
||||
if let Err(e) = STATE.set_enabled(value) {
|
||||
log::error!("[qbz-qt] plex set_enabled failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Seam for Settings > Local Library > Plex ("write metadata back to Plex").
|
||||
// That settings surface is not ported yet, so nothing calls this today.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_metadata_write_enabled(value: bool) {
|
||||
ensure_bound();
|
||||
if let Err(e) = STATE.set_metadata_write_enabled(value) {
|
||||
log::error!("[qbz-qt] plex set_metadata_write_enabled failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a manually entered server + token (`persistPlexConfig`): the URL
|
||||
/// is RESOLVED (`proto://host:32400`) before it is stored. Returns the
|
||||
/// resolved base url ("" when the input is unusable).
|
||||
pub fn connect_manual(server_url: &str, token: &str) -> String {
|
||||
ensure_bound();
|
||||
let base = resolve_base_url(server_url);
|
||||
if base.is_empty() {
|
||||
return base;
|
||||
}
|
||||
if let Err(e) = STATE.set_credentials(&base, token.trim()) {
|
||||
log::error!("[qbz-qt] plex set_credentials failed: {e}");
|
||||
return String::new();
|
||||
}
|
||||
let _ = STATE.set_manual_token_mode(true);
|
||||
base
|
||||
}
|
||||
|
||||
pub fn set_selected_sections(keys: &[String]) {
|
||||
ensure_bound();
|
||||
if let Err(e) = STATE.set_selected_section_keys(keys) {
|
||||
log::error!("[qbz-qt] plex set_selected_section_keys failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out of Plex: clear creds + sections + machine id (keeps `enabled`,
|
||||
/// `client_id`, `metadata_write_enabled`) and purge the shared cache DB so
|
||||
/// no Plex rows survive in the browse union.
|
||||
pub fn disconnect() {
|
||||
ensure_bound();
|
||||
if let Err(e) = STATE.disconnect() {
|
||||
log::error!("[qbz-qt] plex disconnect failed: {e}");
|
||||
}
|
||||
if let Err(e) = qbz_plex::plex_cache_clear() {
|
||||
log::warn!("[qbz-qt] plex cache clear failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual sync (#573)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static SYNCING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn is_syncing() -> bool {
|
||||
SYNCING.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// `sync_now` + `syncSelectedPlexLibraries`: fetch the music sections, save
|
||||
/// them, default-select ALL when nothing is persisted, PRUNE the de-selected
|
||||
/// sections (never a full wipe — that would drop hydrated quality rows), then
|
||||
/// per selected section fetch the tracks and save them into the cache DB.
|
||||
/// Returns the total track count written.
|
||||
///
|
||||
/// Re-entrancy: a second call while a sync is in flight returns `Err` instead
|
||||
/// of racing the cache writes.
|
||||
pub async fn sync_now() -> Result<usize, String> {
|
||||
if SYNCING.swap(true, Ordering::SeqCst) {
|
||||
return Err("Plex sync already running".to_string());
|
||||
}
|
||||
let result = sync_inner().await;
|
||||
SYNCING.store(false, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
|
||||
async fn sync_inner() -> Result<usize, String> {
|
||||
let cfg = settings();
|
||||
if !is_configured() {
|
||||
return Err("Plex is not configured".to_string());
|
||||
}
|
||||
let base = cfg.base_url.clone();
|
||||
let token = cfg.token.clone();
|
||||
|
||||
let sections = qbz_plex::plex_get_music_sections(base.trim().to_string(), token.trim().to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let machine_id = settings().machine_id;
|
||||
let server_id = (!machine_id.is_empty()).then_some(machine_id);
|
||||
|
||||
{
|
||||
let sections = sections.clone();
|
||||
let server_id = server_id.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
qbz_plex::plex_cache_save_sections(server_id, sections)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// Default-select ALL when the persisted selection is empty / stale.
|
||||
let available: std::collections::HashSet<String> =
|
||||
sections.iter().map(|s| s.key.clone()).collect();
|
||||
let persisted: Vec<String> = settings()
|
||||
.selected_section_keys
|
||||
.into_iter()
|
||||
.filter(|k| available.contains(k))
|
||||
.collect();
|
||||
let selected: Vec<String> = if persisted.is_empty() {
|
||||
sections.iter().map(|s| s.key.clone()).collect()
|
||||
} else {
|
||||
persisted
|
||||
};
|
||||
set_selected_sections(&selected);
|
||||
|
||||
// Prune ONLY the de-selected sections: a full clear would delete the
|
||||
// hydrated rows BEFORE save_tracks can carry their quality over.
|
||||
{
|
||||
let keep = selected.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || qbz_plex::plex_cache_prune_sections(&keep)).await;
|
||||
}
|
||||
|
||||
let mut total = 0usize;
|
||||
for key in &selected {
|
||||
let tracks = match qbz_plex::plex_get_section_tracks(
|
||||
base.trim().to_string(),
|
||||
token.trim().to_string(),
|
||||
key.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] plex get_section_tracks({key}) failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
total += tracks.len();
|
||||
let server_id = server_id.clone();
|
||||
let key = key.clone();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
qbz_plex::plex_cache_save_tracks(server_id, key, tracks)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
log::info!("[qbz-qt] plex sync: {total} tracks across {} sections", selected.len());
|
||||
Ok(total)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Local Library transport rows + the `qbz_library` -> row mappers.
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization). This file
|
||||
//! owns ONLY the shape the QML view parses (one flat serde document per
|
||||
//! surface) and the pure mapping helpers that fill it. No DB access, no
|
||||
//! state, no Qt.
|
||||
//!
|
||||
//! The `source` field is the row's SOURCE BADGE value and has exactly three
|
||||
//! values app-wide: `"local"` (a user file), `"offline"` (a Qobuz download —
|
||||
//! the Slint's raw `qobuz_download`), `"plex"` (a Plex-cache row). QML keys
|
||||
//! its badge glyph/tint off this string.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use qbz_library::{AudioFormat, LocalAlbum, LocalTrack};
|
||||
use serde::Serialize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows (the QML contract)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One album card (Albums tab + Folders tab flat mode). `id` is the group key
|
||||
/// of the ACTIVE identity mode — folder or metadata — so the detail query can
|
||||
/// round-trip it. Plex albums carry the content-hash key `plex:<hash>`.
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct AlbumRow {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
#[serde(rename = "allArtists")]
|
||||
pub all_artists: String,
|
||||
pub year: String,
|
||||
#[serde(rename = "trackCount")]
|
||||
pub track_count: u32,
|
||||
pub duration: String,
|
||||
#[serde(rename = "qualityTier")]
|
||||
pub quality_tier: String,
|
||||
#[serde(rename = "qualityDetail")]
|
||||
pub quality_detail: String,
|
||||
pub format: String,
|
||||
#[serde(rename = "artKey")]
|
||||
pub art_key: String,
|
||||
/// "local" | "offline" | "plex" (the source badge).
|
||||
pub source: String,
|
||||
#[serde(rename = "directoryPath")]
|
||||
pub directory_path: String,
|
||||
/// Number of distinct contributing folders (metadata mode only; > 1 is
|
||||
/// the "album spans N folders" tooltip case).
|
||||
#[serde(rename = "folderCount")]
|
||||
pub folder_count: u32,
|
||||
}
|
||||
|
||||
/// One track row (Tracks tab, album detail, folder detail).
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct TrackRow {
|
||||
/// The local-library row id as a string. Plex rows carry the namespaced
|
||||
/// id from `local_plex::PLEX_TRACK_ID_FLOOR`, so it can never collide
|
||||
/// with a real `local_tracks.id`.
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
#[serde(rename = "albumId")]
|
||||
pub album_id: String,
|
||||
/// Never a Qobuz artist id — local/Plex rows have none; kept so the
|
||||
/// shared TrackRow.qml "go to artist" arm stays hidden.
|
||||
#[serde(rename = "artistId")]
|
||||
pub artist_id: String,
|
||||
pub number: u32,
|
||||
pub disc: u32,
|
||||
pub duration: String,
|
||||
#[serde(rename = "qualityTier")]
|
||||
pub quality_tier: String,
|
||||
#[serde(rename = "qualityDetail")]
|
||||
pub quality_detail: String,
|
||||
pub format: String,
|
||||
pub year: String,
|
||||
#[serde(rename = "artKey")]
|
||||
pub art_key: String,
|
||||
/// Filled by the artwork window (never on the bulk publish).
|
||||
#[serde(rename = "artPath")]
|
||||
pub art_path: String,
|
||||
/// "local" | "offline" | "plex" (the source badge).
|
||||
pub source: String,
|
||||
pub explicit: bool,
|
||||
#[serde(rename = "isFavorite")]
|
||||
pub is_favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct ArtistRow {
|
||||
pub name: String,
|
||||
#[serde(rename = "albumCount")]
|
||||
pub album_count: u32,
|
||||
#[serde(rename = "trackCount")]
|
||||
pub track_count: u32,
|
||||
#[serde(rename = "artKey")]
|
||||
pub art_key: String,
|
||||
/// "local" | "plex" | "mixed" — the rail's provenance hint.
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// One row of the FLATTENED folder tree (the rail renders a windowed list
|
||||
/// over this array — never a recursive component).
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct TreeNode {
|
||||
pub path: String,
|
||||
pub segment: String,
|
||||
pub depth: i32,
|
||||
#[serde(rename = "isFolder")]
|
||||
pub is_folder: bool,
|
||||
#[serde(rename = "canExpand")]
|
||||
pub can_expand: bool,
|
||||
pub expanded: bool,
|
||||
#[serde(rename = "trackCount")]
|
||||
pub track_count: u32,
|
||||
#[serde(rename = "artKey")]
|
||||
pub art_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct SubfolderRow {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "trackCount")]
|
||||
pub track_count: u32,
|
||||
#[serde(rename = "artKey")]
|
||||
pub art_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct FolderDetail {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "trackCount")]
|
||||
pub track_count: u32,
|
||||
pub subfolders: Vec<SubfolderRow>,
|
||||
pub tracks: Vec<TrackRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct LocalCounts {
|
||||
pub albums: i64,
|
||||
pub artists: i64,
|
||||
pub folders: i64,
|
||||
pub tracks: i64,
|
||||
/// Cached Plex tracks folded into `tracks` (0 when Plex is off) — the
|
||||
/// header can show "N of them from Plex" without a second query.
|
||||
#[serde(rename = "plexTracks")]
|
||||
pub plex_tracks: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn mmss(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
if m >= 60 {
|
||||
format!("{}:{:02}:{:02}", m / 60, m % 60, s)
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Human total ("1 h 12 min" / "42 min").
|
||||
pub fn total_duration(secs: u64) -> String {
|
||||
let mins = secs / 60;
|
||||
if mins >= 60 {
|
||||
format!("{} h {} min", mins / 60, mins % 60)
|
||||
} else {
|
||||
format!("{mins} min")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier for the shared QualityBadge, mirroring its own derivation order
|
||||
/// (MP3 first, then max / hires / cd) so a local card and a Qobuz card can
|
||||
/// never disagree.
|
||||
pub fn tier_of(format: &AudioFormat, bit_depth: Option<u32>, sample_rate_hz: f64) -> &'static str {
|
||||
if matches!(format, AudioFormat::Mp3) {
|
||||
return "mp3";
|
||||
}
|
||||
let khz = if sample_rate_hz >= 1000.0 {
|
||||
sample_rate_hz / 1000.0
|
||||
} else {
|
||||
sample_rate_hz
|
||||
};
|
||||
match bit_depth {
|
||||
Some(b) if b >= 24 && khz > 96.0 => "max",
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if khz >= 44.1 => "cd",
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn basename(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artwork keys (id-keyed windowing: a cover can never land on the wrong row)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn album_key(id: &str) -> String {
|
||||
format!("album:{id}")
|
||||
}
|
||||
pub fn track_key(id: i64) -> String {
|
||||
format!("track:{id}")
|
||||
}
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
format!("folder:{path}")
|
||||
}
|
||||
pub fn artist_key(name: &str) -> String {
|
||||
format!("artist:{name}")
|
||||
}
|
||||
|
||||
/// The badge value for a raw `qbz_library` source column.
|
||||
pub fn badge_source(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
Some("plex") => "plex".into(),
|
||||
Some("qobuz_download") | Some("qobuz_purchase") => "offline".into(),
|
||||
_ => "local".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mappers (they also register the row's artwork SOURCE in the art index —
|
||||
// a local cover path, or a raw Plex `/library/...` thumb path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, String>) -> AlbumRow {
|
||||
let key = album_key(&a.id);
|
||||
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) {
|
||||
art.insert(key.clone(), p.clone());
|
||||
}
|
||||
let folder_count = a
|
||||
.source_folders
|
||||
.as_deref()
|
||||
.map(|s| s.split(',').filter(|x| !x.trim().is_empty()).count() as u32)
|
||||
.unwrap_or(0);
|
||||
AlbumRow {
|
||||
quality_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(),
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(a.bit_depth, Some(a.sample_rate)),
|
||||
format: a.format.to_string(),
|
||||
year: a.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||
duration: total_duration(a.total_duration_secs),
|
||||
track_count: a.track_count,
|
||||
art_key: key,
|
||||
source: badge_source(Some(a.source.as_str())),
|
||||
directory_path: a.directory_path,
|
||||
all_artists: a.all_artists,
|
||||
folder_count,
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
artist: a.artist,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, String>) -> TrackRow {
|
||||
let key = track_key(t.id);
|
||||
if let Some(p) = t.artwork_path.as_ref().filter(|p| !p.is_empty()) {
|
||||
art.insert(key.clone(), p.clone());
|
||||
}
|
||||
TrackRow {
|
||||
id: t.id.to_string(),
|
||||
title: t.title.clone(),
|
||||
artist: t.artist.clone(),
|
||||
album: t.album_group_title.clone(),
|
||||
album_id: t.album_group_key.clone(),
|
||||
artist_id: String::new(),
|
||||
number: t.track_number.unwrap_or(0),
|
||||
disc: t.disc_number.unwrap_or(1),
|
||||
duration: mmss(t.duration_secs),
|
||||
quality_tier: tier_of(&t.format, t.bit_depth, t.sample_rate).into(),
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate)),
|
||||
format: t.format.to_string(),
|
||||
year: t.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||
art_key: key,
|
||||
art_path: String::new(),
|
||||
source: badge_source(t.source.as_deref()),
|
||||
explicit: false,
|
||||
is_favorite: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bridge publishes strings, never structs.
|
||||
pub fn to_json<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Local Library shared state: the per-user `library.db` handle helper, the
|
||||
//! persisted toolbar prefs, and the in-process document cache the loaders
|
||||
//! fill and the playback/artwork paths read back.
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization). ADR-006:
|
||||
//! nothing here re-implements scanning or grouping — every read goes through
|
||||
//! `qbz_library::LibraryDatabase`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_app::user_data::UserDataPaths;
|
||||
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
|
||||
|
||||
use crate::local_rows::{AlbumRow, ArtistRow, LocalCounts, TrackRow, TreeNode};
|
||||
|
||||
/// Tracks-tab page size. The Tracks table is the 16K-row freeze surface, so
|
||||
/// it stays server-paginated; the other tabs are bounded and full-load.
|
||||
pub const TRACKS_PAGE: u64 = 500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database access (library_db.rs `with_db` 1:1 — a fresh connection per op on
|
||||
// the calling BLOCKING thread; `LibraryDatabase` holds a non-Send rusqlite
|
||||
// handle, so it never crosses an await point).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn db_path() -> Option<PathBuf> {
|
||||
let uid = UserDataPaths::load_last_user_id()?;
|
||||
Some(
|
||||
dirs::data_dir()?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(uid.to_string())
|
||||
.join("library.db"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_db<F, R>(f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&LibraryDatabase) -> Result<R, LibraryError>,
|
||||
{
|
||||
let path = db_path()?;
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let db = match LibraryDatabase::open(&path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] local library open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match f(&db) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] local library query failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LocalState {
|
||||
pub albums: Vec<AlbumRow>,
|
||||
pub folders: Vec<AlbumRow>,
|
||||
pub artists: Vec<ArtistRow>,
|
||||
pub tracks: Vec<TrackRow>,
|
||||
/// The RAW rows behind `tracks` (local + Plex). Playback reads these
|
||||
/// instead of re-querying by id — a Plex row has no `local_tracks` id to
|
||||
/// re-query with, and the local path saves an N-query loop.
|
||||
pub tracks_raw: Vec<LocalTrack>,
|
||||
/// The RAW rows behind the OPEN detail pane (album or folder) — same
|
||||
/// rationale as `tracks_raw`: a context-menu enqueue on a Plex detail row
|
||||
/// has no `local_tracks` id to re-query with.
|
||||
pub detail_raw: Vec<LocalTrack>,
|
||||
pub tracks_offset: u64,
|
||||
pub tracks_query: String,
|
||||
pub tracks_sort: String,
|
||||
pub tracks_has_more: bool,
|
||||
/// The FULL flattened tree (visible derivation applies the rail search).
|
||||
pub tree: Vec<TreeNode>,
|
||||
pub tree_search: String,
|
||||
/// artKey -> the artwork SOURCE: an on-disk cover path, or a raw Plex
|
||||
/// `/library/...` thumb path. The artwork window turns these into 256px
|
||||
/// thumbnails (local) or tokenized, disk-cached fetches (Plex).
|
||||
pub art_index: HashMap<String, String>,
|
||||
/// Album identity ("folder" | "metadata") — persisted (locallibrary_ui).
|
||||
pub album_mode: String,
|
||||
pub counts: LocalCounts,
|
||||
/// The OPEN local album's group key ("" when nothing is open).
|
||||
pub album_id: String,
|
||||
/// The OPEN album's VERSIONS — (source-directory key, its tracks). A
|
||||
/// "version" is a distinct PHYSICAL copy; metadata identity can fold
|
||||
/// several directories into one card, and merging their tracks would
|
||||
/// render a duplicated list. Cached so the picker switches with no DB
|
||||
/// round-trip (the Slint's `ALBUM_VERSIONS`). See `local_album_actions`.
|
||||
pub album_versions: Vec<(String, Vec<LocalTrack>)>,
|
||||
/// Index into `album_versions` currently shown by the picker.
|
||||
pub album_version_index: usize,
|
||||
}
|
||||
|
||||
static LOCAL: Mutex<Option<LocalState>> = Mutex::new(None);
|
||||
|
||||
pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
|
||||
let mut guard = LOCAL.lock().unwrap();
|
||||
let s = guard.get_or_insert_with(|| {
|
||||
let prefs = read_prefs();
|
||||
LocalState {
|
||||
album_mode: prefs.albums_id_mode,
|
||||
tracks_sort: prefs.tracks_sort,
|
||||
..LocalState::default()
|
||||
}
|
||||
});
|
||||
f(s)
|
||||
}
|
||||
|
||||
/// Take the art index out, run `f` with it, put it back — the shape every
|
||||
/// loader uses so mapping can register covers without a second lock.
|
||||
pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, String>) -> R) -> R {
|
||||
let mut art = state(|s| std::mem::take(&mut s.art_index));
|
||||
let out = f(&mut art);
|
||||
state(|s| s.art_index = art);
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop every cached document (logout / user switch).
|
||||
pub fn reset() {
|
||||
*LOCAL.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolbar prefs (locallibrary_prefs.rs 1:1 — the SAME json the Slint writes,
|
||||
// so the two frontends share the user's album-identity + sort choices).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Prefs {
|
||||
#[serde(default = "d_off")]
|
||||
pub tracks_group: String,
|
||||
#[serde(default = "d_default")]
|
||||
pub tracks_sort: String,
|
||||
#[serde(default = "d_folder")]
|
||||
pub albums_id_mode: String,
|
||||
#[serde(default)]
|
||||
pub ephemeral_folder: Option<String>,
|
||||
}
|
||||
|
||||
fn d_off() -> String {
|
||||
"off".into()
|
||||
}
|
||||
fn d_default() -> String {
|
||||
"default".into()
|
||||
}
|
||||
fn d_folder() -> String {
|
||||
"folder".into()
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tracks_group: d_off(),
|
||||
tracks_sort: d_default(),
|
||||
albums_id_mode: d_folder(),
|
||||
ephemeral_folder: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("locallibrary_ui.json"))
|
||||
}
|
||||
|
||||
pub fn read_prefs() -> Prefs {
|
||||
let Some(path) = prefs_path() else {
|
||||
return Prefs::default();
|
||||
};
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Prefs::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_prefs(p: &Prefs) {
|
||||
let Some(path) = prefs_path() else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(p) {
|
||||
let _ = std::fs::write(&path, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolbar setters / getters (all cheap; the loads that follow are queued by
|
||||
// the bridge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn group_mode() -> qbz_library::album_grouping::AlbumGroupMode {
|
||||
qbz_library::album_grouping::AlbumGroupMode::from_pref(&state(|s| s.album_mode.clone()))
|
||||
}
|
||||
|
||||
pub fn set_album_mode(mode: &str) {
|
||||
let mode = if mode == "metadata" { "metadata" } else { "folder" };
|
||||
state(|s| s.album_mode = mode.to_string());
|
||||
let mut p = read_prefs();
|
||||
p.albums_id_mode = mode.to_string();
|
||||
write_prefs(&p);
|
||||
}
|
||||
|
||||
pub fn album_mode() -> String {
|
||||
state(|s| s.album_mode.clone())
|
||||
}
|
||||
|
||||
pub fn set_tracks_sort(sort: &str) {
|
||||
state(|s| s.tracks_sort = sort.to_string());
|
||||
let mut p = read_prefs();
|
||||
p.tracks_sort = sort.to_string();
|
||||
write_prefs(&p);
|
||||
}
|
||||
|
||||
pub fn tracks_sort() -> String {
|
||||
state(|s| s.tracks_sort.clone())
|
||||
}
|
||||
|
||||
pub fn set_tracks_query(q: &str) {
|
||||
state(|s| s.tracks_query = q.to_string());
|
||||
}
|
||||
|
||||
pub fn tracks_has_more() -> bool {
|
||||
state(|s| s.tracks_has_more)
|
||||
}
|
||||
|
||||
pub fn counts() -> LocalCounts {
|
||||
state(|s| s.counts.clone())
|
||||
}
|
||||
|
||||
/// Whether the local library is usable at all (a per-user db with at least
|
||||
/// one registered folder). Drives the "nothing indexed yet" empty state.
|
||||
/// A Plex-only setup is ALSO usable — the browse union has content even with
|
||||
/// no registered on-disk folder.
|
||||
pub fn has_library() -> bool {
|
||||
if with_db(|db| db.get_folders()).map(|f| !f.is_empty()).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
crate::local_plex::is_enabled() && crate::local_plex::cached_track_count() > 0
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Folders tab, TREE mode: the flattened lazy folder tree (expanded set +
|
||||
//! one-level fetches, flattened to a windowable array), the rail's
|
||||
//! multi-select state, and the right-hand folder-detail pane.
|
||||
//!
|
||||
//! Split out of `local_library_qt.rs` (phase-24 modularization). Local-only
|
||||
//! by nature — Plex rows have no filesystem folder, so nothing here is
|
||||
//! Plex-aware.
|
||||
//!
|
||||
//! The selection lives HERE, next to the tree it annotates: a tree row's
|
||||
//! `selected` / `selectState` is derived at publish time from the selected
|
||||
//! set, so the array and the selection can never drift. The Qt-facing half
|
||||
//! (publish + the bulk actions) is `local_bulk.rs` — this file stays free of
|
||||
//! cxx-qt so it can run on a blocking thread.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_library::{FolderTreeEntry, LibraryError, LocalTrack};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::local_rows::{basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode};
|
||||
use crate::local_state::{state, with_art, with_db};
|
||||
|
||||
fn entry_to_node(
|
||||
e: &FolderTreeEntry,
|
||||
depth: i32,
|
||||
art: &mut std::collections::HashMap<String, String>,
|
||||
) -> TreeNode {
|
||||
match e {
|
||||
FolderTreeEntry::Folder {
|
||||
path,
|
||||
segment,
|
||||
track_count_under,
|
||||
artwork,
|
||||
} => {
|
||||
let key = folder_key(path);
|
||||
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
|
||||
art.insert(key.clone(), a.clone());
|
||||
}
|
||||
TreeNode {
|
||||
path: path.clone(),
|
||||
segment: segment.clone(),
|
||||
depth,
|
||||
is_folder: true,
|
||||
can_expand: *track_count_under > 0,
|
||||
expanded: false,
|
||||
track_count: *track_count_under,
|
||||
art_key: key,
|
||||
}
|
||||
}
|
||||
FolderTreeEntry::Track { path, segment } => TreeNode {
|
||||
path: path.clone(),
|
||||
segment: segment.clone(),
|
||||
depth,
|
||||
is_folder: false,
|
||||
can_expand: false,
|
||||
expanded: false,
|
||||
track_count: 0,
|
||||
art_key: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the tree with the registered library folders (depth 0).
|
||||
pub fn load_tree_roots_blocking() -> Vec<TreeNode> {
|
||||
let roots = with_db(|db| {
|
||||
let paths = db.get_folders()?;
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
for p in paths {
|
||||
let cnt = db.count_folder_tracks_recursive(&p, false)?;
|
||||
out.push((p, cnt));
|
||||
}
|
||||
Ok::<_, LibraryError>(out)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let nodes: Vec<TreeNode> = roots
|
||||
.into_iter()
|
||||
.map(|(p, cnt)| TreeNode {
|
||||
art_key: folder_key(&p),
|
||||
segment: basename(&p),
|
||||
path: p,
|
||||
depth: 0,
|
||||
is_folder: true,
|
||||
can_expand: cnt > 0,
|
||||
expanded: false,
|
||||
track_count: cnt,
|
||||
})
|
||||
.collect();
|
||||
state(|s| s.tree = nodes.clone());
|
||||
nodes
|
||||
}
|
||||
|
||||
/// Collapse: drop the contiguous descendant block (pure UI, no query).
|
||||
pub fn tree_collapse(path: &str) -> Vec<TreeNode> {
|
||||
state(|s| {
|
||||
if let Some(pos) = s.tree.iter().position(|n| n.path == path) {
|
||||
let depth = s.tree[pos].depth;
|
||||
s.tree[pos].expanded = false;
|
||||
let mut end = pos + 1;
|
||||
while end < s.tree.len() && s.tree[end].depth > depth {
|
||||
end += 1;
|
||||
}
|
||||
s.tree.drain(pos + 1..end);
|
||||
}
|
||||
s.tree.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand: fetch ONE child level and splice it in (never a recursive
|
||||
/// preload).
|
||||
pub fn tree_expand_blocking(path: &str) -> Vec<TreeNode> {
|
||||
let children = with_db(|db| db.list_folder_children(path, false)).unwrap_or_default();
|
||||
let nodes = state(|s| s.tree.iter().position(|n| n.path == path).map(|pos| (pos, s.tree[pos].depth)));
|
||||
let Some((pos, depth)) = nodes else {
|
||||
return state(|s| s.tree.clone());
|
||||
};
|
||||
let mapped = with_art(|art| {
|
||||
children
|
||||
.iter()
|
||||
.map(|e| entry_to_node(e, depth + 1, art))
|
||||
.collect::<Vec<TreeNode>>()
|
||||
});
|
||||
state(|s| {
|
||||
s.tree[pos].expanded = true;
|
||||
for (i, n) in mapped.into_iter().enumerate() {
|
||||
s.tree.insert(pos + 1 + i, n);
|
||||
}
|
||||
s.tree.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tree_collapse_all() -> Vec<TreeNode> {
|
||||
state(|s| {
|
||||
s.tree.retain(|n| n.depth == 0);
|
||||
for n in s.tree.iter_mut() {
|
||||
n.expanded = false;
|
||||
}
|
||||
s.tree.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_tree_search(query: &str) {
|
||||
state(|s| s.tree_search = query.trim().to_lowercase());
|
||||
}
|
||||
|
||||
/// The rail's VISIBLE set: the flattened tree filtered by the rail search,
|
||||
/// keeping every match AND its ancestors so the tree stays navigable.
|
||||
fn visible_nodes() -> Vec<TreeNode> {
|
||||
state(|s| {
|
||||
if s.tree_search.is_empty() {
|
||||
return s.tree.clone();
|
||||
}
|
||||
let q = &s.tree_search;
|
||||
let matches: Vec<String> = s
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|n| n.segment.to_lowercase().contains(q))
|
||||
.map(|n| n.path.clone())
|
||||
.collect();
|
||||
s.tree
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
n.segment.to_lowercase().contains(q)
|
||||
|| matches.iter().any(|m| m.starts_with(&format!("{}/", n.path)))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// What the bridge publishes: the visible set, annotated with the current
|
||||
/// selection.
|
||||
pub fn tree_visible() -> Vec<TreeNodeOut> {
|
||||
annotate(visible_nodes())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rail multi-select (LocalLibraryView.slint:1779 bulk bar; the Slint's
|
||||
// `TREE_SELECTED` map + `apply_tree` annotation, 1:1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A tree row AS PUBLISHED: the node plus its selection annotation. The
|
||||
/// annotation is DERIVED per publish and never stored on the node, so the
|
||||
/// tree array and the selected set cannot disagree.
|
||||
#[derive(Serialize)]
|
||||
pub struct TreeNodeOut {
|
||||
#[serde(flatten)]
|
||||
pub node: TreeNode,
|
||||
/// Track rows only (folders use `selectState`).
|
||||
pub selected: bool,
|
||||
/// Folder rows only: 0 none / 1 partial / 2 all — the QML `SelectCheck`
|
||||
/// tri-state.
|
||||
#[serde(rename = "selectState")]
|
||||
pub select_state: i32,
|
||||
}
|
||||
|
||||
/// The selected TRACK records, keyed by file path. The key is exactly the
|
||||
/// `path` a track node carries, which is what lets a folder's tri-state be a
|
||||
/// prefix RANGE instead of a per-folder recursive query.
|
||||
///
|
||||
/// Select MODE itself is not mirrored here: QML owns the flag (it drives
|
||||
/// nothing but chrome) and tells us only when it turns OFF, which is when the
|
||||
/// selection must be dropped.
|
||||
static TREE_SEL: Mutex<Option<HashMap<String, LocalTrack>>> = Mutex::new(None);
|
||||
|
||||
fn sel<R>(f: impl FnOnce(&mut HashMap<String, LocalTrack>) -> R) -> R {
|
||||
let mut guard = TREE_SEL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
/// Annotate the visible rows. ONE sorted view of the selected paths is built
|
||||
/// per publish; each folder's "how many of my tracks are selected" is then a
|
||||
/// binary-searched range and each track row a binary search — instead of the
|
||||
/// naive per-folder scan of the whole selection (a 16K-track "check all"
|
||||
/// against a few hundred open rows is millions of prefix compares).
|
||||
fn annotate(nodes: Vec<TreeNode>) -> Vec<TreeNodeOut> {
|
||||
let mut paths: Vec<String> = sel(|s| s.keys().cloned().collect());
|
||||
if paths.is_empty() {
|
||||
return nodes
|
||||
.into_iter()
|
||||
.map(|node| TreeNodeOut {
|
||||
node,
|
||||
selected: false,
|
||||
select_state: 0,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
paths.sort_unstable();
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| {
|
||||
let (selected, select_state) = if node.is_folder {
|
||||
// Byte-order range over "<path>/…": the upper bound is the
|
||||
// same prefix with its trailing '/' (0x2F) bumped to '0'
|
||||
// (0x30), which is the next byte value.
|
||||
let lo = format!("{}/", node.path);
|
||||
let hi = format!("{}0", node.path);
|
||||
let start = paths.partition_point(|p| p.as_str() < lo.as_str());
|
||||
let end = paths.partition_point(|p| p.as_str() < hi.as_str());
|
||||
let under = (end - start) as u32;
|
||||
let st = if under == 0 {
|
||||
0
|
||||
} else if node.track_count > 0 && under >= node.track_count {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(false, st)
|
||||
} else {
|
||||
(paths.binary_search(&node.path).is_ok(), 0)
|
||||
};
|
||||
TreeNodeOut {
|
||||
node,
|
||||
selected,
|
||||
select_state,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn tree_selected_count() -> i32 {
|
||||
sel(|s| s.len() as i32)
|
||||
}
|
||||
|
||||
/// Rail header toggle. LEAVING select mode drops the selection (Slint
|
||||
/// `toggle_tree_select_mode`) — re-entering always starts clean.
|
||||
pub fn set_tree_select_mode(on: bool) {
|
||||
if !on {
|
||||
tree_clear_selection();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the selection. Also the logout / user-switch hook — the records
|
||||
/// belong to the previous user's `library.db`, so `local_library_qt::reset`
|
||||
/// must call this next to `local_state::reset`.
|
||||
pub fn tree_clear_selection() {
|
||||
sel(|s| s.clear());
|
||||
}
|
||||
|
||||
/// The selected rows in scan (path) order — the order a bulk enqueue plays.
|
||||
pub fn tree_selected_snapshot() -> Vec<LocalTrack> {
|
||||
let mut rows: Vec<LocalTrack> = sel(|s| s.values().cloned().collect());
|
||||
rows.sort_by(|a, b| a.file_path.cmp(&b.file_path));
|
||||
rows
|
||||
}
|
||||
|
||||
/// Folder checkbox: toggle every track UNDER it, recursively. Already all
|
||||
/// selected -> deselect them; otherwise select them all.
|
||||
pub fn toggle_folder_select_blocking(path: &str) {
|
||||
let tracks = with_db(|db| db.list_folder_tracks_recursive(path, false)).unwrap_or_default();
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
sel(|s| {
|
||||
let all = tracks.iter().all(|t| s.contains_key(&t.file_path));
|
||||
if all {
|
||||
for t in &tracks {
|
||||
s.remove(&t.file_path);
|
||||
}
|
||||
} else {
|
||||
for t in tracks {
|
||||
s.insert(t.file_path.clone(), t);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Track checkbox. Deselect is a map removal; SELECT has to resolve the
|
||||
/// record, and the tree only carries paths — so the parent folder's direct
|
||||
/// listing is the (single, cheap) query that yields it.
|
||||
pub fn toggle_track_select_blocking(path: &str) {
|
||||
if sel(|s| s.remove(path)).is_some() {
|
||||
return;
|
||||
}
|
||||
let parent = std::path::Path::new(path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).unwrap_or_default();
|
||||
if let Some(t) = tracks.into_iter().find(|t| t.file_path == path) {
|
||||
sel(|s| {
|
||||
s.insert(path.to_string(), t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk-bar "Check all" — TWO-WAY, like the Slint: when everything under the
|
||||
/// registered roots is already selected the button un-selects it all.
|
||||
pub fn tree_select_all_blocking() {
|
||||
let roots = with_db(|db| db.get_folders()).unwrap_or_default();
|
||||
let mut all: Vec<LocalTrack> = Vec::new();
|
||||
for p in roots {
|
||||
if let Some(mut rows) = with_db(|db| db.list_folder_tracks_recursive(&p, false)) {
|
||||
all.append(&mut rows);
|
||||
}
|
||||
}
|
||||
if all.is_empty() {
|
||||
return;
|
||||
}
|
||||
sel(|s| {
|
||||
let every = all.iter().all(|t| s.contains_key(&t.file_path));
|
||||
if every {
|
||||
for t in &all {
|
||||
s.remove(&t.file_path);
|
||||
}
|
||||
} else {
|
||||
for t in all {
|
||||
s.insert(t.file_path.clone(), t);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The right pane of tree mode: subfolders (cover cards) + the folder's
|
||||
/// DIRECT tracks + the recursive count shown next to the name.
|
||||
pub fn load_folder_detail_blocking(path: &str) -> FolderDetail {
|
||||
let children = with_db(|db| db.list_folder_children(path, false)).unwrap_or_default();
|
||||
let tracks = with_db(|db| db.list_folder_tracks(path, false)).unwrap_or_default();
|
||||
let count = with_db(|db| db.count_folder_tracks_recursive(path, false)).unwrap_or(0);
|
||||
let (subfolders, rows) = with_art(|art| {
|
||||
let subfolders: Vec<SubfolderRow> = children
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
FolderTreeEntry::Folder {
|
||||
path,
|
||||
segment,
|
||||
track_count_under,
|
||||
artwork,
|
||||
} => {
|
||||
let key = folder_key(path);
|
||||
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
|
||||
art.insert(key.clone(), a.clone());
|
||||
}
|
||||
Some(SubfolderRow {
|
||||
path: path.clone(),
|
||||
name: segment.clone(),
|
||||
track_count: *track_count_under,
|
||||
art_key: key,
|
||||
})
|
||||
}
|
||||
FolderTreeEntry::Track { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<TrackRow> = tracks.iter().map(|t| map_track(t, art)).collect();
|
||||
(subfolders, rows)
|
||||
});
|
||||
// Raw rows for context-menu enqueue (see `local_playback`).
|
||||
state(|s| s.detail_raw = tracks.clone());
|
||||
FolderDetail {
|
||||
path: path.to_string(),
|
||||
name: basename(path),
|
||||
track_count: count,
|
||||
subfolders,
|
||||
tracks: rows,
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,17 @@ mod home_qt;
|
||||
mod library_db_qt;
|
||||
mod library_qt;
|
||||
mod local_library_qt;
|
||||
mod local_rows;
|
||||
mod local_state;
|
||||
mod local_plex;
|
||||
mod local_albums;
|
||||
mod local_tree;
|
||||
mod local_artwork;
|
||||
mod local_playback;
|
||||
mod local_bridge_ops;
|
||||
mod local_bulk;
|
||||
mod local_ephemeral;
|
||||
mod local_album_actions;
|
||||
mod nav_qt;
|
||||
mod now_playing;
|
||||
mod output_labels;
|
||||
|
||||
Reference in new issue
Block a user