fix(visualizer): refine scopes and seekbar

Make immersive scopes responsive and derive readable colors from album artwork. Use distinct full-size bar and compact contour renderers for the seek waveform.

cx
This commit is contained in:
vicrodh committed 2026-08-25 08:21:21 -06:00
1 parent 88e776fcdb
commit 5501221fdc
8 files changed
+178 -38

No files matched your search

+12 -1
View File
@@ -109,7 +109,18 @@ QVector<QPointF> mapPoints(const QVariantList &source,
points.push_back(QPointF(pad + (0.5 + side * 0.47) * drawWidth,
pad + (0.5 - mid * 0.47) * drawHeight));
} else {
const qreal value = qBound<qreal>(-1.0, source[index].toReal() * gain, 1.0);
// The DSP publishes two pitch-locked periods. Repeat that stable
// window according to the item's aspect ratio so a wide monitor
// gains temporal detail instead of stretching two enormous
// lobes. A square panel remains the original two-period scope.
const int repeats = qBound(1, qRound(width / qMax<qreal>(1.0, height)), 4);
const qreal wrapped = std::fmod(index * repeats, count - 1.0);
const int first = qBound(0, static_cast<int>(std::floor(wrapped)), count - 1);
const int second = qMin(first + 1, count - 1);
const qreal fraction = wrapped - first;
const qreal sample = source[first].toReal() * (1.0 - fraction)
+ source[second].toReal() * fraction;
const qreal value = qBound<qreal>(-1.0, sample * gain, 1.0);
points.push_back(QPointF(pad + index * drawWidth / qMax(1, count - 1),
pad + (0.5 - value * 0.47) * drawHeight));
}
+79 -13
View File
@@ -62,6 +62,7 @@ public:
QColor baseGeometryColor;
QColor cacheGeometryColor;
QColor playedGeometryColor;
int geometryRenderMode = -1;
};
void setVertex(QSGGeometry::ColoredPoint2D &vertex,
@@ -100,17 +101,70 @@ void setLayerGeometry(QSGGeometryNode *node,
const std::vector<float> &values,
qreal width,
qreal height,
const QColor &color)
const QColor &color,
int renderMode)
{
auto *geometry = node->geometry();
const int count = static_cast<int>(values.size());
const float centre = static_cast<float>(height * 0.5);
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.
geometry->allocate(6 + std::max(0, count - 1) * 6);
geometry->setDrawingMode(QSGGeometry::DrawTriangles);
auto *vertices = geometry->vertexDataAsColoredPoint2D();
const float railHalf = std::min(0.42f, halfHeight * 0.12f);
setQuad(vertices, 0.0f, centre - railHalf, static_cast<float>(width),
centre + railHalf, color, 0.32f, 0.32f);
if (count < 2) {
node->markDirty(QSGNode::DirtyGeometry);
return;
}
const float lineHalf = std::clamp(static_cast<float>(height * 0.085), 0.62f, 1.15f);
const float swing = static_cast<float>(height * 0.36);
auto pointAt = [&](int index) {
const float x = index * static_cast<float>(width) / (count - 1);
const float y = centre + (0.5f - values[index]) * swing * 2.0f;
return QPointF(x, y);
};
int vertex = 6;
for (int index = 0; index + 1 < count; ++index) {
const QPointF first = pointAt(index);
const QPointF second = pointAt(index + 1);
const QPointF delta = second - first;
const qreal length = std::hypot(delta.x(), delta.y());
const QPointF normal = length > 0.0001
? QPointF(-delta.y() / length * lineHalf,
delta.x() / length * lineHalf)
: QPointF(0.0, lineHalf);
setVertex(vertices[vertex], static_cast<float>((first + normal).x()),
static_cast<float>((first + normal).y()), color, 0.92f);
setVertex(vertices[vertex + 1], static_cast<float>((first - normal).x()),
static_cast<float>((first - normal).y()), color, 0.92f);
setVertex(vertices[vertex + 2], static_cast<float>((second + normal).x()),
static_cast<float>((second + normal).y()), color, 0.92f);
setVertex(vertices[vertex + 3], static_cast<float>((second + normal).x()),
static_cast<float>((second + normal).y()), color, 0.92f);
setVertex(vertices[vertex + 4], static_cast<float>((first - normal).x()),
static_cast<float>((first - normal).y()), color, 0.92f);
setVertex(vertices[vertex + 5], static_cast<float>((second - normal).x()),
static_cast<float>((second - normal).y()), color, 0.92f);
vertex += 6;
}
node->markDirty(QSGNode::DirtyGeometry);
return;
}
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->setDrawingMode(QSGGeometry::DrawTriangles);
auto *vertices = geometry->vertexDataAsColoredPoint2D();
const float centre = static_cast<float>(height * 0.5);
const float halfHeight = std::max(1.0f, static_cast<float>(height * 0.47));
// A permanent hairline makes the temporal axis readable before the whole
// track has been analysed. Its three clipped color layers retain the
@@ -127,10 +181,10 @@ void setLayerGeometry(QSGGeometryNode *node,
constexpr int groupSize = 8;
const int groupBreaks = (count - 1) / groupSize;
const float nominalSlot = static_cast<float>(width) / count;
const float groupGap = std::min(1.7f, std::max(0.65f, nominalSlot * 0.48f));
const float groupGap = std::min(2.8f, std::max(1.1f, nominalSlot * 0.52f));
const float usableWidth = std::max(1.0f, static_cast<float>(width) - groupBreaks * groupGap);
const float slot = usableWidth / count;
const float barWidth = std::max(0.65f, std::min(slot * 0.66f, slot - 0.18f));
const float barWidth = std::max(1.2f, std::min(slot * 0.74f, slot - 0.35f));
int vertex = 6;
for (int index = 0; index < count; ++index) {
@@ -152,7 +206,7 @@ void setLayerGeometry(QSGGeometryNode *node,
node->markDirty(QSGNode::DirtyGeometry);
}
std::vector<float> renderValues(const QVariantList &source, int columns)
std::vector<float> renderValues(const QVariantList &source, int columns, float gamma)
{
if (source.isEmpty() || columns < 2)
return {};
@@ -165,7 +219,7 @@ std::vector<float> renderValues(const QVariantList &source, int columns)
float peak = 0.0f;
for (int index = begin; index < std::min(end, sourceCount); ++index)
peak = std::max(peak, std::clamp(source[index].toFloat(), 0.0f, 1.0f));
values[column] = std::pow(peak, 1.42f);
values[column] = std::pow(peak, gamma);
}
std::vector<float> scratch(columns, 0.0f);
@@ -242,6 +296,15 @@ void SeekWaveformItem::setPlayedColor(const QColor &color)
update();
}
void SeekWaveformItem::setRenderMode(int mode)
{
mode = mode == 1 ? 1 : 0;
if (m_renderMode == mode)
return;
m_renderMode = mode;
update();
}
QSGNode *SeekWaveformItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
{
if (m_values.isEmpty() || width() <= 0.0 || height() <= 0.0) {
@@ -262,17 +325,20 @@ QSGNode *SeekWaveformItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData
|| root->cacheGeometryColor != m_cacheColor
|| root->playedGeometryColor != m_playedColor;
if (root->geometryRevision != m_valuesRevision || root->geometrySize != size()
|| colorsChanged) {
const int columns = qBound(24, static_cast<int>(std::ceil(width() / 2.45)), 512);
const auto values = renderValues(m_values, columns);
setLayerGeometry(root->baseLayer, values, width(), height(), m_baseColor);
setLayerGeometry(root->cacheLayer, values, width(), height(), m_cacheColor);
setLayerGeometry(root->playedLayer, values, width(), height(), m_playedColor);
|| 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);
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);
root->geometryRevision = m_valuesRevision;
root->geometrySize = size();
root->baseGeometryColor = m_baseColor;
root->cacheGeometryColor = m_cacheColor;
root->playedGeometryColor = m_playedColor;
root->geometryRenderMode = m_renderMode;
}
return root;
}
+4
View File
@@ -14,6 +14,7 @@ class SeekWaveformItem : public QQuickItem
Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor)
Q_PROPERTY(QColor cacheColor READ cacheColor WRITE setCacheColor)
Q_PROPERTY(QColor playedColor READ playedColor WRITE setPlayedColor)
Q_PROPERTY(int renderMode READ renderMode WRITE setRenderMode)
public:
explicit SeekWaveformItem(QQuickItem *parent = nullptr);
@@ -30,6 +31,8 @@ public:
void setCacheColor(const QColor &color);
QColor playedColor() const { return m_playedColor; }
void setPlayedColor(const QColor &color);
int renderMode() const { return m_renderMode; }
void setRenderMode(int mode);
protected:
QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override;
@@ -41,6 +44,7 @@ private:
QColor m_baseColor{ 64, 68, 74 };
QColor m_cacheColor{ 128, 132, 140 };
QColor m_playedColor{ 63, 169, 232 };
int m_renderMode = 0;
quint64 m_valuesRevision = 0;
};
+49 -12
View File
@@ -10,7 +10,40 @@ Item {
// 0 = goniometer, 1 = pitch-locked oscilloscope.
property int scopeMode: 0
property color traceColor: theme.accent
// Immersive scopes choose the brightest colour extracted from the
// current cover. Compact Large-NPB scopes leave this false and receive
// the same reactive primary colour as its other three visualizers.
property bool albumContrast: false
property bool compact: false
readonly property color albumPrimary: QbzShell.ambientPrimary
readonly property color albumSecondary: QbzShell.ambientSecondary
readonly property color albumAccent: QbzShell.ambientAccent
function relativeLuminance(c) {
const r = c.r <= 0.03928 ? c.r / 12.92 : Math.pow((c.r + 0.055) / 1.055, 2.4)
const g = c.g <= 0.03928 ? c.g / 12.92 : Math.pow((c.g + 0.055) / 1.055, 2.4)
const b = c.b <= 0.03928 ? c.b / 12.92 : Math.pow((c.b + 0.055) / 1.055, 2.4)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function brightestAlbumColor() {
const candidates = [root.albumPrimary, root.albumSecondary,
root.albumAccent]
var best = candidates[0]
var bestLuminance = relativeLuminance(best)
for (var i = 1; i < candidates.length; ++i) {
const luminance = relativeLuminance(candidates[i])
if (luminance > bestLuminance) {
best = candidates[i]
bestLuminance = luminance
}
}
return best
}
readonly property color effectiveTraceColor:
root.albumContrast ? root.brightestAlbumColor() : root.traceColor
readonly property color guideColor: Qt.rgba(
root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, root.scopeMode === 0 ? 0.19 : 0.12)
// Instrument guides stay deliberately quieter than the trace. They are
// static scene-graph nodes; only the native trace item changes per frame.
@@ -19,16 +52,14 @@ Item {
y: root.compact ? 4 : parent.height * 0.08
width: 1
height: root.compact ? parent.height - 8 : parent.height * 0.84
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b,
root.scopeMode === 0 ? 0.22 : 0.12)
color: root.guideColor
}
Rectangle {
x: root.compact ? 4 : parent.width * 0.08
anchors.verticalCenter: parent.verticalCenter
width: root.compact ? parent.width - 8 : parent.width * 0.84
height: 1
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b,
root.scopeMode === 0 ? 0.22 : 0.15)
color: root.guideColor
}
Repeater {
@@ -39,7 +70,8 @@ Item {
y: parent.height * 0.14
width: 1
height: parent.height * 0.72
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.08)
color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.07)
}
}
@@ -50,7 +82,8 @@ Item {
height: width
color: "transparent"
border.width: 1
border.color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.18)
border.color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.18)
radius: width / 2
}
Rectangle {
@@ -60,7 +93,8 @@ Item {
height: width
color: "transparent"
border.width: 1
border.color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.09)
border.color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.09)
radius: width / 2
}
Repeater {
@@ -71,7 +105,8 @@ Item {
width: parent.width * 0.76
height: 1
rotation: index === 0 ? 45 : -45
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.08)
color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.08)
}
}
@@ -80,7 +115,7 @@ Item {
anchors.margins: root.compact ? 3 : 8
mode: root.scopeMode
points: root.scopeMode === 0 ? QbzViz.goniometer : QbzViz.oscilloscope
traceColor: root.traceColor
traceColor: root.effectiveTraceColor
lineWidth: root.compact ? 1.35 : 2.2
trailDepth: root.scopeMode === 0 ? (root.compact ? 3 : 5) : (root.compact ? 2 : 4)
fillOpacity: root.compact ? 0.08 : 0.16
@@ -101,7 +136,8 @@ Item {
anchors.verticalCenter: parent.verticalCenter
height: 2
radius: 1
color: Qt.rgba(theme.textMuted.r, theme.textMuted.g, theme.textMuted.b, 0.22)
color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.22)
Rectangle {
width: 4
height: 10
@@ -109,7 +145,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
x: (parent.width - width) * Math.max(0, Math.min(1,
(QbzViz.stereoCorrelation + 1) * 0.5))
color: root.traceColor
color: root.effectiveTraceColor
}
}
Text {
@@ -117,7 +153,8 @@ Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: QbzViz.stereoCorrelation.toFixed(2)
color: theme.textMuted
color: Qt.rgba(root.effectiveTraceColor.r, root.effectiveTraceColor.g,
root.effectiveTraceColor.b, 0.72)
font.pixelSize: 11
}
}
+26 -6
View File
@@ -145,9 +145,11 @@ Item {
ImmersiveAtmosphere {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& !(QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 7 || QbzImmersive.mode === 8))
source: QbzImmersive.atmosphereUrl
fallbackSource: QbzPlayer.npArtworkPath
animated: QbzPlayer.npPlaying && QbzImmersive.open
animated: visible && QbzPlayer.npPlaying && QbzImmersive.open
dim: 0.15
}
@@ -308,6 +310,20 @@ Item {
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 7
visible: active
sourceComponent: Item {
// A single host-generated blurred cover, without the animated
// four-layer atmosphere. The dark veil makes one album-derived
// high-luminance trace colour hold over the whole viewport.
Rectangle { anchors.fill: parent; color: "#090909" }
Image {
anchors.fill: parent
source: QbzImmersive.atmosphereUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
mipmap: true
opacity: status === Image.Ready ? 0.78 : 0.0
}
Rectangle { anchors.fill: parent; color: "#66000000" }
ScopePanel {
width: Math.max(1, Math.min(parent.width - 96, parent.height - 196))
height: width
@@ -315,7 +331,7 @@ Item {
anchors.bottom: parent.bottom
anchors.bottomMargin: 132
scopeMode: 0
traceColor: theme.accent
albumContrast: true
}
}
}
@@ -325,14 +341,18 @@ Item {
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 8
visible: active
sourceComponent: Item {
Rectangle { anchors.fill: parent; color: "#000000" }
ScopePanel {
width: Math.max(1, Math.min(parent.width - 96, parent.height - 196))
height: width
anchors.horizontalCenter: parent.horizontalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: 48
anchors.rightMargin: 48
anchors.topMargin: 96
anchors.bottomMargin: 132
scopeMode: 1
traceColor: theme.accent
albumContrast: true
}
}
}
@@ -234,6 +234,7 @@ Rectangle {
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
playedColor: theme.accent
renderMode: 1
}
// Buffered / cache line.
+5 -4
View File
@@ -308,7 +308,7 @@ Rectangle {
// dock (AppShell.slint's large-active padding).
Item {
width: parent.width
height: 20
height: 32
Text {
x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
anchors.verticalCenter: parent.verticalCenter
@@ -323,7 +323,7 @@ Rectangle {
anchors.right: parent.right
anchors.leftMargin: (root.dockWidth > 0 ? root.dockWidth + 6 : 6) + (QbzPlayer.npHasTrack ? 46 : 0)
anchors.rightMargin: 6 + (QbzPlayer.npHasTrack ? 46 : 0)
height: root.waveformVisible ? 18 : 4
height: root.waveformVisible ? 30 : 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: root.waveformVisible ? "transparent" : theme.surfaceElevated
@@ -337,6 +337,7 @@ Rectangle {
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
theme.textMuted.b, 0.35)
playedColor: theme.accent
renderMode: 0
}
// Buffered / cache line — SeekBar.slint:58 paints it
// text-muted @0.35, NOT border-muted: border-muted is an
@@ -406,13 +407,13 @@ Rectangle {
}
}
Item { width: 1; height: 6 }
Item { width: 1; height: 2 }
// --- Controls: the responsive symmetric zones -----------------------
Item {
id: controlsLayout
width: parent.width
height: parent.height - 38
height: parent.height - 40
// The three zones keep PLAY centred and the right cluster pinned,
// but the SongCard is sized against the controls themselves, not
+2 -2
View File
@@ -446,7 +446,7 @@ Rectangle {
sourceComponent: ScopePanel {
compact: true
scopeMode: 0
traceColor: theme.accent
traceColor: band.topColor
}
}
Loader {
@@ -455,7 +455,7 @@ Rectangle {
sourceComponent: ScopePanel {
compact: true
scopeMode: 1
traceColor: theme.accent
traceColor: band.topColor
}
}