feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before publishing anything, so the tracklist waited on an external service plus MusicBrainz. It publishes in stages now — header and tracks first, then each Qobuz rail as it lands, then Last.fm — with every patch merged under a generation counter so a late reply for album A cannot paint onto album B. The artist page was already staged this way; its views were the part still showing bare "Loading…" text. **Skeletons as the standard.** One control, new variants rather than new components, mounted across the views. Its second job is the one the owner named: cleanup. A cover that never resolves settles the placeholder once and reveals the card's own empty state instead of shimmering forever — local albums with no embedded art produce no artwork entry at all, so a placeholder gated only on "not arrived yet" would never stop. **Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid was one batch emitted after the whole window resolved. Each cover emits as it resolves now, visible band before prefetch margin. **Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke where the document carries it, the surrounding-line dimming, the controls flyout and the translation layer the v10 backend already supported. Exactly one row subscribes to the 30 Hz output; the timer drops to 250ms when paused or hidden. **Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal swipe, matching #653 — including the part that matters, which is that a swipe over a carousel scrolls the carousel. The layer is declared first in the shell so everything else gets first refusal. **Context menus.** Brought to the Slint inventory, Play later included, with right-click opening the same menu as the button everywhere. Entries whose seam does not exist are hidden rather than shipped inert. Two controls that rendered and no-opped are now honestly disabled: the My QBZ nav row, which routes to a view the shell has no case for, and three sidebar menu entries with no backing call. The sidebar caret was never a condition bug. The row is a positioner and the name cell's width ignored the count label, so a folder WITH content pushed the chevron past the clip — which is why it vanished exactly when it mattered. cc
This commit is contained in:
52 files changed
+4962
-719
No files matched your search
@@ -31,6 +31,16 @@ Rectangle {
|
||||
// so every chrome surface paints above it): the ambient album-colored
|
||||
// field + the dark dim scrim that keeps chrome text legible
|
||||
// (QBZ_BG_DIM-tunable, default 0.35).
|
||||
// Window-level history navigation: mouse side buttons + two-finger
|
||||
// horizontal touchpad swipe (#653 parity). Declared FIRST so the whole UI
|
||||
// hit-tests on top of it — a scroll reaches it only when nothing above
|
||||
// consumed it, which is what keeps horizontal carousels scrolling instead
|
||||
// of navigating. Never consumes a wheel event; accepts only the two side
|
||||
// buttons.
|
||||
NavGestureLayer {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
AmbientField {
|
||||
anchors.fill: parent
|
||||
visible: root.ambientOn
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// Lyrics display-settings flyout — QML port of
|
||||
// crates/qbz-ui/ui/shell/LyricsControlsFlyout.slint (`LyricsControlsPanel`).
|
||||
//
|
||||
// Row order is the reference's exactly: Auto-follow / Font / Size /
|
||||
// Translation language / Active color / Uppercase / Lite fill / Dimming /
|
||||
// footer (Copy lyrics + Reset). Selects instead of segmented pill rows
|
||||
// (ADR-008), and the active color is a fixed swatch palette because there is
|
||||
// no native color input (deviation D6) — the "Theme" reset link shows only
|
||||
// while a custom color is set.
|
||||
//
|
||||
// Every mutation writes the QbzLyrics pref property directly (live preview)
|
||||
// and fires `prefsChanged()` so Rust persists it into the SAME per-user
|
||||
// lyrics_prefs.json the Slint build reads. Changing the translation language
|
||||
// while the translation is ON re-requests the track in the new language
|
||||
// (Rust side) — the row is never a dead control.
|
||||
//
|
||||
// This component is the popup CONTENT; the Popup shell lives at the anchor
|
||||
// site in LyricsPanel.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
import "../controls"
|
||||
|
||||
Rectangle {
|
||||
id: panel
|
||||
|
||||
// Copy gate — ready && lines > 0 (the reference's `can-copy`).
|
||||
property bool canCopy: false
|
||||
|
||||
readonly property int rowWidth: 232 // 260 - 2 * 14 padding
|
||||
readonly property var swatches: [
|
||||
"#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#06b6d4", "#3b82f6"
|
||||
]
|
||||
|
||||
implicitWidth: 260
|
||||
implicitHeight: column.implicitHeight
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Clipboard seam: this shell has no Rust clipboard bridge, so the copy
|
||||
// goes through the standard QML route (a hidden TextEdit's copy()).
|
||||
TextEdit {
|
||||
id: clipboard
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
}
|
||||
|
||||
function copyLyrics() {
|
||||
var text = QbzLyrics.plainText()
|
||||
if (text === "")
|
||||
return
|
||||
clipboard.text = text
|
||||
clipboard.selectAll()
|
||||
clipboard.copy()
|
||||
clipboard.text = ""
|
||||
QbzLyrics.notice = QbzSession.tr("Lyrics copied", QbzSession.trRev)
|
||||
}
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
padding: 14
|
||||
spacing: 12
|
||||
|
||||
// --- Auto-follow ------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 22
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: afToggle.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Auto-follow", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzToggle {
|
||||
id: afToggle
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: QbzLyrics.autoFollow
|
||||
onToggled: function (value) {
|
||||
QbzLyrics.autoFollow = value
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Font --------------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 34
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: fontSelect.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Font", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzSelect {
|
||||
id: fontSelect
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
menuWidth: 150
|
||||
options: [
|
||||
QbzSession.tr("System", QbzSession.trRev),
|
||||
"LINE Seed JP",
|
||||
"Montserrat",
|
||||
"Noto Sans",
|
||||
"Source Sans 3"
|
||||
]
|
||||
currentIndex: QbzLyrics.fontIndex
|
||||
onSelected: function (i) {
|
||||
QbzLyrics.fontIndex = i
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Size --------------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 34
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: sizeSelect.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Size", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzSelect {
|
||||
id: sizeSelect
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
menuWidth: 110
|
||||
options: ["S", "M", "L", "XL"]
|
||||
currentIndex: QbzLyrics.sizeIndex
|
||||
onSelected: function (i) {
|
||||
QbzLyrics.sizeIndex = i
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Translation language (Qobuz v10) ----------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 34
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: langSelect.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Translation language", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzSelect {
|
||||
id: langSelect
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
menuWidth: 118
|
||||
popupWidth: 178
|
||||
options: [
|
||||
QbzSession.tr("Auto (account language)", QbzSession.trRev),
|
||||
"English", "Español", "Français", "Deutsch", "Italiano",
|
||||
"Português", "Nederlands", "日本語", "Русский"
|
||||
]
|
||||
currentIndex: QbzLyrics.translationLanguageIndex
|
||||
onSelected: function (i) {
|
||||
QbzLyrics.translationLanguageIndex = i
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Active color -------------------------------------------------
|
||||
Column {
|
||||
width: panel.rowWidth
|
||||
spacing: 8
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 18
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Active color", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Text {
|
||||
visible: QbzLyrics.useCustomColor
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Theme", QbzSession.trRev)
|
||||
color: themeLinkArea.containsMouse ? theme.accentHover : theme.accent
|
||||
font.pixelSize: 12
|
||||
MouseArea {
|
||||
id: themeLinkArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
QbzLyrics.useCustomColor = false
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: panel.swatches
|
||||
delegate: Rectangle {
|
||||
id: sw
|
||||
required property var modelData
|
||||
readonly property bool picked: QbzLyrics.useCustomColor
|
||||
&& Qt.colorEqual(QbzLyrics.customColor, sw.modelData)
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: sw.modelData
|
||||
border.width: sw.picked ? 2 : (swArea.containsMouse ? 1 : 0)
|
||||
border.color: sw.picked ? theme.textPrimary : theme.borderMuted
|
||||
MouseArea {
|
||||
id: swArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
QbzLyrics.useCustomColor = true
|
||||
QbzLyrics.customColor = sw.modelData
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uppercase -----------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 22
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: upToggle.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Uppercase", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzToggle {
|
||||
id: upToggle
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: QbzLyrics.uppercase
|
||||
onToggled: function (value) {
|
||||
QbzLyrics.uppercase = value
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lite fill (perf) -----------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 22
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: liteToggle.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Lite fill (saves CPU)", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzToggle {
|
||||
id: liteToggle
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: QbzLyrics.liteFill
|
||||
onToggled: function (value) {
|
||||
QbzLyrics.liteFill = value
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dimming --------------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 34
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: dimSelect.left
|
||||
anchors.rightMargin: 8
|
||||
height: parent.height
|
||||
text: QbzSession.tr("Dimming", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzSelect {
|
||||
id: dimSelect
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
menuWidth: 120
|
||||
options: [
|
||||
QbzSession.tr("Off", QbzSession.trRev),
|
||||
QbzSession.tr("Soft", QbzSession.trRev),
|
||||
QbzSession.tr("Strong", QbzSession.trRev)
|
||||
]
|
||||
currentIndex: QbzLyrics.dimmingMode
|
||||
onSelected: function (i) {
|
||||
QbzLyrics.dimmingMode = i
|
||||
QbzLyrics.prefsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer ----------------------------------------------------------
|
||||
Item {
|
||||
width: panel.rowWidth
|
||||
height: 34
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
spacing: 8
|
||||
SettingsButton {
|
||||
width: 112
|
||||
text: QbzSession.tr("Copy lyrics", QbzSession.trRev)
|
||||
enabled: panel.canCopy
|
||||
onClicked: panel.copyLyrics()
|
||||
}
|
||||
SettingsButton {
|
||||
width: 84
|
||||
text: QbzSession.tr("Reset", QbzSession.trRev)
|
||||
onClicked: QbzLyrics.resetPrefs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// One lyric line — QML port of the `LyricsLineRow` component inside
|
||||
// crates/qbz-ui/ui/shell/LyricsLinesView.slint.
|
||||
//
|
||||
// Three stacked pieces, in Slint's order:
|
||||
// 1. INACTIVE / unsynced: one wrapping Text on the dimming ladder.
|
||||
// 2. ACTIVE: per-VISUAL-LINE karaoke highlight — the line is segmented at
|
||||
// the view's content width (Qt font metrics, so the segments match what
|
||||
// the Text actually draws), and each visual row renders a dim base plus
|
||||
// a bright overlay clipped to that row's LOCAL share of the global
|
||||
// progress. Row 1 therefore fills to 100% BEFORE row 2 starts, instead
|
||||
// of every wrapped row lighting at once.
|
||||
// 3. TRANSLATION (Qobuz v10): static text under the original, ~72% size and
|
||||
// dimmer, never uppercased — it follows the line but the karaoke fill
|
||||
// stays keyed on the ORIGINAL timings and text.
|
||||
//
|
||||
// PERFORMANCE CONTRACT: the row never binds the 30Hz `lineProgress` unless it
|
||||
// is the active row. The karaoke Repeater's model is `isActive ? segments :
|
||||
// []`, so on every other row zero delegates exist and zero bindings are
|
||||
// re-evaluated per tick. Segmentation itself runs only when the line becomes
|
||||
// active or when the width / font / size / uppercase inputs change — never
|
||||
// per tick, never per frame.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Column {
|
||||
id: row
|
||||
|
||||
// ---- data ---------------------------------------------------------
|
||||
// Renamed from `index` so a Repeater delegate can carry the required
|
||||
// `index` context property without shadowing it.
|
||||
property int lineIndex: -1
|
||||
property string lineText: ""
|
||||
property string translationText: ""
|
||||
// The sync engine (LyricsSyncEngine) — read ONLY from inside the active
|
||||
// branch, so inactive rows never subscribe to its 30Hz outputs.
|
||||
property var sync: null
|
||||
|
||||
// ---- state (pushed by the view; plain values, no geometry) ---------
|
||||
property bool synced: false
|
||||
property int activeIndex: -1
|
||||
readonly property bool isActive: row.synced && row.lineIndex === row.activeIndex
|
||||
readonly property bool isPast: row.synced && row.activeIndex >= 0
|
||||
&& row.lineIndex < row.activeIndex
|
||||
readonly property int distance: Math.abs(row.lineIndex - row.activeIndex)
|
||||
|
||||
// ---- display prefs -------------------------------------------------
|
||||
property real contentWidth: 0
|
||||
property int sizeInactive: 13
|
||||
property int sizeActive: 15
|
||||
// 0 off · 1 soft · 2 strong
|
||||
property int dimmingMode: 2
|
||||
property bool uppercase: false
|
||||
property string fontFamily: ""
|
||||
property color activeColor: theme.accent
|
||||
// Lite fill: light the active line whole, no per-frame clip sweep.
|
||||
property bool liteFill: false
|
||||
property bool showTranslation: false
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// "" = the window default (Inter) — Qt resets an empty family to the
|
||||
// application font, exactly Slint's empty font-family = "System" option.
|
||||
readonly property string effFamily: row.fontFamily
|
||||
|
||||
width: row.contentWidth
|
||||
// Gap between the original line and its translation (no effect while the
|
||||
// translation is hidden — a single visible child has no spacing).
|
||||
spacing: 3
|
||||
|
||||
// Slint transforms the string for display; the engine still matches on
|
||||
// the raw text.
|
||||
readonly property string displayText: row.uppercase ? row.lineText.toUpperCase() : row.lineText
|
||||
|
||||
// Dimming ladder (LyricsLinesView.slint `line-opacity`): off -> 1.0 all;
|
||||
// soft -> 0.6 inactive; strong (default) is ASYMMETRIC — incoming lines
|
||||
// fade much harder than already-sung ones, for a stronger timing cue.
|
||||
// Unsynced lyrics render the whole list at 0.85. With no active line the
|
||||
// ladder has no reference point: full brightness.
|
||||
function lineOpacity() {
|
||||
if (!row.synced)
|
||||
return 0.85
|
||||
if (row.isActive)
|
||||
return 1.0
|
||||
if (row.dimmingMode === 0 || row.activeIndex < 0)
|
||||
return 1.0
|
||||
if (row.dimmingMode === 1)
|
||||
return 0.6
|
||||
if (row.isPast) {
|
||||
if (row.distance === 1) return 0.5
|
||||
if (row.distance === 2) return 0.35
|
||||
if (row.distance === 3) return 0.25
|
||||
return 0.18
|
||||
}
|
||||
if (row.distance === 1) return 0.4
|
||||
if (row.distance === 2) return 0.2
|
||||
if (row.distance === 3) return 0.08
|
||||
return 0.03
|
||||
}
|
||||
|
||||
// ---- INACTIVE / unsynced ------------------------------------------
|
||||
Text {
|
||||
visible: !row.isActive
|
||||
width: row.contentWidth
|
||||
text: row.displayText
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: row.lineOpacity()
|
||||
// 1:1 with Slint's 220ms ease-out: upcoming lines fade IN as they
|
||||
// brighten toward active, passed lines fade OUT to their dim tier,
|
||||
// instead of snapping between dimming steps.
|
||||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
|
||||
color: row.isPast ? theme.textMuted : theme.textSecondary
|
||||
font.family: row.effFamily
|
||||
font.pixelSize: row.sizeInactive
|
||||
font.weight: theme.weightMedium
|
||||
font.letterSpacing: 0.2
|
||||
}
|
||||
|
||||
// ---- ACTIVE: per-visual-line karaoke -------------------------------
|
||||
// Segments: [{ text, start, w }] — `start` is the cumulative width
|
||||
// fraction of all PRIOR segments, `w` this segment's share of the line.
|
||||
property var segments: []
|
||||
|
||||
TextMetrics {
|
||||
id: metrics
|
||||
font.family: row.effFamily
|
||||
font.pixelSize: row.sizeActive
|
||||
font.weight: theme.weightBold
|
||||
font.letterSpacing: 0.2
|
||||
}
|
||||
|
||||
// Greedy word wrap against the SAME metrics the active line is drawn
|
||||
// with, so the segment boundaries match the rendered wrap. Runs on
|
||||
// activation and on any input change — never per tick.
|
||||
function computeSegments() {
|
||||
if (!row.isActive) {
|
||||
if (row.segments.length > 0)
|
||||
row.segments = []
|
||||
return
|
||||
}
|
||||
var text = row.displayText
|
||||
var budget = row.contentWidth
|
||||
if (text === "" || budget <= 0) {
|
||||
row.segments = [{ "text": text, "start": 0.0, "w": 1.0 }]
|
||||
return
|
||||
}
|
||||
var words = text.split(" ")
|
||||
var visual = []
|
||||
var cur = ""
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
var cand = cur === "" ? words[i] : cur + " " + words[i]
|
||||
metrics.text = cand
|
||||
if (metrics.advanceWidth > budget && cur !== "") {
|
||||
visual.push(cur)
|
||||
cur = words[i]
|
||||
} else {
|
||||
cur = cand
|
||||
}
|
||||
}
|
||||
if (cur !== "")
|
||||
visual.push(cur)
|
||||
|
||||
var widths = []
|
||||
var total = 0
|
||||
for (var j = 0; j < visual.length; j++) {
|
||||
metrics.text = visual[j]
|
||||
widths.push(metrics.advanceWidth)
|
||||
total += metrics.advanceWidth
|
||||
}
|
||||
var out = []
|
||||
var acc = 0
|
||||
for (var k = 0; k < visual.length; k++) {
|
||||
var share = total > 0 ? widths[k] / total : 0
|
||||
out.push({ "text": visual[k], "start": acc, "w": share })
|
||||
acc += share
|
||||
}
|
||||
row.segments = out
|
||||
}
|
||||
|
||||
onIsActiveChanged: row.computeSegments()
|
||||
onDisplayTextChanged: row.computeSegments()
|
||||
onContentWidthChanged: row.computeSegments()
|
||||
onSizeActiveChanged: row.computeSegments()
|
||||
onFontFamilyChanged: row.computeSegments()
|
||||
|
||||
Column {
|
||||
visible: row.isActive
|
||||
width: row.contentWidth
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
// Empty model on every inactive row -> no delegates -> the 30Hz
|
||||
// progress is bound exactly once, on the active row.
|
||||
model: row.isActive ? row.segments : []
|
||||
|
||||
delegate: Item {
|
||||
id: seg
|
||||
required property var modelData
|
||||
width: segBase.width
|
||||
height: segBase.height
|
||||
|
||||
Text {
|
||||
id: segBase
|
||||
text: seg.modelData.text
|
||||
wrapMode: Text.NoWrap
|
||||
color: theme.textSecondary
|
||||
font.family: row.effFamily
|
||||
font.pixelSize: row.sizeActive
|
||||
font.weight: theme.weightBold
|
||||
font.letterSpacing: 0.2
|
||||
}
|
||||
|
||||
// Bright (sung) overlay, clipped to this segment's LOCAL fill:
|
||||
// local = clamp((progress - start) / w, 0, 1)
|
||||
Item {
|
||||
id: fillClip
|
||||
x: 0
|
||||
y: 0
|
||||
height: segBase.height
|
||||
clip: true
|
||||
// Lite fill (pref): the active line is fully lit and
|
||||
// STATIC — no per-frame clip animation, so the surface
|
||||
// stops repainting while a line is held.
|
||||
width: row.liteFill
|
||||
? segBase.width
|
||||
: segBase.width * Math.max(0, Math.min(1,
|
||||
seg.modelData.w > 0
|
||||
? ((row.sync ? row.sync.lineProgress : 0) - seg.modelData.start) / seg.modelData.w
|
||||
: 0))
|
||||
Behavior on width {
|
||||
enabled: !row.liteFill
|
||||
NumberAnimation {
|
||||
duration: row.sync ? row.sync.fillAnimMs : 0
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
Text {
|
||||
x: 0
|
||||
y: 0
|
||||
width: segBase.width
|
||||
text: seg.modelData.text
|
||||
wrapMode: Text.NoWrap
|
||||
color: row.activeColor
|
||||
font.family: row.effFamily
|
||||
font.pixelSize: row.sizeActive
|
||||
font.weight: theme.weightBold
|
||||
font.letterSpacing: 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TRANSLATION ----------------------------------------------------
|
||||
Text {
|
||||
visible: row.showTranslation && row.translationText !== ""
|
||||
width: row.contentWidth
|
||||
text: row.translationText
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: row.lineOpacity()
|
||||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutQuad } }
|
||||
// Slint: text-muted when past, text-secondary @ 0.7 otherwise.
|
||||
color: row.isPast
|
||||
? theme.textMuted
|
||||
: Qt.rgba(theme.textSecondary.r, theme.textSecondary.g,
|
||||
theme.textSecondary.b, 0.7)
|
||||
font.family: row.effFamily
|
||||
font.pixelSize: Math.round((row.isActive ? row.sizeActive : row.sizeInactive) * 0.72)
|
||||
font.weight: theme.weightRegular
|
||||
font.letterSpacing: 0.2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Shared lyrics lines renderer — QML port of
|
||||
// crates/qbz-ui/ui/shell/LyricsLinesView.slint.
|
||||
//
|
||||
// Everything arrives through properties; nothing here references the sidebar
|
||||
// or window geometry, so a future miniplayer / immersive host can mount it
|
||||
// with its own padding, gap and type scale (neither host exists in the Qt
|
||||
// port yet).
|
||||
//
|
||||
// What it owns:
|
||||
// - the scrolling body: top/bottom spacers (so the first/last SYNCED line
|
||||
// can center), one LyricsLineRow per line, the shared scrollbar;
|
||||
// - AUTO-CENTER: on every active-line change the row's LAYOUT-relative
|
||||
// geometry is read one frame later (a 16ms one-shot, the same trick the
|
||||
// Slint view uses for its recenter epoch — the row's font-size flip
|
||||
// resizes it, so measuring in the same pass would read the OLD height)
|
||||
// and the viewport is moved so the row sits in the middle. SMOOTH for
|
||||
// moves of <= 2 lines, INSTANT for bigger jumps and the initial sync,
|
||||
// 1:1 with Slint. The animation drives `contentY` only on the auto-follow
|
||||
// path, so user wheel input is never animated.
|
||||
// - the four embedded lyric font families (lazy: a FontLoader only gets a
|
||||
// source once its option is selected).
|
||||
//
|
||||
// Unsynced/plain documents render as a static top-aligned list: no spacers,
|
||||
// no active line, no scroll-follow.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: view
|
||||
|
||||
// ---- data ----------------------------------------------------------
|
||||
// Array of { timeMs, endMs, text, translation }.
|
||||
property var lines: []
|
||||
property bool synced: false
|
||||
property bool showTranslation: false
|
||||
// The LyricsSyncEngine instance driving the highlight.
|
||||
property var sync: null
|
||||
readonly property int activeIndex: view.sync ? view.sync.activeIndex : -1
|
||||
|
||||
// ---- layout knobs (a mini/immersive host overrides these) -----------
|
||||
property int sidePadding: 6
|
||||
property int lineGap: 12
|
||||
|
||||
// ---- display prefs --------------------------------------------------
|
||||
property int sizeInactive: 13
|
||||
property int sizeActive: 15
|
||||
property int dimmingMode: 2
|
||||
property bool autoFollow: true
|
||||
property bool uppercase: false
|
||||
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
|
||||
property int fontIndex: 0
|
||||
property color activeColor: theme.accent
|
||||
property bool liteFill: false
|
||||
|
||||
// Seek request from a line click (the host decides whether to honour it).
|
||||
signal seekRequested(int timeMs)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property real contentWidth: Math.max(0, width - 2 * sidePadding)
|
||||
|
||||
// ---- embedded lyric fonts (lazy) -------------------------------------
|
||||
readonly property string fontDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/fonts/"
|
||||
FontLoader { id: fLineSeed; source: view.fontIndex === 1 ? view.fontDir + "LINESeedJP-Regular.ttf" : "" }
|
||||
FontLoader { id: fMontserrat; source: view.fontIndex === 2 ? view.fontDir + "Montserrat-VariableFont_wght.ttf" : "" }
|
||||
FontLoader { id: fNoto; source: view.fontIndex === 3 ? view.fontDir + "NotoSans-VariableFont_wdth,wght.ttf" : "" }
|
||||
FontLoader { id: fSource; source: view.fontIndex === 4 ? view.fontDir + "SourceSans3-VariableFont_wght.ttf" : "" }
|
||||
|
||||
// "" = the window default (Inter) = Slint's "System" option. A family
|
||||
// that has not finished loading falls back to the default rather than
|
||||
// rendering in a wrong face.
|
||||
readonly property string fontFamily: {
|
||||
if (view.fontIndex === 1) return fLineSeed.status === FontLoader.Ready ? fLineSeed.name : ""
|
||||
if (view.fontIndex === 2) return fMontserrat.status === FontLoader.Ready ? fMontserrat.name : ""
|
||||
if (view.fontIndex === 3) return fNoto.status === FontLoader.Ready ? fNoto.name : ""
|
||||
if (view.fontIndex === 4) return fSource.status === FontLoader.Ready ? fSource.name : ""
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- auto-center scroll ----------------------------------------------
|
||||
// Last index centered on; -1 = nothing yet -> the next center is INSTANT.
|
||||
property int lastCentered: -1
|
||||
readonly property real spacerHeight: Math.max(0, flick.height / 2 - sizeActive * 1.5)
|
||||
|
||||
onActiveIndexChanged: {
|
||||
if (view.activeIndex < 0)
|
||||
view.lastCentered = -1
|
||||
centerTimer.restart()
|
||||
}
|
||||
onLinesChanged: {
|
||||
view.lastCentered = -1
|
||||
flick.contentY = 0
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: centerTimer
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: view.centerActive()
|
||||
}
|
||||
|
||||
function centerActive() {
|
||||
if (!view.autoFollow || !view.synced)
|
||||
return
|
||||
var idx = view.activeIndex
|
||||
if (idx < 0)
|
||||
return
|
||||
var item = rows.itemAt(idx)
|
||||
if (!item)
|
||||
return
|
||||
var max = Math.max(0, flick.contentHeight - flick.height)
|
||||
var target = Math.max(0, Math.min(
|
||||
item.y + item.height / 2 - flick.height / 2, max))
|
||||
// Smooth for adjacent moves (<= 2 lines), INSTANT for bigger jumps
|
||||
// and the initial sync.
|
||||
var smooth = view.lastCentered >= 0 && Math.abs(idx - view.lastCentered) <= 2
|
||||
view.lastCentered = idx
|
||||
scrollAnim.stop()
|
||||
if (smooth) {
|
||||
scrollAnim.to = target
|
||||
scrollAnim.start()
|
||||
} else {
|
||||
flick.contentY = target
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: scrollAnim
|
||||
target: flick
|
||||
property: "contentY"
|
||||
duration: 260
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: body.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: body
|
||||
width: flick.width
|
||||
leftPadding: view.sidePadding
|
||||
rightPadding: view.sidePadding
|
||||
spacing: view.lineGap
|
||||
|
||||
// Top spacer — synced docs only (so line 0 can center).
|
||||
Item {
|
||||
width: 1
|
||||
height: view.synced ? view.spacerHeight : 10
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: rows
|
||||
model: view.lines
|
||||
|
||||
// The delegate root is a plain Item so the click target can
|
||||
// anchors.fill it — anchoring a child INSIDE the row's Column
|
||||
// would fight the positioner.
|
||||
delegate: Item {
|
||||
id: rowSlot
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: view.contentWidth
|
||||
height: lineRow.height
|
||||
|
||||
LyricsLineRow {
|
||||
id: lineRow
|
||||
contentWidth: view.contentWidth
|
||||
lineText: rowSlot.modelData.text !== undefined ? rowSlot.modelData.text : ""
|
||||
translationText: rowSlot.modelData.translation !== undefined
|
||||
? rowSlot.modelData.translation : ""
|
||||
lineIndex: rowSlot.index
|
||||
sync: view.sync
|
||||
synced: view.synced
|
||||
activeIndex: view.activeIndex
|
||||
sizeInactive: view.sizeInactive
|
||||
sizeActive: view.sizeActive
|
||||
dimmingMode: view.dimmingMode
|
||||
uppercase: view.uppercase
|
||||
fontFamily: view.fontFamily
|
||||
activeColor: view.activeColor
|
||||
liteFill: view.liteFill
|
||||
showTranslation: view.showTranslation
|
||||
}
|
||||
|
||||
// Click-to-seek on stamped lines. NOT in the Slint
|
||||
// reference (which has neither click-to-seek nor a
|
||||
// user-scroll pause) — kept from the existing Qt panel
|
||||
// rather than removing working behaviour; see the report.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: rowSlot.modelData.timeMs !== null
|
||||
&& rowSlot.modelData.timeMs !== undefined
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: view.seekRequested(rowSlot.modelData.timeMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom spacer — synced docs only (so the last line can center).
|
||||
Item {
|
||||
width: 1
|
||||
height: view.synced ? view.spacerHeight : 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
target: flick
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,23 @@
|
||||
// crates/qbz-ui/ui/shell/LyricsSidebar.slint.
|
||||
//
|
||||
// Header (LYRICS overline + "{title} - {artist}" meta + display-settings
|
||||
// trigger + close X), body by status (loading / no-lyrics / offline-miss
|
||||
// / error / the lines view), source attribution line. Synced rendering:
|
||||
// LINE-level highlight + auto-follow driven by the 1 Hz position feed
|
||||
// (QbzPlayer.npElapsedSecs; the Slint engine ticks ~30 Hz and can fill
|
||||
// word-level karaoke — both noted POC-NOTEs). Lines with timestamps are
|
||||
// click-to-seek (LyricsLinesView behavior).
|
||||
// trigger + close X), body by status (loading / no-lyrics / offline-miss /
|
||||
// error / the shared lines view), the floating translate toggle over the
|
||||
// body's bottom-right corner, and the source-attribution footer.
|
||||
//
|
||||
// POC-NOTEs: display settings flyout (size/dimming/uppercase/font/active
|
||||
// color — Slint defaults hardcoded here: 13/15px, accent, auto-follow
|
||||
// ON, no dimming of surrounding lines), the translate toggle, word-level
|
||||
// karaoke, the LRCLIB contribute link is rendered but opens via the
|
||||
// system browser like upstream.
|
||||
// The sync is NOT the 1 Hz playback poll any more: LyricsSyncEngine ticks at
|
||||
// ~30Hz off the player's millisecond position and pulls the active line +
|
||||
// the karaoke fraction from Rust (word-anchored on Qobuz wsync documents).
|
||||
// The 280px-adapted type scale from the reference is applied here (S 11/13 ·
|
||||
// M 13/15 · L 16/19 · XL 20/24) and handed to the shared lines view together
|
||||
// with the persisted display prefs.
|
||||
//
|
||||
// The Qt port has no toast host, so translation failures and the copy
|
||||
// confirmation surface as an inline notice strip instead of vanishing.
|
||||
//
|
||||
// NOT hosted here (no such surface exists in the Qt port yet): the immersive
|
||||
// lyrics-focus panel and the miniplayer lyrics surface. LyricsLinesView and
|
||||
// LyricsSyncEngine are written host-agnostic so both can mount them later.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -25,33 +30,38 @@ Rectangle {
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
readonly property var doc: JSON.parse(QbzShell.lyricsJson)
|
||||
readonly property var lines: doc.lines || []
|
||||
readonly property bool synced: doc.synced === true
|
||||
|
||||
// Active line (line-level sync, 1 Hz cadence).
|
||||
readonly property int activeIndex: {
|
||||
if (!synced) return -1
|
||||
var nowMs = (QbzPlayer.npElapsedSecs || 0) * 1000
|
||||
var idx = -1
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].timeMs !== null && lines[i].timeMs <= nowMs) idx = i
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
|
||||
onActiveIndexChanged: followActive()
|
||||
function followActive() {
|
||||
if (root.activeIndex < 0 || linesColumn.children.length === 0) return
|
||||
var row = linesColumn.children[Math.min(root.activeIndex, linesColumn.children.length - 1)]
|
||||
var target = row.y + row.height / 2 - linesFlick.height / 2
|
||||
target = Math.max(0, Math.min(target, linesFlick.contentHeight - linesFlick.height))
|
||||
if (Math.abs(linesFlick.contentY - target) > 30) {
|
||||
followAnim2.to = target
|
||||
followAnim2.restart()
|
||||
// The document Rust publishes: {status, synced, provider, error, lines}.
|
||||
readonly property var doc: JSON.parse(QbzLyrics.docJson)
|
||||
readonly property var lines: doc.lines !== undefined && doc.lines !== null ? doc.lines : []
|
||||
readonly property bool synced: doc.synced === true
|
||||
readonly property bool ready: doc.status === 2 && root.lines.length > 0
|
||||
|
||||
// 280px-adapted type scale (LyricsSidebar.slint size matrix).
|
||||
readonly property int sizeInactive: QbzLyrics.sizeIndex === 0 ? 11
|
||||
: QbzLyrics.sizeIndex === 2 ? 16
|
||||
: QbzLyrics.sizeIndex === 3 ? 20 : 13
|
||||
readonly property int sizeActive: QbzLyrics.sizeIndex === 0 ? 13
|
||||
: QbzLyrics.sizeIndex === 2 ? 19
|
||||
: QbzLyrics.sizeIndex === 3 ? 24 : 15
|
||||
|
||||
Component.onCompleted: QbzLyrics.boot()
|
||||
|
||||
// ---- sync engine -------------------------------------------------
|
||||
LyricsSyncEngine {
|
||||
id: syncEngine
|
||||
// Tick only while the panel is on screen and the doc can be followed.
|
||||
gateOpen: root.visible && root.ready && root.synced
|
||||
}
|
||||
|
||||
// Every doc commit resets the ladder and re-lands on the right line
|
||||
// (the Slint `kick()` on commit / panel open).
|
||||
Connections {
|
||||
target: QbzLyrics
|
||||
function onDocJsonChanged() {
|
||||
syncEngine.reset()
|
||||
syncEngine.kick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +89,9 @@ Rectangle {
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
Text {
|
||||
visible: QbzPlayer.npTitle !== ""
|
||||
visible: QbzLyrics.trackTitle !== ""
|
||||
width: parent.width
|
||||
text: QbzPlayer.npTitle + " - " + QbzPlayer.npArtist
|
||||
text: QbzLyrics.trackTitle + " - " + QbzLyrics.trackArtist
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
@@ -93,8 +103,9 @@ Rectangle {
|
||||
anchors.rightMargin: theme.spacingSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
// Display settings (IconButton) — opens the flyout stub.
|
||||
// Display settings — opens the controls flyout.
|
||||
Rectangle {
|
||||
id: dsButton
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
@@ -111,7 +122,10 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: dsFlyout.open()
|
||||
onClicked: dsFlyout.opened ? dsFlyout.close() : dsFlyout.open()
|
||||
ToolTip.visible: containsMouse && !dsFlyout.opened
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: QbzSession.tr("Display settings", QbzSession.trRev)
|
||||
}
|
||||
}
|
||||
// Close X (Slint-convention, homologated with Queue).
|
||||
@@ -145,12 +159,13 @@ Rectangle {
|
||||
|
||||
// --- Body by status ----------------------------------------------
|
||||
Item {
|
||||
id: body
|
||||
width: parent.width
|
||||
height: parent.height - 57 - sourceLine.height
|
||||
height: parent.height - 57 - sourceLine.height - noticeStrip.height
|
||||
|
||||
// Loading.
|
||||
Column {
|
||||
visible: doc.status === 1
|
||||
visible: root.doc.status === 1
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
|
||||
@@ -164,7 +179,8 @@ Rectangle {
|
||||
|
||||
// No lyrics (idle / not-found / ready-with-zero-lines).
|
||||
Column {
|
||||
visible: doc.status === 0 || doc.status === 3 || (doc.status === 2 && root.lines.length === 0)
|
||||
visible: root.doc.status === 0 || root.doc.status === 3
|
||||
|| (root.doc.status === 2 && root.lines.length === 0)
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
spacing: 12
|
||||
@@ -197,7 +213,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Qt.openUrlExternally("https://lrclibup.boidu.dev/")
|
||||
onClicked: QbzShell.openExternalUrl("https://lrclibup.boidu.dev/")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +221,7 @@ Rectangle {
|
||||
|
||||
// Offline with nothing cached.
|
||||
Text {
|
||||
visible: doc.status === 5
|
||||
visible: root.doc.status === 5
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
text: QbzSession.tr("Lyrics unavailable offline", QbzSession.trRev)
|
||||
@@ -217,79 +233,122 @@ Rectangle {
|
||||
|
||||
// Error.
|
||||
Text {
|
||||
visible: doc.status === 4
|
||||
visible: root.doc.status === 4
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
text: doc.error || ""
|
||||
color: "#e0564f"
|
||||
text: root.doc.error !== undefined ? root.doc.error : ""
|
||||
color: theme.danger
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Lyrics lines (status 2, lines > 0).
|
||||
Flickable {
|
||||
id: linesFlick
|
||||
visible: doc.status === 2 && root.lines.length > 0
|
||||
// The lines (status 2, lines > 0).
|
||||
LyricsLinesView {
|
||||
id: linesView
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: linesColumn.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
visible: root.ready
|
||||
lines: root.lines
|
||||
synced: root.synced
|
||||
showTranslation: QbzLyrics.showTranslation
|
||||
sync: syncEngine
|
||||
sizeInactive: root.sizeInactive
|
||||
sizeActive: root.sizeActive
|
||||
dimmingMode: QbzLyrics.dimmingMode
|
||||
autoFollow: QbzLyrics.autoFollow
|
||||
uppercase: QbzLyrics.uppercase
|
||||
fontIndex: QbzLyrics.fontIndex
|
||||
liteFill: QbzLyrics.liteFill
|
||||
activeColor: QbzLyrics.useCustomColor ? QbzLyrics.customColor : theme.accent
|
||||
onSeekRequested: function (timeMs) {
|
||||
if (QbzPlayer.npDurationSecs > 0)
|
||||
QbzPlayer.seek(timeMs / 1000 / QbzPlayer.npDurationSecs)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: linesColumn
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingMd
|
||||
rightPadding: theme.spacingMd
|
||||
topPadding: theme.spacingMd
|
||||
bottomPadding: 48
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
model: root.lines
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
required property int index
|
||||
property bool isActive: index === root.activeIndex
|
||||
|
||||
width: linesColumn.width
|
||||
text: modelData.text
|
||||
// Synced: active line = accent, 15px; the rest
|
||||
// secondary 13px. Unsynced: uniform secondary.
|
||||
color: !root.synced ? theme.textSecondary
|
||||
: isActive ? theme.accent : theme.textSecondary
|
||||
font.pixelSize: root.synced && isActive ? 15 : 13
|
||||
font.weight: root.synced && isActive ? theme.weightSemibold : theme.weightRegular
|
||||
wrapMode: Text.WordWrap
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: modelData.timeMs !== null
|
||||
cursorShape: modelData.timeMs !== null ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (modelData.timeMs !== null && QbzPlayer.npDurationSecs > 0)
|
||||
QbzPlayer.seek(modelData.timeMs / 1000 / QbzPlayer.npDurationSecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Floating translate toggle — bottom-right overlay of the body,
|
||||
// mounted only while the current doc advertises translations.
|
||||
Rectangle {
|
||||
visible: QbzLyrics.translationAvailable && root.ready
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 4
|
||||
color: trArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
opacity: QbzLyrics.translationBusy ? 0.5 : 1.0
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "translate"
|
||||
width: 20
|
||||
height: 20
|
||||
tintName: QbzLyrics.showTranslation
|
||||
? "accent" : (trArea.containsMouse ? "primary" : "muted")
|
||||
}
|
||||
MouseArea {
|
||||
id: trArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzLyrics.toggleTranslation()
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: QbzLyrics.showTranslation
|
||||
? QbzSession.tr("Hide lyrics translation", QbzSession.trRev)
|
||||
: QbzSession.tr("Show lyrics translation", QbzSession.trRev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
|
||||
NumberAnimation {
|
||||
id: followAnim2
|
||||
target: linesFlick
|
||||
property: "contentY"
|
||||
duration: 250
|
||||
easing.type: Easing.InOutQuad
|
||||
// --- Inline notice (this shell has no toast host) -----------------
|
||||
Rectangle {
|
||||
id: noticeStrip
|
||||
width: parent.width
|
||||
height: QbzLyrics.notice !== "" ? noticeText.implicitHeight + 14 : 0
|
||||
visible: height > 0
|
||||
color: theme.surfaceElevated
|
||||
Text {
|
||||
id: noticeText
|
||||
anchors.left: parent.left
|
||||
anchors.right: dismiss.left
|
||||
anchors.leftMargin: theme.spacingSm
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzLyrics.notice
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
QbzIcon {
|
||||
id: dismiss
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: theme.spacingSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "x"
|
||||
width: 12
|
||||
height: 12
|
||||
tintName: "muted"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzLyrics.clearNotice()
|
||||
}
|
||||
}
|
||||
// Auto-dismiss, toast-style.
|
||||
Timer {
|
||||
interval: 5000
|
||||
repeat: false
|
||||
running: QbzLyrics.notice !== ""
|
||||
onTriggered: QbzLyrics.clearNotice()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Source attribution ------------------------------------------
|
||||
Row {
|
||||
id: sourceLine
|
||||
visible: doc.status === 2 && root.lines.length > 0
|
||||
visible: root.ready
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingSm
|
||||
rightPadding: theme.spacingSm
|
||||
@@ -303,8 +362,8 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
text: doc.provider === "lrclib" ? "LRCLIB"
|
||||
: doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
|
||||
text: root.doc.provider === "lrclib" ? "LRCLIB"
|
||||
: root.doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
|
||||
color: theme.accent
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
@@ -313,26 +372,21 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Display-settings flyout (visual stub — POC-NOTE: the prefs) ----
|
||||
// --- Display-settings flyout -----------------------------------------
|
||||
Popup {
|
||||
id: dsFlyout
|
||||
x: parent.width - 260
|
||||
y: 64
|
||||
// Right edge aligned with the close button's right edge, 8px below
|
||||
// the trigger (the reference's anchor math).
|
||||
x: root.width - theme.spacingSm - 260
|
||||
y: 8 + 32 + 8
|
||||
width: 260
|
||||
padding: 14
|
||||
closePolicy: Popup.CloseOnPressOutside
|
||||
background: Rectangle {
|
||||
color: theme.surfaceCard
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
}
|
||||
contentItem: Text {
|
||||
width: parent ? parent.width : 0
|
||||
text: "QBZ Qt POC — display settings are not wired yet (Slint defaults apply)"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
height: flyoutBody.implicitHeight
|
||||
padding: 0
|
||||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||||
background: null
|
||||
contentItem: LyricsControlsFlyout {
|
||||
id: flyoutBody
|
||||
canCopy: root.ready
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Lyrics sync engine — the QML half of crates/qbz/src/lyrics_sync.rs.
|
||||
//
|
||||
// Slint runs this as a Rust `slint::Timer` that PUSHES active-index /
|
||||
// line-progress into a global. cxx-qt has no UI-thread timer primitive and
|
||||
// pumping 30 cross-thread hops a second would be strictly worse, so here the
|
||||
// CADENCE lives in QML and the MATH lives in Rust: every tick pulls three
|
||||
// scalar invokables off QbzLyrics (position, active index, karaoke fraction —
|
||||
// the same `qbz_lyrics::sync` pure functions, over the same Rust-held parsed
|
||||
// document, so native Qobuz wsync word stamps drive the fill).
|
||||
//
|
||||
// Cadence is ADAPTIVE, 1:1 with the Slint engine:
|
||||
// - ACTIVE 33ms (~30Hz) while the gate is open (surface shown + doc synced
|
||||
// + ready + effectively playing);
|
||||
// - IDLE 250ms otherwise — the tick then only re-checks the gate.
|
||||
// Every tick recomputes from the ABSOLUTE position, which makes seeks
|
||||
// inherently safe: `fillAnimMs` is zeroed across discontinuities (line change
|
||||
// or backward move) so the fill first-paints exactly at the reported
|
||||
// progress, and smoothed (45ms, just above the tick) while continuous.
|
||||
// Writes are equality-guarded — an unchanged tick sets nothing, so an idle
|
||||
// track drives zero repaints.
|
||||
//
|
||||
// `kick()` runs one immediate pass that IGNORES the playing gate (doc commit
|
||||
// / panel open), so the ladder lands on the correct line even while paused.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: engine
|
||||
|
||||
// Host gate: the surface is on screen AND the document is synced+ready.
|
||||
property bool gateOpen: false
|
||||
|
||||
// ---- outputs (bind these; never poll them) ------------------------
|
||||
readonly property int activeIndex: engine._index
|
||||
readonly property real lineProgress: engine._progress
|
||||
readonly property int fillAnimMs: engine._animMs
|
||||
|
||||
// Slint FILL_ANIM_MS — slightly above the tick so a late tick does not
|
||||
// stutter the sweep.
|
||||
readonly property int smoothAnimMs: 45
|
||||
|
||||
property int _index: -1
|
||||
property real _progress: 0.0
|
||||
property int _animMs: 0
|
||||
// Whether the last pass saw playback running (drives the cadence).
|
||||
property bool _fast: false
|
||||
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
Timer {
|
||||
id: tick
|
||||
interval: engine._fast ? 33 : 250
|
||||
repeat: true
|
||||
running: engine.gateOpen
|
||||
onTriggered: engine.pump(true)
|
||||
}
|
||||
|
||||
// Gate opening = panel opened mid-song: land on the right line at once.
|
||||
onGateOpenChanged: {
|
||||
if (gateOpen)
|
||||
engine.kick()
|
||||
else
|
||||
engine._fast = false
|
||||
}
|
||||
|
||||
// Reset to the pre-first-line state — the host calls this on every doc
|
||||
// commit so the view's "initial centering is instant" rule applies again.
|
||||
function reset() {
|
||||
engine._index = -1
|
||||
engine._progress = 0.0
|
||||
engine._animMs = 0
|
||||
engine._fast = false
|
||||
}
|
||||
|
||||
// One immediate pass that bypasses the playing gate.
|
||||
function kick() {
|
||||
engine.pump(false)
|
||||
}
|
||||
|
||||
function pump(requirePlaying) {
|
||||
if (!engine.gateOpen) {
|
||||
engine._fast = false
|
||||
return
|
||||
}
|
||||
var playing = QbzLyrics.playing()
|
||||
if (requirePlaying && !playing) {
|
||||
// Pause freezes the ladder + fill in place (Slint parity: the
|
||||
// rAF gate stops, the position holds).
|
||||
engine._fast = false
|
||||
return
|
||||
}
|
||||
var pos = QbzLyrics.positionMs()
|
||||
var idx = QbzLyrics.activeIndexAt(pos)
|
||||
var frac = QbzLyrics.fillFractionAt(pos, idx)
|
||||
|
||||
var indexChanged = idx !== engine._index
|
||||
var backward = frac < engine._progress - 0.0005
|
||||
// Discontinuity: paint EXACTLY at the reported progress, no smoothing
|
||||
// across it. Written FIRST so the row's Behavior picks up the new
|
||||
// duration before the progress write starts the animation.
|
||||
var anim = (indexChanged || backward) ? 0 : engine.smoothAnimMs
|
||||
if (engine._animMs !== anim)
|
||||
engine._animMs = anim
|
||||
if (indexChanged)
|
||||
engine._index = idx
|
||||
if (indexChanged || Math.abs(frac - engine._progress) >= 0.001)
|
||||
engine._progress = frac
|
||||
|
||||
engine._fast = playing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Window-level history-navigation gestures — the QML port of the #653
|
||||
// handler in crates/qbz-ui/ui/shell/AppShell.slint:244-321.
|
||||
//
|
||||
// Two input paths, ONE nav. Both end in QbzShell.navigateBack /
|
||||
// navigateForward — the same calls the three sacred header buttons make —
|
||||
// and both honour QbzShell.canBack / canForward, so a back gesture with no
|
||||
// history does nothing, silently (no beep, no flash, no half-state).
|
||||
//
|
||||
// 1. Mouse side buttons (Qt.BackButton / Qt.ForwardButton). They must
|
||||
// work anywhere in the window, which is why this is a window-level
|
||||
// layer and not something inside HeaderBar.
|
||||
// 2. Two-finger horizontal touchpad swipe.
|
||||
//
|
||||
// STACKING IS LOAD-BEARING — mount this as the FIRST child of the shell
|
||||
// root so the whole UI paints and hit-tests on top of it (see GLUE NEEDED
|
||||
// in the round report). Qt delivers a wheel event front-to-back through
|
||||
// every item under the cursor until one accepts it
|
||||
// (QQuickDeliveryAgentPrivate::deliverSinglePointEventUntilAccepted), so a
|
||||
// scroll only reaches this bottom layer when nothing above consumed it:
|
||||
//
|
||||
// * a HORIZONTAL carousel Flickable/ListView CAN flick on x, so it
|
||||
// accepts horizontal deltas -> the rail scrolls and no navigation
|
||||
// fires. This is the whole point of the bottom placement;
|
||||
// * a vertical-only page scroller cannot flick on x, and Qt's Flickable
|
||||
// only accepts an axis it can actually move with a non-zero delta on
|
||||
// that axis, so a pure-horizontal delta is left unaccepted and falls
|
||||
// through to here. Slint reaches the same outcome by the same rule
|
||||
// (Flickable::is_allowed_scroll_direction rejects when delta.y == 0
|
||||
// and the viewport does not overflow horizontally).
|
||||
//
|
||||
// The wheel handler ALWAYS leaves the event unaccepted, on every path out,
|
||||
// so no scroll routing anywhere in the shell changes because of this file.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
MouseArea {
|
||||
id: gestures
|
||||
|
||||
// Side buttons ONLY. Every other button is not in acceptedButtons, so
|
||||
// Qt never even targets this layer for them and the UI above keeps its
|
||||
// clicks untouched.
|
||||
acceptedButtons: Qt.BackButton | Qt.ForwardButton
|
||||
// No hover grab and no cursorShape assignment: this layer must stay
|
||||
// invisible to hover states and to the cursor stack above it.
|
||||
hoverEnabled: false
|
||||
// Explicit even though it is the default — without it MouseArea drops
|
||||
// phased (touchpad) wheel events straight to QQuickItem and onWheel
|
||||
// never runs.
|
||||
scrollGestureEnabled: true
|
||||
|
||||
// --- Tunables (Slint parity) -----------------------------------------
|
||||
// 70 logical px accumulated in one gesture — AppShell.slint:297. High
|
||||
// enough that resting fingers or small trackpad noise never trip it,
|
||||
// low enough that a deliberate swipe crosses it in one motion.
|
||||
readonly property real swipeThreshold: 70
|
||||
// Gesture-boundary fallback for platforms that report no scroll phase
|
||||
// (xcb/XI2 smooth scrolling sends Qt.NoScrollPhase) — AppShell.slint:275
|
||||
// uses the same 300 ms idle timer, because Slint's PointerScrollEvent
|
||||
// carries no phase at all.
|
||||
readonly property int swipeIdleMs: 300
|
||||
// Notched-wheel fallback: logical px per wheel notch. Slint's winit
|
||||
// backend converts a line delta with `LineDelta(lx, ly) => (lx * 60.,
|
||||
// ly * 60.)` (i-slint-backend-winit event_loop.rs:397), and one Qt
|
||||
// notch is 120 angle units == one line, so 60 px keeps a horizontal
|
||||
// tilt-wheel behaving exactly like it does in the Slint build (two
|
||||
// notches to cross the 70 px threshold).
|
||||
readonly property real pxPerNotch: 60
|
||||
readonly property int anglePerNotch: 120
|
||||
|
||||
// --- Gesture accumulator ---------------------------------------------
|
||||
// One navigation per gesture. Deltas arrive as wheel events; the
|
||||
// gesture boundary is the scroll phase when the platform provides one,
|
||||
// and otherwise the idle timeout below. A direction flip also starts a
|
||||
// fresh gesture, and `swipeFired` suppresses re-triggers until the
|
||||
// gesture ends.
|
||||
property real swipeAccum: 0
|
||||
property bool swipeFired: false
|
||||
|
||||
Timer {
|
||||
id: swipeIdle
|
||||
interval: gestures.swipeIdleMs
|
||||
repeat: false
|
||||
onTriggered: gestures.resetSwipe()
|
||||
}
|
||||
|
||||
function resetSwipe() {
|
||||
gestures.swipeAccum = 0
|
||||
gestures.swipeFired = false
|
||||
swipeIdle.stop()
|
||||
}
|
||||
|
||||
// --- Mouse side buttons ----------------------------------------------
|
||||
onPressed: function (mouse) {
|
||||
if (mouse.button === Qt.BackButton) {
|
||||
if (QbzShell.canBack)
|
||||
QbzShell.navigateBack()
|
||||
} else if (mouse.button === Qt.ForwardButton) {
|
||||
if (QbzShell.canForward)
|
||||
QbzShell.navigateForward()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Two-finger horizontal swipe --------------------------------------
|
||||
onWheel: function (wheel) {
|
||||
// MouseArea sets the event ACCEPTED the moment an onWheel handler is
|
||||
// connected (QQuickMouseArea::wheelEvent -> we.setAccepted(
|
||||
// isWheelConnected())), so clearing it is mandatory and has to
|
||||
// happen before any early return — otherwise this layer would
|
||||
// swallow every scroll the views above it declined.
|
||||
wheel.accepted = false
|
||||
|
||||
// Qt.ScrollBegin / ScrollEnd / ScrollMomentum are Qt::ScrollPhase,
|
||||
// registered on the QML Qt object via Q_ENUM_NS(ScrollPhase)
|
||||
// (qnamespace.h:1855 in the Qt 6.11 headers on this box).
|
||||
if (wheel.phase === Qt.ScrollBegin) {
|
||||
gestures.resetSwipe()
|
||||
} else if (wheel.phase === Qt.ScrollEnd) {
|
||||
gestures.resetSwipe()
|
||||
return
|
||||
} else if (wheel.phase === Qt.ScrollMomentum) {
|
||||
// Kinetic tail after the fingers lift is not a new gesture, and
|
||||
// it must never fire a second navigation.
|
||||
return
|
||||
}
|
||||
|
||||
// Touchpads report pixel deltas in logical px (the same unit as
|
||||
// Slint's delta-x). A notched wheel reports angle deltas only.
|
||||
var dx = wheel.pixelDelta.x
|
||||
var dy = wheel.pixelDelta.y
|
||||
if (dx === 0 && dy === 0) {
|
||||
dx = wheel.angleDelta.x / gestures.anglePerNotch * gestures.pxPerNotch
|
||||
dy = wheel.angleDelta.y / gestures.anglePerNotch * gestures.pxPerNotch
|
||||
}
|
||||
|
||||
// Only clearly horizontal-dominant deltas count toward the gesture;
|
||||
// vertical scroll jitter is ignored (AppShell.slint:285).
|
||||
if (dx === 0 || Math.abs(dx) <= Math.abs(dy))
|
||||
return
|
||||
|
||||
// A direction flip starts a fresh gesture.
|
||||
if (gestures.swipeAccum !== 0 && (gestures.swipeAccum > 0) !== (dx > 0)) {
|
||||
gestures.swipeAccum = dx
|
||||
gestures.swipeFired = false
|
||||
} else {
|
||||
gestures.swipeAccum += dx
|
||||
}
|
||||
swipeIdle.restart()
|
||||
|
||||
if (gestures.swipeFired || Math.abs(gestures.swipeAccum) < gestures.swipeThreshold)
|
||||
return
|
||||
|
||||
// DIRECTION (macOS/GNOME natural-scrolling convention, identical to
|
||||
// AppShell.slint:298-312): fingers RIGHT = back, fingers LEFT =
|
||||
// forward. With libinput natural scrolling ON (the GNOME default)
|
||||
// fingers right yield a NEGATIVE horizontal delta, hence the signs.
|
||||
// Qt and Slint share this sign convention — both add the delta to
|
||||
// the content/viewport offset — so the mapping ports 1:1.
|
||||
//
|
||||
// The Slint build lets users who run without natural scrolling flip
|
||||
// this with AppearanceState.invert-swipe-navigation. That pref has
|
||||
// no counterpart on QbzShell yet, so this port hard-codes the
|
||||
// pref's DEFAULT (false / not inverted). No settings row is drawn
|
||||
// for it: a toggle with nothing behind it would be a no-op control.
|
||||
if (gestures.swipeAccum < 0) {
|
||||
if (QbzShell.canBack)
|
||||
QbzShell.navigateBack()
|
||||
} else {
|
||||
if (QbzShell.canForward)
|
||||
QbzShell.navigateForward()
|
||||
}
|
||||
gestures.swipeFired = true
|
||||
gestures.swipeAccum = 0
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
@@ -57,7 +58,43 @@ Rectangle {
|
||||
var i
|
||||
for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
|
||||
for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
|
||||
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
||||
if (urls.length > 0) {
|
||||
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
||||
root.artPulse = true
|
||||
artPulseOff.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// --- skeleton pulse (PER-ITEM COVERS ONLY) ---------------------------
|
||||
// The queue is LOCAL state — it never "loads", so this panel has no
|
||||
// list-level skeleton ON PURPOSE: there is no asynchronous fetch to
|
||||
// stand in for. What IS asynchronous is each row's cover, which is why
|
||||
// the placeholders here are per-item and nothing else.
|
||||
// GATING RULE: freeze on NOT VISIBLE — the panel collapsed, or the
|
||||
// window minimized/hidden. NEVER on lost focus (a tiling desktop keeps
|
||||
// windows visible and unfocused).
|
||||
property bool skelPhase: false
|
||||
readonly property bool windowShowing: root.Window.window
|
||||
? (root.Window.window.visibility !== Window.Minimized
|
||||
&& root.Window.window.visibility !== Window.Hidden)
|
||||
: true
|
||||
// A cover that never resolves (no art, or a failed fetch) leaves the map
|
||||
// untouched, so every placeholder is bounded — see QbzSkeleton settleMs.
|
||||
readonly property int artSettleMs: 2500
|
||||
property bool artPulse: false
|
||||
Timer {
|
||||
id: artPulseOff
|
||||
interval: root.artSettleMs
|
||||
onTriggered: root.artPulse = false
|
||||
}
|
||||
Timer {
|
||||
interval: 900
|
||||
repeat: true
|
||||
running: root.artPulse && root.visible && root.windowShowing
|
||||
onTriggered: root.skelPhase = !root.skelPhase
|
||||
}
|
||||
function coverPending(url) {
|
||||
return (url || "") !== "" && (root.coverMap[url] || "") === ""
|
||||
}
|
||||
|
||||
component QueueTab: Text {
|
||||
@@ -132,6 +169,16 @@ Rectangle {
|
||||
source: root.coverMap[row.artUrl] || ""
|
||||
radius: 4
|
||||
}
|
||||
// Per-item: this thumbnail clears when ITS cover lands, so
|
||||
// History fills in progressively rather than all at once.
|
||||
QbzSkeleton {
|
||||
variant: "art"
|
||||
anchors.fill: parent
|
||||
blockRadius: 4
|
||||
visible: root.coverPending(row.artUrl)
|
||||
phase: root.skelPhase
|
||||
settleMs: root.artSettleMs
|
||||
}
|
||||
}
|
||||
|
||||
// Title + artist.
|
||||
@@ -389,6 +436,15 @@ Rectangle {
|
||||
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
|
||||
radius: 4
|
||||
}
|
||||
QbzSkeleton {
|
||||
variant: "art"
|
||||
anchors.fill: parent
|
||||
blockRadius: 4
|
||||
visible: root.currentRow
|
||||
? root.coverPending(root.currentRow.artUrl) : false
|
||||
phase: root.skelPhase
|
||||
settleMs: root.artSettleMs
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 34 - 28 - 2 * 9
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
// (ADR-010 mount-site gating).
|
||||
//
|
||||
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
|
||||
// flyouts are out of scope — rows here navigate (only "home" exists, so
|
||||
// only Discover is live) and carry the SidebarDirectRow active treatment
|
||||
// (surface-hover bg + primary text/icon) for the current section.
|
||||
// POC-NOTE: the playlist/folder tree below the nav is out of scope; the
|
||||
// "PLAYLISTS" header + toolbar render for parity and the Slint empty state
|
||||
// shows ("No playlists yet.").
|
||||
// flyouts are out of scope — rows here navigate straight to their view and
|
||||
// carry the SidebarDirectRow active treatment (surface-hover bg + primary
|
||||
// text/icon) for the current section. Discover / Library / Local Library
|
||||
// have views; My QBZ does NOT and is therefore rendered disabled (a row
|
||||
// that clicks into nothing is a defect, not a stub).
|
||||
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
|
||||
// sort, search, expand/collapse, drag-drop target); folder CREATION,
|
||||
// the Playlist Manager and the importer have no seam and are disabled in
|
||||
// the "..." menu.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -94,12 +97,16 @@ Rectangle {
|
||||
property string name: ""
|
||||
property string label: ""
|
||||
property bool active: root.activeNav === navId
|
||||
// No view behind this section in this port — render it as
|
||||
// unavailable rather than as a live row that clicks into nothing.
|
||||
property bool disabled: false
|
||||
signal clicked()
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 34
|
||||
radius: 6
|
||||
color: (navArea.containsMouse || active) ? theme.surfaceHover : "transparent"
|
||||
opacity: disabled ? 0.45 : 1.0
|
||||
color: (!disabled && (navArea.containsMouse || active)) ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
@@ -135,7 +142,8 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: navArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !navRow.disabled
|
||||
hoverEnabled: !navRow.disabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: navRow.clicked()
|
||||
}
|
||||
@@ -185,12 +193,16 @@ Rectangle {
|
||||
QbzShell.navigateTo("local")
|
||||
}
|
||||
}
|
||||
// GAP: there is no MyQBZ view in this port (AppShell's loader
|
||||
// maps home/library/local/localalbum/album/artist/settings/
|
||||
// search/playlist — no "myqbz"). It used to take the active
|
||||
// treatment on click and go nowhere; it now renders disabled.
|
||||
NavRow {
|
||||
navId: "myqbz"
|
||||
name: "qbz-symbolic"
|
||||
// Slint: MyQbzBrandingState.label, default "My QBZ".
|
||||
label: QbzSession.tr("My QBZ", QbzSession.trRev)
|
||||
onClicked: root.activeNav = "myqbz"
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,9 +411,17 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
// New folder / Manage / Import — visual stubs
|
||||
// (POC-NOTE: folder management + importer are out
|
||||
// of scope).
|
||||
// New folder / Manage playlists / Import — DISABLED.
|
||||
// GAP, not a half-path: this port has no folder
|
||||
// creation seam (`sidebar_qt.rs` exposes only
|
||||
// load/sort/search/toggle-folder — no create/delete),
|
||||
// no Playlist Manager view, and no importer. They used
|
||||
// to render as live-looking rows (hover highlight +
|
||||
// pointing-hand cursor) whose click did nothing at
|
||||
// all; a control that renders and no-ops is a defect,
|
||||
// so they now read as unavailable: dimmed, no hover
|
||||
// treatment, no pointer cursor, no MouseArea. Restore
|
||||
// the interactive form together with the seam.
|
||||
Repeater {
|
||||
model: [
|
||||
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
|
||||
@@ -413,26 +433,21 @@ Rectangle {
|
||||
width: parent ? parent.width : 0
|
||||
height: 30
|
||||
radius: 5
|
||||
color: stubArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
color: "transparent"
|
||||
opacity: 0.4
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
spacing: 8
|
||||
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
||||
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
|
||||
Text {
|
||||
height: parent.height
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: stubArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,10 +593,27 @@ Rectangle {
|
||||
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
|
||||
}
|
||||
}
|
||||
// Name (hidden in mini).
|
||||
// Name (hidden in mini). A Row is a positioner
|
||||
// with no stretch, so this width must reserve
|
||||
// EVERY later cell + its spacing or the excess
|
||||
// is pushed past the row's right edge and the
|
||||
// `clip: true` on the Flickable eats it.
|
||||
// (Sidebar.slint gets this free: its
|
||||
// HorizontalLayout stretches the name cell.)
|
||||
// Reserve: 8 + 24 icon slot always; for a
|
||||
// FOLDER also 8 + 14 chevron, plus 8 + the
|
||||
// count label while it is laid out. The old
|
||||
// flat -22 ignored the count, pushing the
|
||||
// chevron ~15px past the clip — which is why
|
||||
// the caret vanished on exactly the folders
|
||||
// that HAVE children (owner report).
|
||||
Text {
|
||||
visible: !root.mini
|
||||
width: parent.width - 24 - (isFolder ? 22 : 0)
|
||||
width: Math.max(0, parent.width - 32
|
||||
- (isFolder
|
||||
? 22 + (countLabel.visible
|
||||
? countLabel.implicitWidth + 8 : 0)
|
||||
: 0))
|
||||
height: parent.height
|
||||
text: modelData.name
|
||||
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
|
||||
@@ -590,8 +622,12 @@ Rectangle {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// Folder count (hover) + chevron.
|
||||
// Folder count (hover) + chevron. The count is
|
||||
// hover-FADED, not hidden — opacity does not
|
||||
// free layout space, so it must stay in the
|
||||
// name's reserve above at all times.
|
||||
Text {
|
||||
id: countLabel
|
||||
visible: !root.mini && isFolder && modelData.count > 0
|
||||
height: parent.height
|
||||
text: modelData.count
|
||||
|
||||
Reference in new issue
Block a user