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:
vicrodh committed 2026-07-28 15:25:00 -06:00
1 parent ade156a72a
commit 31b1484c01
47 files changed
+8116 -2968

No files matched your search

+1
View File
@@ -6632,6 +6632,7 @@ dependencies = [
"qbz-lyrics",
"qbz-models",
"qbz-offline-cache",
"qbz-plex",
"qbz-qobuz",
"qbz-text-utils",
"qbz-theme",
+1
View File
@@ -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" }
+27
View File
@@ -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()
+1
View File
@@ -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"
+386
View File
@@ -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"
}
+109
View File
@@ -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) }
}
}
}
}
}
+10
View File
@@ -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).
+464
View File
@@ -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;
}
+304
View File
@@ -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))),
_ => {}
}
}
+96
View File
@@ -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
View File
@@ -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();
});
}
+248
View File
@@ -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();
});
}
+242
View File
@@ -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;
}
+539
View File
@@ -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
+370
View File
@@ -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;
}
+503
View File
@@ -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)
}
+294
View File
@@ -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())
}
+253
View File
@@ -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
}
+396
View File
@@ -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,
}
}
+11
View File
@@ -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;