Files
qbz/crates/qbz-qt/qml/shell/LyricsLinesView.qml
T
vicrodh 31da9c57bb fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening:

- Chrome: the back bar is a drag surface and shrank to 44px with one shared
  button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a
  permanent cluster in the bar; the dead Share-logs button left the kiosk
  Settings header.
- Transport bar: the hidden overflow menu is gone; Volume is always visible
  with a vertical sheet, and Desktop mode has its own button.
- Now Playing: one centred transport row (New-bar order); lyrics are centred
  (shared LyricsLinesView gains an opt-in centered, desktop default false).
- Local Library: the media funnel and an Open (ephemeral) entry point reach
  the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the
  desktop LocalArtistsTab is untouched); the Artists drill-down query no
  longer hangs off a stale derived guard.
- Search: the All-tab dashboard shelves render again — they were mounted in a
  Loader Component that read the delegate id under reuse and bound an empty
  model; they are direct children now.
- Album detail: icon actions the height of the quality badge plus play next /
  later / queue, an inline floating quality badge, and zebra rows; both album
  headers show a quality badge.
- Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the
  window controls.

Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the
runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6,
Node gate 5/5, release build 0 warnings.

cc
2026-09-07 12:33:28 -06:00

238 lines
8.9 KiB
QML

// 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 the miniplayer and Immersive hosts mount it with
// their own padding, gap and type scale.
//
// 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 embedded lyric font families (lazy: one FontLoader exists only while
// a non-System 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
/// Opt-in centered lines (kiosk Now Playing). Desktop leaves it false, so
/// its left-aligned lyrics are unchanged. LyricsLineRow already implements
/// the centered layout AND the centered word-sync highlight offset.
property bool centered: 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
// Unsung part of the active karaoke line (see LyricsLineRow.qml).
property color inactiveColor: theme.textSecondary
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 font (lazy) --------------------------------------
readonly property string fontDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/fonts/"
readonly property string fontSource: {
if (view.fontIndex === 1) return view.fontDir + "LINESeedJP-Regular.ttf"
if (view.fontIndex === 2) return view.fontDir + "Montserrat-VariableFont_wght.ttf"
if (view.fontIndex === 3) return view.fontDir + "NotoSans-VariableFont_wdth,wght.ttf"
if (view.fontIndex === 4) return view.fontDir + "SourceSans3-VariableFont_wght.ttf"
return ""
}
Component {
id: fontLoaderComponent
FontLoader { source: view.fontSource }
}
Loader {
id: selectedFontLoader
active: view.fontSource !== ""
sourceComponent: fontLoaderComponent
}
// "" = the real Qt/system default = 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:
selectedFontLoader.item
&& selectedFontLoader.item.status === FontLoader.Ready
? selectedFontLoader.item.name : ""
// ---- 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
inactiveColor: view.inactiveColor
liteFill: view.liteFill
showTranslation: view.showTranslation
centered: view.centered
}
// 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
}
}