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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user