fix: stabilize playback memory, local library sync and Qt settings

Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.

Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.

Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.

cx
This commit is contained in:
vicrodh committed 2026-09-13 02:57:21 -06:00
1 parent 3cf1f32177
commit e291eb3af4
118 files changed
+7128 -883

No files matched your search

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#4285f4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#888888"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#cccccc"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#fbbf24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -230,6 +230,8 @@ Item {
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -413,6 +415,8 @@ Item {
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -197,6 +197,8 @@ Item {
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: queryInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -243,6 +243,7 @@ Item {
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
id: searchInput
width: parent.width - 22
height: parent.height
@@ -208,6 +208,7 @@ Popup {
font.pixelSize: theme.fontLegal
}
TextEdit {
QbzTextEditMenu { }
id: factValue
x: 154
width: parent.width - x
@@ -256,6 +257,7 @@ Popup {
font.weight: theme.weightSemibold
}
TextEdit {
QbzTextEditMenu { }
x: 12
y: 28
width: parent.width - 92
@@ -339,6 +339,8 @@ Item {
opacity: root.loading ? 0.5 : 1.0
TextInput {
QbzTextEditMenu { }
id: urlInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -542,6 +544,8 @@ Item {
opacity: root.loading ? 0.5 : 1.0
TextInput {
QbzTextEditMenu { }
id: serviceInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -667,6 +671,8 @@ Item {
opacity: nameInput.enabled ? 1.0 : 0.5
TextInput {
QbzTextEditMenu { }
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -258,6 +258,8 @@ Item {
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -359,6 +361,7 @@ Item {
border.width: 1
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
@@ -0,0 +1,103 @@
// Thin transport rail. The thumb extends outside the 3px layout slot; the
// shell stacks its transport above the content and the time bubble uses Overlay.
import QtQuick
import QtQuick.Controls
import "../theme"
Item {
id: root
height: 3
property bool hasTrack: false
property real progress: 0
property real cacheProgress: 0
property real seekableMax: 1
property real durationSecs: 0
signal seekRequested(real fraction)
function clamp01(value) { return Math.min(Math.max(value, 0), 1) }
function fmt(secs) {
var whole = Math.floor(Math.max(0, secs))
return Math.floor(whole / 60) + ":" + (whole % 60 < 10 ? "0" : "") + whole % 60
}
QbzTheme { id: theme }
Rectangle {
id: track
anchors.fill: parent
radius: 2
color: theme.surfaceElevated
Rectangle {
width: parent.width * root.clamp01(root.cacheProgress)
height: parent.height
radius: 2
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.35)
}
Rectangle {
width: parent.width * root.clamp01(root.progress)
height: parent.height
radius: 2
color: theme.accent
}
Rectangle {
objectName: "compactSeekThumb"
width: 12; height: 12; radius: 6
color: theme.textPrimary
x: parent.width * root.clamp01(root.progress) - width / 2
anchors.verticalCenter: parent.verticalCenter
visible: area.containsMouse && root.hasTrack
}
}
MouseArea {
id: area
objectName: "compactSeekArea"
width: parent.width
height: 18
anchors.verticalCenter: parent.verticalCenter
hoverEnabled: true
enabled: root.hasTrack
cursorShape: root.clamp01(mouseX / width) > root.seekableMax
? Qt.ForbiddenCursor : Qt.PointingHandCursor
onClicked: root.seekRequested(Math.min(root.clamp01(mouseX / width), root.seekableMax))
}
Item {
id: tip
objectName: "compactSeekTip"
parent: Overlay.overlay
z: 10000
visible: root.visible && root.hasTrack && area.containsMouse
readonly property real fraction: root.clamp01(area.mouseX / Math.max(1, area.width))
// Explicit geometry dependencies also reposition a stationary tooltip
// when the window resizes; mapping alone does not notify QML bindings.
readonly property point anchor: {
var geometry = root.x + root.y + root.width + root.height
var windowHeight = parent ? parent.height : 0
return parent ? root.mapToItem(parent, root.width * fraction, 0) : Qt.point(0, 0)
}
width: bubble.width
height: bubble.height + 9
x: Math.max(4, Math.min((parent ? parent.width : width) - width - 4, anchor.x - width / 2))
y: anchor.y - height + 3
Rectangle {
id: bubble
width: label.implicitWidth + 14
height: 18
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderMuted
Text {
id: label
objectName: "compactSeekTime"
anchors.centerIn: parent
text: root.fmt(tip.fraction * root.durationSecs)
color: theme.textPrimary
font.pixelSize: 11
}
}
Rectangle {
width: 9; height: 9
rotation: 45
color: theme.surfaceElevated
anchors.horizontalCenter: bubble.horizontalCenter
y: bubble.height - height / 2
}
}
}
@@ -224,6 +224,7 @@ Rectangle {
// No clip: TextInput scissors itself (:207) and the
// placeholder elides. A redundant second scissor.
TextInput {
QbzTextEditMenu { }
id: input
anchors.fill: parent
color: theme.textPrimary
+38 -13
View File
@@ -22,26 +22,38 @@ Item {
id: root
property string label: ""
property string description: ""
property bool kioskHost: false
property bool selected: false
signal clicked()
QbzTheme { id: theme }
implicitWidth: row.implicitWidth
implicitHeight: Math.max(18, lbl.implicitHeight)
implicitWidth: lbl.implicitWidth + 24
implicitHeight: Math.max(root.kioskHost ? 44 : 18, row.implicitHeight)
activeFocusOnTab: enabled
Accessible.role: Accessible.RadioButton
Accessible.name: label
Accessible.description: description
Accessible.checked: selected
Accessible.onPressAction: if (enabled) clicked()
Keys.onSpacePressed: clicked()
Keys.onReturnPressed: clicked()
Keys.onEnterPressed: clicked()
Row {
id: row
width: parent.width
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Rectangle {
anchors.verticalCenter: parent.verticalCenter
y: Math.max(0, (lbl.height - height) / 2)
width: 18
height: 18
radius: 9
border.width: root.selected ? 0 : 1.5
border.color: theme.textMuted
border.width: root.selected && !root.activeFocus ? 0 : 1.5
border.color: root.activeFocus ? theme.accent : theme.textMuted
color: root.selected ? theme.accent : "transparent"
Rectangle {
visible: root.selected
@@ -52,19 +64,32 @@ Item {
color: theme.accentGlyphColor
}
}
Text {
id: lbl
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
Column {
width: parent.width - 24
spacing: 4
Text {
id: lbl
width: parent.width
text: root.label
color: theme.textPrimary
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
wrapMode: Text.WordWrap
verticalAlignment: Text.AlignVCenter
}
Text {
visible: root.description !== ""
width: parent.width
text: root.description
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.WordWrap
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
onClicked: { root.forceActiveFocus(); root.clicked() }
}
}
+1
View File
@@ -337,6 +337,7 @@ Rectangle {
width: parent.width - 14 - 8
height: parent.height
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
color: theme.textPrimary
@@ -50,6 +50,8 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
TextEdit {
QbzTextEditMenu { }
id: input
width: flick.width
text: root.text
@@ -0,0 +1,66 @@
// Pointer editing for both TextInput and TextEdit, including native TextFields.
// Keep focus on the editor so form bindings and focus-loss commits stay intact.
import QtQuick
import com.blitzfc.qbz
Item {
id: root
anchors.fill: parent
readonly property var editor: parent
readonly property bool secret: editor.echoMode !== undefined && editor.echoMode !== TextInput.Normal
readonly property bool writable: editor.enabled && !editor.readOnly
Component.onCompleted: editor.selectByMouse = true
property int editRevision: 0
Connections {
target: root.editor
ignoreUnknownSignals: true
function onTextEdited() { root.editRevision += 1 }
}
function perform(action) {
editor.forceActiveFocus()
const before = editor.text
const revision = editRevision
if (action === "copy" && !secret) editor.copy()
else if (action === "cut" && writable && !secret) editor.cut()
else if (action === "paste" && writable) editor.paste()
else if (action === "select-all") editor.selectAll()
// Drive the same callback as typing if this Qt version did not emit
// textEdited for cut/paste. Avoid double requests on versions that do.
// TextEdit already notifies its consumers via textChanged.
if (editor.text !== before && editor instanceof TextInput && editRevision === revision)
editor.textEdited()
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function(mouse) {
root.editor.forceActiveFocus()
menuLoader.active = true
menuLoader.item.openAtCursor(root.editor, mouse.x, mouse.y)
}
}
// Allocate menu rows/icons only when this field is first right-clicked.
Loader {
id: menuLoader
active: false
sourceComponent: CardMenu {
id: menu
objectName: "textEditContextMenu"
menuWidth: 180
focus: false
entries: [
{label: QbzSession.tr("Cut", QbzSession.trRev), icon: "scissors", action: "cut",
enabled: root.writable && !root.secret && root.editor.selectedText !== ""},
{label: QbzSession.tr("Copy", QbzSession.trRev), icon: "copy", action: "copy",
enabled: !root.secret && root.editor.selectedText !== ""},
{label: QbzSession.tr("Paste", QbzSession.trRev), icon: "clipboard", action: "paste",
enabled: root.writable && root.editor.canPaste},
{label: QbzSession.tr("Select all", QbzSession.trRev), icon: "square-check-big", action: "select-all",
enabled: root.editor.text !== ""}
]
onPicked: function(action) { root.perform(action) }
}
}
}
+3 -1
View File
@@ -14,12 +14,14 @@ Item {
property string label: ""
property string description: ""
property bool rowEnabled: true
property bool fitDescription: false
default property alias control: controlHost.data
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: kioskHost ? labelColumn.height + controlHost.height + 24 : (description === "" ? 52 : 64)
height: kioskHost ? labelColumn.height + controlHost.height + 24
: Math.max(description === "" ? 52 : 64, fitDescription ? labelColumn.height + 16 : 0)
Column {
id: labelColumn
@@ -220,6 +220,7 @@ Item {
border.width: input.activeFocus ? 1 : 0
border.color: theme.focusRing
TextInput {
QbzTextEditMenu { }
id: input
anchors.fill: parent
leftPadding: 9
@@ -190,6 +190,8 @@ Item {
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: queryInput
anchors.fill: parent
anchors.leftMargin: 12
+1
View File
@@ -83,6 +83,7 @@ Rectangle {
KioskTab { text: "+"; onClicked: QbzMyQbz.createOpen(root.kind) }
}
TextField {
QbzTextEditMenu { }
id: search
anchors.top: root.compactHeader ? root.top : tabs.bottom
anchors.left: root.compactHeader ? tabs.right : parent.left
@@ -154,6 +154,7 @@ Rectangle {
TouchButton { text: "⋯" + (root.doc.selectedCount > 0 ? " " + root.doc.selectedCount : ""); onClicked: root.showMenu() }
}
TextField {
QbzTextEditMenu { }
y: root.compactHeader ? 70 : 124
width: parent.width
height: 48
+1 -1
View File
@@ -854,7 +854,7 @@ Rectangle {
// per-row batch-root cost as the quality cell above.
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
source: root.showArtwork ? (root.item.artPath || "") : ""
radius: 4
}
// Per-item cover placeholder — clears when THIS row's cover lands,
@@ -56,6 +56,17 @@ Column {
spacing: 4
// --- Theme row state (theme_qt.rs via the bridge) --------------------
SettingRow { kioskHost: root.kioskHost;
fitDescription: true
label: QbzSession.tr("Visual resource profile", QbzSession.trRev)
description: QbzSession.tr("Low uses the Small player, compact headers, no dynamic background, header gradient, spectrum, animated play indicator, sidebar collages or optional track artwork. Default restores these visual defaults. Editing a managed option selects Custom. Audio quality and playback memory are independent.", QbzSession.trRev)
QbzSelect { kioskHost: root.kioskHost;
menuWidth: 240
options: [QbzSession.tr("Low", QbzSession.trRev), QbzSession.tr("Default", QbzSession.trRev), QbzSession.tr("Custom", QbzSession.trRev)]
currentIndex: Math.max(0, ["low", "default", "custom"].indexOf(root.doc.appearanceProfile || "default"))
onSelected: function(i) { QbzBridge.settingsString("appearance-profile", ["low", "default", "custom"][i]) }
}
}
readonly property var themeEntries: {
try {
return JSON.parse(QbzShell.themeListJson)
@@ -668,6 +668,8 @@ Item {
border.color: manualInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: manualInput
anchors.fill: parent
anchors.leftMargin: 12
+106 -33
View File
@@ -10,6 +10,7 @@
// (settings_qt/library.rs).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -51,11 +52,43 @@ Column {
if (!ts) return QbzSession.tr("Never", QbzSession.trRev)
return Qt.formatDateTime(new Date(ts * 1000), "yyyy-MM-dd hh:mm")
}
// Shared column geometry (header + rows MUST agree):
// checkbox 20 · folder (rest) · last-scan 120 · status 84 · actions 110.
readonly property int actionsW: root.kioskHost ? 200 : 110
readonly property var statusLabels: ({
active: QbzSession.tr("Active", QbzSession.trRev),
hidden: QbzSession.tr("Hidden", QbzSession.trRev),
missing: QbzSession.tr("Missing", QbzSession.trRev),
disconnected: QbzSession.tr("Disconnected", QbzSession.trRev),
denied: QbzSession.tr("Access denied", QbzSession.trRev),
unavailable: QbzSession.tr("Unavailable", QbzSession.trRev),
checking: QbzSession.tr("Checking...", QbzSession.trRev)
})
readonly property var statusDescriptions: ({
active: QbzSession.tr("The folder is accessible and visible in the library.", QbzSession.trRev),
hidden: QbzSession.tr("The folder is accessible but hidden by your preference.", QbzSession.trRev),
missing: QbzSession.tr("The folder path no longer exists. Reconnect it, change its path or remove it from the library.", QbzSession.trRev),
disconnected: QbzSession.tr("A connection failure prevents access to this storage.", QbzSession.trRev),
denied: QbzSession.tr("QBZ does not have permission to access this folder.", QbzSession.trRev),
unavailable: QbzSession.tr("The folder could not be accessed. The check timed out or returned another access error.", QbzSession.trRev),
checking: QbzSession.tr("Folder access is being checked or has not been checked yet.", QbzSession.trRev)
})
FontMetrics {
id: statusMetrics
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
font.weight: theme.weightMedium
}
readonly property int statusW: Math.ceil(Math.max(110,
...Object.values(root.statusLabels).map(function(s) { return statusMetrics.advanceWidth(s) + 20 })))
readonly property int actionButtonW: root.kioskHost ? 44 : 40
readonly property int actionsW: 4 * actionButtonW + 3 * 4
function folderColW(rowWidth) {
return Math.max(60, rowWidth - (root.kioskHost ? 44 : 20) - 120 - 84 - actionsW - 4 * 12)
return Math.max(100, rowWidth - (root.kioskHost ? 44 : 20) - 120 - statusW - actionsW - 4 * 12)
}
property string lastPickedPath: ""
onLibChanged: {
const picked = root.lib.picked_path || ""
if (picked !== lastPickedPath) {
lastPickedPath = picked
if (picked !== "") { root.pendingPath = picked; pathField.text = picked }
}
}
Item {
@@ -103,10 +136,7 @@ Column {
onEdited: function (v) { root.pendingPath = v }
onCommitted: function (v) { root.pendingPath = v }
}
// The native chooser (settings_qt/library.rs pick_and_add_folder).
// It adds the folder itself, so there is nothing to clear here —
// the typed field is the OTHER route to the same insert, kept
// because pasting a path is faster for network mounts.
// Browse fills the same path field as typing; Add is the single commit.
SettingsButton { kioskHost: root.kioskHost;
iconName: "folder"
text: QbzSession.tr("Browse...", QbzSession.trRev)
@@ -160,6 +190,20 @@ Column {
wrapMode: Text.WordWrap
}
// Keep every column reachable on narrow windows; never paint actions over status.
Flickable {
id: folderTable
width: root.width
height: tableRows.height
contentWidth: Math.max(width, 100 + (root.kioskHost ? 44 : 20) + 120 + root.statusW + root.actionsW + 70)
contentHeight: height
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Column {
id: tableRows
width: folderTable.contentWidth
spacing: 4
// Table header (columns MUST match the rows below).
Item {
visible: root.folders.length > 0
@@ -190,7 +234,15 @@ Column {
verticalAlignment: Text.AlignVCenter
}
Text {
width: 84
width: root.actionsW
height: parent.height
text: QbzSession.tr("Actions", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Text {
width: root.statusW
height: parent.height
text: QbzSession.tr("STATUS", QbzSession.trRev)
color: theme.textMuted
@@ -198,7 +250,7 @@ Column {
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Item { width: root.actionsW; height: 1 }
}
}
Rectangle {
@@ -214,7 +266,7 @@ Column {
id: folderRow
required property var modelData
width: root.width
width: folderTable.contentWidth
height: root.kioskHost ? 64 : 40
radius: theme.radiusSm
color: root.isSelected(modelData.id) ? theme.surfaceElevated
@@ -249,14 +301,22 @@ Column {
spacing: 8
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
// POC-NOTE: no `network` glyph in this icon set — a
// network folder uses `link`, a local one hard-drive.
name: folderRow.modelData.isNetwork ? "link" : "hard-drive"
objectName: "folderType-" + folderRow.modelData.id
name: folderRow.modelData.isNetwork ? "server" : "hard-drive"
ToolTip.visible: typeHover.containsMouse
ToolTip.delay: 400
ToolTip.text: folderRow.modelData.isNetwork
? QbzSession.tr("Network folder", QbzSession.trRev)
: QbzSession.tr("Local folder", QbzSession.trRev)
MouseArea {
id: typeHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
width: 16
height: 16
tintName: folderRow.modelData.isNetwork
? (folderRow.modelData.accessible ? "accent" : "favorite")
: "secondary"
tintName: folderRow.modelData.isNetwork ? "accent" : "secondary"
}
Text {
width: parent.width - 24
@@ -277,38 +337,25 @@ Column {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// STATUS (read-only, like the Slint).
Text {
width: 84
height: parent.height
text: !folderRow.modelData.enabled
? QbzSession.tr("Disabled", QbzSession.trRev)
: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible)
? QbzSession.tr("Unavailable", QbzSession.trRev)
: QbzSession.tr("Active", QbzSession.trRev)
color: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible
&& folderRow.modelData.enabled) ? theme.danger : theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// ACTIONS: settings · enable/disable · scan this folder ·
// remove it. The eye button duplicates the edit modal's
// "Enabled" toggle on purpose — it predates the modal and is
// one click instead of three for the thing people do most.
Row {
objectName: "folderActions-" + folderRow.modelData.id
width: root.actionsW
height: parent.height
spacing: 4
layoutDirection: Qt.RightToLeft
SettingsButton { kioskHost: root.kioskHost;
width: root.actionButtonW
anchors.verticalCenter: parent.verticalCenter
iconName: "trash-2"
onClicked: QbzBridge.settingsString("library-remove-folders",
JSON.stringify([folderRow.modelData.id]))
}
SettingsButton { kioskHost: root.kioskHost;
width: root.actionButtonW
anchors.verticalCenter: parent.verticalCenter
iconName: "refresh-cw"
enabled: folderRow.modelData.enabled && root.lib.scanning !== true
@@ -316,6 +363,7 @@ Column {
String(folderRow.modelData.id))
}
SettingsButton { kioskHost: root.kioskHost;
width: root.actionButtonW
anchors.verticalCenter: parent.verticalCenter
iconName: folderRow.modelData.enabled ? "eye" : "eye-off"
onClicked: QbzBridge.settingsString("library-folder-enabled",
@@ -326,16 +374,41 @@ Column {
// the same place; this port had no affordance at all
// until the modal landed.
SettingsButton { kioskHost: root.kioskHost;
width: root.actionButtonW
anchors.verticalCenter: parent.verticalCenter
iconName: "settings-2"
onClicked: QbzBridge.settingsString("library-folder-edit-open",
String(folderRow.modelData.id))
}
}
Text {
objectName: "folderStatus-" + folderRow.modelData.id
width: root.statusW
height: parent.height
text: root.statusLabels[folderRow.modelData.status || "checking"] || root.statusLabels.unavailable
color: ["missing", "disconnected", "denied", "unavailable"].indexOf(folderRow.modelData.status) >= 0
? theme.danger : theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
ToolTip.visible: statusHover.containsMouse
ToolTip.delay: 400
ToolTip.text: root.statusDescriptions[folderRow.modelData.status || "checking"] || root.statusDescriptions.unavailable
MouseArea {
id: statusHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
}
}
}
}
}
}
// --------------------------- scan progress ---------------------------
Item {
visible: root.lib.scanning === true
+273 -4
View File
@@ -3,6 +3,7 @@
// every control rides the settingsJson document and the settings invokables.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -13,6 +14,35 @@ Column {
id: root
property var doc: ({})
property var confirmHost: null
function applyMemory() {
if (root.doc.playbackMemoryApplyBusy === true) return
const track = QbzPlayer.npHasTrack ? QbzPlayer.npTrackId : ""
if (QbzPlayer.npPlaying || QbzPlayer.npLoading) {
if (!root.confirmHost) return
root.confirmHost.ask(
QbzSession.tr("Apply playback memory profile?", QbzSession.trRev),
QbzSession.tr("Playback buffers will be released. If playing, the current track will stop and restart from the beginning. If paused, it will stay stopped at 0:00. Your queue and disk cache will be kept.", QbzSession.trRev),
QbzSession.tr("Apply & free memory", QbzSession.trRev),
function () { QbzBridge.settingsString("playback-cache-apply", "restart:" + track) })
} else {
QbzBridge.settingsString("playback-cache-apply", "idle:" + track)
}
}
readonly property var cachePolicy: doc.playbackCache || ({})
readonly property var cacheUsage: doc.playbackCacheUsage || ({})
readonly property var storage: doc.playbackStorage || ({})
readonly property var memoryProfileIds: ["auto", "high", "desktop", "low", "custom"]
readonly property string memoryProfile: doc.playbackMemoryProfile ||
(cachePolicy.profile || ((cachePolicy.dynamic || cachePolicy.min_mib != null || cachePolicy.max_mib != null) ? "custom" : "auto"))
readonly property var memoryProfileDescriptions: [
QbzSession.tr("Auto selects Desktop or Low from the computer's RAM. Fixed cache budget; dynamic growth is off. Recommended default.", QbzSession.trRev),
QbzSession.tr("High starts with a 400 MiB cache budget and grows up to 1600 MiB when memory is available. It shrinks after inactivity or memory pressure. For long Hi-Res tracks and computers with spare RAM.", QbzSession.trRev),
QbzSession.tr("Desktop uses a fixed 400 MiB cache budget and normal prefetch. Larger tracks use disk-backed buffers.", QbzSession.trRev),
QbzSession.tr("Low uses a 50 MiB cache budget, smaller startup buffers and reduced prefetch. Large tracks use disk; speculative Hi-Res prefetch is skipped. Useful on Raspberry Pi or when saving RAM.", QbzSession.trRev),
QbzSession.tr("Custom uses your cache limits and growth preference below. Prefetch follows the computer's detected memory class. Editing a preset switches to Custom.", QbzSession.trRev)
]
QbzTheme { id: theme }
@@ -39,11 +69,8 @@ Column {
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Gapless playback", QbzSession.trRev)
description: QbzSession.tr("Play consecutive same-format tracks without a gap.", QbzSession.trRev)
// Disabled while Streaming only is on — gapless needs the local cache.
rowEnabled: root.doc.streamingOnly !== true
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.gapless === true
enabled: root.doc.streamingOnly !== true
onToggled: function (v) { QbzBridge.settingsBool("gapless", v) }
}
}
@@ -143,7 +170,8 @@ Column {
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Streaming only", QbzSession.trRev)
description: QbzSession.tr("Skip writing tracks to the local cache while streaming.", QbzSession.trRev)
fitDescription: true
description: QbzSession.tr("Mimics official clients’ streaming behavior with lower memory usage. Skips the playback cache and its protection against network interruptions: QBZ’s cache keeps upcoming audio ready for more reliable playback and instant replay.", QbzSession.trRev)
QbzToggle { kioskHost: root.kioskHost;
checked: root.doc.streamingOnly === true
onToggled: function (v) { QbzBridge.settingsBool("streaming-only", v) }
@@ -159,4 +187,245 @@ Column {
onSelected: function (i) { QbzBridge.settingsSelect("retry-behavior", i) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("PLAYBACK CACHE", QbzSession.trRev) }
GroupBox {
id: memoryProfiles
objectName: "playbackMemoryFieldset"
width: root.width
padding: 16
topPadding: label.implicitHeight + 28
title: QbzSession.tr("Playback memory profile", QbzSession.trRev)
Accessible.role: Accessible.Grouping
Accessible.name: title
label: Text {
x: memoryProfiles.leftPadding
y: 12
text: memoryProfiles.title
color: theme.textPrimary
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
font.weight: theme.weightMedium
}
background: Rectangle {
color: "transparent"
border.color: theme.borderSubtle
radius: 6
}
contentItem: Column {
spacing: 16
Text {
objectName: "playbackMemoryStreamingNotice"
width: parent.width
visible: root.doc.streamingOnly === true
text: QbzSession.tr("These options do not apply while Streaming only is enabled. Turn it off to choose a playback memory profile and apply changes.", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: 16
Repeater {
id: profileOptions
model: root.memoryProfileIds
delegate: QbzRadioOption {
required property int index
required property string modelData
objectName: "memoryProfile-" + modelData
width: parent.width
kioskHost: root.kioskHost
label: [QbzSession.tr("Auto — recommended", QbzSession.trRev),
QbzSession.tr("High — dynamic", QbzSession.trRev),
QbzSession.tr("Desktop", QbzSession.trRev),
QbzSession.tr("Low — Pi style", QbzSession.trRev),
QbzSession.tr("Custom", QbzSession.trRev)][index]
description: root.memoryProfileDescriptions[index]
selected: root.memoryProfile === modelData
enabled: root.doc.streamingOnly !== true
opacity: enabled ? 1 : 0.45
onClicked: QbzBridge.settingsString("playback-memory-profile", modelData)
function step(delta) {
const next = profileOptions.itemAt((index + delta + 5) % 5)
next.forceActiveFocus()
next.clicked()
}
Keys.onUpPressed: step(-1)
Keys.onDownPressed: step(1)
Keys.onLeftPressed: step(-1)
Keys.onRightPressed: step(1)
}
}
}
Column {
objectName: "playbackMemoryCustomOptions"
width: parent.width
visible: root.memoryProfile === "custom"
spacing: 4
SettingRow { kioskHost: root.kioskHost;
fitDescription: true
label: QbzSession.tr("Grow cache when memory is available", QbzSession.trRev)
description: QbzSession.tr("Allow the cache to grow within your maximum when RAM is available. High enables this automatically. Editing this option switches to Custom.", QbzSession.trRev)
rowEnabled: root.doc.streamingOnly !== true
QbzToggle { kioskHost: root.kioskHost;
checked: root.cachePolicy.dynamic === true
enabled: root.doc.streamingOnly !== true
onToggled: function (v) { QbzBridge.settingsBool("playback-cache-dynamic", v) }
}
}
SettingRow { kioskHost: root.kioskHost;
fitDescription: true
label: QbzSession.tr("Minimum cache budget (MiB)", QbzSession.trRev)
description: QbzSession.tr("Base budget, not reserved RAM. Memory pressure can reduce it. Leave blank for the recommended value.", QbzSession.trRev)
rowEnabled: root.doc.streamingOnly !== true
QbzLineEdit { kioskHost: root.kioskHost;
width: 240
enabled: root.doc.streamingOnly !== true
text: root.cachePolicy.min_mib == null ? "" : String(root.cachePolicy.min_mib)
placeholder: String(Math.round((root.cacheUsage.base_size_bytes || root.cacheUsage.recommended_size_bytes || 419430400) / 1048576))
onCommitted: function (s) { QbzBridge.settingsString("playback-cache-min", s) }
}
}
SettingRow { kioskHost: root.kioskHost;
fitDescription: true
label: QbzSession.tr("Maximum cache budget (MiB)", QbzSession.trRev)
description: QbzSession.tr("Growth ceiling; available memory may impose a lower limit. Leave blank for automatic.", QbzSession.trRev)
rowEnabled: root.doc.streamingOnly !== true && root.cachePolicy.dynamic === true
QbzLineEdit { kioskHost: root.kioskHost;
width: 240
enabled: root.doc.streamingOnly !== true && root.cachePolicy.dynamic === true
text: root.cachePolicy.max_mib == null ? "" : String(root.cachePolicy.max_mib)
placeholder: String(Math.round((root.cacheUsage.ceiling_size_bytes || 419430400) / 1048576))
onCommitted: function (s) { QbzBridge.settingsString("playback-cache-max", s) }
}
}
}
Column {
objectName: "playbackMemoryTradeoff"
width: parent.width
spacing: 4
Text {
width: parent.width
text: QbzSession.tr("RAM and storage trade-off", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: QbzSession.tr("All profiles preserve audio quality and can use disk cache. Lower memory budgets rely more on storage speed and writes; more RAM can reduce that work. Cached disk tracks are read from disk even in High. Choose storage appropriate for your listening workload.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.WordWrap
}
}
Text {
width: parent.width
text: QbzSession.tr("The profile already applies to new buffers. Apply now to release existing playback buffers and the memory cache. Memory use can grow again as playback continues.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.WordWrap
}
Row {
objectName: "playbackMemoryActions"
anchors.right: parent.right
spacing: 8
SettingsButton {
objectName: "playbackMemoryReset"
kioskHost: root.kioskHost
text: QbzSession.tr("Restore defaults", QbzSession.trRev)
enabled: root.doc.streamingOnly !== true && root.doc.playbackMemoryApplyBusy !== true
onClicked: QbzBridge.settingsString("playback-cache-reset", "")
}
SettingsButton {
objectName: "playbackMemoryApply"
kioskHost: root.kioskHost
text: QbzSession.tr("Apply & free memory", QbzSession.trRev)
busy: root.doc.playbackMemoryApplyBusy === true
enabled: root.doc.streamingOnly !== true && !busy
onClicked: root.applyMemory()
}
}
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Memory cache usage", QbzSession.trRev)
description: QbzSession.tr("Used / current budget: %1 / %2 MiB. This excludes decoder memory and offline downloads.", QbzSession.trRev)
.arg(Math.round((root.cacheUsage.current_size_bytes || 0) / 1048576))
.arg(Math.round((root.cacheUsage.max_size_bytes || 0) / 1048576))
SettingsButton { kioskHost: root.kioskHost;
text: QbzSession.tr("Refresh", QbzSession.trRev)
onClicked: QbzBridge.settingsString("playback-cache-refresh", "")
}
}
Column {
objectName: "playbackStorageSetting"
width: root.width
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Playback storage folder", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
font.weight: theme.weightMedium
}
Text {
width: parent.width
text: QbzSession.tr("Choose an existing folder on the drive you want to use. QBZ stores its playback cache and temporary track buffers in a qbz-playback subfolder. Offline downloads are separate. Leave blank for the default location.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.WordWrap
}
Row {
width: parent.width
spacing: 8
QbzLineEdit {
objectName: "playbackStoragePath"
kioskHost: root.kioskHost
width: Math.max(0, Math.min(storageBrowse.width * 2, parent.width - storageBrowse.width - parent.spacing))
text: root.storage.candidate || ""
onCommitted: function(path) { QbzBridge.settingsString("playback-storage-folder", path) }
}
SettingsButton {
id: storageBrowse
objectName: "playbackStorageBrowse"
kioskHost: root.kioskHost
text: QbzSession.tr("Browse...", QbzSession.trRev)
onClicked: QbzBridge.settingsString("playback-storage-browse", "")
}
}
Text {
objectName: "playbackStorageActiveHint"
width: parent.width
text: QbzSession.tr("Current playback storage", QbzSession.trRev) + ": " + (root.storage.active || "—")
+ "\n" + QbzSession.tr("Changing the folder takes effect after restarting QBZ. Existing cache files stay in the old location; they are not moved or deleted automatically.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
wrapMode: Text.Wrap
}
}
Text {
width: root.width
visible: (root.storage.error || "") !== ""
text: QbzSession.tr("Could not use this playback storage folder:", QbzSession.trRev) + " " + (root.storage.error || "")
wrapMode: Text.Wrap
color: theme.danger
font.pixelSize: theme.fontLegal
}
SettingRow { kioskHost: root.kioskHost;
visible: (root.storage.sandbox || "") !== ""
fitDescription: true
label: (root.storage.sandbox || "") + " — " + QbzSession.tr("Folder access", QbzSession.trRev)
description: root.storage.sandbox === "Flatpak"
? QbzSession.tr("If the sandbox blocks this folder, run the command below in a host terminal, restart QBZ, then select the folder again. Only grant access to a folder you intend to use.", QbzSession.trRev)
: QbzSession.tr("Snap can access supported home folders and removable storage under /mnt, /media or /run/media. For removable storage, connect the interface below and retry. Snap cannot grant arbitrary folder access with a Flatpak-style override.", QbzSession.trRev)
}
CommandBlock {
visible: (root.storage.command || "") !== ""
width: root.width
command: root.storage.command || ""
}
}
@@ -446,6 +446,7 @@ Item {
PlaybackSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
confirmHost: settingsConfirmHost
}
}
}
+4
View File
@@ -254,6 +254,10 @@ Rectangle {
NowPlayingBar {
id: npb
// The small seek thumb/hit area extends above the bar by a few pixels.
// Only Small needs this overlap. Large's cover dock is a later
// sibling and must paint over the full-width player background.
z: QbzShell.npbMode === 2 ? 1 : 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
+23 -56
View File
@@ -175,23 +175,15 @@ Rectangle {
try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) }
}
/// Purchases in the TITLE BAR — the third row of the §7.1 truth table.
///
/// This is the EXACT COMPLEMENT of `Sidebar.qml`'s `purchasesVisible`, and
/// it has to be, because that property WITHDRAWS the sidebar row for this
/// configuration. Until this existed the entry was withdrawn from one host
/// and offered by none: with `show_purchases` and `nav_tb_purchases` both on
/// and a custom title bar, Purchases simply had no way in.
///
/// `nav_tb_purchases` only RELOCATES; `show_purchases` is the master gate.
/// Under system chrome or with no title bar there is nowhere to relocate TO,
/// so the sidebar keeps it and this stays false.
// Follow navigation into the header, including the closed-sidebar fallback.
// The separate relocation preference also works while navigation stays in
// the sidebar; only that explicit title-bar placement needs custom chrome.
readonly property bool purchasesInHeader:
root.settingsDoc.showPurchases === true
&& !QbzSession.offline
&& root.settingsDoc.navTbPurchases === true
&& !QbzShell.systemTitleBar
&& !QbzShell.hideTitleBar
&& (root.headerTabsOn || root.headerCompactOn
|| (root.settingsDoc.navTbPurchases === true
&& !QbzShell.systemTitleBar && !QbzShell.hideTitleBar))
// Highlighted section — derived from the live view (see NavFlyout), OR'd
// in the triggers with "my menu is open" (Slint highlights off
@@ -256,17 +248,17 @@ Rectangle {
visible: purchaseTab.showIcon
anchors.verticalCenter: parent.verticalCenter
name: "shopping-bag"
width: 16
height: 16
tintName: purchaseTab.isActive ? "primary" : "muted"
width: purchaseTab.compact ? 16 : 14
height: width
tintName: purchaseTab.isActive ? "textPrimary" : "secondary"
}
Text {
visible: !purchaseTab.compact
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Purchases", QbzSession.trRev)
color: purchaseTab.isActive ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
color: theme.textPrimary
font.pixelSize: 11
font.weight: purchaseTab.isActive ? theme.weightSemibold : theme.weightRegular
}
}
@@ -460,6 +452,13 @@ Rectangle {
}
}
// Explicit Purchases relocation with the other navigation in the sidebar.
PurchaseTab {
visible: root.purchasesInHeader && !root.headerTabsOn && !root.headerCompactOn
showIcon: root.width >= 1140
anchors.verticalCenter: parent.verticalCenter
}
// Compact section nav — while the sidebar is fully closed (so the
// sections stay reachable), or always when the nav is in the header
// and "Compact header navigation" is ON (HeaderBar.slint:974).
@@ -618,6 +617,7 @@ Rectangle {
border.width: 1
border.color: theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: plSearch
anchors.fill: parent
anchors.leftMargin: 6
@@ -880,6 +880,7 @@ Rectangle {
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.left: parent.left
anchors.right: parent.right
@@ -898,10 +899,8 @@ Rectangle {
// the reference opens on the FIRST keystroke >= 2 chars and
// debounces only the LOAD. Rust's version guard is what discards
// the superseded loads.
// Shared by keyboard edits (onTextEdited) and the edit menu's
// cut/paste, which are PROGRAMMATIC changes — TextInput only
// emits textEdited for user input, so a menu paste would
// otherwise change the text without ever driving the cortinilla.
// The shared edit menu dispatches the same textEdited callback
// as typing, once, including on Qt versions where paste does not.
function applyLiveQuery() {
if (text.trim().length < 2) {
QbzSearch.cortinillaDismiss()
@@ -911,38 +910,6 @@ Rectangle {
}
onTextEdited: applyLiveQuery()
// QoL round: clipboard access by pointer, not only Ctrl+C/X/V —
// QML TextInput has no system edit menu of its own. RightButton
// only, so left clicks keep placing the cursor.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
searchEditMenu.openAtCursor(searchInput, mouse.x, mouse.y)
}
}
CardMenu {
id: searchEditMenu
menuWidth: 160
entries: {
var t = QbzSession.tr
var r = QbzSession.trRev
var hasSel = searchInput.selectedText !== ""
return [
{ "label": t("Cut", r), "icon": "scissors", "action": "cut", "enabled": hasSel },
{ "label": t("Copy", r), "icon": "copy", "action": "copy", "enabled": hasSel },
{ "label": t("Paste", r), "icon": "clipboard", "action": "paste", "enabled": searchInput.canPaste },
{ "label": t("Select all", r), "icon": "square-check-big", "action": "select-all", "enabled": searchInput.text !== "" },
]
}
onPicked: function (a) {
if (a === "cut") { searchInput.cut(); searchInput.applyLiveQuery() }
else if (a === "copy") searchInput.copy()
else if (a === "paste") { searchInput.paste(); searchInput.applyLiveQuery() }
else if (a === "select-all") searchInput.selectAll()
}
}
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
// a keyboard selection -> activate the row; open + none -> full
// search; closed -> plain submit (also Search > All).
@@ -261,6 +261,7 @@ Item {
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.left: searchGlyph.right
anchors.leftMargin: 8
@@ -488,6 +489,7 @@ Item {
tintName: "accent"
}
TextInput {
QbzTextEditMenu { }
anchors.left: linkGlyph.right
anchors.leftMargin: 8
anchors.right: urlCopy.left
+7 -98
View File
@@ -44,9 +44,6 @@ Rectangle {
// PlayerBarSmall.slint).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: theme.ambientOn
// Feature parked for a later release. Keep the compact renderer in place
// but use the classic seekbar consistently with the other NPB modes.
readonly property bool waveformVisible: false
/// AppShell's one shared hover-tooltip overlay.
property Item tooltip: null
@@ -142,27 +139,6 @@ Rectangle {
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Seek clamp (closes PARITY-DEBT #15, QML half) -------------------
// Same SeekBar the full bar mounts (PlayerBarSmall.slint:186 passes
// seekable-max: NowPlayingState.seekable-max into SeekBar.slint), so the
// behaviour is identical here: while a track is still downloading the
// seek target is LOCKED to the furthest fraction that has arrived and the
// cursor turns not-allowed past it. Source: state.slint:4402, fed by
// playback.rs:5304 `buffer_progress.clamp(0,1)`, published as
// QbzPlayer.npSeekableMax. A fully-available track reports 1.0, so the
// Math.min() is a no-op and local/cached seeking stays free.
function clamp01(v) {
return Math.min(Math.max(v, 0), 1)
}
// SeekBar.slint:98 — Math.min(clamp01(mouse-x / width), seekable-max).
function seekTarget(fraction) {
return Math.min(root.clamp01(fraction), QbzPlayer.npSeekableMax)
}
// SeekBar.slint:93 — clamp01(mouse-x / width) > seekable-max.
function beyondSeekable(fraction) {
return root.clamp01(fraction) > QbzPlayer.npSeekableMax
}
// --- Track Info (album/TrackInfoModal.slint) -------------------------
// The (i) button and the song-card title open the MODAL (scrim + centered
// card) — PlayerBarSmall.slint fires media-action("track", id,
@@ -214,81 +190,14 @@ Rectangle {
spacing: 0
// === A. TOP full-width seekbar (the content/bar divider) ========
Item {
QbzCompactSeekBar {
width: parent.width
height: 3
Rectangle {
id: seekTrack
width: parent.width
height: root.waveformVisible ? 13 : 3
anchors.verticalCenter: parent.verticalCenter
radius: 2
color: root.waveformVisible ? "transparent" : theme.surfaceElevated
SeekWaveformItem {
anchors.fill: parent
visible: root.waveformVisible
values: QbzPlayer.npSeekWaveform
playedProgress: QbzPlayer.npProgress
cacheProgress: QbzPlayer.npCacheProgress
baseColor: theme.surfaceElevated
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
playedColor: theme.accent
renderMode: 1
}
// Buffered / cache line.
Rectangle {
visible: !root.waveformVisible
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
// Alpha in the material, not a container `opacity`: an
// always-on opacity node pins this quad in its own batch,
// and the three seek quads use compatible materials
// (QSGSmoothColorMaterial::compare() returns 0), so folding
// it lets rail + cache + progress merge.
}
// Playback progress line.
Rectangle {
visible: !root.waveformVisible
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
height: parent.height
radius: 2
color: theme.accent
}
// Hover thumb.
Rectangle {
width: 12
height: 12
radius: 6
color: theme.textPrimary
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
anchors.verticalCenter: parent.verticalCenter
opacity: seekArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 100 } }
}
}
// Tall, easy-to-grab hit area over the thin line.
MouseArea {
id: seekArea
width: parent.width
height: 18
anchors.verticalCenter: seekTrack.verticalCenter
hoverEnabled: true
// No-drop cursor over the not-yet-downloaded region
// (SeekBar.slint:93-95).
cursorShape: root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor
: Qt.PointingHandCursor
enabled: QbzPlayer.npHasTrack
// Lock the seek target to what has downloaded while streaming
// (SeekBar.slint:96-99).
onClicked: QbzPlayer.seek(root.seekTarget(mouseX / width))
}
hasTrack: QbzPlayer.npHasTrack
progress: QbzPlayer.npProgress
cacheProgress: QbzPlayer.npCacheProgress
seekableMax: QbzPlayer.npSeekableMax
durationSecs: QbzPlayer.npDurationSecs
onSeekRequested: function (fraction) { QbzPlayer.seek(fraction) }
}
// === B. CONTROLS ROW — symmetric 3 columns =======================
@@ -144,6 +144,7 @@ Popup {
boundsBehavior: Flickable.StopAtBounds
clip: true
TextEdit {
QbzTextEditMenu { }
id: statusText
width: statusFlick.width
readOnly: true
@@ -214,6 +215,7 @@ Popup {
boundsBehavior: Flickable.StopAtBounds
clip: true
TextEdit {
QbzTextEditMenu { }
id: logText
width: logFlick.width
readOnly: true
+2 -1
View File
@@ -434,7 +434,7 @@ Rectangle {
// visible queue row.
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
source: qrRoot.artworkLeading ? (root.coverMap[row.artUrl] || "") : ""
radius: 4
}
// Per-item: this thumbnail clears when ITS cover lands, so
@@ -1358,6 +1358,7 @@ Rectangle {
border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle
color: theme.surfaceElevated
TextInput {
QbzTextEditMenu { }
id: customInput
anchors.fill: parent
anchors.leftMargin: 9
+2
View File
@@ -1016,6 +1016,7 @@ Rectangle {
border.width: 1
border.color: theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: myqbzSearchEdit
anchors.fill: parent
anchors.leftMargin: 6
@@ -1292,6 +1293,7 @@ Rectangle {
border.width: 1
border.color: theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: searchEdit
anchors.fill: parent
anchors.leftMargin: 6
@@ -121,7 +121,7 @@ Item {
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
source: root.visible && QbzPlayer.npHasTrack ? QbzPlayer.npArtworkPath : ""
radius: theme.radiusMd
}
QbzIcon {
+3 -2
View File
@@ -66,7 +66,8 @@ Rectangle {
readonly property bool pulled: root.item.qobuzUnavailable === true
readonly property int cacheStatus: root.item.cacheStatus !== undefined
? root.item.cacheStatus : 0
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
readonly property bool pulledDead: (root.pulled && root.cacheStatus !== 3)
|| root.item.sourceUnavailable === true
/// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
@@ -123,7 +124,7 @@ Rectangle {
/// below all resolve a catalog id.
readonly property bool catalogRow: {
var src = root.item.source || ""
return src !== "local" && src !== "plex"
return src === "" || src === "qobuz"
}
function menuEntries() {
+1
View File
@@ -1484,6 +1484,7 @@ Rectangle {
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
width: parent.width - 21
height: parent.height
color: theme.textPrimary
+9 -5
View File
@@ -148,10 +148,12 @@ Rectangle {
it._albumLc = String(it.album || "").toLowerCase()
it._genreLc = String(it.genre || "").toLowerCase()
if (it.kind === "track" || it.kind === "album") {
if (it.group === "favorites" || it.group === "purchases") {
var tabKey = it.kind + ":" + it.id
if (it.group === "favorites" || it.group === "purchases"
|| (it.kind === "album" && isLocalFeedItem(it))) {
var tabKey = membershipKey
if (it.group === "purchases") tabPurchased[tabKey] = true
else tabFavorites[tabKey] = true
else if (it.group === "favorites" || it.isFavorite === true)
tabFavorites[tabKey] = true
if (!tabSeen[tabKey]) {
tabSeen[tabKey] = true
totals[it.kind + "s"]++
@@ -674,13 +676,15 @@ Rectangle {
var x = feed[i]
var keep = false
if (sourceTab && x.kind === sourceKind) {
var sourceKey = sourceKind + ":" + x.id
var sourceKey = x._membershipKey
var isPurchased = purchased[sourceKey] === true
var isFavorite = favorites[sourceKey] === true
var included = matchesSources(isPurchased, isFavorite, false)
var localRelease = sourceKind === "album" && isLocalFeedItem(x)
if (localRelease && !showLocal && hideableLocalAlbum(x)) included = false
// One representative row per included entity. A purchase row
// wins even in Favorites-only so its quality badge survives.
keep = included && ((isPurchased && x.group === "purchases")
keep = included && (localRelease || (isPurchased && x.group === "purchases")
|| (!isPurchased && x.group === "favorites"))
if (keep) {
if (tabSeen[sourceKey]) keep = false
@@ -556,6 +556,7 @@ Rectangle {
height: parent.height
clip: true
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
color: theme.textPrimary
@@ -122,6 +122,7 @@ Rectangle {
height: parent.height
clip: true
TextInput {
QbzTextEditMenu { }
id: mpSearch
anchors.fill: parent
color: theme.textPrimary
+1
View File
@@ -737,6 +737,7 @@ Rectangle {
spacing: 6
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
TextInput {
QbzTextEditMenu { }
width: parent.width - 19
height: parent.height
color: theme.textPrimary
+1
View File
@@ -692,6 +692,7 @@ Rectangle {
border.color: customMinutes.activeFocus
? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: customMinutes
anchors.fill: parent
anchors.leftMargin: 9
@@ -114,6 +114,19 @@ Item {
// may additionally show the catalog mark in the mixed feed.
source: cell.item.source
sources: cell.item.sources || []
catalogAffordances: !cell.view.isLocalFeedItem(cell.item)
localMode: cell.view.isLocalFeedItem(cell.item)
localPlaylistAffordance: localMode
hostFavorite: localMode && badgeSources.some(function (source) {
return ["local", "plex", "jellyfin", "subsonic", "navidrome",
"gonic", "airsonic", "astiga"].indexOf(source) >= 0
})
onFavoriteRequested: QbzLocal.albumToggleFavorite(cell.item.id,
cell.item.title || "", cell.item.artist || "", artworkUrl,
JSON.stringify(badgeSources))
onOpenRequested: QbzAlbum.openAlbum(cell.item.id)
onPlayRequested: QbzPlayer.playAlbum(cell.item.id)
onEnqueueRequested: function (mode) { QbzPlayer.enqueueAlbum(cell.item.id, mode) }
showSourceBadge: cell.view.showSourceBadges
}
}
@@ -259,6 +259,7 @@ Rectangle {
border.color: nameField.activeFocus
? theme.accent : theme.surfaceElevated
TextInput {
QbzTextEditMenu { }
id: nameField
anchors.fill: parent
anchors.leftMargin: 12
@@ -53,6 +53,7 @@
// consumer.
import QtQuick
import "../../controls"
import com.blitzfc.qbz
import "../../theme"
@@ -267,6 +268,7 @@ Item {
tintName: "muted"
}
TextInput {
QbzTextEditMenu { }
id: searchInput
width: parent.width - 22 - (clearBtn.visible ? clearBtn.width + 8 : 0)
height: 21