feat(qt): expand immersive visualizer experience
cx
This commit is contained in:
16 files changed
+1163
-142
No files matched your search
@@ -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.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user