feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. cc
This commit is contained in:
47 files changed
+8116
-2968
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user