fix(player): refine immersive lyrics and seekbars

Reuse the split lyrics renderer for full-screen timed fill, add a contrast shadow and expose the immersive song card there. Render the compact seekbar as a stable three-sine motif and make the larger waveform use normalized rounded capsules with stronger peaks.

cx
This commit is contained in:
vicrodh committed 2026-08-25 09:38:43 -06:00
1 parent 5501221fdc
commit 9b4bb865bf
6 files changed
+172 -53

No files matched your search

+105 -18
View File
@@ -97,6 +97,70 @@ void setQuad(QSGGeometry::ColoredPoint2D *vertices,
setVertex(vertices[5], right, bottom, color, bottomAlpha);
}
constexpr int capsuleCapSegments = 4;
constexpr int capsuleVertexCount = 12 + capsuleCapSegments * 6;
void setTriangle(QSGGeometry::ColoredPoint2D *vertices,
const QPointF &first,
const QPointF &second,
const QPointF &third,
const QColor &color,
float firstAlpha,
float secondAlpha,
float thirdAlpha)
{
setVertex(vertices[0], static_cast<float>(first.x()), static_cast<float>(first.y()),
color, firstAlpha);
setVertex(vertices[1], static_cast<float>(second.x()), static_cast<float>(second.y()),
color, secondAlpha);
setVertex(vertices[2], static_cast<float>(third.x()), static_cast<float>(third.y()),
color, thirdAlpha);
}
void setVerticalCapsule(QSGGeometry::ColoredPoint2D *vertices,
float left,
float top,
float right,
float bottom,
const QColor &color)
{
constexpr qreal pi = 3.14159265358979323846;
const float centreX = (left + right) * 0.5f;
const float centreY = (top + bottom) * 0.5f;
const float radius = std::min((right - left) * 0.5f, (bottom - top) * 0.5f);
const float topCentre = top + radius;
const float bottomCentre = bottom - radius;
setQuad(vertices, left, topCentre, right, centreY, color, 0.62f, 1.0f);
setQuad(vertices + 6, left, centreY, right, bottomCentre, color, 1.0f, 0.58f);
int vertex = 12;
for (int segment = 0; segment < capsuleCapSegments; ++segment) {
const qreal firstAngle = pi + pi * segment / capsuleCapSegments;
const qreal secondAngle = pi + pi * (segment + 1) / capsuleCapSegments;
setTriangle(vertices + vertex,
QPointF(centreX, topCentre),
QPointF(centreX + std::cos(firstAngle) * radius,
topCentre + std::sin(firstAngle) * radius),
QPointF(centreX + std::cos(secondAngle) * radius,
topCentre + std::sin(secondAngle) * radius),
color, 0.62f, 0.42f, 0.42f);
vertex += 3;
}
for (int segment = 0; segment < capsuleCapSegments; ++segment) {
const qreal firstAngle = pi * segment / capsuleCapSegments;
const qreal secondAngle = pi * (segment + 1) / capsuleCapSegments;
setTriangle(vertices + vertex,
QPointF(centreX, bottomCentre),
QPointF(centreX + std::cos(firstAngle) * radius,
bottomCentre + std::sin(firstAngle) * radius),
QPointF(centreX + std::cos(secondAngle) * radius,
bottomCentre + std::sin(secondAngle) * radius),
color, 0.58f, 0.38f, 0.38f);
vertex += 3;
}
}
void setLayerGeometry(QSGGeometryNode *node,
const std::vector<float> &values,
qreal width,
@@ -110,10 +174,10 @@ void setLayerGeometry(QSGGeometryNode *node,
const float halfHeight = std::max(1.0f, static_cast<float>(height * 0.47));
if (renderMode == 1) {
// Small NPB: the RMS document has no signed samples, so present its
// loudness contour as one continuous, centre-crossing curve. A few
// dozen well-spaced segments read as a waveform at 11 px; hundreds
// of sub-pixel bars only read as a rough blue rail.
// Small NPB: this is a stable three-sine motif rather than decoded
// RMS. At thirteen pixels the real envelope cannot communicate track
// structure, while a smooth repeated contour remains readable and
// still carries played/cache clipping as a seek affordance.
geometry->allocate(6 + std::max(0, count - 1) * 6);
geometry->setDrawingMode(QSGGeometry::DrawTriangles);
auto *vertices = geometry->vertexDataAsColoredPoint2D();
@@ -162,7 +226,7 @@ void setLayerGeometry(QSGGeometryNode *node,
const int visibleBars = static_cast<int>(std::count_if(values.begin(), values.end(),
[](float value) { return value > 0.0005f; }));
geometry->allocate(6 + visibleBars * 12);
geometry->allocate(6 + visibleBars * capsuleVertexCount);
geometry->setDrawingMode(QSGGeometry::DrawTriangles);
auto *vertices = geometry->vertexDataAsColoredPoint2D();
@@ -178,13 +242,13 @@ void setLayerGeometry(QSGGeometryNode *node,
return;
}
constexpr int groupSize = 8;
constexpr int groupSize = 12;
const int groupBreaks = (count - 1) / groupSize;
const float nominalSlot = static_cast<float>(width) / count;
const float groupGap = std::min(2.8f, std::max(1.1f, nominalSlot * 0.52f));
const float groupGap = std::min(2.4f, std::max(0.9f, nominalSlot * 0.34f));
const float usableWidth = std::max(1.0f, static_cast<float>(width) - groupBreaks * groupGap);
const float slot = usableWidth / count;
const float barWidth = std::max(1.2f, std::min(slot * 0.74f, slot - 0.35f));
const float barWidth = std::clamp(slot * 0.56f, 1.8f, 3.6f);
int vertex = 6;
for (int index = 0; index < count; ++index) {
@@ -196,12 +260,9 @@ void setLayerGeometry(QSGGeometryNode *node,
const float left = slot * index + groupGap * (index / groupSize)
+ (slot - barWidth) * 0.5f;
const float right = left + barWidth;
setQuad(vertices + vertex, left, centre - amplitude, right, centre,
color, 0.48f, 0.98f);
vertex += 6;
setQuad(vertices + vertex, left, centre, right, centre + amplitude,
color, 0.98f, 0.42f);
vertex += 6;
setVerticalCapsule(vertices + vertex, left, centre - amplitude,
right, centre + amplitude, color);
vertex += capsuleVertexCount;
}
node->markDirty(QSGNode::DirtyGeometry);
}
@@ -233,6 +294,30 @@ std::vector<float> renderValues(const QVariantList &source, int columns, float g
}
values.swap(scratch);
}
const float maximum = *std::max_element(values.begin(), values.end());
if (maximum > 0.0005f) {
for (float &value : values)
value = std::clamp(value / maximum, 0.0f, 1.0f);
}
return values;
}
std::vector<float> decorativeWaveValues(int columns, qreal width)
{
std::vector<float> values(columns, 0.5f);
if (columns < 2)
return values;
constexpr qreal twoPi = 6.28318530717958647692;
const qreal repeats = std::max<qreal>(3.0, width / 210.0);
for (int index = 0; index < columns; ++index) {
const qreal progress = static_cast<qreal>(index) / (columns - 1);
const qreal phase = twoPi * repeats * progress;
const qreal composite = 0.55 * std::sin(phase)
+ 0.28 * std::sin(phase * 2.0 + 0.85)
+ 0.17 * std::sin(phase * 3.0 + 2.1);
values[index] = static_cast<float>(0.5 + composite * 0.46);
}
return values;
}
@@ -307,7 +392,7 @@ void SeekWaveformItem::setRenderMode(int mode)
QSGNode *SeekWaveformItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
if (m_values.isEmpty() || width() <= 0.0 || height() <= 0.0) {
if ((m_values.isEmpty() && m_renderMode == 0) || width() <= 0.0 || height() <= 0.0) {
delete oldNode;
return nullptr;
}
@@ -327,9 +412,11 @@ QSGNode *SeekWaveformItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData
if (root->geometryRevision != m_valuesRevision || root->geometrySize != size()
|| colorsChanged || root->geometryRenderMode != m_renderMode) {
const int columns = m_renderMode == 1
? qBound(32, static_cast<int>(std::ceil(width() / 9.0)), 192)
: qBound(40, static_cast<int>(std::ceil(width() / 5.5)), 256);
const auto values = renderValues(m_values, columns, m_renderMode == 1 ? 0.9f : 0.78f);
? qBound(64, static_cast<int>(std::ceil(width() / 3.0)), 512)
: qBound(48, static_cast<int>(std::ceil(width() / 7.5)), 240);
const auto values = m_renderMode == 1
? decorativeWaveValues(columns, width())
: renderValues(m_values, columns, 0.72f);
setLayerGeometry(root->baseLayer, values, width(), height(), m_baseColor, m_renderMode);
setLayerGeometry(root->cacheLayer, values, width(), height(), m_cacheColor, m_renderMode);
setLayerGeometry(root->playedLayer, values, width(), height(), m_playedColor, m_renderMode);
@@ -4,10 +4,9 @@
// built fresh — shell/SongCard.qml is the bar's INTERACTIVE meta-link card
// with AudioStamp; this twin is NON-interactive with the E chip).
//
// Mounted by ImmersiveView as layer 4: visible ONLY
// `viewMode==0 && mode==6 && npHasTrack` (trap 19), bottom-right 24px
// insets, and it does NOT fade with the auto-hide chrome
// (ImmersiveView.slint:1602-1605). Content-sized: the card hugs its content
// Mounted by ImmersiveView as layer 4 over Wave Bed, full Lyrics and every
// shader scene, bottom-right with 24px insets. It does NOT fade with the
// auto-hide chrome. Content-sized: the card hugs its content
// and the mount pins it bottom-right, so a wider card grows LEFTWARD.
//
// meta column (right-aligned): title 15px semibold capped 420px + elide
@@ -29,8 +29,8 @@
// spectrum band and the viz eye toggle; port of the Slint root
// TouchArea :1283-1286). Stacked BELOW panels + chrome.
// 3. FOCUS panels (viewMode==0 && mode==N) / the SPLIT layout (viewMode==1)
// 4. ImmersiveSongCard (viewMode==0 && mode==6 && npHasTrack — trap 19;
// does NOT fade with the chrome, :1602-1605)
// 4. ImmersiveSongCard (Wave Bed, full Lyrics and shader scenes;
// does NOT fade with the chrome)
// 5. ImmersivePlayerBar (B5, :1607-1616) — centered, y = height-90-24,
// fades with the chrome like the header
// 6. ImmersiveHeader band
@@ -356,7 +356,7 @@ Item {
}
}
}
// FOCUS Lyrics (mode 4, B4 §6.6): the giant-line variant. It shares the
// FOCUS Lyrics (mode 4, B4 §6.6): the one-line variant. It shares the
// immersive LyricsSyncEngine with the split lyrics mount (trap 22 — the
// gate covers BOTH lyric surfaces and nothing else). No entry load: Qt
// fetches lyrics automatically per track (§5.5).
@@ -638,9 +638,10 @@ Item {
}
// --- Layer 4: ImmersiveSongCard (§5.1, :1602-1605) ----------------------
// Slint shows it bottom-right ONLY over the shader backgrounds OR in
// WaveBed (shader-mode > 0 || (view-mode == 0 && mode == 6)) &&
// has-track — the focus panels render their own metadata. In v1
// Keep the compact bottom-right metadata over shader backgrounds,
// Wave Bed and full-screen Lyrics. The lyrics surface deliberately keeps
// one line at a time, so the card supplies the otherwise absent track
// context without competing with the active line.
// shader-mode was constant 0 so the first arm was dead (trap 19); A1
// makes it real. NON-interactive; does NOT fade with the auto-hide
// chrome (no opacity binding here — that is deliberate).
@@ -651,7 +652,8 @@ Item {
anchors.rightMargin: 24
anchors.bottomMargin: 24
visible: (QbzShaderScene.scene > 0
|| (QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6))
|| (QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 4 || QbzImmersive.mode === 6)))
&& QbzPlayer.npHasTrack
}
@@ -1,10 +1,10 @@
// LyricsFocusPanel — FOCUS mode 4, the giant-line lyrics variant (§6.6 of
// LyricsFocusPanel — FOCUS mode 4, the one-line lyrics variant (§6.6 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveLyricsFocusPanel.slint:50-151.
//
// One giant centered CURRENT line, cross-faded on every line change; NO
// karaoke fill (whole-line swap only, exactly like the Slint/Svelte source
// which omits activeProgress). Data: the QbzLyrics doc (lines/status/synced,
// One centered CURRENT line, cross-faded on every line change. The host still
// advances one line at a time, but the active line now reuses the split-view
// typography and per-line time fill. Data: the QbzLyrics doc (lines/status/synced,
// passed in by the mount) + the SHARED immersive LyricsSyncEngine instance
// from ImmersiveView (`sync`) — the Slint reads the one global LyricsState
// active-index; Qt has one engine per surface group, and the focus panel +
@@ -18,12 +18,12 @@
// does not animate), a 16ms one-shot releases it and the 500ms
// cubic-bezier(0.22,1,0.36,1) runs forward.
//
// The line takes QbzShell.ambientAccent — the album-art accent that is the
// COMPLEMENT of the cover's dominant hue (ImmersiveState.lyrics-accent),
// NOT the UI lyrics color, NOT plain white (:102-107).
// The line uses split-lyrics white plus a restrained native shadow so the
// timed fill remains legible over every immersive background.
import QtQuick
import com.blitzfc.qbz
import "../shell"
Item {
id: root
@@ -51,9 +51,23 @@ Item {
readonly property bool noLyrics: root.status === 3
|| (root.status === 2 && root.lines.length === 0)
// Responsive font size (clamp(28px, 5vw, 56px), :50-51) — vw = the
// panel's own width (full-viewport in the focus placement).
readonly property real lineSize: Math.max(28, Math.min(root.width * 0.05, 56))
// Same responsive active size as immersive split lyrics. Keeping this
// scale identical prevents a jarring type jump between full and split.
readonly property real lineSize: Math.round(Math.max(28,
Math.min(root.width * 0.022, 42)))
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 fontFamily: {
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 ""
}
// --- Entry-animation retrigger (:57-74) --------------------------------
readonly property int watchedIndex: root.activeIndex
@@ -82,19 +96,24 @@ Item {
anchors.rightMargin: 60
anchors.bottomMargin: 140
// Current line — fadeInUp-lite (:100-126). Centered in the block.
Text {
// Current line — one line at a time, with the exact split-view
// karaoke renderer and a restrained native shadow.
LyricsLineRow {
visible: root.hasCurrent
anchors.centerIn: parent
// width min(parent, vw*0.9) (:114).
width: Math.min(parent.width, root.width * 0.9)
text: root.currentLine
color: QbzShell.ambientAccent
font.pixelSize: root.lineSize
font.weight: Font.Bold // 700
font.letterSpacing: 0.2
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
contentWidth: Math.min(parent.width, root.width * 0.9)
lineText: root.currentLine
lineIndex: root.activeIndex
sync: root.sync
synced: root.synced
activeIndex: root.activeIndex
sizeInactive: root.lineSize
sizeActive: root.lineSize
uppercase: QbzLyrics.uppercase
fontFamily: root.fontFamily
activeColor: "#ffffff"
liteFill: false
textShadow: true
// The translate-up is applied via anchors.verticalCenterOffset so
// the centering anchor survives the offset.
anchors.verticalCenterOffset: root.entering ? 24 : 0
+13
View File
@@ -57,6 +57,11 @@ Column {
// Lite fill: light the active line whole, no per-frame clip sweep.
property bool liteFill: false
property bool showTranslation: false
// The full-screen one-line host enables this lightweight native text
// shadow. It avoids an extra blurred layer while keeping every glyph
// readable over arbitrary album artwork.
property bool textShadow: false
property color textShadowColor: "#b0000000"
QbzTheme { id: theme }
@@ -115,6 +120,8 @@ Column {
font.pixelSize: row.sizeInactive
font.weight: theme.weightMedium
font.letterSpacing: 0.2
style: row.textShadow ? Text.Raised : Text.Normal
styleColor: row.textShadowColor
}
// ---- ACTIVE: per-visual-line karaoke -------------------------------
@@ -209,6 +216,8 @@ Column {
font.pixelSize: row.sizeActive
font.weight: theme.weightBold
font.letterSpacing: 0.2
style: row.textShadow ? Text.Raised : Text.Normal
styleColor: row.textShadowColor
}
// Bright (sung) overlay, clipped to this segment's LOCAL fill:
@@ -246,6 +255,8 @@ Column {
font.pixelSize: row.sizeActive
font.weight: theme.weightBold
font.letterSpacing: 0.2
style: row.textShadow ? Text.Raised : Text.Normal
styleColor: row.textShadowColor
}
}
}
@@ -269,5 +280,7 @@ Column {
font.pixelSize: Math.round((row.isActive ? row.sizeActive : row.sizeInactive) * 0.72)
font.weight: theme.weightRegular
font.letterSpacing: 0.2
style: row.textShadow ? Text.Raised : Text.Normal
styleColor: row.textShadowColor
}
}
@@ -45,8 +45,7 @@ Rectangle {
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: theme.ambientOn
readonly property bool waveformVisible: QbzShell.seekbarWaveform
&& QbzPlayer.npSeekWaveformAnalyzed > 0
&& QbzPlayer.npSeekWaveform.length > 0
&& QbzPlayer.npHasTrack
QbzTheme { id: theme }
@@ -219,7 +218,7 @@ Rectangle {
Rectangle {
id: seekTrack
width: parent.width
height: root.waveformVisible ? 11 : 3
height: root.waveformVisible ? 13 : 3
anchors.verticalCenter: parent.verticalCenter
radius: 2
color: root.waveformVisible ? "transparent" : theme.surfaceElevated