spotify-player: run a daemon; fix progress bar; add progress+volume to media card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2652d14a8f
commit
82de631064
2 changed files with 144 additions and 1 deletions
|
|
@ -133,7 +133,57 @@ in
|
||||||
# of the wheel is a dozen requests and a 429. Use PipeWire for volume
|
# of the wheel is a dozen requests and a 429. Use PipeWire for volume
|
||||||
# instead — same sink, instant, free.
|
# instead — same sink, instant, free.
|
||||||
enable_mouse_scroll_volume = false;
|
enable_mouse_scroll_volume = false;
|
||||||
|
# The librespot device is only usable once Spotify's backend has accepted
|
||||||
|
# it, which takes ~20s from process start — that is the wait before you can
|
||||||
|
# play anything, and it is server-side, so there is nothing local to speed
|
||||||
|
# up. Instead, pay it once at login: the daemon below holds the device for
|
||||||
|
# the whole session, and DaemonOnly keeps the TUI from registering a second
|
||||||
|
# one, so opening the TUI is instant and plays straight onto the daemon's
|
||||||
|
# device.
|
||||||
|
enable_streaming = "DaemonOnly";
|
||||||
};
|
};
|
||||||
|
# Rectangle gauges only ever use `playback_progress_bar`: ratatui paints the
|
||||||
|
# filled part in its fg and the rest in its bg, and `_unfilled` is ignored
|
||||||
|
# (it is Line-only). The upstream default is fg Green / bg BrightBlack, and
|
||||||
|
# stylix collapses the whole 16-colour palette onto three colours — Green
|
||||||
|
# #41afb2 and BrightBlack #3fa8ab are the same teal by eye, so the bar
|
||||||
|
# rendered as one solid block with no visible progress. Repainting it
|
||||||
|
# foreground-on-background is the only pair stylix guarantees is distinct.
|
||||||
|
# mkForce because stylix owns this list and find_theme takes the *first*
|
||||||
|
# match by name, so an appended second "stylix" would be ignored; the
|
||||||
|
# palette below reproduces stylix's own mapping so nothing else changes.
|
||||||
|
themes = lib.mkForce [
|
||||||
|
{
|
||||||
|
name = "stylix";
|
||||||
|
palette = with config.lib.stylix.colors.withHashtag; {
|
||||||
|
background = base00;
|
||||||
|
foreground = base05;
|
||||||
|
black = base00;
|
||||||
|
red = base08;
|
||||||
|
green = base0B;
|
||||||
|
yellow = base0A;
|
||||||
|
blue = base0D;
|
||||||
|
magenta = base0E;
|
||||||
|
cyan = base0C;
|
||||||
|
white = base05;
|
||||||
|
bright_black = base03;
|
||||||
|
bright_red = base08;
|
||||||
|
bright_green = base0B;
|
||||||
|
bright_yellow = base0A;
|
||||||
|
bright_blue = base0D;
|
||||||
|
bright_magenta = base0E;
|
||||||
|
bright_cyan = base0C;
|
||||||
|
bright_white = base07;
|
||||||
|
};
|
||||||
|
component_style = {
|
||||||
|
playback_progress_bar = {
|
||||||
|
fg = "BrightWhite";
|
||||||
|
bg = "Black";
|
||||||
|
};
|
||||||
|
playback_progress_bar_unfilled.bg = "Black";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
# The home-manager module only writes keymap.toml when `keymaps` is
|
# The home-manager module only writes keymap.toml when `keymaps` is
|
||||||
# non-empty, so `actions` alone would silently emit nothing. Restating a
|
# non-empty, so `actions` alone would silently emit nothing. Restating a
|
||||||
# default binding is the cheapest way to make the file exist.
|
# default binding is the cheapest way to make the file exist.
|
||||||
|
|
@ -166,11 +216,34 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Holds the librespot Spotify Connect device for the whole session, so the
|
||||||
|
# ~20s the Spotify backend takes to accept a new device is paid once at login
|
||||||
|
# instead of on every launch. Also means the MPRIS card in quickshell keeps
|
||||||
|
# working with no terminal open. Type=forking because the daemonize crate
|
||||||
|
# double-forks and the parent exits; it writes no pidfile, so systemd guesses.
|
||||||
|
systemd.user.services.spotify-player-daemon = lib.mkIf isDesktop {
|
||||||
|
Unit = {
|
||||||
|
Description = "spotify-player daemon (holds the Spotify Connect device)";
|
||||||
|
After = [ "graphical-session.target" "pipewire.service" ];
|
||||||
|
PartOf = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
Service = {
|
||||||
|
Type = "forking";
|
||||||
|
ExecStart = "${config.programs.spotify-player.package}/bin/spotify_player --daemon";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 10;
|
||||||
|
};
|
||||||
|
Install.WantedBy = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
|
||||||
# Upstream ships no .desktop file (TUI only), so the launcher can't find it.
|
# Upstream ships no .desktop file (TUI only), so the launcher can't find it.
|
||||||
|
# enable_media_control=false: media-control is not gated on `is_daemon`, so
|
||||||
|
# without this both the daemon and the TUI export org.mpris.MediaPlayer2 and
|
||||||
|
# quickshell would show two cards for one player. The daemon owns MPRIS.
|
||||||
xdg.desktopEntries.spotify-player = lib.mkIf isDesktop {
|
xdg.desktopEntries.spotify-player = lib.mkIf isDesktop {
|
||||||
name = "Spotify Player";
|
name = "Spotify Player";
|
||||||
genericName = "Music Player";
|
genericName = "Music Player";
|
||||||
exec = "ghostty --title=spotify-player -e spotify_player";
|
exec = "ghostty --title=spotify-player -e spotify_player -o enable_media_control=false";
|
||||||
icon = "spotify";
|
icon = "spotify";
|
||||||
terminal = false;
|
terminal = false;
|
||||||
categories = [ "Audio" "Music" "Player" "AudioVideo" ];
|
categories = [ "Audio" "Music" "Player" "AudioVideo" ];
|
||||||
|
|
|
||||||
|
|
@ -6159,6 +6159,14 @@ in
|
||||||
};
|
};
|
||||||
add(player.identity);
|
add(player.identity);
|
||||||
add(player.desktopEntry);
|
add(player.desktopEntry);
|
||||||
|
// The bus name's last segment is the app's own token —
|
||||||
|
// spotify-player advertises identity "Spotify Player"
|
||||||
|
// but its Pipewire stream is "spotify_player", and the
|
||||||
|
// space/underscore mismatch defeats substring matching,
|
||||||
|
// so the card never found its stream and the volume row
|
||||||
|
// stayed hidden. Only the last segment: everything
|
||||||
|
// before it is the shared org.mpris.MediaPlayer2 prefix.
|
||||||
|
add((player.dbusName || "").split(".").pop());
|
||||||
|
|
||||||
let ns = Pipewire.nodes.values;
|
let ns = Pipewire.nodes.values;
|
||||||
for (let i = 0; i < ns.length; i++) {
|
for (let i = 0; i < ns.length; i++) {
|
||||||
|
|
@ -6182,6 +6190,11 @@ in
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
if (!isFinite(s) || s < 0) s = 0;
|
||||||
|
return Math.floor(s / 60) + ":" + ("0" + Math.floor(s % 60)).slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
// Live spectrum for the media cards' rings. cava runs only
|
// Live spectrum for the media cards' rings. cava runs only
|
||||||
// while this popup is open AND something is playing, so it
|
// while this popup is open AND something is playing, so it
|
||||||
// isn't capturing audio in the background. levels is a
|
// isn't capturing audio in the background. levels is a
|
||||||
|
|
@ -6699,6 +6712,63 @@ in
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track progress. MPRIS only pushes a
|
||||||
|
// position on seek or track change, so
|
||||||
|
// `position` would sit frozen between
|
||||||
|
// them; re-emitting positionChanged
|
||||||
|
// makes bindings re-read it against the
|
||||||
|
// clock. Ticking only while the popup is
|
||||||
|
// open and the track is playing keeps it
|
||||||
|
// off the CPU the rest of the time.
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
visible: mediaCard.modelData.lengthSupported
|
||||||
|
&& mediaCard.modelData.length > 0
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
running: calPopup.open
|
||||||
|
&& mediaCard.modelData.playbackState === MprisPlaybackState.Playing
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: mediaCard.modelData.positionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
SText {
|
||||||
|
id: mediaPosLabel
|
||||||
|
width: 32
|
||||||
|
text: calPopup.fmtTime(mediaCard.modelData.position)
|
||||||
|
color: Theme.base04
|
||||||
|
font.pixelSize: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
PillSlider {
|
||||||
|
width: parent.width - mediaPosLabel.width - mediaLenLabel.width - 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: 16
|
||||||
|
trackH: 4
|
||||||
|
value: mediaCard.modelData.length > 0
|
||||||
|
? Math.min(1, mediaCard.modelData.position / mediaCard.modelData.length)
|
||||||
|
: 0
|
||||||
|
fillColor: Theme.base05
|
||||||
|
onMoved: (v) => {
|
||||||
|
let p = mediaCard.modelData;
|
||||||
|
if (p && p.canSeek && p.length > 0) p.position = v * p.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SText {
|
||||||
|
id: mediaLenLabel
|
||||||
|
width: 32
|
||||||
|
text: calPopup.fmtTime(mediaCard.modelData.length)
|
||||||
|
color: Theme.base04
|
||||||
|
font.pixelSize: 10
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-source volume — same per-app path
|
// Per-source volume — same per-app path
|
||||||
// as the volume widget. Shown when the
|
// as the volume widget. Shown when the
|
||||||
// player's Pipewire stream is matched.
|
// player's Pipewire stream is matched.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue