Files
qbz/crates/qbz-qt/qml/immersive/ReactiveSplitPanel.qml
T
vicrodh e2e414b56b fix(qt): wrap the split-card title at full size; themed badge on the song card
The cinematic split card's shared shrink-to-fit typeScale is retired: a
long title now word-wraps at its nominal size instead of scaling the
whole metadata block down, and the album/artist lines elide instead of
dictating a reduction. The bottom-right song card's badge also drops the
overAmbient chip — that card only mounts over dark surfaces and the dark
backdrop read as a bigger badge; the themed look was already right.

cc
2026-08-31 16:51:05 -06:00

524 lines
21 KiB
QML

// 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
// 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)
// The album's OWN dominant hue, lifted for legibility (AmbientAccent).
// This used to be the exact COMPLEMENT of the ambient primary, which is
// how a deep-blue cover got an orange waveform/seek — the accent now
// stays in the album palette (2026-08-31 visual cleanup). Unlike FOCUS
// Wave Bed this is not mirrored: every column grows upward from one
// baseline.
AmbientAccent { id: ambientAccent }
readonly property color waveColor: ambientAccent.value
readonly property string fontDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/fonts/"
readonly property string fontSource: {
if (QbzLyrics.fontIndex === 1) return root.fontDir + "LINESeedJP-Regular.ttf"
if (QbzLyrics.fontIndex === 2) return root.fontDir + "Montserrat-VariableFont_wght.ttf"
if (QbzLyrics.fontIndex === 3) return root.fontDir + "NotoSans-VariableFont_wdth,wght.ttf"
if (QbzLyrics.fontIndex === 4) return root.fontDir + "SourceSans3-VariableFont_wght.ttf"
return ""
}
Component {
id: fontLoaderComponent
FontLoader { source: root.fontSource }
}
Loader {
id: selectedFontLoader
active: root.fontSource !== ""
sourceComponent: fontLoaderComponent
}
readonly property string lyricFontFamily:
selectedFontLoader.item
&& selectedFontLoader.item.status === FontLoader.Ready
? selectedFontLoader.item.name : ""
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
// Fixed nominal faces, clamped by the CARD's height only. The
// shared shrink-to-fit typeScale is retired (owner, 2026-08-31
// round 3): a long title now WRAPS at full size instead of
// scaling the whole block down, and the subordinate lines
// elide rather than dictate anyone's size.
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))
Column {
id: metadataStack
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
// "Now Playing" indicator at 1.5x, tinted with the
// album-palette accent instead of the theme accent so it
// matches the waveform/seek (owner request 2026-08-31).
Item {
visible: QbzPlayer.npPlaying
width: parent.width
height: visible ? 21 : 0
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 12
EqualizerBars {
tint: root.waveColor
barScale: 1.5
active: root.visible && QbzImmersive.open
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: QbzSession.tr("Now Playing",
QbzSession.trRev)
color: root.waveColor
font.pixelSize: 18
font.weight: Font.DemiBold
font.letterSpacing: 0.5
anchors.verticalCenter: parent.verticalCenter
}
}
}
// Three metadata lines: title / album / artist. The title
// wraps at its full size instead of shrinking to fit.
Text {
width: parent.width
text: QbzPlayer.npTitle
color: "#f5ffffff"
font.pixelSize: nowPlayingPane.nominalTitleSize
font.weight: Font.Bold
wrapMode: Text.WordWrap
}
Text {
visible: QbzPlayer.npAlbum !== ""
width: parent.width
text: QbzPlayer.npAlbum
color: "#8cffffff"
font.italic: true
font.pixelSize: nowPlayingPane.nominalMetaSize
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
Text {
visible: QbzPlayer.npArtist !== ""
width: parent.width
text: QbzPlayer.npArtist
color: "#b3ffffff"
font.pixelSize: nowPlayingPane.nominalMetaSize
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
Item {
visible: QbzPlayer.npQualityTier !== ""
width: parent.width
height: visible ? qualityBadge.implicitHeight : 0
QualityBadgeFull {
id: qualityBadge
anchors.left: parent.left
compact: true
overAmbient: true
scaleFactor: 1.5
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"
// 35% white so the karaoke fill reads (see
// LyricsFocusPanel.qml).
inactiveColor: "#59ffffff"
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"
}
}
}