feat(qt): expand immersive visualizer experience

cx
This commit is contained in:
vicrodh committed 2026-08-27 19:16:27 -06:00
1 parent 79f91492bb
commit b1e27457e8
16 files changed
+1163 -142

No files matched your search

+5 -4
View File
@@ -292,13 +292,14 @@ void main() {
// turned furthest from both foreground families. Two jobs: it gives the
// arcs and worms something to read against (they were bright objects on
// void, which is what made the disc look like a cut-out), and it fills the
// space the arc gate deliberately leaves open. It is deliberately dim and
// slow — this is atmosphere, not a fourth animated element competing for
// attention.
// space the arc gate deliberately leaves open. The owner wants the Plasma
// field materially darker than the other scenes, so this wash stays around
// half its original injection while the foreground arcs/worms retain their
// full music-reactive range.
vec2 aw = c * 1.6 + vec2(t * 0.045, -t * 0.038);
float amb = vnoise(aw + curl_noise(aw) * 0.35);
float vign = 1.0 - smoothstep(0.30, 0.95, r);
field += ambA * (0.030 + lvl * 0.055) * (0.35 + amb * 0.65) * (0.25 + vign * 0.75);
field += ambA * (0.016 + lvl * 0.030) * (0.35 + amb * 0.65) * (0.25 + vign * 0.75);
// Treble sparkle on the crests — fast brilliance, so it fires with the
// cymbals instead of glowing with the average.
Binary file not shown.
+259 -129
View File
@@ -3,8 +3,8 @@
// width=parent-48 (§5.2 of the 2026-08-02 immersive-port contract).
//
// Three zones:
// LEFT — the view-menu trigger (44x36 glass) + a 256px QbzContextMenu
// popup with the FOCUS and SPLIT groups in SLINT ORDER. Every row
// LEFT — a 122x36 previous | picker | next capsule + a wide three-column
// popup (two FOCUS columns, one SPLIT). Every row
// fires QbzImmersive.setView(...); the data-panel rows also fire
// their §5.5 entry load (Queue / Track Info / Suggestions —
// Lyrics needs nothing: Qt fetches lyrics automatically per
@@ -23,6 +23,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -41,8 +42,39 @@ Item {
readonly property bool trailingTrigger:
QbzShell.isMacos && !QbzShell.systemTitleBar
// Persistent custom-chrome drag strip. Tauri kept a top-level
// data-tauri-drag-region over Immersive, but the Qt port never brought
// that surface across; the full-coverage click blocker therefore left no
// route to start a system move. Declared FIRST so every real control below
// wins hit-testing, matching HeaderBar.qml's proven drag pattern.
MouseArea {
anchors.fill: parent
// Immersive must remain draggable whenever native chrome is absent,
// including the explicit hide-titlebar preference: there is no second
// title strip behind this overlay to rescue that configuration.
enabled: !QbzShell.systemTitleBar
&& header.Window.window !== null
&& header.Window.window.visibility !== Window.FullScreen
acceptedButtons: Qt.LeftButton
property bool dragStarted: false
onPressed: dragStarted = false
onReleased: dragStarted = false
onPositionChanged: {
if (pressed && !dragStarted && header.Window.window) {
dragStarted = true
header.Window.window.startSystemMove()
}
}
onDoubleClicked: {
var w = header.Window.window
if (w)
w.visibility = w.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
// --- LEFT: the view menu ---------------------------------------------
// --- LEFT: previous | view menu | next -------------------------------
Rectangle {
id: viewTrigger
// MACOS ONLY: over on the right, beside the capsule. Everywhere else it
@@ -65,7 +97,7 @@ Item {
// no meaning. Reserving the maximum keeps both still.
x: header.trailingTrigger ? header.width - 112 - 12 - width : 0
anchors.verticalCenter: parent.verticalCenter
width: 44
width: 122
height: 36
// Half the height — same silhouette as the capsule it now sits beside.
radius: height / 2
@@ -73,25 +105,81 @@ Item {
color: "#80000000"
border.width: 1
border.color: "#2effffff"
QbzIcon {
name: "layout-grid"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
clip: true
Rectangle {
width: 38
height: parent.height
color: previousArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "chevron-left"
width: 15
height: 15
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: previousArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.cycle(-1)
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.openBelowLeft(viewTrigger)
Rectangle {
x: 38
width: 46
height: parent.height
color: menuArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "layout-grid"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.openBelowLeft(viewTrigger)
}
}
Rectangle {
x: 84
width: 38
height: parent.height
color: nextArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "chevron-right"
width: 15
height: 15
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: nextArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.cycle(1)
}
}
Rectangle { x: 38; width: 1; height: parent.height; color: "#24ffffff" }
Rectangle { x: 83; width: 1; height: parent.height; color: "#24ffffff" }
}
QbzContextMenu {
id: viewMenu
menuWidth: 256
contentSpacing: 1
// The old single column could run past a short display. The owner
// explicitly chose a wider three-column picker: two balanced FOCUS
// columns and one SPLIT column.
menuWidth: 660
contentSpacing: 0
// §5.2 popup chrome: #0b0b0ef7, radius 16 (Slint RRGGBBAA converted).
background: Rectangle {
color: "#f70b0b0e"
@@ -109,6 +197,82 @@ Item {
// "suggestions" -> QbzSuggestions.load(QbzPlayer.npTrackId) (B4)
// "" -> none (Lyrics rows: Qt fetches lyrics
// automatically per track, §5.5).
readonly property var focusRows: {
var rows = [
{ "vm": 0, "m": 0, "sp": -1, "entry": "",
"label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
{ "vm": 0, "m": 1, "sp": -1, "entry": "",
"label": QbzSession.tr("Static", QbzSession.trRev) },
{ "vm": 0, "m": 2, "sp": -1, "entry": "",
"label": QbzSession.tr("Coverflow", QbzSession.trRev) },
{ "vm": 0, "m": 4, "sp": -1, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 0, "m": 5, "sp": -1, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "vm": 0, "m": 3, "sp": -1, "entry": "",
"label": QbzSession.tr("Spectrum", QbzSession.trRev) },
{ "vm": 0, "m": 6, "sp": -1, "entry": "",
"label": QbzSession.tr("Wave Bed", QbzSession.trRev) },
{ "vm": 0, "m": 9, "sp": -1, "entry": "",
"label": QbzSession.tr("Reactive Rings", QbzSession.trRev) },
]
if (QbzShell.gpuTier) {
rows.push({ "vm": 0, "m": 7, "sp": -1, "entry": "",
"label": QbzSession.tr("Goniometer", QbzSession.trRev) })
rows.push({ "vm": 0, "m": 8, "sp": -1, "entry": "",
"label": QbzSession.tr("Oscilloscope", QbzSession.trRev) })
}
if (QbzShell.shaderScenesAvailable) {
rows.push(
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 1,
"label": QbzSession.tr("Plasma", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 2,
"label": QbzSession.tr("Tunnel", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 3,
"label": QbzSession.tr("Aurora", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 7,
"label": QbzSession.tr("Ambient", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 4,
"label": QbzSession.tr("Spectral Ribbon", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 5,
"label": QbzSession.tr("Line Bed", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 8,
"label": QbzSession.tr("Tunnel Flow", QbzSession.trRev) })
}
return rows
}
readonly property var splitRows: [
{ "vm": 1, "m": -1, "sp": 0, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 1, "entry": "trackinfo",
"label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 2, "entry": "suggestions",
"label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 3, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 4, "entry": "",
"label": QbzSession.tr("Now Playing", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 5, "entry": "",
"label": QbzSession.tr("Now Playing", QbzSession.trRev)
+ " · " + QbzSession.tr("Lyrics", QbzSession.trRev) },
]
readonly property int focusCut: Math.ceil(focusRows.length / 2)
function rowIsActive(row) {
if (row.sc !== undefined && row.sc >= 0)
return QbzImmersive.viewMode === 0
&& QbzShaderScene.scene === row.sc
if (row.vm === 0)
return QbzImmersive.viewMode === 0
&& QbzImmersive.mode === row.m
&& QbzShaderScene.scene === 0
return QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === row.sp
&& QbzShaderScene.scene === 0
}
function fireRow(row) {
if (row.sc !== undefined && row.sc >= 0) {
// Shader scene row: FOCUS view + scene N (Slint sets
@@ -136,6 +300,25 @@ Item {
viewMenu.close()
}
// The columns are presentation only. Arrow navigation is ONE circular
// sequence in menu order: every FOCUS panel/scene, then every SPLIT
// panel, crossing the family boundary in both directions.
function cycle(direction) {
var rows = focusRows.concat(splitRows)
if (rows.length === 0)
return
var current = -1
for (var i = 0; i < rows.length; ++i) {
if (rowIsActive(rows[i])) {
current = i
break
}
}
var next = current < 0 ? (direction < 0 ? rows.length - 1 : 0)
: (current + direction + rows.length) % rows.length
fireRow(rows[next])
}
Component {
id: menuRow
Rectangle {
@@ -143,16 +326,7 @@ Item {
// A1: shader rows (sc >= 0) are active when their scene is
// on; FOCUS panel rows additionally require the scene OFF
// (Slint: `active: is-focus && sh == 0 && md == N`).
readonly property bool active: {
if (modelData.sc !== undefined && modelData.sc >= 0)
return QbzShaderScene.scene === modelData.sc
if (modelData.vm === 0)
return QbzImmersive.viewMode === 0
&& QbzImmersive.mode === modelData.m
&& QbzShaderScene.scene === 0
return QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === modelData.sp
}
readonly property bool active: viewMenu.rowIsActive(modelData)
width: parent ? parent.width : 0
height: 33
radius: 8
@@ -193,111 +367,67 @@ Item {
}
}
// Group headers: 11px / 700 / #ffffff80 / letter-spacing 1.5 /
// ALL-CAPS (:481-487). The msgids are already caps.
Text {
// Group headers: FOCUS spans its two balanced columns; SPLIT owns the
// third. The row below determines the popup's height from its tallest
// column, so the picker never needs its own scroll area.
Row {
width: viewMenu.availableWidth
height: 26
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("FOCUS", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Repeater {
// FOCUS owner order: the two reading surfaces sit directly below
// Coverflow; the reactive visualizers follow them. The two native
// scopes are appended only on the GPU tier: Software can draw
// their QML axes but not the scene-graph trace.
model: {
var rows = [
{ "vm": 0, "m": 0, "sp": -1, "entry": "",
"label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
{ "vm": 0, "m": 1, "sp": -1, "entry": "",
"label": QbzSession.tr("Static", QbzSession.trRev) },
{ "vm": 0, "m": 2, "sp": -1, "entry": "",
"label": QbzSession.tr("Coverflow", QbzSession.trRev) },
{ "vm": 0, "m": 4, "sp": -1, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 0, "m": 5, "sp": -1, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "vm": 0, "m": 3, "sp": -1, "entry": "",
"label": QbzSession.tr("Spectrum", QbzSession.trRev) },
{ "vm": 0, "m": 6, "sp": -1, "entry": "",
"label": QbzSession.tr("Wave Bed", QbzSession.trRev) },
]
if (QbzShell.gpuTier) {
rows.push({ "vm": 0, "m": 7, "sp": -1, "entry": "",
"label": QbzSession.tr("Goniometer", QbzSession.trRev) })
rows.push({ "vm": 0, "m": 8, "sp": -1, "entry": "",
"label": QbzSession.tr("Oscilloscope", QbzSession.trRev) })
}
return rows
spacing: 8
Text {
width: (parent.width - 16) / 3 * 2 + 8
height: parent.height
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("FOCUS", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Text {
width: (parent.width - 16) / 3
height: parent.height
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("SPLIT", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
delegate: menuRow
}
Repeater {
// A1 shader rows (:654-711), inside the FOCUS group like Slint
// (no separate header there). SHIPPED scenes only — the Slint
// order is Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon,
// Line Bed: Plasma shipped with A2, the Ribbon with A3; Liquid
// Spectrum is parked in Slint itself, so its row DOES NOT
// appear (00-CONTRACT ruling 1); Line Bed shipped with A4 and
// appears LAST here, the shipped-set tail.
// There is NO "Off" row (owner ruling 2026-08-15: Slint has
// none — a panel row already turns the scene off, and `g`
// cycles back to 0). The whole group hides on tiers that
// cannot run the shaders (the Slint `if shader-scenes-available`
// gates) — QbzShell.shaderScenesAvailable is the single source
// of truth, seeded from the Rust renderer probe. The six A-block
// labels are EXISTING qbz-ui msgids; B1's "Tunnel Flow" is the
// one NEW msgid (added to all 8 locales).
model: QbzShell.shaderScenesAvailable ? [
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 1,
"label": QbzSession.tr("Plasma", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 2,
"label": QbzSession.tr("Tunnel", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 3,
"label": QbzSession.tr("Aurora", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 7,
"label": QbzSession.tr("Ambient", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 4,
"label": QbzSession.tr("Spectral Ribbon", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 5,
"label": QbzSession.tr("Line Bed", QbzSession.trRev) },
// B1: Tunnel Flow (sc 8) — the QT-ONLY scene (spec 02),
// after Line Bed. Menu-only: NOT in the `g` ring (spec 02
// §5). NEW msgid (added to all 8 locales).
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 8,
"label": QbzSession.tr("Tunnel Flow", QbzSession.trRev) },
] : []
delegate: menuRow
}
Text {
height: 26
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("SPLIT", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Repeater {
// SPLIT rows (:741-780): Lyrics(0), Track Info(1),
// Suggestions(2), Queue(3).
model: [
{ "vm": 1, "m": -1, "sp": 0, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 1, "entry": "trackinfo",
"label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 2, "entry": "suggestions",
"label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 3, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: menuRow
Row {
id: menuColumns
width: viewMenu.availableWidth
spacing: 8
readonly property real columnWidth: (width - spacing * 2) / 3
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.focusRows.slice(0, viewMenu.focusCut)
delegate: menuRow
}
}
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.focusRows.slice(viewMenu.focusCut)
delegate: menuRow
}
}
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.splitRows
delegate: menuRow
}
}
}
}
+36 -5
View File
@@ -145,7 +145,8 @@ Item {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& !(QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 7 || QbzImmersive.mode === 8))
&& (QbzImmersive.mode === 7 || QbzImmersive.mode === 8
|| QbzImmersive.mode === 9))
source: QbzImmersive.atmosphereUrl
fallbackSource: QbzPlayer.npArtworkPath
animated: visible && QbzPlayer.npPlaying && QbzImmersive.open
@@ -303,6 +304,11 @@ Item {
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
}
ReactiveRingsPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 9
}
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
@@ -383,8 +389,9 @@ Item {
}
// --- The shared immersive lyrics engine + document (§6.6, trap 22) ------
// ONE LyricsSyncEngine for BOTH immersive lyric surfaces (FOCUS mode 4
// AND SPLIT panel 0); the gate is the §6.6 formula verbatim
// ONE LyricsSyncEngine for all immersive lyric surfaces (FOCUS mode 4,
// legacy SPLIT panel 0 and cinematic SPLIT panel 5); the gate extends
// the §6.6 formula
// (lyrics_sync.rs:214-217 parity). The QbzLyrics doc fields are parsed
// once here and handed to both mounts.
readonly property var immLyricsDoc: {
@@ -405,7 +412,9 @@ Item {
gateOpen: QbzImmersive.open
&& QbzShaderScene.scene === 0 // A1: the scenes replace the panels
&& ((QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4)
|| (QbzImmersive.viewMode === 1 && QbzImmersive.splitPanel === 0))
|| (QbzImmersive.viewMode === 1
&& (QbzImmersive.splitPanel === 0
|| QbzImmersive.splitPanel === 5)))
}
// Every doc commit resets the ladder and re-lands on the right line (the
// LyricsPanel.qml:60-66 idiom — the Slint kick() on commit/panel open).
@@ -430,6 +439,7 @@ Item {
anchors.fill: parent
// A1: a shader scene replaces the SPLIT layout too (:1416 parity).
visible: QbzShaderScene.scene === 0 && QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel >= 0 && QbzImmersive.splitPanel <= 3
// D2: re-request the large art when the SPLIT layout becomes visible
// (leftCol's own request is gated on this visibility).
onVisibleChanged: if (visible) leftCol.requestArtSize()
@@ -637,6 +647,26 @@ Item {
}
}
// The two cinematic SPLIT variants replace the legacy 50/50 composition
// with the supplied full-viewport artwork stage. One shared component
// guarantees that the background/card/waveform stay pixel-identical; only
// the card's right-hand content changes between metadata+seek and lyrics.
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 1
&& (QbzImmersive.splitPanel === 4
|| QbzImmersive.splitPanel === 5)
visible: active
sourceComponent: ReactiveSplitPanel {
lyricsMode: QbzImmersive.splitPanel === 5
lines: root.immLyricsLines
synced: root.immLyricsSynced
status: root.immLyricsStatus
sync: immLyricsEngine
}
}
// --- Layer 4: ImmersiveSongCard (§5.1, :1602-1605) ----------------------
// Keep the compact bottom-right metadata over shader backgrounds,
// Wave Bed, full-screen Lyrics and the two scope views. The lyrics surface
@@ -654,7 +684,8 @@ Item {
visible: (QbzShaderScene.scene > 0
|| (QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 4 || QbzImmersive.mode === 6
|| QbzImmersive.mode === 7 || QbzImmersive.mode === 8)))
|| QbzImmersive.mode === 7 || QbzImmersive.mode === 8
|| QbzImmersive.mode === 9)))
&& QbzPlayer.npHasTrack
}
@@ -0,0 +1,304 @@
// ReactiveRingsPanel — the fused Qt port of Tauri's EnergyBandsPanel and
// TransientPulsePanel. Five persistent concentric rings breathe from the five
// semantic energy bands; detected onsets launch a bounded pool of expanding
// pulse rings. Both halves use the artwork palette already published by the
// Qt shell, so no image sampling or audio-backend work is duplicated here.
//
// THE PULSE LAW: QbzShaderScene.packJson is parsed and STASHED on publish.
// State is applied only on QbzShell.pulseMs, in the same event-loop turn as
// every other visualizer. There is no private Timer, animation driver or
// data-fed Behavior. The pool is static in the scene graph (12 delegates),
// and it parks as soon as its last pulse ring fades.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
Rectangle {
id: root
color: "#000000"
clip: true
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
readonly property bool active: root.visible && QbzImmersive.open
&& root.windowShowing
readonly property real minDim: Math.min(root.width, root.height)
// Applied audio state. The upstream processor has already smoothed these
// five semantic bands; applying the freshest pack directly keeps this
// panel intentionally quicker than the 90ms bar visualizers.
property var bandEnergy: [0, 0, 0, 0, 0]
property real globalEnergy: 0
property real colorPhase: 0
property real previousBass: 0
property real previousHit: 0
property int hitCooldown: 0
// Expanding ring records use radius/speed as fractions of minDim, so the
// same pulse reads identically on a laptop and a 4K television.
property var pulseRings: []
property int nextColor: 0
readonly property int maxPulseRings: 12
// The publish-edge stash. Nothing binds it; writing it alone cannot dirty
// the scene. It is consumed on the shared pulse below.
property var pendingPack: null
// Force the QString bridge values through QML's color type before the JS
// interpolation helper reads r/g/b channels.
readonly property color primaryColor: QbzShell.ambientPrimary
readonly property color accentColor: QbzShell.ambientAccent
readonly property color secondaryColor: QbzShell.ambientSecondary
readonly property var palette: [
root.primaryColor,
root.accentColor,
root.secondaryColor,
Qt.lighter(root.primaryColor, 1.32),
Qt.lighter(root.accentColor, 1.24),
Qt.lighter(root.secondaryColor, 1.30)
]
readonly property var bandNames: ["SUB", "BASS", "MID", "PRES", "AIR"]
function paletteColor(position, alpha) {
var count = root.palette.length
var wrapped = position % count
if (wrapped < 0)
wrapped += count
var lo = Math.floor(wrapped)
var hi = (lo + 1) % count
var mix = wrapped - lo
var a = Qt.lighter(root.palette[lo], 1.0)
var b = Qt.lighter(root.palette[hi], 1.0)
return Qt.rgba(a.r + (b.r - a.r) * mix,
a.g + (b.g - a.g) * mix,
a.b + (b.b - a.b) * mix,
Math.max(0, Math.min(1, alpha)))
}
function stashCurrentPack() {
try {
root.pendingPack = JSON.parse(QbzShaderScene.packJson)
} catch (e) {
// A malformed pack keeps the last good visual state.
}
}
onVisibleChanged: if (visible) stashCurrentPack()
Component.onCompleted: stashCurrentPack()
Connections {
target: QbzShaderScene
function onPackJsonChanged() { root.stashCurrentPack() }
}
Connections {
target: QbzPlayer
function onNpTrackIdChanged() {
// A new palette starts from a clean pulse history; otherwise the
// previous song's final bass edge can manufacture a first-frame hit.
root.previousBass = 0
root.previousHit = 0
root.hitCooldown = 0
root.pulseRings = []
}
}
Connections {
target: QbzShell
function onPulseMsChanged() {
if (!root.active)
return
var pack = root.pendingPack
var spawn = false
var intensity = 0
if (pack !== null) {
root.pendingPack = null
var lo = pack.energyLo || [0, 0, 0, 0]
var hi = pack.energyHi || [0, 0, 0, 0]
var energy = [lo[0] || 0, lo[1] || 0, lo[2] || 0,
lo[3] || 0, hi[0] || 0]
root.bandEnergy = energy
root.globalEnergy = (energy[0] + energy[1] + energy[2]
+ energy[3] + energy[4]) / 5
// Tauri's supplementary bass-delta detector, strengthened by
// the host-side AC-coupled onset. A two-frame cooldown still
// resolves dense drums while preventing one envelope from
// launching a ring on every decay sample.
var bass = (energy[0] * 2.0 + energy[1] * 1.5) / 3.5
var bassDelta = bass - root.previousBass
root.previousBass = bass
var beatAc = pack.beatAc || 0
var transientAmp = pack.transient || 0
var hit = Math.max(beatAc, transientAmp * 0.78)
if (root.hitCooldown > 0)
root.hitCooldown--
if (root.hitCooldown <= 0
&& (bassDelta > 0.045
|| (hit > 0.16 && hit - root.previousHit > 0.035))) {
spawn = true
intensity = Math.min(1, Math.max(bassDelta * 4.2, hit))
root.hitCooldown = 2
}
root.previousHit = hit
// Move colour only while real audio is moving. A silent or
// paused panel therefore converges to zero writes/presents.
if (QbzPlayer.npPlaying && root.globalEnergy > 0.006)
root.colorPhase = (root.colorPhase + 0.008
+ root.globalEnergy * 0.035 + beatAc * 0.055) % 6
}
var old = root.pulseRings
var next = []
for (var i = 0; i < old.length; ++i) {
var ring = old[i]
var radius = ring.radius + ring.speed
var alpha = ring.alpha * 0.94
if (alpha >= 0.012 && radius <= ring.maxRadius * 1.35) {
next.push({
"radius": radius,
"maxRadius": ring.maxRadius,
"alpha": alpha,
"lineWidth": ring.lineWidth,
"speed": ring.speed,
"color": ring.color
})
}
}
if (spawn && QbzPlayer.npPlaying) {
next.push({
"radius": 0.11,
"maxRadius": 0.34 + intensity * 0.23,
"alpha": 0.58 + intensity * 0.40,
"lineWidth": 2.0 + intensity * 4.5,
"speed": 0.006 + intensity * 0.013,
"color": root.nextColor
})
root.nextColor = (root.nextColor + 1) % root.palette.length
if (next.length > root.maxPulseRings)
next.shift()
}
if (next.length > 0 || old.length > 0 || spawn)
root.pulseRings = next
}
}
// A cheap radial glow made from four persistent discs. It retains the
// centre bloom of both Tauri panels without a full-screen blur pass.
Repeater {
model: 4
delegate: Rectangle {
required property int index
readonly property real energy: root.globalEnergy
readonly property real discSize: root.minDim
* (0.10 + (3 - index) * 0.047 + energy * 0.035)
width: discSize
height: discSize
x: (root.width - width) / 2
y: (root.height - height) / 2
radius: width / 2
color: root.paletteColor(index * 0.7 + root.colorPhase,
(0.018 + energy * 0.045) * (index + 1))
}
}
// The transient half: a static 12-delegate pool, each record expanding
// outward and fading on the shared pulse.
Repeater {
model: root.maxPulseRings
delegate: Item {
required property int index
readonly property bool populated: index < root.pulseRings.length
readonly property var ring: populated ? root.pulseRings[index] : null
readonly property real diameter: populated
? ring.radius * root.minDim * 2 : 0
visible: populated
x: (root.width - diameter) / 2
y: (root.height - diameter) / 2
width: diameter
height: diameter
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: width / 2
color: "transparent"
border.width: parent.ring ? parent.ring.lineWidth + 6 : 0
border.color: parent.ring
? root.paletteColor(parent.ring.color + root.colorPhase,
parent.ring.alpha * 0.20) : "transparent"
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
border.width: parent.ring ? Math.max(1, parent.ring.lineWidth) : 0
border.color: parent.ring
? root.paletteColor(parent.ring.color + root.colorPhase,
parent.ring.alpha) : "transparent"
}
}
}
// The energy-band half: five concentric rings, ordered outer (sub) to
// inner (air), each with a low-cost halo arc and a crisp core arc.
Repeater {
model: 5
delegate: Item {
required property int index
readonly property real energy: root.bandEnergy[index] || 0
readonly property real radiusF: 0.115 + (4 - index) * 0.066
+ energy * 0.058
readonly property real diameter: radiusF * root.minDim * 2
x: (root.width - diameter) / 2
y: (root.height - diameter) / 2
width: diameter
height: diameter
Rectangle {
anchors.fill: parent
anchors.margins: -(3 + parent.energy * 5)
radius: width / 2
color: "transparent"
border.width: 7 + parent.energy * 9
border.color: root.paletteColor(index + root.colorPhase,
0.055 + parent.energy * 0.18)
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
border.width: 2 + parent.energy * 4
border.color: root.paletteColor(index + root.colorPhase,
0.20 + parent.energy * 0.72)
}
}
}
Row {
visible: root.height >= 560
anchors.horizontalCenter: parent.horizontalCenter
y: root.height / 2 + root.minDim * 0.41
Repeater {
model: 5
delegate: Text {
required property int index
width: 46
text: root.bandNames[index]
horizontalAlignment: Text.AlignHCenter
color: root.paletteColor(index + root.colorPhase,
0.32 + (root.bandEnergy[index] || 0) * 0.55)
font.pixelSize: 10
font.family: "monospace"
font.letterSpacing: 0.8
}
}
}
}
@@ -0,0 +1,527 @@
// ReactiveSplitPanel — the two cinematic SPLIT variants requested from the
// 2026-08-27 reference frame + annotated owner revision. Both share the live
// Immersive ambient stage, one large album-blurred card and an upward WaveBed.
// The standard variant keeps title, artist and an always-present SECOND seek
// bar inside the card; lyricsMode
// replaces that entire metadata/seek block with the current lyric, one line at
// a time. The ordinary ImmersivePlayerBar remains an independent layer.
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../shell"
import "../theme"
Item {
id: root
property bool lyricsMode: false
property var lines: []
property bool synced: false
property int status: 0
property var sync: null
readonly property int activeIndex: root.sync ? root.sync.activeIndex : -1
readonly property string currentLine:
(root.activeIndex >= 0 && root.activeIndex < root.lines.length
&& root.lines[root.activeIndex].text !== undefined)
? root.lines[root.activeIndex].text : ""
readonly property bool waiting: root.synced && root.status === 2
&& root.lines.length > 0 && root.currentLine === ""
readonly property bool noLyrics: root.status === 3
|| (root.status === 2 && root.lines.length === 0)
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
QbzTheme { id: theme }
// Geometry from the owner's annotated 1280x720 composition. `stageTop` is
// below the header; `playerTop` is the fixed y of ImmersivePlayerBar. The
// card consumes 70% of that useful height and 85% of the viewport width.
// Portrait/narrow windows cap height against width so the text column can
// never be eaten by the square artwork.
readonly property real stageTop: Math.min(72,
Math.max(56, root.height * 0.09))
readonly property real playerTop: Math.max(root.stageTop + 160,
root.height - 114)
readonly property real stageHeight: Math.max(160,
root.playerTop - root.stageTop)
readonly property real cardWidth: Math.max(1,
Math.min(root.width - 32, root.width * 0.85))
readonly property real cardHeight: Math.max(120,
Math.min(root.stageHeight * 0.70, root.cardWidth * 0.56))
readonly property real cardTop: root.stageTop
+ Math.max(0, root.stageHeight - root.cardHeight) * 0.55
// Same 16px curve as the NPB, shared by the outer card and artwork.
readonly property real cardRadius: 16
readonly property real cardArtSize: Math.min(root.cardHeight * 0.94,
root.cardWidth * 0.42)
readonly property real waveBottom: root.playerTop - 8
// Consume 80% of the REAL card-to-NPB slot. The remaining 20% is the air
// above the WaveBed; the 8px below is its clearance from the player bar.
readonly property real waveRoom: Math.max(2, root.waveBottom
- (root.cardTop + root.cardHeight))
readonly property real waveHeight: Math.max(2,
root.waveRoom * 0.80)
readonly property int waveBars: 48
// The card's blurred cover is deliberately oversized before it is
// sampled. No cover edge therefore coincides with the glass-card edge.
readonly property real cardBackdropOverscan: Math.max(36,
Math.min(root.cardWidth, root.cardHeight) * 0.12)
// Exact complementary hue to the ambient primary, lifted just enough to
// stay legible over a dark atmosphere. Unlike FOCUS Wave Bed this is not
// mirrored: every column grows upward from one baseline.
readonly property color ambientPrimary: QbzShell.ambientPrimary
readonly property color waveColor: Qt.lighter(Qt.rgba(
1.0 - root.ambientPrimary.r,
1.0 - root.ambientPrimary.g,
1.0 - root.ambientPrimary.b, 1.0), 1.18)
readonly property string fontDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/fonts/"
FontLoader { id: fLineSeed; source: QbzLyrics.fontIndex === 1 ? root.fontDir + "LINESeedJP-Regular.ttf" : "" }
FontLoader { id: fMontserrat; source: QbzLyrics.fontIndex === 2 ? root.fontDir + "Montserrat-VariableFont_wght.ttf" : "" }
FontLoader { id: fNoto; source: QbzLyrics.fontIndex === 3 ? root.fontDir + "NotoSans-VariableFont_wdth,wght.ttf" : "" }
FontLoader { id: fSource; source: QbzLyrics.fontIndex === 4 ? root.fontDir + "SourceSans3-VariableFont_wght.ttf" : "" }
readonly property string lyricFontFamily: {
if (QbzLyrics.fontIndex === 1) return fLineSeed.status === FontLoader.Ready ? fLineSeed.name : ""
if (QbzLyrics.fontIndex === 2) return fMontserrat.status === FontLoader.Ready ? fMontserrat.name : ""
if (QbzLyrics.fontIndex === 3) return fNoto.status === FontLoader.Ready ? fNoto.name : ""
if (QbzLyrics.fontIndex === 4) return fSource.status === FontLoader.Ready ? fSource.name : ""
return ""
}
function fmt(sec) {
sec = Math.max(0, Math.floor(sec))
var m = Math.floor(sec / 60)
var s = sec % 60
return m + ":" + (s < 10 ? "0" : "") + s
}
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(Math.min(960,
Math.max(160, root.cardArtSize))))
}
onVisibleChanged: requestArtSize()
onWidthChanged: requestArtSize()
onHeightChanged: requestArtSize()
Component.onCompleted: requestArtSize()
// The lower complementary WaveBed is a visualizer, not the seek bar. Its
// 48 wider delegates are permanent; only their height bindings move.
VizSettle {
id: waveformSettle
live: root.visible && QbzImmersive.open
easeK: 0.62
target: {
var samples = QbzViz.waveform
var out = new Array(root.waveBars)
for (var i = 0; i < root.waveBars; ++i) {
var at = Math.floor(i * 256 / root.waveBars)
var value = samples[at] || 0
if (value < 0)
value = -value
out[i] = Math.min(1, value)
}
return out
}
}
Item {
id: waveform
anchors.horizontalCenter: parent.horizontalCenter
y: root.waveBottom - height
width: root.cardWidth * 0.98
height: root.waveHeight
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Qt.rgba(root.waveColor.r, root.waveColor.g,
root.waveColor.b, 0.50)
}
Repeater {
model: root.waveBars
delegate: Rectangle {
required property int index
readonly property real slot: waveform.width / root.waveBars
x: index * slot + (slot - width) / 2
anchors.bottom: parent.bottom
width: Math.max(3, slot * 0.76)
height: Math.max(2, waveformSettle.at(index)
* (waveform.height - 1))
radius: Math.min(3, width / 2)
color: Qt.rgba(root.waveColor.r, root.waveColor.g,
root.waveColor.b, index % 5 === 0 ? 1.0 : 0.82)
}
}
}
// The ambient field remains visible OUTSIDE this card. Inside, an opaque
// crop of the actual cover is blurred and then darkened into glass. The
// cover therefore shows through the glass, but the animated ambient layer
// behind the card never does.
Item {
id: card
x: (root.width - width) / 2
y: root.cardTop
width: root.cardWidth
height: root.cardHeight
// A post-effect rectangular clip is intentional here: the background
// already has a rounded mask, while this hard boundary guarantees that
// neither its blur nor the artwork shadow can leak below the card.
clip: true
Rectangle {
anchors.fill: parent
radius: root.cardRadius
color: "#ee10171f"
}
// Rounded mask for the GPU blur arm. `radius + clip` does not clip QML
// children to a curve, so use the same measured MultiEffect mask idiom
// as RoundedImage and MiniShell.
Item {
id: cardBackdropMask
anchors.fill: parent
visible: false
layer.enabled: !root._noShaders && root.artSource !== ""
layer.smooth: true
Rectangle {
anchors.fill: parent
radius: root.cardRadius
color: "#ffffff"
}
}
Item {
id: cardBackdropClip
anchors.fill: parent
visible: !root._noShaders && root.artSource !== ""
clip: true
layer.enabled: visible
layer.smooth: true
layer.effect: MultiEffect {
// Mask in a SECOND, outer pass. MultiEffect applies its blur
// after sampling the source, so a combined blur+mask can bleed
// a few pixels back into the rounded corners. This pass is the
// final compositor and therefore contains the blur completely.
maskEnabled: true
maskSource: cardBackdropMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
}
Item {
anchors.fill: parent
clip: true
layer.enabled: cardBackdropClip.visible
layer.smooth: true
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: 0.58
}
Image {
anchors.fill: parent
anchors.margins: -root.cardBackdropOverscan
source: root.artSource
asynchronous: true
cache: true
smooth: true
mipmap: true
fillMode: Image.PreserveAspectCrop
}
}
}
// Software/Null renderers cannot execute MultiEffect. Keep the source
// contract (the cover, never Ambient) and retain rounded corners; only
// the blur itself degrades.
RoundedImage {
anchors.fill: parent
radius: root.cardRadius
source: root.artSource
visible: root._noShaders && root.artSource !== ""
}
Rectangle {
anchors.fill: parent
radius: root.cardRadius
color: "#b80a1017"
}
Rectangle {
anchors.fill: parent
radius: root.cardRadius
gradient: Gradient {
GradientStop { position: 0.0; color: "#22000000" }
GradientStop { position: 0.55; color: "#7a090e14" }
GradientStop { position: 1.0; color: "#bd070b10" }
}
}
readonly property real artInset: (height - root.cardArtSize) / 2
readonly property real contentGap: Math.max(20, width * 0.032)
readonly property real contentRight: Math.max(20, width * 0.032)
// Static artwork shadow, then the responsive 94%-height square.
Rectangle {
x: card.artInset
y: card.artInset + 8
width: root.cardArtSize
height: root.cardArtSize
radius: root.cardRadius
color: "#99000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 0.55
}
}
Rectangle {
x: card.artInset
y: card.artInset
width: root.cardArtSize
height: root.cardArtSize
radius: root.cardRadius
color: "#1cffffff"
visible: root.artSource === ""
}
RoundedImage {
x: card.artInset
y: card.artInset
width: root.cardArtSize
height: root.cardArtSize
radius: root.cardRadius
source: root.artSource
visible: root.artSource !== ""
}
Item {
id: cardContent
anchors.left: parent.left
anchors.leftMargin: card.artInset + root.cardArtSize
+ card.contentGap
anchors.right: parent.right
anchors.rightMargin: card.contentRight
anchors.top: parent.top
anchors.topMargin: card.artInset
anchors.bottom: parent.bottom
anchors.bottomMargin: card.artInset
// Standard now-playing card. Its metadata is one left-aligned
// typographic unit whose centre lands exactly on the artwork's
// vertical centre. The independent seek remains at the bottom.
Item {
id: nowPlayingPane
anchors.fill: parent
visible: !root.lyricsMode
readonly property string artistAlbum: QbzPlayer.npArtist
+ (QbzPlayer.npAlbum !== ""
? " — " + QbzPlayer.npAlbum : "")
readonly property real nominalTitleSize: Math.max(24,
Math.min(48, card.height * 0.105))
readonly property real nominalMetaSize: Math.max(13,
Math.min(21, card.height * 0.052))
TextMetrics {
id: titleMetrics
text: QbzPlayer.npTitle
font.pixelSize: nowPlayingPane.nominalTitleSize
font.weight: Font.Bold
}
TextMetrics {
id: artistAlbumMetrics
text: nowPlayingPane.artistAlbum
font.pixelSize: nowPlayingPane.nominalMetaSize
}
// One shared scale is derived from whichever metadata line is
// widest. The title remains the largest face, never elides,
// and every subordinate label follows its reduction.
readonly property real typeScale: {
var scale = 1.0
if (titleMetrics.advanceWidth > 0)
scale = Math.min(scale,
nowPlayingPane.width / titleMetrics.advanceWidth)
if (artistAlbumMetrics.advanceWidth > 0)
scale = Math.min(scale,
nowPlayingPane.width / artistAlbumMetrics.advanceWidth)
return Math.max(0.05, Math.min(1.0, scale))
}
Column {
id: metadataStack
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Math.max(3,
Math.round(7 * nowPlayingPane.typeScale))
Item {
visible: QbzPlayer.npPlaying
width: parent.width
height: visible ? Math.max(12,
Math.round(14 * nowPlayingPane.typeScale)) : 0
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Math.max(4,
Math.round(8 * nowPlayingPane.typeScale))
EqualizerBars {
tint: theme.accent
active: root.visible && QbzImmersive.open
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: QbzSession.tr("Now Playing",
QbzSession.trRev)
color: theme.accent
font.pixelSize: Math.max(1,
12 * nowPlayingPane.typeScale)
font.weight: Font.DemiBold
font.letterSpacing: 0.5
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
width: parent.width
text: QbzPlayer.npTitle
color: "#f5ffffff"
font.pixelSize: Math.max(1,
nowPlayingPane.nominalTitleSize
* nowPlayingPane.typeScale)
font.weight: Font.Bold
wrapMode: Text.NoWrap
elide: Text.ElideNone
}
Text {
width: parent.width
text: nowPlayingPane.artistAlbum
color: "#a8ffffff"
font.pixelSize: Math.max(1,
nowPlayingPane.nominalMetaSize
* nowPlayingPane.typeScale)
wrapMode: Text.NoWrap
elide: Text.ElideNone
}
Item {
visible: QbzPlayer.npQualityTier !== ""
width: parent.width
height: visible ? qualityBadge.implicitHeight : 0
QualityBadgeFull {
id: qualityBadge
anchors.left: parent.left
compact: true
scaleFactor: nowPlayingPane.typeScale
tier: QbzPlayer.npQualityTier
detail: QbzPlayer.npQualityDetail
}
}
}
// Always mounted and never tied to the NPB/chrome visibility.
TinyBar {
id: secondSeek
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: seekTimes.top
anchors.bottomMargin: 2
value: QbzPlayer.npProgress
fill: root.waveColor
enabled: QbzPlayer.npDurationSecs > 0
onChanged: function (v) {
QbzPlayer.seek(Math.min(v, QbzPlayer.npSeekableMax))
}
}
Item {
id: seekTimes
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.max(10, card.height * 0.07)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.fmt(QbzPlayer.npElapsedSecs)
color: Qt.rgba(root.waveColor.r, root.waveColor.g,
root.waveColor.b, 0.82)
font.pixelSize: Math.max(10,
Math.min(13, card.height * 0.035))
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.fmt(QbzPlayer.npDurationSecs)
color: "#70ffffff"
font.pixelSize: Math.max(10,
Math.min(13, card.height * 0.035))
}
}
}
// Lyric-card variant: the title, artist and second seek block are
// replaced as one unit by exactly one synced lyric entry.
Item {
anchors.fill: parent
visible: root.lyricsMode
LyricsLineRow {
visible: root.currentLine !== ""
anchors.verticalCenter: parent.verticalCenter
contentWidth: parent.width
lineText: root.currentLine
lineIndex: root.activeIndex
sync: root.sync
synced: root.synced
activeIndex: root.activeIndex
sizeInactive: Math.max(22,
Math.min(44, card.height * 0.105))
sizeActive: sizeInactive
uppercase: QbzLyrics.uppercase
fontFamily: root.lyricFontFamily
activeColor: "#ffffff"
liteFill: false
centered: false
textShadow: true
}
Text {
visible: root.currentLine === "" && root.waiting
anchors.centerIn: parent
text: "♪"
color: "#70ffffff"
font.pixelSize: Math.max(32, card.height * 0.13)
}
Text {
visible: root.currentLine === "" && !root.waiting && root.noLyrics
anchors.fill: parent
text: QbzSession.tr("No lyrics", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: Math.max(17,
Math.min(26, card.height * 0.07))
font.italic: true
wrapMode: Text.WordWrap
verticalAlignment: Text.AlignVCenter
}
}
}
Rectangle {
anchors.fill: parent
radius: root.cardRadius
color: "transparent"
border.width: 1
border.color: "#32ffffff"
}
}
}