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:
6 files changed
+172
-53
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user