diff --git a/crates/qbz-qt/cxx/scope_trace_item.cpp b/crates/qbz-qt/cxx/scope_trace_item.cpp index 53e94f80b..96d257171 100644 --- a/crates/qbz-qt/cxx/scope_trace_item.cpp +++ b/crates/qbz-qt/cxx/scope_trace_item.cpp @@ -109,7 +109,18 @@ QVector 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(-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(1.0, height)), 4); + const qreal wrapped = std::fmod(index * repeats, count - 1.0); + const int first = qBound(0, static_cast(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(-1.0, sample * gain, 1.0); points.push_back(QPointF(pad + index * drawWidth / qMax(1, count - 1), pad + (0.5 - value * 0.47) * drawHeight)); } diff --git a/crates/qbz-qt/cxx/seek_waveform_item.cpp b/crates/qbz-qt/cxx/seek_waveform_item.cpp index 0e9c69def..909cc2a22 100644 --- a/crates/qbz-qt/cxx/seek_waveform_item.cpp +++ b/crates/qbz-qt/cxx/seek_waveform_item.cpp @@ -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 &values, qreal width, qreal height, - const QColor &color) + const QColor &color, + int renderMode) { auto *geometry = node->geometry(); const int count = static_cast(values.size()); + const float centre = static_cast(height * 0.5); + const float halfHeight = std::max(1.0f, static_cast(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(width), + centre + railHalf, color, 0.32f, 0.32f); + if (count < 2) { + node->markDirty(QSGNode::DirtyGeometry); + return; + } + + const float lineHalf = std::clamp(static_cast(height * 0.085), 0.62f, 1.15f); + const float swing = static_cast(height * 0.36); + auto pointAt = [&](int index) { + const float x = index * static_cast(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((first + normal).x()), + static_cast((first + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 1], static_cast((first - normal).x()), + static_cast((first - normal).y()), color, 0.92f); + setVertex(vertices[vertex + 2], static_cast((second + normal).x()), + static_cast((second + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 3], static_cast((second + normal).x()), + static_cast((second + normal).y()), color, 0.92f); + setVertex(vertices[vertex + 4], static_cast((first - normal).x()), + static_cast((first - normal).y()), color, 0.92f); + setVertex(vertices[vertex + 5], static_cast((second - normal).x()), + static_cast((second - normal).y()), color, 0.92f); + vertex += 6; + } + node->markDirty(QSGNode::DirtyGeometry); + return; + } + const int visibleBars = static_cast(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(height * 0.5); - const float halfHeight = std::max(1.0f, static_cast(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(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(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 renderValues(const QVariantList &source, int columns) +std::vector renderValues(const QVariantList &source, int columns, float gamma) { if (source.isEmpty() || columns < 2) return {}; @@ -165,7 +219,7 @@ std::vector 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 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(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(std::ceil(width() / 9.0)), 192) + : qBound(40, static_cast(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; } diff --git a/crates/qbz-qt/cxx/seek_waveform_item.h b/crates/qbz-qt/cxx/seek_waveform_item.h index 5c206431a..942122cb3 100644 --- a/crates/qbz-qt/cxx/seek_waveform_item.h +++ b/crates/qbz-qt/cxx/seek_waveform_item.h @@ -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; }; diff --git a/crates/qbz-qt/qml/controls/ScopePanel.qml b/crates/qbz-qt/qml/controls/ScopePanel.qml index ccbba5d9b..a1879c779 100644 --- a/crates/qbz-qt/qml/controls/ScopePanel.qml +++ b/crates/qbz-qt/qml/controls/ScopePanel.qml @@ -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 } } diff --git a/crates/qbz-qt/qml/immersive/ImmersiveView.qml b/crates/qbz-qt/qml/immersive/ImmersiveView.qml index 7e47de4f0..25b32ce8a 100644 --- a/crates/qbz-qt/qml/immersive/ImmersiveView.qml +++ b/crates/qbz-qt/qml/immersive/ImmersiveView.qml @@ -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 } } } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 590bbdf7a..2c3ad60e3 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -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. diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 6e47163c9..c273f4279 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/SpectrumBand.qml b/crates/qbz-qt/qml/shell/SpectrumBand.qml index fd6420f2c..36473a259 100644 --- a/crates/qbz-qt/qml/shell/SpectrumBand.qml +++ b/crates/qbz-qt/qml/shell/SpectrumBand.qml @@ -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 } }