diff --git a/home-manager/fred.nix b/home-manager/fred.nix index 9b8ef88..8bd715c 100644 --- a/home-manager/fred.nix +++ b/home-manager/fred.nix @@ -133,7 +133,57 @@ in # of the wheel is a dozen requests and a 429. Use PipeWire for volume # instead — same sink, instant, free. 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 # non-empty, so `actions` alone would silently emit nothing. Restating a # 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. + # 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 { name = "Spotify 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"; terminal = false; categories = [ "Audio" "Music" "Player" "AudioVideo" ]; diff --git a/settings/quickshell.nix b/settings/quickshell.nix index bcc5502..30ebb4e 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -6159,6 +6159,14 @@ in }; add(player.identity); 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; for (let i = 0; i < ns.length; i++) { @@ -6182,6 +6190,11 @@ in 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 // while this popup is open AND something is playing, so it // 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 // as the volume widget. Shown when the // player's Pipewire stream is matched.