diff --git a/contrib/quickshell/BandStream.qml b/contrib/quickshell/BandStream.qml new file mode 100644 index 0000000..d96e42a --- /dev/null +++ b/contrib/quickshell/BandStream.qml @@ -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 + } +} diff --git a/contrib/quickshell/MediaIcon.qml b/contrib/quickshell/MediaIcon.qml new file mode 100644 index 0000000..63f2e17 --- /dev/null +++ b/contrib/quickshell/MediaIcon.qml @@ -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(); + } + } +} diff --git a/contrib/quickshell/NowPlaying.qml b/contrib/quickshell/NowPlaying.qml new file mode 100644 index 0000000..9f717ff --- /dev/null +++ b/contrib/quickshell/NowPlaying.qml @@ -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 } + } + } +} diff --git a/contrib/quickshell/README.md b/contrib/quickshell/README.md new file mode 100644 index 0000000..19e5731 --- /dev/null +++ b/contrib/quickshell/README.md @@ -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. diff --git a/contrib/quickshell/TransportButton.qml b/contrib/quickshell/TransportButton.qml new file mode 100644 index 0000000..de05237 --- /dev/null +++ b/contrib/quickshell/TransportButton.qml @@ -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() } + } +} diff --git a/contrib/quickshell/Visualizer.qml b/contrib/quickshell/Visualizer.qml new file mode 100644 index 0000000..9715a0f --- /dev/null +++ b/contrib/quickshell/Visualizer.qml @@ -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); + } + } + } +} diff --git a/contrib/quickshell/shell.qml b/contrib/quickshell/shell.qml new file mode 100644 index 0000000..8c6a887 --- /dev/null +++ b/contrib/quickshell/shell.qml @@ -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(); + } + } + } + } +}