Files
dotfiles/quickshell/bar/MediaPill.qml
T

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
}
}