diff --git a/.config/eww/eww.scss b/.config/eww/eww.scss new file mode 100644 index 0000000..53893e5 --- /dev/null +++ b/.config/eww/eww.scss @@ -0,0 +1,55 @@ +* { + all: unset; +} + +.lyrics-box { + background-color: rgba(0, 0, 0, 0.55); + border-radius: 12px; + padding: 14px 20px; + font-family: "DejaVu Sans Mono", monospace; + /* Fixed regardless of how long/short the current line's text is -- + otherwise the box (not the window, which is already a fixed + 620px) visibly shrinks and grows to fit whichever line is + currently longest. 580px = 620px window width minus this box's + own 20px left/right padding. */ + min-width: 580px; +} + +.lyrics-context, .lyrics-current { + font-size: 15px; + margin: 2px 0; +} + +.lyrics-context { + color: #888888; +} + +.lyrics-current { + /* Same weight as the context lines on purpose -- font-size is set + per-instance via the bound :style attribute (shrinks for long + lines, see lyrics-listen.sh's font_size_for), not here. */ + color: #ffe66d; + opacity: 1; + transition: opacity 300ms ease; +} + +.lyrics-current.dim { + /* The brief in-between state on a line change -- see + lyrics-listen.sh's two-phase "reveal" emission. Opacity only, so + this row's own height never changes and nothing above/below it + moves. */ + opacity: 0.15; +} + +.lyrics-close { + color: #888888; + font-size: 12px; + margin: 4px 6px 0 0; + padding: 2px 5px; + border-radius: 6px; +} + +.lyrics-close:hover { + color: #ffffff; + background-color: rgba(255, 255, 255, 0.15); +} diff --git a/.config/eww/eww.yuck b/.config/eww/eww.yuck new file mode 100644 index 0000000..22c871d --- /dev/null +++ b/.config/eww/eww.yuck @@ -0,0 +1,47 @@ +;; Lyrics scroll widget -- pinned always-on-top overlay showing the +;; current synced (.lrc) lyrics line with 1 line of context above and 2 +;; below, sourced from "mpdtui -lyrics-line" via scripts/lyrics-listen.sh +;; (see that script for why this is deflisten/push rather than +;; defpoll/pull -- it needs to emit an intermediate "dimmed" frame for +;; the current line's opacity transition below to animate from). +;; +;; The current line's own opacity (not a `revealer`, which was tried +;; first -- it collapses its child to zero height while hidden, shrinking +;; then re-growing the whole box on every line change) fades between +;; `reveal`'s two states; its font-size is bound to `current_size` +;; (scripts/lyrics-listen.sh's font_size_for) so a long line shrinks +;; instead of overflowing the box's fixed width. +;; +;; The outer revealer (visible) auto-hides the whole widget when MPD +;; isn't actively playing -- collapsing it rather than "eww close"-ing +;; the window itself, which would kill the very deflisten script that's +;; supposed to bring it back (see lyrics-listen.sh's own doc comment). + +(deflisten lyrics :initial "{\"visible\": false, \"reveal\": true, \"current_size\": 15, \"above\": \"\", \"current\": \"\", \"below1\": \"\", \"below2\": \"\"}" + "~/.config/eww/scripts/lyrics-listen.sh") + +;; :close-window closes lyrics-window, not the daemon -- but note (see +;; lyrics-listen.sh and personal-systemd-services.md §7) this is a +;; *manual* close, not the auto-hide-on-pause path: it does kill the +;; deflisten script backing `lyrics` (eww only keeps one running while +;; some open window uses its variable), and won't come back on its own. +;; Run "eww open lyrics-window" to bring it back afterward. +(defwidget lyrics-widget [] + (revealer :transition "slidedown" :duration "400ms" :reveal {lyrics.visible} + (overlay + (box :orientation "v" :space-evenly false :halign "center" :class "lyrics-box" + (label :class "lyrics-context" :text {lyrics.above} :limit-width 70) + (label :class {lyrics.reveal ? "lyrics-current" : "lyrics-current dim"} + :style "font-size: ${lyrics.current_size}px;" + :text {lyrics.current} :limit-width 70) + (label :class "lyrics-context" :text {lyrics.below1} :limit-width 70) + (label :class "lyrics-context" :text {lyrics.below2} :limit-width 70)) + (button :class "lyrics-close" :halign "end" :valign "start" + :onclick "eww close lyrics-window" "✕")))) + +(defwindow lyrics-window + :monitor 0 + :geometry (geometry :x "0%" :y "4%" :width "620px" :height "1px" :anchor "top center") + :stacking "fg" + :windowtype "normal" + (lyrics-widget)) diff --git a/.config/eww/scripts/lyrics-listen.sh b/.config/eww/scripts/lyrics-listen.sh new file mode 100755 index 0000000..9768e15 --- /dev/null +++ b/.config/eww/scripts/lyrics-listen.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Streams the current synced-lyrics window to eww's `lyrics` deflisten +# variable as one JSON object per line, forever, one tick per second. +# +# Runs "mpdtui -lyrics-line" once per tick (its own 4-line window: 1 line +# of context above the current line, the current line, 2 lines below -- +# see mpdtui's internal/lyricsline) and pushes the result straight +# through -- except when the *current* line (line 2) has changed since +# the previous tick: then it first pushes "reveal: false" (still showing +# the *old* current text, dimmed) and sleeps briefly before pushing +# "reveal: true" with the new text at full opacity, so eww.yuck's +# opacity transition on the current-line label has two distinct states +# to animate between. A poll-based (defpoll) variable can't do this: it +# only ever holds one value at a time, so there's no way to briefly show +# the "dimmed" state in between two polled snapshots -- deflisten's +# whole point is pushing as many intermediate frames as needed, on our +# own schedule, not eww's. +# +# "reveal" deliberately drives an *opacity* transition, not a `revealer` +# widget wrapping the label -- a `revealer` was tried first and collapses +# its child to zero height while hidden, which shrinks (then re-grows) +# the whole box every single line change. Opacity leaves the label's own +# row height untouched the entire time, so nothing above/below it moves. +# +# current_size scales the current line's own font down for long lines +# (see font_size_for) so a long line doesn't overflow the box's fixed +# width instead of needing the box to grow to fit it. +# +# Also emits "visible" (true only when MPD is actively [playing]), which +# eww.yuck uses to wrap the *entire* widget in an outer revealer -- NOT +# "eww close"/"eww open" on the window itself. Closing the window that +# way was tried first and is a trap: eww only keeps a deflisten script +# alive while some open window is actually using its variable, so a +# script that closes its own window gets killed the moment it does -- +# nothing is left running to ever reopen it. Collapsing an outer +# revealer keeps the window (and this script) alive the whole time, +# avoiding that self-inflicted dead end entirely. + +# font_size_for: the current line's font shrinks in three steps as its +# character count grows, so a long line stays inside the box's fixed +# 580px content width rather than needing the box to widen or the text +# to wrap. Thresholds picked for DejaVu Sans Mono at the base 15px size +# (~9px/char): 45 chars is where it starts getting close to the box's +# ~540px usable text width (580px minus the box's own 20px*2 padding). +font_size_for() { + local len=$1 + if [ "$len" -gt 60 ]; then + echo 11 + elif [ "$len" -gt 45 ]; then + echo 13 + else + echo 15 + fi +} + +prev_current="" +while true; do + if mpc status 2>/dev/null | grep -q '\[playing\]'; then + playing="true" + else + playing="false" + fi + + if [ "$playing" = "true" ]; then + mpdtui -lyrics-line >/tmp/mpdtui-lyrics-line.txt 2>/dev/null + above=$(sed -n '1p' /tmp/mpdtui-lyrics-line.txt) + current=$(sed -n '2p' /tmp/mpdtui-lyrics-line.txt) + below1=$(sed -n '3p' /tmp/mpdtui-lyrics-line.txt) + below2=$(sed -n '4p' /tmp/mpdtui-lyrics-line.txt) + current_size=$(font_size_for "${#current}") + + if [ "$current" != "$prev_current" ] && [ -n "$prev_current" ]; then + jq -cn --argjson visible "$playing" --argjson size "$current_size" --arg above "$above" --arg current "$prev_current" --arg below1 "$below1" --arg below2 "$below2" \ + '{visible: $visible, reveal: false, current_size: $size, above: $above, current: $current, below1: $below1, below2: $below2}' + sleep 0.2 + fi + + jq -cn --argjson visible "$playing" --argjson size "$current_size" --arg above "$above" --arg current "$current" --arg below1 "$below1" --arg below2 "$below2" \ + '{visible: $visible, reveal: true, current_size: $size, above: $above, current: $current, below1: $below1, below2: $below2}' + + prev_current="$current" + else + jq -cn --argjson visible "$playing" \ + '{visible: $visible, reveal: true, current_size: 15, above: "", current: "", below1: "", below2: ""}' + prev_current="" + fi + + sleep 1 +done diff --git a/.config/systemd/user/eww.service b/.config/systemd/user/eww.service new file mode 100644 index 0000000..d717bea --- /dev/null +++ b/.config/systemd/user/eww.service @@ -0,0 +1,18 @@ +[Unit] +Description=Eww Widget Daemon (lyrics scroll widget) +After=graphical-session.target + +[Service] +Type=simple +# scripts/lyrics-listen.sh shells out to "mpdtui" (installed via Homebrew +# at /home/linuxbrew/.linuxbrew/bin), which isn't on a systemd user +# service's own minimal default PATH -- unlike an interactive shell, this +# doesn't source .zshrc/.bashrc/.profile at all. +Environment=PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/eww daemon --no-daemonize +ExecStartPost=/usr/bin/sleep 2 +ExecStartPost=/usr/bin/eww open lyrics-window +Restart=on-failure + +[Install] +WantedBy=default.target personal-services.target diff --git a/workspace/aistuff b/workspace/aistuff index 4b4d7cd..e19aa02 160000 --- a/workspace/aistuff +++ b/workspace/aistuff @@ -1 +1 @@ -Subproject commit 4b4d7cd5392dd68f3f0a6cc78aa939a0531d9959 +Subproject commit e19aa02ba8c39ad0c388ed0874369371fccf6e56