contrib/quickshell: add now-playing widget for Omarchy

A notification-sized Quickshell card centered along the bottom of
each screen, driven by cliamp's MPRIS service for transport and
position and by cliamp visstream for the live spectrum.

Renders a Winamp 2-style segmented spectrum analyzer with falling
peak caps and pristine Canvas2D transport icons. Theme is read
directly from the active Omarchy theme via FileView watching
~/.config/omarchy/current/theme/colors.toml so theme swaps apply
live. Sharp 90-degree corners throughout. Press Esc or Q to exit
the widget (click first to grant keyboard focus).
This commit is contained in:
Bjarne Øverli
2026-05-14 19:11:28 +02:00
parent e7dc6d447b
commit 568a734862
7 changed files with 754 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
// Long-lived `cliamp visstream` Process that parses one NDJSON frame per
// line and exposes the latest bands + visualizer mode as reactive properties.
//
// Uses imperative running control (not binding) so the respawn timer can
// flip the process back on after cliamp restarts.
import Quickshell.Io
import QtQuick
Item {
id: root
property int fps: 30
property bool enabled: true
property var bands: []
property string mode: ""
function _parseLine(line) {
if (!line) return;
try {
const resp = JSON.parse(line);
if (!resp || !resp.ok) return;
if (resp.bands) root.bands = resp.bands;
if (resp.visualizer) root.mode = resp.visualizer;
} catch (e) { /* ignore parse errors */ }
}
Process {
id: proc
command: ["cliamp", "visstream", "--fps", String(root.fps)]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (line) => root._parseLine(line)
}
}
Component.onCompleted: if (root.enabled) proc.running = true
onEnabledChanged: proc.running = root.enabled
// Respawn loop: if cliamp wasn't up yet (or restarted), keep retrying.
Timer {
interval: 2000
running: root.enabled && !proc.running
repeat: true
onTriggered: proc.running = true
}
}
+132
View File
@@ -0,0 +1,132 @@
// Crisp, font-independent media transport icons drawn with Canvas2D.
//
// Shapes: "prev", "play", "pause", "next", "stop". The icon fills a square
// bounded by implicitWidth x implicitHeight (default 14 x 14) and is centred
// inside that square. Color follows the active theme.
import QtQuick
Item {
id: root
property string shape: "play"
property color color: "#d4be98"
property real size: 14
implicitWidth: size
implicitHeight: size
onShapeChanged: canvas.requestPaint()
onColorChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
const ctx = getContext("2d");
ctx.reset();
const w = width, h = height;
const s = Math.min(w, h);
// Inset so strokes/triangle tips don't clip on the canvas edge.
const pad = Math.max(1, Math.round(s * 0.12));
const x0 = (w - s) / 2 + pad;
const y0 = (h - s) / 2 + pad;
const sz = s - pad * 2;
ctx.fillStyle = root.color;
ctx.strokeStyle = root.color;
ctx.lineJoin = "round";
ctx.lineCap = "round";
switch (root.shape) {
case "prev": drawPrev(ctx, x0, y0, sz); break;
case "next": drawNext(ctx, x0, y0, sz); break;
case "pause": drawPause(ctx, x0, y0, sz); break;
case "stop": drawStop(ctx, x0, y0, sz); break;
case "play":
default: drawPlay(ctx, x0, y0, sz); break;
}
}
function triangleRight(ctx, x, y, w, h) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + w, y + h / 2);
ctx.lineTo(x, y + h);
ctx.closePath();
ctx.fill();
}
function triangleLeft(ctx, x, y, w, h) {
ctx.beginPath();
ctx.moveTo(x + w, y);
ctx.lineTo(x, y + h / 2);
ctx.lineTo(x + w, y + h);
ctx.closePath();
ctx.fill();
}
function drawPlay(ctx, x0, y0, sz) {
// Single right triangle, slightly narrower than the bounds for balance.
triangleRight(ctx, x0, y0, sz, 0.92);
}
function drawPause(ctx, x0, y0, sz) {
const barW = Math.max(2, Math.round(sz * 0.28));
const gap = Math.max(2, Math.round(sz * 0.18));
const totalW = barW * 2 + gap;
const left = x0 + (sz - totalW) / 2;
ctx.fillRect(left, y0, barW, sz);
ctx.fillRect(left + barW + gap, y0, barW, sz);
}
function drawStop(ctx, x0, y0, sz) {
const side = sz * 0.86;
const inset = (sz - side) / 2;
ctx.fillRect(x0 + inset, y0 + inset, side, side);
}
function drawPrev(ctx, x0, y0, sz) {
// Vertical bar + left-pointing double triangle.
const barW = Math.max(1.5, sz * 0.14);
ctx.fillRect(x0, y0, barW, sz);
// Two stacked triangles forming the doubled arrow.
const tStart = x0 + barW + Math.max(1, sz * 0.06);
const tSpan = (x0 + sz) - tStart;
const tHalf = tSpan / 2;
ctx.beginPath();
ctx.moveTo(tStart, y0 + sz / 2);
ctx.lineTo(tStart + tHalf, y0);
ctx.lineTo(tStart + tHalf, y0 + sz);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(tStart + tHalf, y0 + sz / 2);
ctx.lineTo(tStart + tSpan, y0);
ctx.lineTo(tStart + tSpan, y0 + sz);
ctx.closePath();
ctx.fill();
}
function drawNext(ctx, x0, y0, sz) {
// Right-pointing double triangle + trailing vertical bar.
const barW = Math.max(1.5, sz * 0.14);
const barX = x0 + sz - barW;
ctx.fillRect(barX, y0, barW, sz);
const tEnd = barX - Math.max(1, sz * 0.06);
const tSpan = tEnd - x0;
const tHalf = tSpan / 2;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x0 + tHalf, y0 + sz / 2);
ctx.lineTo(x0, y0 + sz);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(x0 + tHalf, y0);
ctx.lineTo(tEnd, y0 + sz / 2);
ctx.lineTo(x0 + tHalf, y0 + sz);
ctx.closePath();
ctx.fill();
}
}
}
+271
View File
@@ -0,0 +1,271 @@
// Notification-sized now-playing card for cliamp. Visualizer-first.
//
// Layout (top to bottom):
// - prominent spectrum visualizer
// - title (bold) + artist (dim)
// - thin seekable progress bar + time readout
// - transport row: << play/pause >>
//
// Driven by an MprisPlayer for transport + position. Theme colors come from
// the active Omarchy theme at ~/.config/omarchy/current/theme/colors.toml,
// watched for changes so theme swaps update the widget live.
import Quickshell
import Quickshell.Services.Mpris
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
Item {
id: root
property var player: null
property color bg: "#181616"
property color edge: "#0d0c0c"
property color fg: "#c5c9c5"
property color dim: "#a6a69c"
property color accent: "#658594"
property color green: "#8a9a7b"
property color yellow: "#c4b28a"
property color red: "#c4746e"
FileView {
id: themeFile
path: (Quickshell.env("HOME") || "") + "/.config/omarchy/current/theme/colors.toml"
watchChanges: true
// Omarchy's theme swap does `rm -rf current/theme && mv next-theme current/theme`,
// so there's a brief window where the file genuinely doesn't exist. Quiet the
// log and schedule a retry instead of spamming warnings.
printErrors: false
onFileChanged: reload()
onLoaded: root._applyOmarchyTheme(text())
onLoadFailed: reloadTimer.restart()
}
Timer {
id: reloadTimer
interval: 400
repeat: false
onTriggered: themeFile.reload()
}
function _applyOmarchyTheme(src) {
if (!src) return;
const lines = String(src).split("\n");
const re = /^\s*([A-Za-z0-9_]+)\s*=\s*"?(#?[0-9A-Fa-f]+)"?\s*$/;
const t = {};
for (let i = 0; i < lines.length; ++i) {
const m = lines[i].match(re);
if (m) t[m[1]] = m[2];
}
if (t.background) root.bg = t.background;
if (t.foreground) root.fg = t.foreground;
if (t.accent) root.accent = t.accent;
if (t.color2) root.green = t.color2;
if (t.color3) root.yellow = t.color3;
if (t.color1) root.red = t.color1;
if (t.color8) root.dim = t.color8;
else root.dim = Qt.darker(root.fg, 1.7);
// Card border: prefer `selection_background` (subtle dark gray), fall
// back to `color8` (medium gray), then to a darkened foreground.
if (t.selection_background) root.edge = t.selection_background;
else if (t.color8) root.edge = t.color8;
else root.edge = Qt.darker(root.fg, 3.0);
}
readonly property bool ready: player !== null
readonly property bool playing: ready && player.isPlaying
readonly property real len: ready && player.lengthSupported ? player.length : 0
property real livePosition: 0
Timer {
interval: 250
running: root.ready && root.playing
repeat: true
onTriggered: root.livePosition = root.player.position
}
Connections {
target: root.player
function onPlaybackStateChanged() { root.livePosition = root.player.position }
function onTrackTitleChanged() { root.livePosition = root.player.position }
function onPositionChanged() { root.livePosition = root.player.position }
}
function fmt(seconds) {
if (!isFinite(seconds) || seconds < 0) return "--:--";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return m + ":" + (r < 10 ? "0" : "") + r;
}
BandStream {
id: stream
fps: 30
enabled: root.ready
}
Rectangle {
anchors.fill: parent
radius: 0
color: Qt.rgba(root.bg.r, root.bg.g, root.bg.b, 0.92)
border.color: root.edge
border.width: 1
}
Visualizer {
id: vis
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 10
anchors.leftMargin: 6
anchors.rightMargin: 6
height: 40
bands: stream.bands
barColor: root.accent
accentColor: root.yellow
warnColor: root.red
}
ColumnLayout {
anchors.top: vis.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.bottomMargin: 8
anchors.topMargin: 6
spacing: 5
RowLayout {
Layout.fillWidth: true
spacing: 6
MediaIcon {
shape: root.playing ? "play" : "pause"
color: root.green
size: 10
opacity: root.ready ? 1.0 : 0.35
Layout.preferredWidth: 12
Layout.preferredHeight: 12
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.ready ? (root.player.trackTitle || "Unknown title")
: "cliamp: not running"
color: root.fg
font.family: "monospace"
font.pixelSize: 12
font.bold: true
textFormat: Text.PlainText
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.ready ? (root.player.trackArtist || "")
: ""
color: root.dim
font.family: "monospace"
font.pixelSize: 10
visible: text.length > 0
textFormat: Text.PlainText
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Item {
id: barWrap
Layout.fillWidth: true
Layout.preferredHeight: 10
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 2
color: root.dim
opacity: 0.5
radius: 0
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
height: 2
width: parent.width * (root.len > 0 ? Math.min(1, root.livePosition / root.len) : 0)
color: root.accent
radius: 0
}
Rectangle {
visible: root.ready && root.len > 0
width: 6; height: 6; radius: 0
color: root.accent
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(parent.width - width,
parent.width * (root.livePosition / root.len) - width / 2))
}
MouseArea {
anchors.fill: parent
enabled: root.ready && root.player.canSeek && root.len > 0
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: (mouse) => {
const frac = Math.max(0, Math.min(1, mouse.x / width));
const target = frac * root.len;
root.player.position = target;
root.livePosition = target;
}
}
}
Text {
text: root.fmt(root.livePosition) + " / " + root.fmt(root.len)
color: root.dim
font.family: "monospace"
font.pixelSize: 9
Layout.preferredWidth: 76
horizontalAlignment: Text.AlignRight
}
}
RowLayout {
Layout.fillWidth: true
spacing: 16
Item { Layout.fillWidth: true }
TransportButton {
shape: "prev"
iconSize: 12
enabled: root.ready && root.player.canGoPrevious
fgColor: root.fg
hoverColor: root.yellow
onActivated: root.player.previous()
}
TransportButton {
shape: root.playing ? "pause" : "play"
iconSize: 14
enabled: root.ready && root.player.canTogglePlaying
fgColor: root.accent
hoverColor: root.green
onActivated: root.player.togglePlaying()
}
TransportButton {
shape: "next"
iconSize: 12
enabled: root.ready && root.player.canGoNext
fgColor: root.fg
hoverColor: root.yellow
onActivated: root.player.next()
}
Item { Layout.fillWidth: true }
}
}
}
+86
View File
@@ -0,0 +1,86 @@
# cliamp quickshell widget
A notification-sized "now playing" card for [Quickshell](https://quickshell.org), centered along the bottom of every screen and driven by cliamp's MPRIS service (`org.mpris.MediaPlayer2.cliamp`). Shows a prominent live spectrum visualizer, title, artist, a click-to-seek progress bar, time readout, and prev/play-pause/next buttons. Colors are picked up from the active Omarchy theme (`~/.config/omarchy/current/theme/colors.toml`) and update live when the theme changes. Hides itself when cliamp is not running. Click the card and press Esc or Q to quit the widget.
Linux only. Requires Quickshell 0.2+ and cliamp running with its default MPRIS service enabled (it is by default on Linux).
## Quick start
Run it directly without installing:
```sh
qs -p contrib/quickshell/shell.qml
```
Or install as a named Quickshell config:
```sh
mkdir -p ~/.config/quickshell
ln -s "$PWD/contrib/quickshell" ~/.config/quickshell/cliamp
qs -c cliamp
```
Then start cliamp in another terminal. The bar appears on every screen, anchored to the top edge.
## Customization
Theme colors come from Omarchy's active theme file:
```
~/.config/omarchy/current/theme/colors.toml
```
Mapping into the widget:
| Omarchy key | Widget role |
| --- | --- |
| `background` | card background |
| `foreground` | primary text (title) |
| `accent` | visualizer bars, progress fill |
| `color2` | playing indicator / play button (green slot) |
| `color3` | peak markers, hover (yellow slot) |
| `color1` | red slot |
| `selection_background` | card border (falls back to `color8`) |
| `color8` | secondary text / dim slot (falls back to a darker `foreground`) |
The card border uses `selection_background` (a muted dark gray in most Omarchy themes), falling back to `color8` if that key is missing. Switching theme rewrites `colors.toml` in place; the widget watches it and re-applies colors live (no reload needed).
To reposition the card, edit `shell.qml`. The defaults anchor a transparent full-width strip to the bottom of every screen and center a 320 x 140 card inside it with a 16 px gap from the edge:
```qml
PanelWindow {
anchors { bottom: true; left: true; right: true }
margins { bottom: 16 }
implicitHeight: 140
color: "transparent"
NowPlaying {
width: 320
height: parent.height
anchors.horizontalCenter: parent.horizontalCenter
}
}
```
Swap `bottom` for `top` to flip to the top edge, or replace the `horizontalCenter` anchor with `anchors.left` / `anchors.right` to push it into a corner.
## Files
| File | Purpose |
| --- | --- |
| `shell.qml` | Entry point. Wires up a `PanelWindow` per screen and finds cliamp on the MPRIS bus. |
| `NowPlaying.qml` | The bar widget itself. |
| `TransportButton.qml` | Reusable prev/play/next button (hover + enabled state). |
| `MediaIcon.qml` | Resolution-independent transport icons drawn with Canvas2D (prev / play / pause / next / stop). |
| `Visualizer.qml` | Canvas-based ClassicPeak spectrum (bars + falling peak markers). |
| `BandStream.qml` | Wraps `cliamp visstream` and exposes the live 10-band frames as reactive properties. |
## Notes
- The widget polls `MprisPlayer.position` on a 250 ms timer while playing, since Quickshell's MPRIS service does not emit reactive updates for position drift.
- Player detection uses `dbusName === "cliamp"` (cliamp registers the well-known name `org.mpris.MediaPlayer2.cliamp` in `mediactl/service_linux.go`).
- Clicking the progress bar issues an MPRIS `SetPosition`, which cliamp handles via `playback.SetPositionMsg`.
- Theme colors come from the active Omarchy theme via a `FileView` watching `~/.config/omarchy/current/theme/colors.toml`. The TOML is parsed in QML with a small regex (no external script). Theme swaps update the card without reloading Quickshell.
- Spectrum bands stream over the cliamp IPC socket via `cliamp visstream`. One long-lived subprocess per widget.
- The widget renders a Winamp 2-style spectrum analyzer: each band is a stack of LED segments with a tiny gap between them, with a falling peak cap. Three-tone gradient across the height — the Omarchy accent for the bottom rows, `color3` (yellow) for the middle, `color1` (red) for the top. The bar block spans the full card width, edge to edge.
- All UI elements use sharp 90-degree corners (no `radius`) to match a terminal aesthetic.
+36
View File
@@ -0,0 +1,36 @@
// Borderless transport button with hover color shift, drawing a MediaIcon
// instead of a font glyph. shape: "prev" | "play" | "pause" | "next" | "stop".
import QtQuick
Item {
id: root
property string shape: "play"
property color fgColor: "#d4be98"
property color hoverColor: "#d8a657"
property bool enabled: true
property real iconSize: 14
signal activated()
property bool hovered: false
implicitWidth: iconSize + 12
implicitHeight: iconSize + 8
MediaIcon {
anchors.centerIn: parent
shape: root.shape
size: root.iconSize
color: root.hovered && root.enabled ? root.hoverColor : root.fgColor
opacity: root.enabled ? 1.0 : 0.35
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: root.hovered = true
onExited: root.hovered = false
onClicked: { if (root.enabled) root.activated() }
}
}
+117
View File
@@ -0,0 +1,117 @@
// Winamp 2-inspired spectrum analyzer: stacked LED segments per band with
// falling peak caps. Three-tone gradient drawn over each bar (low / mid /
// high) so the column "lights up" the way the classic skin did.
//
// Driven by 10-band frames from BandStream. Colors come from the active
// Omarchy theme via NowPlaying.qml.
import QtQuick
Item {
id: root
property var bands: []
// Three-tone color stack. Bottom -> top: barColor (low), accentColor
// (mid), warnColor (top). The user passes the active theme accent,
// yellow, and red into these.
property color barColor: "#a9b665"
property color accentColor: "#d8a657"
property color warnColor: "#ea6962"
// Segment geometry. `segH` and `segGap` define the LED-stack look — keep
// segGap >= 1 so the dark line between segments stays visible.
property int segH: 3
property int segGap: 1
implicitWidth: 320
implicitHeight: 56
property var peaks: Array(10).fill(0)
Timer {
// Drives peak decay independent of band update rate. Skips the state
// write when nothing moved so a paused player doesn't allocate and
// emit a peaksChanged signal at 30 Hz.
interval: 33
running: true
repeat: true
onTriggered: {
const cur = root.peaks;
const next = cur.slice();
let dirty = false;
for (let i = 0; i < next.length; ++i) {
const v = root.bands[i] || 0;
const nv = v > next[i] ? v : Math.max(0, next[i] - 0.018);
if (nv !== cur[i]) dirty = true;
next[i] = nv;
}
if (dirty) {
root.peaks = next;
canvas.requestPaint();
}
}
}
onBandsChanged: canvas.requestPaint()
onBarColorChanged: canvas.requestPaint()
onAccentColorChanged: canvas.requestPaint()
onWarnColorChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d");
ctx.reset();
const w = width, h = height;
const b = root.bands || [];
const n = b.length || 10;
// Bar geometry: fill the full width, tight gaps.
const gap = 2;
const bw = Math.max(2, Math.floor((w - gap * (n - 1)) / n));
const xStart = Math.max(0, Math.floor((w - (bw * n + gap * (n - 1))) / 2));
// Number of LED rows that fit. Cap at 24-ish for the classic
// Winamp density.
const rows = Math.max(4, Math.floor(h / (root.segH + root.segGap)));
const lowRows = Math.round(rows * 0.55);
const midRows = Math.round(rows * 0.30);
// Build the per-row color stack once.
const rowColors = new Array(rows);
for (let r = 0; r < rows; ++r) {
if (r < lowRows) rowColors[r] = root.barColor;
else if (r < lowRows + midRows) rowColors[r] = root.accentColor;
else rowColors[r] = root.warnColor;
}
// Draw stacks.
for (let i = 0; i < n; ++i) {
const v = Math.max(0, Math.min(1, b[i] || 0));
const lit = Math.round(v * rows);
const x = xStart + i * (bw + gap);
for (let r = 0; r < lit; ++r) {
const y = h - (r + 1) * (root.segH + root.segGap) + root.segGap;
if (y < 0) break;
ctx.fillStyle = rowColors[r];
ctx.fillRect(x, y, bw, root.segH);
}
}
// Peak caps: one bright segment, theme-yellow, sitting at the
// top of the peak position.
ctx.fillStyle = root.accentColor;
for (let i = 0; i < n; ++i) {
const p = Math.max(0, Math.min(1, root.peaks[i] || 0));
if (p <= 0) continue;
const peakRow = Math.max(1, Math.round(p * rows));
const y = h - peakRow * (root.segH + root.segGap) + root.segGap;
if (y < 0) continue;
const x = xStart + i * (bw + gap);
ctx.fillRect(x, y, bw, root.segH);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// Entry point: run with `qs -p contrib/quickshell/shell.qml`
// or symlink this directory into ~/.config/quickshell/cliamp/ and run `qs -c cliamp`.
//
// Renders a notification-sized "now playing" card centered along the bottom of
// every screen, driven by cliamp's MPRIS service. Hides when cliamp is gone.
import Quickshell
import Quickshell.Services.Mpris
import Quickshell.Wayland
import QtQuick
Scope {
id: root
readonly property var cliampPlayer: {
for (let i = 0; i < Mpris.players.values.length; ++i) {
const p = Mpris.players.values[i];
if (p.dbusName === "cliamp" || p.identity === "Cliamp")
return p;
}
return null;
}
Variants {
model: Quickshell.screens
PanelWindow {
id: panel
required property var modelData
screen: modelData
// Full-width strip along the bottom; the card is centered inside it.
// The strip itself is transparent, so only the card is visible.
anchors {
bottom: true
left: true
right: true
}
margins { bottom: 16 }
exclusionMode: ExclusionMode.Ignore
// Take keyboard focus on demand so the user can press Esc/Q to
// dismiss the widget. Focus is acquired when the card is clicked.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
implicitHeight: 140
color: "transparent"
visible: root.cliampPlayer !== null
NowPlaying {
width: 320
height: parent.height
anchors.horizontalCenter: parent.horizontalCenter
player: root.cliampPlayer
focus: true
Keys.onPressed: (e) => {
if (e.key === Qt.Key_Escape || e.key === Qt.Key_Q) Qt.quit();
}
}
}
}
}