277 lines
9.4 KiB
QML
277 lines
9.4 KiB
QML
// Compact now-playing card for cliamp. Dense Winamp-style layout.
|
|
//
|
|
// Layout (300x72, sharp edges):
|
|
// row 1: title (bold) ............................ time (mm:ss/mm:ss)
|
|
// row 2: artist (dim) ............................ << play/pause >>
|
|
// row 3: 10-band spectrum visualizer (full width)
|
|
// row 4: thin seekable progress line
|
|
//
|
|
// 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
|
|
|
|
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);
|
|
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
|
|
readonly property real progress: len > 0 ? Math.min(1, livePosition / len) : 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.visible && root.ready
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 0
|
|
color: Qt.rgba(root.bg.r, root.bg.g, root.bg.b, 0.94)
|
|
border.color: root.edge
|
|
border.width: 1
|
|
}
|
|
|
|
Item {
|
|
id: inner
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
anchors.rightMargin: 8
|
|
anchors.topMargin: 5
|
|
anchors.bottomMargin: 5
|
|
|
|
Text {
|
|
id: titleT
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: timeT.left
|
|
anchors.rightMargin: 8
|
|
height: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
text: root.ready ? (root.player.trackTitle || qsTr("Unknown title"))
|
|
: qsTr("cliamp: not running")
|
|
color: root.fg
|
|
font.family: "monospace"
|
|
font.pixelSize: 12
|
|
font.bold: true
|
|
textFormat: Text.PlainText
|
|
}
|
|
Text {
|
|
id: timeT
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
height: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: root.fmt(root.livePosition) + "/" + root.fmt(root.len)
|
|
color: root.dim
|
|
font.family: "monospace"
|
|
font.pixelSize: 10
|
|
}
|
|
|
|
// Explicit width/height on each button overrides TransportButton's
|
|
// implicit padding so the row stays compact. The row width tracks
|
|
// timeT.width so the transport cluster occupies the same horizontal
|
|
// column as the timestamp above it; spacing is computed to evenly
|
|
// distribute the three buttons across that width.
|
|
Row {
|
|
id: transport
|
|
anchors.top: titleT.bottom
|
|
anchors.topMargin: 2
|
|
anchors.right: parent.right
|
|
width: timeT.width
|
|
height: 16
|
|
spacing: Math.max(2, (width - 52) / 2)
|
|
|
|
TransportButton {
|
|
width: 16; height: 16
|
|
shape: "prev"
|
|
accessibleName: qsTr("Previous track")
|
|
iconSize: 10
|
|
enabled: root.ready && root.player.canGoPrevious
|
|
fgColor: root.dim
|
|
hoverColor: root.yellow
|
|
onActivated: root.player.previous()
|
|
}
|
|
TransportButton {
|
|
width: 20; height: 16
|
|
shape: root.playing ? "pause" : "play"
|
|
accessibleName: root.playing ? qsTr("Pause") : qsTr("Play")
|
|
iconSize: 12
|
|
enabled: root.ready && root.player.canTogglePlaying
|
|
fgColor: root.accent
|
|
hoverColor: root.green
|
|
onActivated: root.player.togglePlaying()
|
|
}
|
|
TransportButton {
|
|
width: 16; height: 16
|
|
shape: "next"
|
|
accessibleName: qsTr("Next track")
|
|
iconSize: 10
|
|
enabled: root.ready && root.player.canGoNext
|
|
fgColor: root.dim
|
|
hoverColor: root.yellow
|
|
onActivated: root.player.next()
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: artistT
|
|
anchors.top: titleT.bottom
|
|
anchors.topMargin: 2
|
|
anchors.left: parent.left
|
|
anchors.right: transport.left
|
|
anchors.rightMargin: 8
|
|
height: 16
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
text: root.ready ? (root.player.trackArtist || "") : ""
|
|
color: root.dim
|
|
font.family: "monospace"
|
|
font.pixelSize: 11
|
|
textFormat: Text.PlainText
|
|
}
|
|
|
|
Visualizer {
|
|
id: vis
|
|
anchors.top: artistT.bottom
|
|
anchors.topMargin: 2
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 22
|
|
bands: stream.bands
|
|
barColor: root.green
|
|
accentColor: root.yellow
|
|
warnColor: root.red
|
|
segH: 2
|
|
segGap: 1
|
|
}
|
|
|
|
Item {
|
|
id: barWrap
|
|
anchors.top: vis.bottom
|
|
anchors.topMargin: 3
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 4
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width
|
|
height: 1
|
|
color: root.dim
|
|
opacity: 0.45
|
|
radius: 0
|
|
}
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: 1
|
|
width: parent.width * root.progress
|
|
color: root.accent
|
|
radius: 0
|
|
}
|
|
Rectangle {
|
|
visible: root.ready && root.len > 0
|
|
width: 4; height: 4; radius: 0
|
|
color: root.accent
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: Math.max(0, Math.min(parent.width - width,
|
|
parent.width * root.progress - width / 2))
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
// Expand the hit area vertically so the 1px line is actually clickable.
|
|
anchors.topMargin: -4
|
|
anchors.bottomMargin: -4
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|