Files
qbz/crates/qbz-qt/qml/views/local/LocalGenresTab.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

514 lines
21 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: "dsd", label: "DSD" },
{ 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()
// The collapsed strip keeps one line of the applied filters visible, so
// hiding the panels never hides WHAT is currently filtering the results.
readonly property bool collapsedChipsActive:
(root.view ? root.view.genresBrowserCollapsed : false)
&& activeFilterChips.length > 0
readonly property int chipRowThickness: 36
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) }
}
// One removable applied-filter chip — shared between the collapsed strip
// and the "no albums match" empty state.
component ActiveFilterChip: Rectangle {
id: chip
required property var modelData
width: chipContent.implicitWidth + 18
height: 28
radius: 6
color: chipMouse.containsMouse
? theme.elevatedHoverFill
: theme.elevatedFill
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
Row {
id: chipContent
anchors.centerIn: parent
spacing: 7
Text {
text: chip.modelData.label
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
QbzIcon {
name: "x"
width: 11
height: 11
anchors.verticalCenter: parent.verticalCenter
tintName: chipMouse.containsMouse ? "textPrimary" : "muted"
}
}
MouseArea {
id: chipMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clearFilterChip(chip.modelData)
}
}
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.collapsedChipsActive
? root.collapsedThickness + root.chipRowThickness
: 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.surfaceElevatedHover : 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
onContainsMouseChanged: tips.hover(
containsMouse, parent, "explorer-collapse",
browser.collapsed
? QbzSession.tr("Show filter panels", QbzSession.trRev)
: QbzSession.tr("Hide filter panels", QbzSession.trRev))
onClicked: {
// The label flips with the state; a bubble captured before
// the click would keep lying until the pointer re-enters.
tips.hide("explorer-collapse")
root.view.genresBrowserCollapsed = !root.view.genresBrowserCollapsed
}
}
}
// Applied filters, one line, while the panels are hidden. Each chip
// removes its filter immediately; overflow stays on this line and
// wheel-scrolls, it never wraps into the reclaimed space.
Item {
visible: !root.verticalFilters && root.collapsedChipsActive
x: 0
y: root.filterPosition === "bottom" ? parent.height - 28 : 0
width: parent.width
height: 28
ListView {
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(contentWidth, parent.width)
height: parent.height
orientation: ListView.Horizontal
spacing: 8
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.activeFilterChips
delegate: ActiveFilterChip { }
}
}
}
// The vertical rail is 24px wide — no room for a readable line there, so
// left/right layouts put the same strip across the top of the results.
Item {
visible: root.verticalFilters && root.collapsedChipsActive
x: results.x
y: 10
width: results.width
height: 28
ListView {
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(contentWidth, parent.width)
height: parent.height
orientation: ListView.Horizontal
spacing: 8
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.activeFilterChips
delegate: ActiveFilterChip { }
}
}
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
: (root.verticalFilters && root.collapsedChipsActive
? 10 + root.chipRowThickness : 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 - y - 10)
: (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: ActiveFilterChip { }
}
}
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
}
}
// Hover tooltip for the collapse caret. The overlay takes no pointer and
// owns no animator, so an idle one costs nothing (LocalChrome.qml does the
// same for the header's controls; this tab body is outside its bounds).
QbzTooltip {
id: tips
anchors.fill: parent
z: 900
}
}