Files
dotfiles/quickshell/services/Media.qml
T
nvrl c36a74e861 feat(quickshell): idle chain, drifting wallpaper, and a few fixes
- Idle is handled in the shell instead of hypridle: the screen dims
  slowly at 4 minutes, locks at 5, powers down at 6, and suspends at 15
  on battery. Idle inhibitors hold every stage off; Super+Shift+I does
  the same by hand and puts a cup on the rail.
- The shell draws the wallpaper (backdrop/), so hyprpaper is gone. The
  picture is drawn a little larger than the screen and drifts with the
  workspace a beat after the windows, and dims a shade while windows are
  open. The path lives in quickshell/wallpaper.
- The lock screen and power menu say "Take your time." instead of the
  NieR slogan.
- hyprpaper is no longer launched twice, the media module keeps the
  player that played last instead of an empty browser tab, and grouped
  windows show thin untitled tabs with their place in the group (2/3)
  in the bar title.
- qt6ct uses Darkly; the package list gains adw-gtk-theme and darkly-bin.
2026-10-04 23:10:19 +02:00

122 lines
4.9 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
// MPRIS. Prefers whichever player is actually playing, falling back to the last
// one that was, so the bar does not flip between Spotify and a stray browser tab.
Singleton {
id: root
property string preferredId: ""
// The player that was playing most recently, so pausing it keeps it in
// the bar instead of handing the slot to whichever player is listed first.
property string lastPlayingId: ""
readonly property var players: Mpris.players?.values ?? []
readonly property bool hasPlayer: active !== null
readonly property MprisPlayer active: {
if (players.length === 0) return null;
// An explicit pick wins, while it still exists.
if (root.preferredId !== "") {
for (const p of players) if (p.dbusName === root.preferredId) return p;
}
for (const p of players) if (p.isPlaying) return p;
if (root.lastPlayingId !== "") {
for (const p of players) if (p.dbusName === root.lastPlayingId) return p;
}
// Browsers register a player for every tab with a media element, most
// of them with no track at all. Prefer one that has something to show.
for (const p of players) if (p.trackTitle) return p;
return players[0];
}
// Remember whichever player was playing last. Bound through the active
// player, so it updates whenever playback starts anywhere.
readonly property string playingId: {
for (const p of players) if (p.isPlaying) return p.dbusName;
return "";
}
onPlayingIdChanged: if (root.playingId !== "") root.lastPlayingId = root.playingId
readonly property string title: active?.trackTitle || ""
readonly property string artist: active?.trackArtist || ""
readonly property string album: active?.trackAlbum || ""
readonly property string artUrl: active?.trackArtUrl || ""
readonly property string identity: active?.identity || ""
readonly property bool playing: active?.isPlaying ?? false
readonly property bool canGoNext: active?.canGoNext ?? false
readonly property bool canGoPrevious: active?.canGoPrevious ?? false
readonly property bool positionSupported: active?.positionSupported ?? false
readonly property bool lengthSupported: active?.lengthSupported ?? false
readonly property bool hasTimeline: positionSupported && lengthSupported && length > 0
readonly property bool canSeek: (active?.canSeek ?? false) && hasTimeline
readonly property real length: lengthSupported ? (active?.length ?? 0) : 0
readonly property real position: positionSupported ? (active?.position ?? 0) : 0
readonly property real progress: hasTimeline ? Math.min(1, position / length) : 0
readonly property string label: {
if (!hasPlayer) return "Nothing playing";
if (title === "") return identity || "Unknown track";
if (artist === "") return title;
return artist + " — " + title;
}
signal trackChanged()
onTitleChanged: trackChanged()
// MPRIS position is pull-only; tick it so the seek bar moves.
Timer {
interval: 1000
running: root.playing && root.positionSupported
repeat: true
onTriggered: if (root.active) root.active.positionChanged()
}
function playPause() { if (active?.canTogglePlaying) active.togglePlaying(); }
function next() { if (canGoNext) active.next(); }
function previous() { if (canGoPrevious) active.previous(); }
function seekTo(fraction: real) {
if (!canSeek || length <= 0) return;
active.position = Math.max(0, Math.min(length, fraction * length));
}
function raise() { if (active?.canRaise) active.raise(); }
function select(player: MprisPlayer) {
root.preferredId = player ? player.dbusName : "";
}
// Nerd Font glyph for the source app, so the bar reads at a glance.
readonly property string sourceIcon: {
const id = (identity || "").toLowerCase();
if (id.includes("spotify")) return "";
if (id.includes("firefox")) return "";
if (id.includes("chrom") || id.includes("brave")) return "";
if (id.includes("mpv")) return "";
if (id.includes("vlc")) return "󰕼";
if (id.includes("jellyfin")) return "󰋩";
if (id.includes("youtube")) return "";
return "󰝚";
}
// Quickshell exposes MPRIS position/length in seconds (with millisecond
// precision), not the protocol's raw microseconds.
function timeString(seconds: real): string {
if (!(seconds > 0)) return "0:00";
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return h + ":" + String(m % 60).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
return m + ":" + String(s).padStart(2, "0");
}
}