diff --git a/home-manager/fred.nix b/home-manager/fred.nix index 2b23159..0688adc 100644 --- a/home-manager/fred.nix +++ b/home-manager/fred.nix @@ -51,188 +51,6 @@ in }; }; - # spotify-player — native librespot TUI replacing the Electron Spotify app. - # stylix's spotify-player target writes the colour theme. - # MPRIS (quickshell's media card), notifications and streaming are all on by - # default on Linux, so only the off-by-default visualiser needs setting. - programs.spotify-player = lib.mkIf isDesktop { - enable = true; - # 26.05 ships 0.23.0, whose Web API token is cached with refresh_token = - # null: once it expires (~1h) nothing can renew it and the library just - # spins until the app is restarted. 0.24.1 treats a non-renewable cached - # token as unusable and re-runs the PKCE flow instead. Drop this override - # once the pinned channel reaches 0.24.1. - package = pkgs.spotify-player.overrideAttrs (old: rec { - version = "0.24.1"; - src = pkgs.fetchFromGitHub { - owner = "aome510"; - repo = "spotify-player"; - tag = "v${version}"; - hash = "sha256-+GADmRl4XMwV8TfYZjEeyKDDfda3bDPzeerhYryX6vA="; - }; - cargoDeps = pkgs.rustPlatform.fetchCargoVendor { - inherit src; - hash = "sha256-CSZ5sZ+d7Jhi43ipaWXKupYPFgWCbCx4RMTQN8emu9o="; - }; - # Give initialize_playback's retry loop a much longer window, so we don't - # have to pick our own device from the popup on every launch. Upstream - # retries the transfer 5 times, once a second; our librespot device isn't - # registered with the Spotify backend until ~20s after startup, so all - # five attempts 404 and it gives up: - # Trying to connect to device (id=…) - # Connection failed (device_id=…): http error: status code 404 Not Found - # The loop breaks on the first success, so a bigger bound costs nothing. - # The CLI is no help here: `connect --name` resolves names against the Web - # API device list, which doesn't contain the integrated device at all. - # - # Widen it by slowing the delay rather than by adding rounds: each round - # costs up to three Web API calls, and we now share the bundled client ID - # with every other spotify-player/ncspot user, so 60 one-second rounds was - # ~180 requests per launch into a shared rate-limit bucket (429s). 12 - # rounds five seconds apart covers the same 60s for a fifth of the calls. - # Both loops in this file are `for _ in 0..5` / `from_secs(1)`; the leading - # indentation is what pins each replacement to the intended one. - # - # The third replacement hits the other loop, in retry_playback_state: it - # re-reads the playback state five times after *every* playback-changing - # action, so one flick of the volume wheel costs a write plus five reads - # per notch. The state is updated optimistically client-side anyway, so - # two confirmations are plenty. - postPatch = (old.postPatch or "") + '' - substituteInPlace spotify_player/src/client/mod.rs \ - --replace-fail " for _ in 0..5 {" \ - " for _ in 0..12 {" \ - --replace-fail " let delay = std::time::Duration::from_secs(1);" \ - " let delay = std::time::Duration::from_secs(5);" \ - --replace-fail " for _ in 0..5 {" \ - " for _ in 0..2 {" - ''; - }); - settings = { - enable_audio_visualization = true; - # No client_id: use the bundled default (ncspot's), as upstream - # recommends. It predates Spotify's 2024-11-27 Web API change and is in - # extended quota mode, so it still gets the editorial/algorithmic - # playlists — Discover Weekly, Made-For-You, browse categories. Any app - # registered today is stuck in restricted quota and Spotify returns those - # as null, which is what left Made-For-You empty while we ran our own. - # - # We did briefly run our own app (private rate-limit bucket) because the - # bundled one's bucket is shared with every other spotify-player/ncspot - # user and we hit 429s. But that burst came from scroll-to-set-volume and - # the post-action confirmation reads, both cut below — so the private - # bucket is no longer worth losing half the library over. - # - # Changing client ID invalidates the cached Web API token: run - # `spotify_player authenticate` once after deploying this. - # - # Scroll-to-set-volume is a rate-limit trap: playback runs through a local - # librespot softmixer, but the mixer is only set once at session start, so - # every later change is a Web API PUT /me/player/volume round-trip (plus - # confirmation reads) to adjust a mixer inside our own process. One flick - # of the wheel is a dozen requests and a 429. Use PipeWire for volume - # instead — same sink, instant, free. - enable_mouse_scroll_volume = false; - # Streaming stays on in the TUI (the default). A `spotify_player --daemon` - # holding the device, with enable_streaming = "DaemonOnly" here, does move - # the ~20s device registration to login — but the visualiser above is fed - # only by the in-process librespot player (state/mod.rs vis_bands, written - # solely from streaming.rs), so a TUI that isn't the player can never draw - # it. Visualiser and daemon are mutually exclusive; the visualiser wins. - }; - # 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. - keymaps = [ - { - command = "ShowActionsOnCurrentTrack"; - key_sequence = "a"; - } - ]; - # Actions default to the *selected* item, which is useless when listening - # along to someone else's playback — the track was never selected, so - # liking it means finding it by hand. target = "PlayingTrack" acts on - # whatever is actually playing. - actions = [ - { - action = "ToggleLiked"; - key_sequence = "L"; - target = "PlayingTrack"; - } - { - action = "GoToArtist"; - key_sequence = "g P"; - target = "PlayingTrack"; - } - { - action = "GoToAlbum"; - key_sequence = "g B"; - target = "PlayingTrack"; - } - ]; - }; - - # Upstream ships no .desktop file (TUI only), so the launcher can't find it. - # Hyprland parks the instance started at login on the special:music workspace - # (see settings/hyprland.nix), so this toggles that window rather than opening - # a second terminal — a fresh process would mean another ~20s wait for its - # Spotify Connect device. Relaunch first if the window is gone, otherwise the - # toggle just reveals an empty workspace. - xdg.desktopEntries.spotify-player = lib.mkIf isDesktop { - name = "Spotify Player"; - genericName = "Music Player"; - exec = "${pkgs.writeShellScript "spotify-player-toggle" '' - hyprctl clients | grep -q "title: spotify-player" \ - || ghostty --title=spotify-player -e spotify_player - hyprctl dispatch togglespecialworkspace music - ''}"; - icon = "spotify"; - terminal = false; - categories = [ "Audio" "Music" "Player" "AudioVideo" ]; - }; - # Warm Ghostty instance — new windows attach via gtk-single-instance. systemd.user.services.ghostty-daemon = lib.mkIf isDesktop { Unit = { diff --git a/hosts/FredOS-Gaming.nix b/hosts/FredOS-Gaming.nix index d7b88e7..75bfb0e 100644 --- a/hosts/FredOS-Gaming.nix +++ b/hosts/FredOS-Gaming.nix @@ -5,6 +5,7 @@ environment.systemPackages = with pkgs; [ mangohud gamescope + spotify vesktop lsfg-vk lsfg-vk-ui diff --git a/hosts/FredOS-Macbook.nix b/hosts/FredOS-Macbook.nix index 4e82fd3..01be30f 100644 --- a/hosts/FredOS-Macbook.nix +++ b/hosts/FredOS-Macbook.nix @@ -4,6 +4,7 @@ config = lib.mkIf (config.networking.hostName == "FredOS-Macbook") { environment.systemPackages = with pkgs; [ tlp + spotify vesktop faugus-launcher mission-center diff --git a/settings/hyprland.nix b/settings/hyprland.nix index c78cc5d..5225010 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -263,8 +263,8 @@ in hl.layer_rule({ match = { namespace = "quickshell-bar" }, blur = true, ignore_alpha = 0.3 }) hl.layer_rule({ match = { namespace = "quickshell-osd" }, blur = true, ignore_alpha = 0.3 }) - -- Both special-workspace terminals get their own --class, which does - -- two things ghostty's --title cannot. First, gtk-single-instance + -- The scratchpad terminal gets its own --class, which does two + -- things ghostty's --title cannot. First, gtk-single-instance -- means a second `ghostty` hands its request to the running instance -- and exits; forwarded requests keep -e but DROP --title, so the -- window ends up named by the shell and no title rule matches it. @@ -276,28 +276,15 @@ in -- on them reads as "reset this thing", not "close it". local scratchpads = { ["dev.fred.scratchpad"] = "ghostty --class=dev.fred.scratchpad --title=scratchpad", - -- Waits for DNS before starting spotify_player. If its cached Web - -- API token cannot be refreshed it falls back to an interactive - -- browser login (auth.rs: the refresh `Err` arm re-runs the PKCE - -- flow), and a "network unreachable" refresh counts as failure — - -- so starting at hyprland.start, before the link is up, meant a - -- browser window and a fresh Spotify login on every single boot. - -- Costs nothing once the network is up, and also covers the - -- respawn path firing right after a resume. - ["dev.fred.spotify"] = "${pkgs.writeShellScript "spotify-player-scratchpad" '' - n=0 - while [ $n -lt 60 ]; do - ${pkgs.glibc.bin}/bin/getent hosts accounts.spotify.com >/dev/null 2>&1 && break - ${pkgs.coreutils}/bin/sleep 1 - n=$((n + 1)) - done - exec ghostty --class=dev.fred.spotify --title=spotify-player -e spotify_player - ''}", + -- Spotify sets its own class, so no --class trick here; the + -- lookup below lowercases before falling back, which covers both + -- the Wayland app id and the XWayland "Spotify". + ["spotify"] = "spotify", } local shutting_down = false hl.on("hyprland.shutdown", function() shutting_down = true end) hl.on("window.close", function(win) - local cmd = scratchpads[win.class] + local cmd = scratchpads[win.class] or scratchpads[(win.class or ""):lower()] if cmd and not shutting_down then hl.exec_cmd(cmd) end end) @@ -331,10 +318,10 @@ in hl.exec_cmd("wl-paste --type text --watch cliphist store") hl.exec_cmd("wl-paste --type image --watch cliphist store") hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24") - -- Warms spotify-player's Spotify Connect device while you're - -- doing something else; the window rule above swallows it onto - -- special:music silently, so it never steals focus. - hl.exec_cmd(scratchpads["dev.fred.spotify"]) + -- Spotify starts hidden: the window rule below swallows it onto + -- special:music silently, so it never steals focus and SUPER+M is + -- always an instant toggle. + hl.exec_cmd(scratchpads["spotify"]) -- Scratchpad terminal, same trick: parked on special:scratch at -- login so SUPER+Return is always an instant toggle and the shell -- keeps its history/cwd between peeks. @@ -377,16 +364,11 @@ in workspace = "special silent", }) - -- spotify-player lives on a special workspace for its whole - -- session. Its librespot device takes ~20s to be accepted by - -- Spotify's backend, and that cost is per *process*, so relaunching - -- a terminal for it means waiting every time. Parking one instance - -- here pays it once at login; SUPER+M slides it in and out. - -- Matched on the dedicated --class set above, not on title: a shell - -- prompt rewrites its window title, and forwarded single-instance - -- launches never carry --title in the first place. + -- Spotify lives on a special workspace for its whole session; + -- SUPER+M slides it in and out. Matched on class, not title: the + -- title carries the current track. hl.window_rule({ - match = { class = "^dev.fred.spotify$" }, + match = { class = "^[Ss]potify$" }, workspace = "special:music silent", }) @@ -397,15 +379,15 @@ in -- same reason as above, and because matching on the special -- workspace would depend on the assignment rule running first. hl.window_rule({ - name = "spotify-player-inset", - match = { class = "^dev.fred.spotify$" }, + name = "spotify-inset", + match = { class = "^[Ss]potify$" }, float = true, size = "80% 80%", center = true, }) -- Scratchpad terminal — same class-match + float-to-inset pattern as - -- spotify-player above. + -- Spotify above. hl.window_rule({ match = { class = "^dev.fred.scratchpad$" }, workspace = "special:scratch silent", @@ -471,7 +453,7 @@ in hl.bind(mod .. " + F", hl.dsp.window.fullscreen()) hl.bind(mod .. " + P", hl.dsp.window.pseudo()) hl.bind(mod .. " + S", hl.dsp.layout("togglesplit")) - hl.bind(mod .. " + M", toggle_scratchpad("music", "dev.fred.spotify")) + hl.bind(mod .. " + M", toggle_scratchpad("music", "^[Ss]potify$")) hl.bind(mod .. " + Return", toggle_scratchpad("scratch", "dev.fred.scratchpad")) -- Focus diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 6054f29..f0852fd 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -3978,6 +3978,13 @@ in width: Theme.barWidth height: 24 + function openWallDropdown() { + bar.toggleDropdown(wallDropdown, function() { + let pos = wallBtn.mapToItem(bar.contentItem, 0, wallBtn.height / 2); + wallDropdown.dropdownY = pos.y; + }); + } + SIcon { anchors.centerIn: parent text: "image" @@ -3992,10 +3999,12 @@ in anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: bar.toggleDropdown(wallDropdown, function() { - let pos = wallBtn.mapToItem(bar.contentItem, 0, wallBtn.height / 2); - wallDropdown.dropdownY = pos.y; - }) + onClicked: wallBtn.openWallDropdown() + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== wallDropdown) wallBtn.openWallDropdown(); + } + } } } } diff --git a/settings/theming.nix b/settings/theming.nix index 42c7d9c..66cd322 100644 --- a/settings/theming.nix +++ b/settings/theming.nix @@ -9,7 +9,7 @@ # What follows a switch immediately: quickshell, hyprland, ghostty (D-Bus # reload-config), GTK3 apps such as nemo and helium (each wallpaper installs # its own named GTK theme, switched over gsettings) and the folder icon tint. -# On next launch only: GTK4/libadwaita apps, btop, zen, spotify-player. +# On next launch only: GTK4/libadwaita apps, btop, zen. # # stylix stays the source of truth for build-time defaults and for every # target not listed here — walls/wallpaper.png remains its image.