230 lines
8.1 KiB
QML
230 lines
8.1 KiB
QML
import QtQuick
|
|
import QtQuick.Shapes
|
|
import QtQuick.Window
|
|
import Quickshell
|
|
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, chopped into the house shape.
|
|
Item {
|
|
id: artFrame
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 24
|
|
height: 24
|
|
|
|
Shape {
|
|
id: artMask
|
|
anchors.fill: parent
|
|
visible: false
|
|
layer.enabled: true
|
|
layer.smooth: true
|
|
preferredRendererType: Shape.CurveRenderer
|
|
ShapePath {
|
|
fillColor: "white"
|
|
strokeColor: "transparent"
|
|
PathPolyline {
|
|
path: Geom.chamfer(Geom.parallelogram(24, 24, 0.18), 5, [1, 3])
|
|
}
|
|
}
|
|
}
|
|
|
|
Image {
|
|
id: art
|
|
anchors.fill: parent
|
|
source: Media.artUrl
|
|
fillMode: Image.PreserveAspectCrop
|
|
cache: true
|
|
asynchronous: true
|
|
visible: status === Image.Ready
|
|
layer.enabled: true
|
|
layer.smooth: true
|
|
layer.effect: MaskedArt { maskItem: artMask }
|
|
}
|
|
|
|
// 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
|
|
font.italic: true
|
|
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
|
|
}
|
|
}
|
|
|
|
// Compact play-state equaliser. It moves only while audio is playing
|
|
// and freezes into three quiet bars when paused.
|
|
//
|
|
// 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: 2
|
|
|
|
// 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 = 4 + (i * 5) % 13; // old first NumberAnimation `to`
|
|
const hi = 14 - (i * 4) % 9; // old second NumberAnimation `to`
|
|
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)
|
|
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: 3
|
|
|
|
Rectangle {
|
|
required property int index
|
|
width: 3
|
|
height: Media.playing ? eq.level(index) : 6
|
|
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(); }
|
|
}
|
|
|
|
SequentialAnimation {
|
|
id: titleGlitch
|
|
NumberAnimation {
|
|
target: artFrame; property: "rotation"
|
|
from: -8; to: 0; duration: Theme.durSlow; easing.type: Easing.OutBack
|
|
}
|
|
}
|
|
|
|
MediaPopout {
|
|
id: popout
|
|
anchorItem: root
|
|
triggerHovered: root.hovered && Media.hasPlayer
|
|
}
|
|
}
|