quickshell: open wallpaper dropdown on hover-switch like other menus
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c87b40d528
commit
9447413fa1
6 changed files with 35 additions and 224 deletions
|
|
@ -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.
|
# Warm Ghostty instance — new windows attach via gtk-single-instance.
|
||||||
systemd.user.services.ghostty-daemon = lib.mkIf isDesktop {
|
systemd.user.services.ghostty-daemon = lib.mkIf isDesktop {
|
||||||
Unit = {
|
Unit = {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
mangohud
|
mangohud
|
||||||
gamescope
|
gamescope
|
||||||
|
spotify
|
||||||
vesktop
|
vesktop
|
||||||
lsfg-vk
|
lsfg-vk
|
||||||
lsfg-vk-ui
|
lsfg-vk-ui
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
config = lib.mkIf (config.networking.hostName == "FredOS-Macbook") {
|
config = lib.mkIf (config.networking.hostName == "FredOS-Macbook") {
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
tlp
|
tlp
|
||||||
|
spotify
|
||||||
vesktop
|
vesktop
|
||||||
faugus-launcher
|
faugus-launcher
|
||||||
mission-center
|
mission-center
|
||||||
|
|
|
||||||
|
|
@ -263,8 +263,8 @@ in
|
||||||
hl.layer_rule({ match = { namespace = "quickshell-bar" }, blur = true, ignore_alpha = 0.3 })
|
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 })
|
hl.layer_rule({ match = { namespace = "quickshell-osd" }, blur = true, ignore_alpha = 0.3 })
|
||||||
|
|
||||||
-- Both special-workspace terminals get their own --class, which does
|
-- The scratchpad terminal gets its own --class, which does two
|
||||||
-- two things ghostty's --title cannot. First, gtk-single-instance
|
-- things ghostty's --title cannot. First, gtk-single-instance
|
||||||
-- means a second `ghostty` hands its request to the running instance
|
-- means a second `ghostty` hands its request to the running instance
|
||||||
-- and exits; forwarded requests keep -e but DROP --title, so the
|
-- and exits; forwarded requests keep -e but DROP --title, so the
|
||||||
-- window ends up named by the shell and no title rule matches it.
|
-- 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".
|
-- on them reads as "reset this thing", not "close it".
|
||||||
local scratchpads = {
|
local scratchpads = {
|
||||||
["dev.fred.scratchpad"] = "ghostty --class=dev.fred.scratchpad --title=scratchpad",
|
["dev.fred.scratchpad"] = "ghostty --class=dev.fred.scratchpad --title=scratchpad",
|
||||||
-- Waits for DNS before starting spotify_player. If its cached Web
|
-- Spotify sets its own class, so no --class trick here; the
|
||||||
-- API token cannot be refreshed it falls back to an interactive
|
-- lookup below lowercases before falling back, which covers both
|
||||||
-- browser login (auth.rs: the refresh `Err` arm re-runs the PKCE
|
-- the Wayland app id and the XWayland "Spotify".
|
||||||
-- flow), and a "network unreachable" refresh counts as failure —
|
["spotify"] = "spotify",
|
||||||
-- 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
|
|
||||||
''}",
|
|
||||||
}
|
}
|
||||||
local shutting_down = false
|
local shutting_down = false
|
||||||
hl.on("hyprland.shutdown", function() shutting_down = true end)
|
hl.on("hyprland.shutdown", function() shutting_down = true end)
|
||||||
hl.on("window.close", function(win)
|
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
|
if cmd and not shutting_down then hl.exec_cmd(cmd) end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -331,10 +318,10 @@ in
|
||||||
hl.exec_cmd("wl-paste --type text --watch cliphist store")
|
hl.exec_cmd("wl-paste --type text --watch cliphist store")
|
||||||
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
||||||
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
|
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
|
||||||
-- Warms spotify-player's Spotify Connect device while you're
|
-- Spotify starts hidden: the window rule below swallows it onto
|
||||||
-- doing something else; the window rule above swallows it onto
|
-- special:music silently, so it never steals focus and SUPER+M is
|
||||||
-- special:music silently, so it never steals focus.
|
-- always an instant toggle.
|
||||||
hl.exec_cmd(scratchpads["dev.fred.spotify"])
|
hl.exec_cmd(scratchpads["spotify"])
|
||||||
-- Scratchpad terminal, same trick: parked on special:scratch at
|
-- Scratchpad terminal, same trick: parked on special:scratch at
|
||||||
-- login so SUPER+Return is always an instant toggle and the shell
|
-- login so SUPER+Return is always an instant toggle and the shell
|
||||||
-- keeps its history/cwd between peeks.
|
-- keeps its history/cwd between peeks.
|
||||||
|
|
@ -377,16 +364,11 @@ in
|
||||||
workspace = "special silent",
|
workspace = "special silent",
|
||||||
})
|
})
|
||||||
|
|
||||||
-- spotify-player lives on a special workspace for its whole
|
-- Spotify lives on a special workspace for its whole session;
|
||||||
-- session. Its librespot device takes ~20s to be accepted by
|
-- SUPER+M slides it in and out. Matched on class, not title: the
|
||||||
-- Spotify's backend, and that cost is per *process*, so relaunching
|
-- title carries the current track.
|
||||||
-- 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.
|
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^dev.fred.spotify$" },
|
match = { class = "^[Ss]potify$" },
|
||||||
workspace = "special:music silent",
|
workspace = "special:music silent",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -397,15 +379,15 @@ in
|
||||||
-- same reason as above, and because matching on the special
|
-- same reason as above, and because matching on the special
|
||||||
-- workspace would depend on the assignment rule running first.
|
-- workspace would depend on the assignment rule running first.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
name = "spotify-player-inset",
|
name = "spotify-inset",
|
||||||
match = { class = "^dev.fred.spotify$" },
|
match = { class = "^[Ss]potify$" },
|
||||||
float = true,
|
float = true,
|
||||||
size = "80% 80%",
|
size = "80% 80%",
|
||||||
center = true,
|
center = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Scratchpad terminal — same class-match + float-to-inset pattern as
|
-- Scratchpad terminal — same class-match + float-to-inset pattern as
|
||||||
-- spotify-player above.
|
-- Spotify above.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^dev.fred.scratchpad$" },
|
match = { class = "^dev.fred.scratchpad$" },
|
||||||
workspace = "special:scratch silent",
|
workspace = "special:scratch silent",
|
||||||
|
|
@ -471,7 +453,7 @@ in
|
||||||
hl.bind(mod .. " + F", hl.dsp.window.fullscreen())
|
hl.bind(mod .. " + F", hl.dsp.window.fullscreen())
|
||||||
hl.bind(mod .. " + P", hl.dsp.window.pseudo())
|
hl.bind(mod .. " + P", hl.dsp.window.pseudo())
|
||||||
hl.bind(mod .. " + S", hl.dsp.layout("togglesplit"))
|
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"))
|
hl.bind(mod .. " + Return", toggle_scratchpad("scratch", "dev.fred.scratchpad"))
|
||||||
|
|
||||||
-- Focus
|
-- Focus
|
||||||
|
|
|
||||||
|
|
@ -3978,6 +3978,13 @@ in
|
||||||
width: Theme.barWidth
|
width: Theme.barWidth
|
||||||
height: 24
|
height: 24
|
||||||
|
|
||||||
|
function openWallDropdown() {
|
||||||
|
bar.toggleDropdown(wallDropdown, function() {
|
||||||
|
let pos = wallBtn.mapToItem(bar.contentItem, 0, wallBtn.height / 2);
|
||||||
|
wallDropdown.dropdownY = pos.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
SIcon {
|
SIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "image"
|
text: "image"
|
||||||
|
|
@ -3992,10 +3999,12 @@ in
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: bar.toggleDropdown(wallDropdown, function() {
|
onClicked: wallBtn.openWallDropdown()
|
||||||
let pos = wallBtn.mapToItem(bar.contentItem, 0, wallBtn.height / 2);
|
onEntered: {
|
||||||
wallDropdown.dropdownY = pos.y;
|
if (bar.activeDropdown) {
|
||||||
})
|
if (bar.activeDropdown !== wallDropdown) wallBtn.openWallDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
# What follows a switch immediately: quickshell, hyprland, ghostty (D-Bus
|
# What follows a switch immediately: quickshell, hyprland, ghostty (D-Bus
|
||||||
# reload-config), GTK3 apps such as nemo and helium (each wallpaper installs
|
# 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.
|
# 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
|
# stylix stays the source of truth for build-time defaults and for every
|
||||||
# target not listed here — walls/wallpaper.png remains its image.
|
# target not listed here — walls/wallpaper.png remains its image.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue