Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens. cx
433 lines
18 KiB
QML
433 lines
18 KiB
QML
// Local Library Explorer — a retractable three- or four-stage browser over
|
|
// the logical album set, surrounding grid/list/expanded-details results from
|
|
// any edge. The leading facet is Genre, Year, or both.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../controls"
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property var view: null
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property string filterPosition: view ? view.genreFiltersPosition : "top"
|
|
readonly property bool verticalFilters: filterPosition === "left" || filterPosition === "right"
|
|
readonly property int sideWidth: 300 // ArtistView's Network sidebar width.
|
|
readonly property int collapsedThickness: 24
|
|
readonly property int horizontalHeight: 266
|
|
|
|
readonly property var facetKinds: root.view && root.view.explorerColumns === "both"
|
|
? ["genre", "year", "artist", "album"]
|
|
: root.view && root.view.explorerColumns === "year"
|
|
? ["year", "artist", "album"]
|
|
: ["genre", "artist", "album"]
|
|
function facetKind(index) { return facetKinds[index] || "album" }
|
|
|
|
function facetTitle(index) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") return QbzSession.tr("Genres", QbzSession.trRev)
|
|
if (kind === "year") return QbzSession.tr("Years", QbzSession.trRev)
|
|
if (kind === "artist") return QbzSession.tr("Artists", QbzSession.trRev)
|
|
return QbzSession.tr("Albums", QbzSession.trRev)
|
|
}
|
|
function facetAllLabel(index) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") return QbzSession.tr("All genres", QbzSession.trRev)
|
|
if (kind === "year") return QbzSession.tr("All years", QbzSession.trRev)
|
|
if (kind === "artist") return QbzSession.tr("All artists", QbzSession.trRev)
|
|
return QbzSession.tr("All albums", QbzSession.trRev)
|
|
}
|
|
function facetQuery(index) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") return root.view.genresSearch
|
|
if (kind === "year") return root.view.genreYearsSearch
|
|
if (kind === "artist") return root.view.genreArtistsSearch
|
|
return root.view.genreAlbumsSearch
|
|
}
|
|
function facetOptions(index) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") return root.view.genreNames
|
|
if (kind === "year") return root.view.genreYearOptions
|
|
if (kind === "artist") return root.view.genreArtistOptions
|
|
return root.view.genreAlbumOptions
|
|
}
|
|
function facetSelected(index) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") return root.view.selectedGenres
|
|
if (kind === "year") return root.view.selectedGenreYears
|
|
if (kind === "artist") return root.view.selectedGenreArtists
|
|
return root.view.selectedGenreAlbums
|
|
}
|
|
function setFacetQuery(index, value) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") root.view.genresSearch = value
|
|
else if (kind === "year") root.view.genreYearsSearch = value
|
|
else if (kind === "artist") root.view.genreArtistsSearch = value
|
|
else root.view.genreAlbumsSearch = value
|
|
}
|
|
function toggleFacet(index, key, modifiers) {
|
|
var kind = facetKind(index)
|
|
if (kind === "genre") root.view.toggleGenre(key, modifiers)
|
|
else if (kind === "year") root.view.toggleGenreYear(key, modifiers)
|
|
else if (kind === "artist") root.view.toggleGenreArtist(key, modifiers)
|
|
else {
|
|
// The details Loader already exists while the third column is
|
|
// visible. Ask it immediately instead of waiting for the filtered
|
|
// ListView to rebuild and mount its replacement delegate.
|
|
if (key !== "" && root.view.genresView === "details"
|
|
&& resultsLoader.item && resultsLoader.item.ensure)
|
|
resultsLoader.item.ensure(key)
|
|
root.view.toggleGenreAlbum(key, modifiers)
|
|
}
|
|
}
|
|
function facetSelectionLabel(index, key) {
|
|
var options = root.facetOptions(index)
|
|
for (var i = 0; i < options.length; i++)
|
|
if (options[i].key === key) return options[i].label
|
|
// A query can hide a selected option from its own column. Resolve
|
|
// albums against the unfiltered logical set; genre/artist keys are
|
|
// already readable names, merely normalized to lower case.
|
|
if (facetKind(index) === "album") {
|
|
for (var j = 0; j < root.view.albums.length; j++)
|
|
if (root.view.albums[j].id === key)
|
|
return root.view.albums[j].title || key
|
|
}
|
|
return key
|
|
}
|
|
function buildActiveFilterChips() {
|
|
if (!root.view) return []
|
|
var out = []
|
|
for (var facet = 0; facet < root.facetKinds.length; facet++) {
|
|
var query = root.facetQuery(facet).trim()
|
|
if (query !== "")
|
|
out.push({ kind: "query", facet: facet,
|
|
label: root.facetTitle(facet) + ": " + query })
|
|
var selected = root.facetSelected(facet)
|
|
var keys = Object.keys(selected)
|
|
for (var i = 0; i < keys.length; i++) {
|
|
if (selected[keys[i]] !== true) continue
|
|
out.push({ kind: "selection", facet: facet, key: keys[i],
|
|
label: root.facetTitle(facet) + ": "
|
|
+ root.facetSelectionLabel(facet, keys[i]) })
|
|
}
|
|
}
|
|
|
|
var tr = QbzSession.trRev
|
|
var filters = [
|
|
{ key: "favorite", label: QbzSession.tr("Favorites only", tr) },
|
|
{ key: "hires", label: QbzSession.tr("Hi-Res", tr) },
|
|
{ key: "cd", label: QbzSession.tr("CD", tr) },
|
|
{ key: "lossy", label: QbzSession.tr("Lossy", tr) },
|
|
{ key: "flac", label: "FLAC" }, { key: "alac", label: "ALAC" },
|
|
{ key: "ape", label: "APE" }, { key: "wav", label: "WAV" },
|
|
{ key: "mp3", label: "MP3" }, { key: "aac", label: "AAC" },
|
|
{ key: "other", label: QbzSession.tr("Other", tr) },
|
|
{ key: "local", label: QbzSession.tr("Local", tr) },
|
|
{ key: "offline", label: QbzSession.tr("Offline cache", tr) },
|
|
{ key: "plex", label: "Plex" }, { key: "jellyfin", label: "Jellyfin" },
|
|
{ key: "subsonic", label: "Subsonic" }
|
|
]
|
|
for (i = 0; i < filters.length; i++)
|
|
if (root.view.filter[filters[i].key] === true)
|
|
out.push({ kind: "filter", key: filters[i].key,
|
|
label: filters[i].label })
|
|
return out
|
|
}
|
|
function clearFilterChip(chip) {
|
|
if (chip.kind === "query") root.setFacetQuery(chip.facet, "")
|
|
else if (chip.kind === "selection")
|
|
root.toggleFacet(chip.facet, chip.key, Qt.ControlModifier)
|
|
else root.view.toggleFilter(chip.key)
|
|
}
|
|
function clearAllActiveFilters() {
|
|
// Clear all four backing queries, including a currently hidden Genre
|
|
// or Year facet. Switching column layouts later must not resurrect a
|
|
// stale search that the visible Clear action appeared to remove.
|
|
root.view.genresSearch = ""
|
|
root.view.genreYearsSearch = ""
|
|
root.view.genreArtistsSearch = ""
|
|
root.view.genreAlbumsSearch = ""
|
|
root.view.selectedGenres = ({})
|
|
root.view.selectedGenreYears = ({})
|
|
root.view.selectedGenreArtists = ({})
|
|
root.view.selectedGenreAlbums = ({})
|
|
root.view.clearFilter()
|
|
}
|
|
readonly property var activeFilterChips: buildActiveFilterChips()
|
|
function caretName() {
|
|
var collapsed = root.view && root.view.genresBrowserCollapsed
|
|
if (filterPosition === "bottom") return collapsed ? "chevron-up" : "chevron-down"
|
|
if (filterPosition === "left") return collapsed ? "chevron-right" : "chevron-left"
|
|
if (filterPosition === "right") return collapsed ? "chevron-left" : "chevron-right"
|
|
return collapsed ? "chevron-down" : "chevron-up"
|
|
}
|
|
|
|
component GenreFacet: LocalGenreColumn {
|
|
required property int facetIndex
|
|
title: root.facetTitle(facetIndex)
|
|
allLabel: root.facetAllLabel(facetIndex)
|
|
query: root.facetQuery(facetIndex)
|
|
options: root.facetOptions(facetIndex)
|
|
selected: root.facetSelected(facetIndex)
|
|
// A title query can temporarily match hundreds of expanded albums.
|
|
// Publishing after each fast keystroke rebuilds that result tree on
|
|
// the GUI thread and makes the next keystroke feel stuck. The edit
|
|
// itself remains immediate; only the derived result waits for the
|
|
// short typing pause.
|
|
debounceMs: root.facetKind(facetIndex) === "album" ? 140 : 90
|
|
onQueryEdited: function(value) { root.setFacetQuery(facetIndex, value) }
|
|
onToggled: function(key, modifiers) { root.toggleFacet(facetIndex, key, modifiers) }
|
|
}
|
|
|
|
Component {
|
|
id: horizontalBrowser
|
|
Row {
|
|
spacing: 8
|
|
Repeater {
|
|
model: root.facetKinds.length
|
|
delegate: GenreFacet {
|
|
required property int index
|
|
facetIndex: index
|
|
width: (parent.width - 8 * (root.facetKinds.length - 1))
|
|
/ root.facetKinds.length
|
|
height: parent.height
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: verticalBrowser
|
|
Column {
|
|
spacing: 8
|
|
Repeater {
|
|
model: root.facetKinds.length
|
|
delegate: GenreFacet {
|
|
required property int index
|
|
facetIndex: index
|
|
width: parent.width
|
|
height: (parent.height - 8 * (root.facetKinds.length - 1))
|
|
/ root.facetKinds.length
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: browser
|
|
readonly property bool collapsed: root.view && root.view.genresBrowserCollapsed
|
|
x: root.verticalFilters
|
|
? (root.filterPosition === "left" ? 8 : root.width - width - 8)
|
|
: 32
|
|
y: root.verticalFilters
|
|
? 10
|
|
: (root.filterPosition === "bottom" ? root.height - height - 10 : 10)
|
|
width: root.verticalFilters
|
|
? (collapsed ? root.collapsedThickness : root.sideWidth)
|
|
: Math.max(0, root.width - 64)
|
|
height: root.verticalFilters
|
|
? Math.max(0, root.height - 20)
|
|
: (collapsed ? root.collapsedThickness : root.horizontalHeight)
|
|
|
|
Loader {
|
|
visible: !browser.collapsed
|
|
x: root.verticalFilters && root.filterPosition === "right"
|
|
? root.collapsedThickness : 0
|
|
y: !root.verticalFilters && root.filterPosition === "bottom"
|
|
? root.collapsedThickness : 0
|
|
width: root.verticalFilters
|
|
? Math.max(0, parent.width - root.collapsedThickness)
|
|
: parent.width
|
|
height: root.verticalFilters
|
|
? parent.height
|
|
: Math.max(0, parent.height - root.collapsedThickness)
|
|
sourceComponent: root.verticalFilters ? verticalBrowser : horizontalBrowser
|
|
}
|
|
|
|
Rectangle {
|
|
x: root.verticalFilters
|
|
? (root.filterPosition === "left" ? parent.width - width : 0)
|
|
: (parent.width - width) / 2
|
|
y: root.verticalFilters
|
|
? (parent.height - height) / 2
|
|
: (root.filterPosition === "bottom" ? 0 : parent.height - height)
|
|
width: root.verticalFilters ? 20 : 42
|
|
height: root.verticalFilters ? 42 : 20
|
|
radius: 5
|
|
color: theme.ambientOn
|
|
? theme.surfaceElevatedA50
|
|
: (caretArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
|
|
border.width: 1
|
|
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: root.caretName()
|
|
width: 13
|
|
height: 13
|
|
tintName: "secondary"
|
|
}
|
|
MouseArea {
|
|
id: caretArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.view.genresBrowserCollapsed = !root.view.genresBrowserCollapsed
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: results
|
|
// Keep already-matching rows during refresh, but never leave an empty
|
|
// filtered viewport blank while the album projection is rebuilding.
|
|
readonly property bool initialLoading: QbzLocal.localAlbumsLoading
|
|
&& root.view.genreAlbumsVisible.length === 0
|
|
x: root.verticalFilters && root.filterPosition === "left"
|
|
? browser.x + browser.width + 8 : 32
|
|
y: !root.verticalFilters && root.filterPosition === "top"
|
|
? browser.y + browser.height + 8 : 10
|
|
width: root.verticalFilters
|
|
? (root.filterPosition === "left"
|
|
? Math.max(0, root.width - x - 32)
|
|
: Math.max(0, browser.x - x - 8))
|
|
: Math.max(0, root.width - 64)
|
|
height: root.verticalFilters
|
|
? Math.max(0, root.height - 20)
|
|
: (root.filterPosition === "bottom"
|
|
? Math.max(0, browser.y - y - 8)
|
|
: Math.max(0, root.height - y - 10))
|
|
|
|
QbzSkeleton {
|
|
visible: results.initialLoading && root.view.genresView === "grid"
|
|
anchors.fill: parent
|
|
variant: "cardGrid"
|
|
cellW: 220
|
|
cellH: 266
|
|
phase: root.view.skelPhase
|
|
}
|
|
QbzSkeleton {
|
|
visible: results.initialLoading && root.view.genresView !== "grid"
|
|
anchors.fill: parent
|
|
variant: "rowList"
|
|
rowH: root.view.genresView === "details" ? 72 : 56
|
|
rowGap: 0
|
|
rowArtSize: root.view.genresView === "details" ? 48 : 40
|
|
phase: root.view.skelPhase
|
|
}
|
|
|
|
Column {
|
|
id: emptyPanel
|
|
visible: !results.initialLoading
|
|
&& root.view.genreAlbumsVisible.length === 0
|
|
anchors.centerIn: parent
|
|
width: Math.min(Math.max(0, parent.width - 64), 720)
|
|
spacing: 14
|
|
|
|
QbzEmptyState {
|
|
width: parent.width
|
|
iconName: "list-filter"
|
|
title: QbzSession.tr("No albums match these filters", QbzSession.trRev)
|
|
body: QbzSession.tr("Try a different genre, artist, album, source or quality.",
|
|
QbzSession.trRev)
|
|
}
|
|
|
|
Flow {
|
|
visible: root.activeFilterChips.length > 0
|
|
width: parent.width
|
|
height: childrenRect.height
|
|
spacing: 8
|
|
|
|
Repeater {
|
|
model: root.activeFilterChips
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: chipRow.implicitWidth + 18
|
|
height: 28
|
|
radius: 6
|
|
color: chipArea.containsMouse
|
|
? theme.surfaceHover
|
|
: (theme.ambientOn ? theme.surfaceElevatedA50
|
|
: theme.surfaceElevated)
|
|
border.width: 1
|
|
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
|
|
|
|
Row {
|
|
id: chipRow
|
|
anchors.centerIn: parent
|
|
spacing: 7
|
|
Text {
|
|
text: parent.parent.modelData.label
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
QbzIcon {
|
|
name: "x"
|
|
width: 11
|
|
height: 11
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: chipArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: chipArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.clearFilterChip(parent.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsButton {
|
|
visible: root.activeFilterChips.length > 0
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: QbzSession.tr("Clear", QbzSession.trRev)
|
|
minWidth: 0
|
|
btnHeight: 30
|
|
onClicked: root.clearAllActiveFilters()
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: resultsLoader
|
|
anchors.fill: parent
|
|
active: !results.initialLoading && root.view.genreAlbumsVisible.length > 0
|
|
sourceComponent: root.view.genresView === "details" ? detailsComponent : collectionComponent
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: collectionComponent
|
|
LocalAlbumCollection {
|
|
view: root.view
|
|
surface: "genres"
|
|
scrollScope: "local:genres"
|
|
rows: root.view.genreAlbumsVisible
|
|
groups: []
|
|
grouped: false
|
|
viewMode: root.view.genresView
|
|
showSource: true
|
|
onOpenRequested: function(id) { root.view.openAlbum(id) }
|
|
onPlayRequested: function(id) {
|
|
QbzLocal.playAlbumFiltered(id, JSON.stringify(root.view.genresFilter || {}), false)
|
|
}
|
|
onEnqueueRequested: function(id, mode) {
|
|
QbzLocal.enqueueAlbumFiltered(id, JSON.stringify(root.view.genresFilter || {}), mode)
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: detailsComponent
|
|
LocalGenreDetails {
|
|
view: root.view
|
|
albums: root.view.genreAlbumsVisible
|
|
}
|
|
}
|
|
}
|