226 lines
8.0 KiB
QML
226 lines
8.0 KiB
QML
import QtQuick
|
|
import QtQuick.Shapes
|
|
import QtQuick.Window
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
import "root:/config"
|
|
import "root:/components"
|
|
import "root:/services"
|
|
import "root:/popouts"
|
|
|
|
// Now-playing module. Collapses to nothing when no player is around.
|
|
BarPill {
|
|
id: root
|
|
|
|
required property var screenRef
|
|
|
|
accent: Theme.primary
|
|
padding: Theme.padS
|
|
visible: Media.hasPlayer || width > 1
|
|
|
|
// Track position, drawn by the pill in its own rule band.
|
|
progress: Media.hasTimeline ? Media.progress : -1
|
|
|
|
implicitWidth: Media.hasPlayer
|
|
? layout.implicitWidth + contentPad * 2
|
|
: 0
|
|
|
|
Behavior on implicitWidth {
|
|
NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo }
|
|
}
|
|
|
|
clip: true
|
|
|
|
onClicked: Media.playPause()
|
|
onRightClicked: Media.next()
|
|
onMiddleClicked: Media.raise()
|
|
onScrolled: delta => delta > 0 ? Media.next() : Media.previous()
|
|
|
|
Row {
|
|
id: layout
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.left: parent.left
|
|
spacing: Theme.padS
|
|
|
|
// Album art, rounded, in a hairline frame.
|
|
ClippingRectangle {
|
|
id: artFrame
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 24
|
|
height: 24
|
|
radius: Theme.radiusS
|
|
color: Theme.surface
|
|
border.width: 1
|
|
border.color: Theme.overlay
|
|
|
|
Image {
|
|
id: art
|
|
anchors.fill: parent
|
|
anchors.margins: 1
|
|
source: Media.artUrl
|
|
fillMode: Image.PreserveAspectCrop
|
|
cache: true
|
|
asynchronous: true
|
|
visible: status === Image.Ready
|
|
sourceSize.width: 48
|
|
sourceSize.height: 48
|
|
}
|
|
|
|
// Fallback: the source app's glyph.
|
|
Icon {
|
|
anchors.centerIn: parent
|
|
visible: art.status !== Image.Ready
|
|
text: Media.sourceIcon
|
|
font.pixelSize: Theme.fsLarge
|
|
color: Theme.primary
|
|
}
|
|
}
|
|
|
|
// Title and artist size to the text, up to a cap.
|
|
//
|
|
// These used to be a flat 130px wide each, which meant a short title
|
|
// still reserved 130px and left a hand-sized hole between the artist and
|
|
// the equaliser — the pill looked broken rather than compact. They take
|
|
// what they need now and only start scrolling past the cap.
|
|
Column {
|
|
id: meta
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 0
|
|
|
|
readonly property real cap: 150
|
|
|
|
Text {
|
|
width: Math.min(meta.cap, implicitWidth)
|
|
height: 14
|
|
text: Media.title || Media.identity
|
|
color: Theme.text
|
|
elide: Text.ElideRight
|
|
font.pixelSize: Theme.fsSmall
|
|
font.family: Theme.fontDisplay
|
|
font.weight: Font.DemiBold
|
|
renderType: Text.NativeRendering
|
|
}
|
|
|
|
Text {
|
|
width: Math.min(meta.cap, implicitWidth)
|
|
height: 12
|
|
text: Media.artist || Media.album || Media.identity
|
|
color: Theme.muted
|
|
elide: Text.ElideRight
|
|
font.pixelSize: Theme.fsMicro
|
|
font.family: Theme.fontMono
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
// Signal meter: five thin rounded bars, NieR's audio readout rather than a
|
|
// chunky equaliser. It moves only while audio is playing and settles
|
|
// into a row of dots when paused — the channel is open, there
|
|
// is just nothing on it.
|
|
//
|
|
// Sampled from a timer rather than tweened by an infinite
|
|
// NumberAnimation. DP-1 runs at 240 Hz, and a frame-synced animation
|
|
// here repainted the entire bar 240 times a second: this pill sits
|
|
// inside ancestors that use layer.enabled together with
|
|
// Shape.CurveRenderer, and the album art carries a MultiEffect pass, so
|
|
// every one of those frames re-rendered several layer textures. Measured
|
|
// on its own, this one animation cost about two thirds of a CPU core
|
|
// whenever anything was playing.
|
|
//
|
|
// The timer samples a sine, it does not step through arbitrary levels: a
|
|
// low tick rate only looks like the original if consecutive ticks stay
|
|
// near each other. Each bar keeps the eased breathing motion and the
|
|
// period it had before — the curve is just read 25 times a second
|
|
// instead of 240.
|
|
Row {
|
|
id: eq
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 3
|
|
|
|
// Sampling interval — the one knob trading smoothness against cost.
|
|
// Frames are the whole expense: a full-bar repaint runs about 0.3% of
|
|
// a core (the bar spans 2544x40 on a 240 Hz output), so cost scales
|
|
// linearly with this rate. 16 ms holds 60 Hz, which is visually
|
|
// indistinguishable from the original frame-synced tween at a quarter
|
|
// of its ~75%.
|
|
readonly property int tickMs: 16
|
|
property int phase: 0
|
|
|
|
// Reproduces the original per-bar tween exactly, sampled instead of
|
|
// frame-synced. Each bar eased between two heights with its own pair
|
|
// of durations, so the three differed in both swing and period — the
|
|
// middle bar moved barely a pixel while the outer two ran in
|
|
// opposition. Giving all three the full swing, as a single symmetric
|
|
// sine would, is three times the motion and reads far busier than
|
|
// this meter is supposed to.
|
|
function level(i: int): real {
|
|
const lo = 2 + (i * 3) % 5;
|
|
const hi = 14 - (i * 4) % 7;
|
|
const d1 = 320 + i * 90; // ...and their durations
|
|
const d2 = 280 + i * 70;
|
|
|
|
const t = (eq.phase * eq.tickMs) % (d1 + d2);
|
|
// Easing.InOutSine, which is what both halves used.
|
|
const ease = x => (1 - Math.cos(Math.PI * x)) / 2;
|
|
|
|
return t < d1
|
|
? hi + (lo - hi) * ease(t / d1)
|
|
: lo + (hi - lo) * ease((t - d1) / d2);
|
|
}
|
|
|
|
Timer {
|
|
interval: eq.tickMs
|
|
running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) && !GameMode.active
|
|
repeat: true
|
|
onTriggered: eq.phase++
|
|
// Restart the cycle from its trough, so playback always begins
|
|
// from a settled meter rather than mid-swing.
|
|
onRunningChanged: if (!running) eq.phase = 0
|
|
}
|
|
|
|
Repeater {
|
|
model: 5
|
|
|
|
Rectangle {
|
|
required property int index
|
|
width: 2
|
|
radius: 1
|
|
height: Media.playing ? eq.level(index) : 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Media.playing ? Theme.primary : Theme.muted
|
|
|
|
Behavior on color {
|
|
ColorAnimation { duration: Theme.durBase }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onVisibleChanged: if (visible) titleGlitch.restart()
|
|
|
|
Connections {
|
|
target: Media
|
|
function onTrackChanged() { titleGlitch.restart(); }
|
|
}
|
|
|
|
// A new track resolves in, the way NieR text does: a brief dip and back.
|
|
SequentialAnimation {
|
|
id: titleGlitch
|
|
NumberAnimation {
|
|
target: meta; property: "opacity"
|
|
to: 0.25; duration: 60
|
|
}
|
|
NumberAnimation {
|
|
target: meta; property: "opacity"
|
|
to: 1; duration: Theme.durSlow; easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
|
|
MediaPopout {
|
|
id: popout
|
|
anchorItem: root
|
|
triggerHovered: root.hovered && Media.hasPlayer
|
|
}
|
|
}
|