fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening: - Chrome: the back bar is a drag surface and shrank to 44px with one shared button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a permanent cluster in the bar; the dead Share-logs button left the kiosk Settings header. - Transport bar: the hidden overflow menu is gone; Volume is always visible with a vertical sheet, and Desktop mode has its own button. - Now Playing: one centred transport row (New-bar order); lyrics are centred (shared LyricsLinesView gains an opt-in centered, desktop default false). - Local Library: the media funnel and an Open (ephemeral) entry point reach the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the desktop LocalArtistsTab is untouched); the Artists drill-down query no longer hangs off a stale derived guard. - Search: the All-tab dashboard shelves render again — they were mounted in a Loader Component that read the delegate id under reuse and bound an empty model; they are direct children now. - Album detail: icon actions the height of the quality badge plus play next / later / queue, an inline floating quality badge, and zebra rows; both album headers show a quality badge. - Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the window controls. Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6, Node gate 5/5, release build 0 warnings. cc
This commit is contained in:
24 files changed
+1273
-328
No files matched your search
@@ -19,7 +19,16 @@
|
||||
// 3. the artwork window REGISTRY — the one policy that cannot be split per
|
||||
// surface, because eviction is only correct against every live surface at
|
||||
// once (the desktop `_windows` finding, reproduced);
|
||||
// 4. the nav geometry it publishes into QbzKioskNav.
|
||||
// 4. the nav geometry it publishes into QbzKioskNav;
|
||||
// 5. the media FUNNEL (quality / format / source / favorites) — the SAME
|
||||
// persisted state as the desktop view, seeded from `QbzLocal.albumsFilter`
|
||||
// and written through the desktop's own setters (owner report 2026-09-07:
|
||||
// a funnel set on the desktop kept filtering the kiosk with no control to
|
||||
// see or clear it);
|
||||
// 6. the "Open" entry point (folder / audio CD / SACD image) that starts an
|
||||
// ephemeral session — the desktop reaches it through LocalChrome's
|
||||
// toolbar and the nav flyout, neither of which the kiosk mounts, so the
|
||||
// Ephemeral tab was unreachable from the kiosk (owner report 2026-09-07).
|
||||
// Every tab body is its own file, mounted behind `Loader.active`, and each one
|
||||
// consumes the authoritative reader for its surface.
|
||||
//
|
||||
@@ -39,7 +48,9 @@
|
||||
// the OUTGOING state) and restores it on the way back.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
@@ -110,6 +121,206 @@ Rectangle {
|
||||
property string selectedArtist: ""
|
||||
property string selectedGenre: ""
|
||||
|
||||
// =====================================================================
|
||||
// The media funnel
|
||||
// =====================================================================
|
||||
// One quality/format/source funnel follows the user across Albums,
|
||||
// Artists, Genres and Tracks; Favorites is an Albums/Genres-only predicate
|
||||
// that survives visits to the other tabs. Mirrors LocalLibraryView.qml
|
||||
// :323-383 — same seed, same writers, same split into the shared "common"
|
||||
// part — so the two shells cannot disagree about what is filtered.
|
||||
property var albumsFilter: root.parseFilter(QbzLocal.albumsFilter)
|
||||
readonly property var commonFilter: {
|
||||
var out = Object.assign({}, root.albumsFilter || ({}))
|
||||
delete out.favorite
|
||||
return out
|
||||
}
|
||||
readonly property bool favoriteOnly: root.albumsFilter.favorite === true
|
||||
/// The funnel the ACTIVE tab sees.
|
||||
readonly property var filter: (root.activeTab === "albums" || root.activeTab === "genres")
|
||||
? root.albumsFilter : root.commonFilter
|
||||
/// The descriptor handed to the native readers (which know no favorites).
|
||||
readonly property string filterJson: JSON.stringify(root.commonFilter)
|
||||
readonly property int filterCount: {
|
||||
var n = 0
|
||||
for (var k in root.filter)
|
||||
if (root.filter[k])
|
||||
n++
|
||||
return n
|
||||
}
|
||||
function parseFilter(json) {
|
||||
if (!json || json === "")
|
||||
return ({})
|
||||
try {
|
||||
return JSON.parse(json) || ({})
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
/// The ONE writer (LocalLibraryView.qml:361-383). Persists through the
|
||||
/// desktop's setters and re-queries whatever the active tab reads.
|
||||
function setFilter(f) {
|
||||
var value = f || ({})
|
||||
var favorite = (root.activeTab === "albums" || root.activeTab === "genres")
|
||||
? value.favorite === true : root.favoriteOnly
|
||||
var common = Object.assign({}, value)
|
||||
delete common.favorite
|
||||
var albumValue = Object.assign({}, common)
|
||||
if (favorite)
|
||||
albumValue.favorite = true
|
||||
root.albumsFilter = albumValue
|
||||
QbzLocal.setAlbumsFilterJson(Object.keys(albumValue).length === 0
|
||||
? "" : JSON.stringify(albumValue))
|
||||
// Tracks owns a server-side descriptor; its setter resets the paged
|
||||
// query, so it is only called while Tracks is the visible consumer.
|
||||
// A later tab switch installs the shared state (loadActiveTab).
|
||||
if (root.activeTab === "tracks")
|
||||
QbzLocal.tracksSetFilterJson(JSON.stringify(common))
|
||||
else
|
||||
root.loadActiveTab()
|
||||
}
|
||||
function toggleFilter(key) {
|
||||
var f = Object.assign({}, root.filter)
|
||||
f[key] = !f[key]
|
||||
if (!f[key])
|
||||
delete f[key]
|
||||
root.setFilter(f)
|
||||
}
|
||||
function clearFilter() { root.setFilter({}) }
|
||||
// A later republish still wins (a media server connected or removed
|
||||
// prunes the saved funnel). Guarded on a real difference so writing the
|
||||
// property back does not bounce.
|
||||
Connections {
|
||||
target: QbzLocal
|
||||
function onAlbumsFilterChanged() {
|
||||
var next = root.parseFilter(QbzLocal.albumsFilter)
|
||||
if (JSON.stringify(next) !== JSON.stringify(root.albumsFilter))
|
||||
root.albumsFilter = next
|
||||
}
|
||||
}
|
||||
|
||||
// The client-side predicates for the LEGACY documents, ported from
|
||||
// LocalLibraryView.qml (sourceBucket :795, applyFilter :810,
|
||||
// artistMatchesFilter :862). The native readers filter in Rust from the
|
||||
// same descriptor.
|
||||
function sourceBucket(word) {
|
||||
var source = (word || "local").toLowerCase()
|
||||
if (source === "" || source === "user")
|
||||
return "local"
|
||||
if (source === "qobuz_purchase" || source === "qobuz_download")
|
||||
return "offline"
|
||||
if (source === "navidrome" || source === "gonic"
|
||||
|| source === "airsonic" || source === "astiga")
|
||||
return "subsonic"
|
||||
return source
|
||||
}
|
||||
function applyFilter(rows, selectedFilter) {
|
||||
var selected = selectedFilter || ({})
|
||||
if (Object.keys(selected).length === 0)
|
||||
return rows
|
||||
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
|
||||
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|
||||
|| selected.mp3 || selected.aac || selected.other
|
||||
var sAny = selected.local || selected.offline || selected.plex
|
||||
|| selected.jellyfin || selected.subsonic
|
||||
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
|
||||
var out = []
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var r = rows[i]
|
||||
if (selected.favorite === true && r.isFavorite !== true)
|
||||
continue
|
||||
if (qAny || fAny || sAny) {
|
||||
// Test every active section against ONE physical copy: a
|
||||
// logical album can carry DSD on one copy and FLAC on
|
||||
// another, and DSD+FLAC must not manufacture a parent whose
|
||||
// filtered detail has zero tracks.
|
||||
var variants = r.mediaVariants || []
|
||||
if (variants.length === 0) {
|
||||
var sources = r.sources && r.sources.length > 0
|
||||
? r.sources : [r.source || "local"]
|
||||
variants = []
|
||||
for (var si = 0; si < sources.length; si++)
|
||||
variants.push({ "qualityTier": r.qualityTier || "",
|
||||
"format": r.format || "",
|
||||
"source": sources[si] })
|
||||
}
|
||||
var mediaMatches = false
|
||||
for (var vi = 0; vi < variants.length; vi++) {
|
||||
var variant = variants[vi]
|
||||
var tier = (variant.qualityTier || "").toLowerCase()
|
||||
var fmt = (variant.format || "").toLowerCase()
|
||||
var qok = !qAny
|
||||
|| (selected.dsd && tier === "dsd")
|
||||
|| (selected.hires && (tier === "hires" || tier === "max"))
|
||||
|| (selected.cd && tier === "cd")
|
||||
|| (selected.lossy && (tier === "mp3" || tier === "lossy"))
|
||||
var fok = !fAny || selected[fmt] === true
|
||||
|| (selected.other === true && known[fmt] !== 1)
|
||||
var sok = !sAny || selected[root.sourceBucket(variant.source)] === true
|
||||
if (qok && fok && sok) {
|
||||
mediaMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!mediaMatches)
|
||||
continue
|
||||
}
|
||||
out.push(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
function artistMatchesFilter(artist, selected) {
|
||||
if (!selected || Object.keys(selected).length === 0)
|
||||
return true
|
||||
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
|
||||
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|
||||
|| selected.mp3 || selected.aac || selected.other
|
||||
var sAny = selected.local || selected.offline || selected.plex
|
||||
|| selected.jellyfin || selected.subsonic
|
||||
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
|
||||
var i, value
|
||||
if (qAny) {
|
||||
var qualityOk = false
|
||||
for (i = 0; i < (artist.qualityTiers || []).length; i++) {
|
||||
value = (artist.qualityTiers[i] || "").toLowerCase()
|
||||
if ((selected.dsd && value === "dsd")
|
||||
|| (selected.hires && (value === "hires" || value === "max"))
|
||||
|| (selected.cd && value === "cd")
|
||||
|| (selected.lossy && (value === "mp3" || value === "lossy"))) {
|
||||
qualityOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!qualityOk)
|
||||
return false
|
||||
}
|
||||
if (fAny) {
|
||||
var formatOk = false
|
||||
for (i = 0; i < (artist.formats || []).length; i++) {
|
||||
value = (artist.formats[i] || "").toLowerCase()
|
||||
if (selected[value] === true
|
||||
|| (selected.other === true && known[value] !== 1)) {
|
||||
formatOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!formatOk)
|
||||
return false
|
||||
}
|
||||
if (sAny) {
|
||||
var sourceOk = false
|
||||
for (i = 0; i < (artist.sources || []).length; i++) {
|
||||
if (selected[root.sourceBucket(artist.sources[i])] === true) {
|
||||
sourceOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!sourceOk)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function tabAvailable(tab) {
|
||||
if (tab === "ephemeral")
|
||||
return root.ephemeralActive
|
||||
@@ -158,7 +369,18 @@ Rectangle {
|
||||
QbzShell.navigateTo("localalbum")
|
||||
}
|
||||
|
||||
function loadActiveTab() { QbzLocal.loadTab(root.activeTab) }
|
||||
/// LocalLibraryView.qml:700-707. Tracks owns a server-side descriptor:
|
||||
/// installing the shared funnel IS its load. Albums under "Favorites
|
||||
/// only" reads the legacy document (the native reader knows no
|
||||
/// favorites — LocalLibraryView.qml:341-344, :515).
|
||||
function loadActiveTab() {
|
||||
if (root.activeTab === "tracks")
|
||||
QbzLocal.tracksSetFilterJson(JSON.stringify(root.commonFilter))
|
||||
else if (root.activeTab === "albums" && root.favoriteOnly)
|
||||
QbzLocal.loadTab("albums-legacy")
|
||||
else
|
||||
QbzLocal.loadTab(root.activeTab)
|
||||
}
|
||||
|
||||
onActiveTabChanged: {
|
||||
root.loadActiveTab()
|
||||
@@ -264,8 +486,23 @@ Rectangle {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
readonly property var albums: ((root.activeTab === "albums" && !QbzLocal.localAlbumsNativeActive) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive)) ? root.parseDoc(QbzLocal.localAlbumsJson, []) : []
|
||||
readonly property var artists: (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive) ? root.parseDoc(QbzLocal.localArtistsJson, []) : []
|
||||
// The legacy album/artist documents are read THROUGH the funnel: Albums
|
||||
// and Genres see the favorites predicate, the Artists drill-down the
|
||||
// shared common part (LocalLibraryView.qml:916, :1021, :1213).
|
||||
readonly property var albums: ((root.activeTab === "albums" && (!QbzLocal.localAlbumsNativeActive || root.favoriteOnly)) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
||||
? root.applyFilter(root.parseDoc(QbzLocal.localAlbumsJson, []),
|
||||
root.activeTab === "artists" ? root.commonFilter : root.albumsFilter)
|
||||
: []
|
||||
readonly property var artists: {
|
||||
if (!(root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
||||
return []
|
||||
var rows = root.parseDoc(QbzLocal.localArtistsJson, [])
|
||||
var out = []
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
if (root.artistMatchesFilter(rows[i], root.commonFilter))
|
||||
out.push(rows[i])
|
||||
return out
|
||||
}
|
||||
readonly property var folders: (root.activeTab === "folders") ? root.parseDoc(QbzLocal.localFoldersJson, []) : []
|
||||
readonly property var tracks: (root.activeTab === "tracks" && !QbzLocal.localTracksNativeActive) ? root.parseDoc(QbzLocal.localTracksJson, []) : []
|
||||
readonly property var ephemeral: (root.activeTab === "ephemeral") ? root.parseDoc(QbzLocal.localEphemeralJson, null) : null
|
||||
@@ -451,7 +688,7 @@ Rectangle {
|
||||
id: tabScroll
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.pad
|
||||
anchors.rightMargin: root.pad
|
||||
anchors.rightMargin: root.pad + stripActions.width + 8
|
||||
contentWidth: tabsRow.width
|
||||
contentHeight: height
|
||||
clip: true
|
||||
@@ -521,6 +758,82 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// The strip's right cluster: Open (starts an ephemeral session) and
|
||||
// the funnel. Two 64px touch targets, always mounted — the funnel
|
||||
// button is the only place the kiosk shows that a filter is on.
|
||||
Row {
|
||||
id: stripActions
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.pad
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 64
|
||||
spacing: 4
|
||||
|
||||
Rectangle {
|
||||
id: openBtn
|
||||
width: 64
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: openArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.t("Open")
|
||||
QbzIcon {
|
||||
name: "folder-open"
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.centerIn: parent
|
||||
tintName: "textPrimary"
|
||||
}
|
||||
MouseArea {
|
||||
id: openArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: openMenu.openBelowRight(openBtn)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: filterBtn
|
||||
width: 64
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: filterArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
border.width: root.filterCount > 0 ? 1 : 0
|
||||
border.color: theme.accent
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.t("Quality, format and source filters")
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.centerIn: parent
|
||||
tintName: root.filterCount > 0 ? "accent" : "textPrimary"
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.filterCount > 0
|
||||
x: parent.width - width - 8
|
||||
y: 8
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 10
|
||||
color: theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.filterCount
|
||||
color: theme.accentGlyphColor
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: filterArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: root.openFilterSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: tabStrip.left
|
||||
anchors.right: tabStrip.right
|
||||
@@ -530,6 +843,187 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// The Open menu: LocalChrome.qml:262-282's entries and actions, verbatim
|
||||
// (the audio-CD row is dropped on Windows for the same reason it is
|
||||
// there). The session opens globally and the router lands on the
|
||||
// Ephemeral tab (ContentRouter.qml:606-611).
|
||||
CardMenu {
|
||||
id: openMenu
|
||||
kioskHost: true
|
||||
menuWidth: 260
|
||||
entries: [
|
||||
{ "label": root.t("Open folder…"), "icon": "folder-open", "action": "folder" }
|
||||
].concat(QbzShell.isWindows ? [] : [
|
||||
{ "label": root.t("Open audio CD"), "icon": "disc", "action": "cd" }
|
||||
]).concat([
|
||||
{ "label": root.t("Open SACD image…"), "icon": "disc-3", "action": "sacd" }
|
||||
])
|
||||
onPicked: function (action) {
|
||||
if (action === "folder")
|
||||
QbzLocal.ephemeralOpen()
|
||||
else if (action === "cd")
|
||||
QbzLocal.ephemeralOpenCd()
|
||||
else if (action === "sacd")
|
||||
QbzLocal.ephemeralOpenSacd()
|
||||
}
|
||||
}
|
||||
|
||||
/// The funnel sheet: LocalFilterPopup.qml's sections and chips at touch
|
||||
/// density, centred in the window and clamped inside it.
|
||||
function openFilterSheet() {
|
||||
var win = filterSheet.parent
|
||||
if (win) {
|
||||
filterSheet.x = Math.max(8, (win.width - filterSheet.width) / 2)
|
||||
filterSheet.y = Math.max(8, Math.min(88, win.height - filterSheet.height - 8))
|
||||
}
|
||||
filterSheet.open()
|
||||
}
|
||||
|
||||
component FunnelChip: Rectangle {
|
||||
id: chip
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal toggled()
|
||||
height: 44
|
||||
width: chipLabel.implicitWidth + 32
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: chip.active ? theme.accent : theme.borderSubtle
|
||||
color: chip.active ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: chip.label
|
||||
color: chip.active ? theme.accentGlyphColor : theme.textSecondary
|
||||
font.pixelSize: 15
|
||||
font.weight: chip.active ? theme.weightSemibold : theme.weightRegular
|
||||
}
|
||||
MouseArea {
|
||||
id: chipArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: chip.toggled()
|
||||
}
|
||||
}
|
||||
|
||||
component FunnelHeading: Text {
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: filterSheet
|
||||
parent: Overlay.overlay
|
||||
width: Math.min(560, (parent ? parent.width : 800) - 16)
|
||||
height: Math.min(sheetColumn.height + 32, (parent ? parent.height : 480) - 16)
|
||||
padding: 16
|
||||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||||
background: Rectangle {
|
||||
color: theme.surfaceCard
|
||||
radius: 10
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
}
|
||||
contentItem: Flickable {
|
||||
contentWidth: width
|
||||
contentHeight: sheetColumn.height
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: sheetColumn
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 44
|
||||
Text {
|
||||
width: parent.width - clearBtn.width
|
||||
height: parent.height
|
||||
text: root.t("Filter")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 18
|
||||
font.weight: theme.weightSemibold
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
id: clearBtn
|
||||
visible: root.filterCount > 0
|
||||
width: visible ? clearText.implicitWidth + 32 : 0
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Text {
|
||||
id: clearText
|
||||
anchors.centerIn: parent
|
||||
text: root.t("Clear")
|
||||
color: theme.accent
|
||||
font.pixelSize: 15
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: root.clearFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FunnelHeading {
|
||||
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
||||
text: root.t("Favorites")
|
||||
}
|
||||
Flow {
|
||||
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip {
|
||||
label: root.t("Favorites only")
|
||||
active: root.filter.favorite === true
|
||||
onToggled: root.toggleFilter("favorite")
|
||||
}
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Quality") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: "DSD"; active: root.filter.dsd === true; onToggled: root.toggleFilter("dsd") }
|
||||
FunnelChip { label: root.t("Hi-Res"); active: root.filter.hires === true; onToggled: root.toggleFilter("hires") }
|
||||
FunnelChip { label: root.t("CD"); active: root.filter.cd === true; onToggled: root.toggleFilter("cd") }
|
||||
FunnelChip { label: root.t("Lossy"); active: root.filter.lossy === true; onToggled: root.toggleFilter("lossy") }
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Format") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: "FLAC"; active: root.filter.flac === true; onToggled: root.toggleFilter("flac") }
|
||||
FunnelChip { label: "ALAC"; active: root.filter.alac === true; onToggled: root.toggleFilter("alac") }
|
||||
FunnelChip { label: "APE"; active: root.filter.ape === true; onToggled: root.toggleFilter("ape") }
|
||||
FunnelChip { label: "WAV"; active: root.filter.wav === true; onToggled: root.toggleFilter("wav") }
|
||||
FunnelChip { label: "MP3"; active: root.filter.mp3 === true; onToggled: root.toggleFilter("mp3") }
|
||||
FunnelChip { label: "AAC"; active: root.filter.aac === true; onToggled: root.toggleFilter("aac") }
|
||||
FunnelChip { label: root.t("Other"); active: root.filter.other === true; onToggled: root.toggleFilter("other") }
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Source") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: root.t("Local"); active: root.filter.local === true; onToggled: root.toggleFilter("local") }
|
||||
FunnelChip { label: root.t("Offline cache"); active: root.filter.offline === true; onToggled: root.toggleFilter("offline") }
|
||||
FunnelChip { visible: QbzLocal.plexAvailable; label: "Plex"; active: root.filter.plex === true; onToggled: root.toggleFilter("plex") }
|
||||
FunnelChip { visible: QbzLocal.mediaHasJellyfin; label: "Jellyfin"; active: root.filter.jellyfin === true; onToggled: root.toggleFilter("jellyfin") }
|
||||
FunnelChip { visible: QbzLocal.mediaHasSubsonic; label: "Subsonic"; active: root.filter.subsonic === true; onToggled: root.toggleFilter("subsonic") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Content — ONE tab exists at a time
|
||||
// =====================================================================
|
||||
|
||||
Reference in new issue
Block a user