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
238 lines
8.9 KiB
QML
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
|
|
}
|
|
}
|