diff --git a/.config/eww/assets/player-expand.svg b/.config/eww/assets/player-expand.svg new file mode 100644 index 0000000..be89569 --- /dev/null +++ b/.config/eww/assets/player-expand.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/.config/eww/assets/player-pause.svg b/.config/eww/assets/player-pause.svg new file mode 100644 index 0000000..fad66c1 --- /dev/null +++ b/.config/eww/assets/player-pause.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/.config/eww/assets/player-play.svg b/.config/eww/assets/player-play.svg new file mode 100644 index 0000000..653f172 --- /dev/null +++ b/.config/eww/assets/player-play.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/.config/eww/eww.scss b/.config/eww/eww.scss index 53893e5..e360298 100644 --- a/.config/eww/eww.scss +++ b/.config/eww/eww.scss @@ -53,3 +53,115 @@ color: #ffffff; background-color: rgba(255, 255, 255, 0.15); } + +/* Collapsed state. Both of these wrap an SVG from assets/ (see + eww.yuck) and must contribute *no* visuals of their own -- no + background, border, padding or min-size -- so the SVG's own shape is + the entire widget. Styling the button instead (border-radius + + border) is what produced a rounded rectangle rather than the + concentric-circle design. */ +.player-shape-btn { + background-color: transparent; + background-image: none; + border: none; + box-shadow: none; + padding: 0; + margin: 0; +} + +.player-shape-btn:hover { + /* GTK's default button hover would paint a rectangle behind the + SVG, reintroducing exactly the shape this design avoids. */ + background-color: transparent; + background-image: none; +} + +/* Defines the collapsed state's overall footprint (it is the overlay's + first, size-determining child and draws nothing itself). Slightly + larger than the 64px circle, which sits bottom-right within it, so + the expand triangle can occupy the empty upper-left corner just + outside the circle's arc. Sized so the two nearly touch on the + diagonal: the circle's own outline reaches to ~20px from this box's + top-left, and the triangle's lower tip ends at ~18px. */ +.player-collapsed-sizer { + background-color: transparent; + min-width: 72px; + min-height: 72px; +} + +/* The separate expand triangle -- no negative margin: it is positioned + by the sizer above instead. A negative margin here pushed it outside + the auto-sized window's own bounds and got it clipped. */ +.player-expand-arrow { + background-color: transparent; + background-image: none; + border: none; + box-shadow: none; + padding: 0; + margin: 0; +} + +.player-expand-arrow:hover { + background-color: transparent; + background-image: none; +} + +/* Expanded state: a taller card. */ +.player-card { + background-color: rgba(0, 0, 0, 0.75); + border-radius: 16px; + padding: 20px 24px; + font-family: "DejaVu Sans Mono", monospace; + min-width: 220px; +} + +.player-albumart { + border-radius: 10px; + margin-bottom: 10px; +} + +.player-title { + color: #ffffff; + font-size: 15px; + font-weight: bold; + margin-bottom: 2px; +} + +.player-artist { + color: #aaaaaa; + font-size: 13px; + margin-bottom: 10px; +} + +.player-controls { + margin-top: 4px; +} + +.player-card-actions { + margin: 6px 6px 0 0; +} + +.mpd-btn { + color: #ffffff; + font-size: 16px; + padding: 2px 8px; + margin: 0 2px; + border-radius: 6px; +} + +.mpd-btn:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +.mpd-close { + color: #888888; + font-size: 12px; + padding: 2px 6px; + margin: 0 0 0 6px; + border-radius: 6px; +} + +.mpd-close:hover { + color: #ffffff; + background-color: rgba(255, 255, 255, 0.15); +} diff --git a/.config/eww/eww.yuck b/.config/eww/eww.yuck index 22c871d..18bb3ed 100644 --- a/.config/eww/eww.yuck +++ b/.config/eww/eww.yuck @@ -45,3 +45,93 @@ :stacking "fg" :windowtype "normal" (lyrics-widget)) + +;; Collapsible player, bottom-right. "mpc toggle" flips play<->pause in +;; one command (matches every real media player's own single toggle +;; button rather than separate always-visible Play/Pause buttons, which +;; would be redundant given the state is already known). +(deflisten mpd_status :initial "{\"state\": \"stop\", \"artist\": \"\", \"title\": \"\", \"albumart\": \"/tmp/eww-mpd-albumart-placeholder.png\"}" + "~/.config/eww/scripts/mpd-player-status.sh") + +;; Shared across both monitor instances (not per-window/[win-id]-scoped +;; like the close button below has to be) -- expanding on one screen +;; expands on both. Simplest option and matches mpd_status itself +;; already being one shared value across both instances; ask if you'd +;; rather each screen's card open/close independently, which needs a +;; separate defvar per win-id instead of one shared one. +(defvar player_expanded false) + +;; Absolute, because (image :path ...) goes straight to GTK/gdk-pixbuf, +;; which does no "~" expansion (unlike the deflisten commands above, +;; where a shell expands it). Kept in one variable so there's a single +;; place to change if this config ever moves off this machine. +(defvar player_assets "/home/susamn/.config/eww/assets") + +;; win-id is this specific window instance's own --id (see eww.service's +;; two "eww open --id ... --arg win-id=..." calls, one per monitor) -- +;; needed because "eww close mpd-player-window" (the bare defwindow +;; name) wouldn't match either open instance; both were opened under +;; distinct --id values instead, so the close button has to close by +;; that same id, and each instance needs to know its own. +(defwidget mpd-player-widget [win-id] + ;; halign/valign "end" so whichever branch is showing hugs the + ;; window's own bottom-right anchor instead of centering in it. + (box :orientation "v" :halign "end" :valign "end" :space-evenly false + + ;; Each branch is gated on :visible *and* wrapped in a revealer, not + ;; a revealer alone. A hidden revealer only collapses the axis its + ;; transition animates -- it still reserves the child's full size on + ;; the other axis (and crossfade animates neither), so the hidden + ;; card below was silently reserving its own ~220px width even while + ;; collapsed, pushing the little circle that far off the corner. + ;; :visible false drops the widget from allocation entirely; the + ;; revealer inside it still provides the reveal animation. + (box :visible {!player_expanded} + (revealer :transition "crossfade" :duration "250ms" :reveal {!player_expanded} + ;; The sizer is the overlay's first (size-determining) child: + ;; a bit larger than the 64px circle, so the arrow can sit in + ;; the empty upper-left corner *outside* the circle's arc + ;; without a negative margin (which would push it past the + ;; window's own auto-sized bounds and get clipped). + (overlay + (box :class "player-collapsed-sizer") + (button :class "player-shape-btn" :halign "end" :valign "end" + :onclick "mpc toggle" + (image :path {mpd_status.state == "playing" + ? "${player_assets}/player-pause.svg" + : "${player_assets}/player-play.svg"} + :image-width 64 :image-height 64)) + (button :class "player-expand-arrow" :halign "start" :valign "start" + :onclick "eww update player_expanded=true" + (image :path "${player_assets}/player-expand.svg" + :image-width 20 :image-height 20))))) + + ;; Expanded: a taller card -- album art, title, artist, the same + ;; toggle/stop pair, plus collapse (back to the circle) and close + ;; (this instance only, see win-id above) in the corner. + (box :visible {player_expanded} + (revealer :transition "crossfade" :duration "300ms" :reveal {player_expanded} + (overlay + (box :orientation "v" :space-evenly false :halign "center" :class "player-card" + (image :class "player-albumart" :path {mpd_status.albumart} :image-width 120 :image-height 120) + (label :class "player-title" :text {mpd_status.title == "" ? "Nothing playing" : mpd_status.title} :limit-width 28) + (label :class "player-artist" :text {mpd_status.artist} :limit-width 28) + (box :orientation "h" :space-evenly false :halign "center" :class "player-controls" + (button :class "mpd-btn" :onclick "mpc toggle" {mpd_status.state == "playing" ? "⏸" : "▶"}) + (button :class "mpd-btn" :onclick "mpc stop" "⏹"))) + (box :orientation "h" :halign "end" :valign "start" :class "player-card-actions" + (button :class "mpd-close" :onclick "eww update player_expanded=false" "⌄") + (button :class "mpd-close" :onclick "eww close ${win-id}" "✕"))))))) + +(defwindow mpd-player-window + [win-id] + :monitor 0 + ;; Both width and height auto-size to content (not just height, like + ;; lyrics-window) -- the collapsed circle and expanded card are very + ;; different widths too, not just heights, and a fixed width would + ;; leave the small circle floating in the middle of reserved space + ;; instead of flush against the corner. + :geometry (geometry :x "5px" :y "15px" :width "1px" :height "1px" :anchor "bottom right") + :stacking "fg" + :windowtype "normal" + (mpd-player-widget :win-id win-id)) diff --git a/.config/eww/scripts/mpd-player-status.sh b/.config/eww/scripts/mpd-player-status.sh new file mode 100755 index 0000000..c92620f --- /dev/null +++ b/.config/eww/scripts/mpd-player-status.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Streams MPD's current playback state, track (artist/title), and album +# art path for the mpd player widget's `mpd_status` deflisten variable. +# +# Album art extraction mirrors ~/.config/conky/get-mpd-info.sh's own +# ffmpeg+convert technique exactly, but with its own cache file +# (ALBUMART_PATH/CACHE_FILE below) rather than reusing that script's +# /tmp/conky-albumart.jpg -- this widget shouldn't silently break if +# that conky instance ever stops running. +CACHE_FILE="/tmp/eww-mpd-player-track.txt" +ALBUMART_PATH="/tmp/eww-mpd-albumart.jpg" +# eww's (image) widget hard-errors ("Failed to open file """) on an +# empty :path rather than rendering a blank/placeholder -- so this is +# always emitted instead of "" when there's no real art (nothing +# playing, extraction failed, or the track has no embedded art), never +# left for eww.yuck to handle as a missing-value case. +PLACEHOLDER_PATH="/tmp/eww-mpd-albumart-placeholder.png" +MUSIC_DIR=$(grep -E "^music_directory" ~/.config/mpd/mpd.conf 2>/dev/null | sed 's/music_directory[[:space:]]*"\(.*\)"/\1/' | sed "s|~|$HOME|") +[ -z "$MUSIC_DIR" ] && MUSIC_DIR="$HOME/Music" +[ -f "$PLACEHOLDER_PATH" ] || convert -size 120x120 xc:'#2a2a2a' "$PLACEHOLDER_PATH" 2>/dev/null + +prev="" +while true; do + state=$(mpc status 2>/dev/null | grep -oP '\[\K[^\]]+' || echo "stop") + artist=$(mpc current -f '%artist%' 2>/dev/null) + title=$(mpc current -f '%title%' 2>/dev/null) + file=$(mpc current -f '%file%' 2>/dev/null) + + if [ -n "$file" ]; then + # Only re-extract when the track actually changed -- ffmpeg+convert + # on every 1s tick would be wasteful and pointless (the art + # can't have changed if the track hasn't). + if [ ! -f "$CACHE_FILE" ] || [ "$(cat "$CACHE_FILE" 2>/dev/null)" != "$file" ]; then + rm -f "$ALBUMART_PATH" + full_path="$MUSIC_DIR/$file" + if [ -f "$full_path" ]; then + ffmpeg -i "$full_path" -an -c:v copy "$ALBUMART_PATH" -y 2>/dev/null + if [ -s "$ALBUMART_PATH" ]; then + convert "$ALBUMART_PATH" -resize 120x120! "$ALBUMART_PATH" 2>/dev/null + fi + fi + echo "$file" > "$CACHE_FILE" + fi + else + rm -f "$ALBUMART_PATH" "$CACHE_FILE" + fi + + albumart="$PLACEHOLDER_PATH" + [ -s "$ALBUMART_PATH" ] && albumart="$ALBUMART_PATH" + + line="${state}|${artist}|${title}|${file}" + if [ "$line" != "$prev" ]; then + jq -cn --arg state "$state" --arg artist "$artist" --arg title "$title" --arg albumart "$albumart" \ + '{state: $state, artist: $artist, title: $title, albumart: $albumart}' + prev="$line" + fi + + sleep 1 +done diff --git a/.config/systemd/user/eww.service b/.config/systemd/user/eww.service index d717bea..00cb5fc 100644 --- a/.config/systemd/user/eww.service +++ b/.config/systemd/user/eww.service @@ -1,5 +1,5 @@ [Unit] -Description=Eww Widget Daemon (lyrics scroll widget) +Description=Eww Widget Daemon (lyrics scroll + mpd player widgets) After=graphical-session.target [Service] @@ -12,6 +12,15 @@ Environment=PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/ ExecStart=/usr/bin/eww daemon --no-daemonize ExecStartPost=/usr/bin/sleep 2 ExecStartPost=/usr/bin/eww open lyrics-window +# One instance per monitor -- "--id" lets the same mpd-player-window +# definition be opened more than once simultaneously (normally opening +# a window name that's already open just no-ops/replaces it); "--screen" +# overrides the defwindow's own hardcoded ":monitor 0" per instance. +# "--arg win-id=..." matches each instance's own --id so its close +# button (eww.yuck) can close itself specifically, not the other +# monitor's copy or the (unmatched) bare "mpd-player-window" name. +ExecStartPost=/usr/bin/eww open --id mpd-player-screen0 --screen 0 --arg win-id=mpd-player-screen0 mpd-player-window +ExecStartPost=/usr/bin/eww open --id mpd-player-screen1 --screen 1 --arg win-id=mpd-player-screen1 mpd-player-window Restart=on-failure [Install] diff --git a/workspace/.alias_descriptions b/workspace/.alias_descriptions index e271af8..158da7a 100644 --- a/workspace/.alias_descriptions +++ b/workspace/.alias_descriptions @@ -32,4 +32,9 @@ mtx=MPD Tui player - enlarged with vim motions mt=MPD Tui mini player - small, inline and only what is needed mtp=Search playlists and play with MPD Tui player mtt=Search tracks and play with MPD Tui player +ewwr=Restart the eww widget daemon (picks up yuck/scss/unit file changes) +ewwreload=Reload eww's yuck/scss without restarting the daemon +ewwlog=Tail the eww widget daemon's systemd journal +ewwst=Show the eww widget daemon's systemd service status +mpdwidget=Open or close the mpd player widget on both monitors (mpdwidget open|close) diff --git a/workspace/.aliases.sh b/workspace/.aliases.sh index 652a78d..0c49127 100644 --- a/workspace/.aliases.sh +++ b/workspace/.aliases.sh @@ -18,6 +18,35 @@ alias pfm="$TOOLS_PATH/performance-manager/quick-start.sh" alias mtrm="$TOOLS_PATH/media-trimmer/quick-start.sh" alias att="$TOOLS_PATH/api-testing-tool/quick-start.sh" +# eww widgets (lyrics scroll + mpd player, see ~/.config/eww/) -- +# restart picks up ~/.config/systemd/user/eww.service, eww.yuck, or +# eww.scss changes; reload is lighter (yuck/scss only, no daemon +# restart, but won't pick up a changed unit file). +alias ewwr="systemctl --user restart eww.service" +alias ewwreload="eww reload" +alias ewwlog="journalctl --user -u eww.service -f" +alias ewwst="systemctl --user status eww.service" + +# Reopen the mpd player widget on both monitors after a manual "eww +# close" -- matches eww.service's own ExecStartPost invocations exactly +# (see that file for why win-id is required: each instance's close +# button targets itself by that id, not the shared "mpd-player-window" +# name neither open instance actually uses). +mpdwidget() { + case "$1" in + open) + eww open --id mpd-player-screen0 --screen 0 --arg win-id=mpd-player-screen0 mpd-player-window + eww open --id mpd-player-screen1 --screen 1 --arg win-id=mpd-player-screen1 mpd-player-window + ;; + close) + eww close mpd-player-screen0 mpd-player-screen1 + ;; + *) + echo "Usage: mpdwidget open|close" + ;; + esac +} + pnv() { if [ "$1" = "activate" ]; then if [ -z "$2" ]; then