# home-manager/fred.nix { config, pkgs, lib, inputs, osConfig, ... }: let isDesktop = lib.elem osConfig.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]; in { home.stateVersion = "25.11"; home.packages = with pkgs; [ # ]; home.file.".config/nixpkgs/config.nix".text = '' { allowUnfree = true; } ''; # Wallpaper source — consumed by stylix's image option (set in # settings/stylix.nix) and by quickshell's wallpaper layer. home.file.".local/share/backgrounds/wallpaper.png".source = "${inputs.self}/walls/wallpaper.png"; # btop — stylix's btop target writes the colour theme; we keep our # non-colour preferences here. programs.btop = { enable = true; settings = { theme_background = false; vim_keys = false; proc_filter_kernel = true; }; }; # Ghostty — stylix's ghostty target writes the colour theme. programs.ghostty = lib.mkIf isDesktop { enable = true; settings = { font-family = "CaskaydiaCove NF"; font-size = 11; font-thicken = true; window-padding-x = 10; window-padding-y = 10; window-padding-balance = true; background-opacity = 0.98; confirm-close-surface = false; gtk-titlebar = false; gtk-single-instance = true; # keeps the warm instance alive with zero windows quit-after-last-window-closed = false; cursor-style = "bar"; cursor-style-blink = true; }; }; # 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. xdg.desktopEntries.spotify-player = lib.mkIf isDesktop { name = "Spotify Player"; genericName = "Music Player"; exec = "ghostty --title=spotify-player -e spotify_player"; 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 = { Description = "Ghostty warm instance (no window)"; After = [ "graphical-session.target" ]; PartOf = [ "graphical-session.target" ]; }; Service = { Type = "simple"; ExecStart = "${pkgs.ghostty}/bin/ghostty --initial-window=false"; Restart = "always"; }; Install.WantedBy = [ "graphical-session.target" ]; }; }