#!/usr/bin/env bash # ============================================================================== # Motion Wallpaper — gum-powered TUI for mpvpaper on Omarchy / Hyprland. # # Actions: # toggle (default) Interactive TUI. If running, offers Stop / Change. # start Non-interactive start from saved state (for systemd). # stop Stop mpvpaper and restore the normal wallpaper. # change Pick a new video while already running. # status Print current state (TUI header via gum). # # Files: # ~/.config/motion-wallpaper/state last video + target monitor # ~/Videos/Wallpapers/ optional quick-pick library # ~/.cache/motion-wallpaper.log runtime log # ============================================================================== set -euo pipefail APP_NAME="Motion Wallpaper" STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/motion-wallpaper" STATE_FILE="$STATE_DIR/state" LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}" LOG_FILE="$LOG_DIR/motion-wallpaper.log" LIBRARY_DIR="$HOME/Videos/Wallpapers" # mpv options forwarded via mpvpaper -o. An input-ipc-server socket is opened # so our companion watcher can send pause/resume commands when Hyprland # reports a fullscreen window (mpvpaper's own -p is unreliable on 0.54.x). RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" MPV_IPC_SOCK="$RUNTIME_DIR/motion-wallpaper-mpv.sock" MPV_OPTS="--loop --no-audio --mute=yes --vo=gpu --profile=high-quality --input-ipc-server=$MPV_IPC_SOCK" # ===== theme colors =========================================================== # Pull TUI accents from the active Omarchy theme so the interface follows the # system color scheme (the file is a symlink that swaps when the user runs # omarchy-theme-set). Defaults below are Catppuccin Mocha and only kick in if # colors.toml is missing or unreadable. THEME_COLORS_FILE="$HOME/.config/omarchy/current/theme/colors.toml" load_theme_colors() { COLOR_ACCENT="#cba6f7" COLOR_ERROR="#f38ba8" COLOR_OK="#a6e3a1" COLOR_MUTED="#6c7086" [ -r "$THEME_COLORS_FILE" ] || return 0 local key val while IFS='=' read -r key val; do key="${key// /}" val="${val// /}"; val="${val//\"/}" case "$key" in accent) COLOR_ACCENT="$val" ;; # primary accent color1) COLOR_ERROR="$val" ;; # ANSI red → errors color2) COLOR_OK="$val" ;; # ANSI green → success color8) COLOR_MUTED="$val" ;; # ANSI bright black → muted lines esac done < "$THEME_COLORS_FILE" } load_theme_colors # ===== layout + gum theming =================================================== # Horizontal centering: compute a left-margin for the panel from the live # terminal width, recomputed on every show_panel call (handled inside the # function, not at script load) so floating-window resize is picked up. # Menu items also get the same indent via GUM_CHOOSE_CURSOR. PANEL_WIDTH=52 export GUM_CHOOSE_CURSOR_FOREGROUND="$COLOR_ACCENT" export GUM_CHOOSE_SELECTED_FOREGROUND="$COLOR_ACCENT" export GUM_CHOOSE_HEADER_FOREGROUND="$COLOR_ACCENT" export GUM_CONFIRM_PROMPT_FOREGROUND="$COLOR_ACCENT" export GUM_CONFIRM_SELECTED_BACKGROUND="$COLOR_ACCENT" export GUM_CONFIRM_SELECTED_FOREGROUND="$COLOR_MUTED" export GUM_INPUT_PROMPT_FOREGROUND="$COLOR_ACCENT" export GUM_INPUT_CURSOR_FOREGROUND="$COLOR_ACCENT" export GUM_FILE_HEADER_FOREGROUND="$COLOR_ACCENT" export GUM_FILTER_INDICATOR_FOREGROUND="$COLOR_ACCENT" export GUM_FILTER_HEADER_FOREGROUND="$COLOR_ACCENT" export GUM_SPIN_SPINNER_FOREGROUND="$COLOR_ACCENT" # gum's built-in help footer (e.g. "↑↓ navigate · enter submit") renders # flush-left and there's no flag to indent it, so it visually mismatches # the centered panel and indented menu cursor. Hide it; users know the # arrow-key convention from every other gum-based TUI on the system. export GUM_CHOOSE_SHOW_HELP=false export GUM_FILTER_SHOW_HELP=false export GUM_FILE_SHOW_HELP=false # Compute the left margin needed to horizontally center a PANEL_WIDTH box # inside the terminal. Recomputed on each call so resizes are picked up. panel_margin() { local cols cols="$(tput cols 2>/dev/null || echo "${COLUMNS:-80}")" local m=$(( (cols - PANEL_WIDTH) / 2 )) [ "$m" -lt 0 ] && m=0 printf '%s' "$m" } # Build a string of N spaces (the panel's left margin) so callers can # prepend it to gum --header values to keep labels aligned with the # centered panel above them. panel_indent() { local m m="$(panel_margin)" printf '%*s' "$m" '' } # ===== user-facing strings ==================================================== # All copy lives here so the wording can be retuned without hunting through # the action handlers. Format strings use printf-style %s; menu items must # match verbatim because case branches compare against them. # Header panel labels + values. Labels are LEFT-padded so the colons line up # vertically inside the box (autostart: is the longest at 10 chars, shorter # labels get leading spaces to match). Values follow after a single space, so # both the colons and the values end up column-aligned. TXT_TITLE="◐ $APP_NAME" TXT_LBL_STATUS=" status:" TXT_LBL_TARGET=" target:" TXT_LBL_VIDEO=" video:" TXT_LBL_AUTO="autostart:" TXT_VAL_RUNNING="running" TXT_VAL_STOPPED="stopped" TXT_VAL_NONE="(none)" TXT_VAL_AUTO_ON="enabled" TXT_VAL_AUTO_OFF="disabled" # Menu / picker headers TXT_HDR_MAIN="What would you like to do?" TXT_HDR_MONITOR="Select a monitor" TXT_HDR_LIBRARY_FMT="Choose a video from %s" TXT_HDR_BROWSE_FMT="Browse: %s" # Menu items (used in case branches → keep verbatim) TXT_BTN_STOP="Stop motion wallpaper" TXT_BTN_CHANGE="Change video" TXT_BTN_PICK="Pick a video and start" TXT_BTN_AUTOSTART_ON="Turn autostart ON" TXT_BTN_AUTOSTART_OFF="Turn autostart OFF" TXT_BTN_CANCEL="Cancel" TXT_BTN_START_FMT="Start with %s" TXT_BTN_ALL_MONITORS="All monitors" TXT_BTN_UP="── Up one folder ──" TXT_BTN_BROWSE="── Browse filesystem… ──" # Confirms TXT_CONFIRM_DISABLE_AUTOSTART="Autostart is still enabled — also disable it so the wallpaper doesn't resume after reboot?" TXT_CONFIRM_STOP_RUNNING="Motion wallpaper is still running — stop it now too?" TXT_CONFIRM_OFFER_AUTOSTART="Start motion wallpaper automatically after login / reboot?" # Success messages TXT_OK_STOPPED="Stopped. Normal wallpaper restored." TXT_OK_SWAPPED_FMT="Swapped to %s." TXT_OK_STARTED_FMT="Started %s on %s." TXT_OK_AUTOSTART_ON="Autostart enabled." TXT_OK_AUTOSTART_ON_REBOOT="Autostart enabled — wallpaper will resume after reboot." TXT_OK_AUTOSTART_ON_LATER="Autostart enabled — wallpaper will resume after reboot (once started)." TXT_OK_AUTOSTART_OFF="Autostart disabled." # Errors TXT_ERR_NO_HYPRCTL="hyprctl not found. Are you in Hyprland?" TXT_ERR_NO_JQ="jq is not installed." TXT_ERR_NO_HIS="HYPRLAND_INSTANCE_SIGNATURE is not set — the launcher didn't inherit the Hyprland session. Try running motion-wallpaper-toggle from a regular terminal." TXT_ERR_HYPRCTL_FMT="Could not read monitors from hyprctl. See %s." TXT_ERR_NO_UNIT="motion-wallpaper.service is not installed. Re-run wallpaper.sh." TXT_ERR_AUTOSTART_FAIL="Failed to enable autostart (systemctl error)." TXT_ERR_FILE_FMT="File not found: %s" TXT_ERR_NOT_RUNNING="Motion wallpaper is not running." TXT_ERR_MPVP_FMT="mpvpaper failed to start. See %s for details." TXT_ERR_EMPTY_DIR="No subfolders or videos in this folder." # Notifications TXT_NOTIFY_STARTED_FMT="Motion wallpaper started on %s." TXT_NOTIFY_STOPPED="Motion wallpaper stopped." TXT_NOTIFY_UPDATED="Motion wallpaper updated." # Misc TXT_PRESS_ENTER="Press enter to close…" # Backwards-compat alias used by pick_video's "browse" sentinel match. BROWSE_SENTINEL="$TXT_BTN_BROWSE" mkdir -p "$STATE_DIR" "$LOG_DIR" # ===== helpers ================================================================ log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" } require_gum() { if ! command -v gum >/dev/null 2>&1; then echo "ERROR: gum is not installed. Run: sudo pacman -S gum" >&2 exit 1 fi } require_tty() { if [ ! -t 0 ] || [ ! -t 1 ]; then cat >&2 </dev/null || true # cursor normal (visible, not blinking-only-state) tput rmcup 2>/dev/null || true # leave alt-screen if gum entered it } trap restore_terminal EXIT tui_err() { # Display goes to stderr so callers running tui_err inside $(...) (e.g. # pick_target's failure path) don't capture the styled error string into # their return value. gum style --foreground="$COLOR_ERROR" --bold "ERROR: $1" >&2 log "ERROR: $1" # Hold the terminal open so launcher-spawned windows don't flash away. if [ -t 0 ]; then gum input --placeholder="$TXT_PRESS_ENTER" >/dev/null 2>&1 || true fi } tui_ok() { gum style --foreground="$COLOR_OK" "✓ $1" >&2 log "$1" # Brief pause so the success line stays on screen long enough to read # before action_toggle's loop calls show_panel and clears the screen for # the next menu render. Without this, success feedback flashes by. sleep 0.8 } notify() { # Fire-and-forget system notification so the user sees results after the # TUI terminal closes. if command -v notify-send >/dev/null 2>&1; then notify-send "$APP_NAME" "$1" || true fi log "$1" } load_state() { LAST_VIDEO="" LAST_TARGET="" LAST_DIR="" [ -f "$STATE_FILE" ] || return 0 # Parse KEY="VALUE" lines directly instead of `source`, so a maliciously # crafted video path (e.g. containing `";rm -rf …`) can't execute. local key val while IFS='=' read -r key val; do val="${val#\"}" val="${val%\"}" case "$key" in LAST_VIDEO) LAST_VIDEO="$val" ;; LAST_TARGET) LAST_TARGET="$val" ;; LAST_DIR) LAST_DIR="$val" ;; esac done < "$STATE_FILE" } save_state() { # $1 video, $2 target, $3 (optional) last dir — defaults to dirname of $1 # so the filesystem browser re-opens where the user last landed. local dir="${3:-$(dirname "$1")}" local tmp="$STATE_FILE.tmp" # Subshell so the umask change doesn't leak to the rest of the script. # Atomic write via tmp + mv — avoids leaving a truncated file if the # process dies mid-write. ( umask 077 cat > "$tmp" </dev/null 2>&1 } # ===== autostart (systemd user unit) ========================================== autostart_installed() { systemctl --user list-unit-files motion-wallpaper.service >/dev/null 2>&1 } autostart_enabled() { systemctl --user is-enabled motion-wallpaper.service >/dev/null 2>&1 } autostart_enable() { if ! autostart_installed; then tui_err "$TXT_ERR_NO_UNIT" return 1 fi if ! systemctl --user enable motion-wallpaper.service >/dev/null 2>&1; then tui_err "$TXT_ERR_AUTOSTART_FAIL" return 1 fi log "autostart enabled" return 0 } autostart_disable() { systemctl --user disable motion-wallpaper.service >/dev/null 2>&1 || true log "autostart disabled" } # Returns the status panel as a string. Used both as a stand-alone display # (action_status / show_header) and via show_panel above each gum prompt. # Compact on purpose (4 data rows, no title, no inner padding) so panel + # gum's --header label + menu still fit in narrow floating windows. header_text() { load_state local status_line target_line video_line autostart_line if is_running; then status_line="$TXT_LBL_STATUS $(gum style --foreground="$COLOR_OK" "$TXT_VAL_RUNNING")" else status_line="$TXT_LBL_STATUS $(gum style --foreground="$COLOR_MUTED" "$TXT_VAL_STOPPED")" fi target_line="$TXT_LBL_TARGET ${LAST_TARGET:-$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_VAL_NONE")}" # Show only the video's basename in the panel — the full path was wrapping # to a second row in narrow floating windows and pushing other content off. local video_display="" if [ -n "${LAST_VIDEO:-}" ]; then video_display="$(basename "$LAST_VIDEO")" else video_display="$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_VAL_NONE")" fi video_line="$TXT_LBL_VIDEO $video_display" if autostart_enabled; then autostart_line="$TXT_LBL_AUTO $(gum style --foreground="$COLOR_OK" "$TXT_VAL_AUTO_ON")" else autostart_line="$TXT_LBL_AUTO $(gum style --foreground="$COLOR_MUTED" "$TXT_VAL_AUTO_OFF")" fi gum style --border=rounded --border-foreground="$COLOR_ACCENT" \ --padding="0 1" --width="$PANEL_WIDTH" \ --margin="0 $(panel_margin)" \ "$status_line" \ "$target_line" \ "$video_line" \ "$autostart_line" } show_header() { header_text } # Print the status panel followed by a blank line, ready for a `gum choose` # prompt to render directly below. Output goes to stderr so that callers # inside command substitution (pick_target, pick_video, browse_filesystem — # whose stdout carries the selected value) don't capture the panel bytes # into the result. gum choose itself renders its TUI on stderr too and # writes only the selection to stdout, so this matches gum's convention. # # The leading clear is essential: each prompt in a chained flow (e.g. main # menu → pick_target → pick_video) calls show_panel, and without a clear the # previous prompt's panel is still on-screen and the new one stacks below it, # producing the "duplicate panel" effect. Clear goes to stderr so command # substitution can't capture the escape bytes. # # Side effect: refreshes GUM_CHOOSE_CURSOR so the menu cursor aligns under # the centered panel using the live terminal width. The export only takes # effect for child gum processes spawned after show_panel returns, which is # exactly the order in our prompts (show_panel; gum choose ...). show_panel() { tput clear >&2 2>/dev/null || printf '\033[2J\033[H' >&2 header_text >&2 echo >&2 local m m="$(panel_margin)" local indent indent="$(printf '%*s' "$m" '')" export GUM_CHOOSE_CURSOR="${indent}> " } # ===== selection ============================================================== ensure_hyprland_env() { # The TUI can be launched from contexts whose HYPRLAND_INSTANCE_SIGNATURE # isn't usable. Three failure modes, all silent: # 1) HIS unset (fresh login shell, cron, ssh). # 2) HIS set but points to a dead session — the `hypr//` directory # and its socket files can linger on disk after the Hyprland process # exits, so a dir-exists check is not enough. # 3) Walker's env retained an old HIS across a Hyprland restart. # `hyprctl instances` is the source of truth: it only reports live # instances. Always cross-check against it and rewrite HIS if ours isn't # in the list. local his="${HYPRLAND_INSTANCE_SIGNATURE:-}" local live live="$(hyprctl instances 2>/dev/null | awk '/^instance /{sub(/:$/,"",$2); print $2}')" if [ -z "$live" ]; then # No live Hyprland — nothing we can do, let the caller surface an error. return 0 fi if [ -n "$his" ] && printf '%s\n' "$live" | grep -qxF "$his"; then return 0 fi local sig sig="$(printf '%s\n' "$live" | head -n 1)" export HYPRLAND_INSTANCE_SIGNATURE="$sig" if [ -n "$his" ]; then log "replaced stale HYPRLAND_INSTANCE_SIGNATURE ($his → $sig)" else log "recovered HYPRLAND_INSTANCE_SIGNATURE=$sig from hyprctl instances" fi } get_monitors() { # hyprctl's failure modes are noisy: when HYPRLAND_INSTANCE_SIGNATURE isn't # set (can happen when the TUI is spawned from a launcher whose env doesn't # inherit the Hyprland session), hyprctl prints a plain-text error to stdout # and exits 0 — which then makes jq complain loudly. Validate the JSON and # fall back to parsing the human-readable form. ensure_hyprland_env local mon_json mon_text mon_json="$(hyprctl monitors -j 2>/dev/null || true)" if [ -n "$mon_json" ] && printf '%s' "$mon_json" | jq -e . >/dev/null 2>&1; then printf '%s' "$mon_json" | jq -r '.[].name' return 0 fi mon_text="$(hyprctl monitors 2>/dev/null || true)" if [ -n "$mon_text" ]; then printf '%s\n' "$mon_text" \ | awk '/^Monitor /{print $2; ok=1} END{exit !ok}' \ && return 0 fi log "get_monitors: hyprctl produced no usable output (HIS=${HYPRLAND_INSTANCE_SIGNATURE:-unset})" return 1 } pick_target() { command -v hyprctl >/dev/null || { tui_err "$TXT_ERR_NO_HYPRCTL"; return 1; } command -v jq >/dev/null || { tui_err "$TXT_ERR_NO_JQ"; return 1; } local monitors if ! monitors="$(get_monitors)" || [ -z "$monitors" ]; then if [ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then tui_err "$TXT_ERR_NO_HIS" else tui_err "$(printf "$TXT_ERR_HYPRCTL_FMT" "$LOG_FILE")" fi return 1 fi local count count="$(printf '%s\n' "$monitors" | wc -l)" if [ "$count" -eq 0 ]; then # get_monitors said success but the list is empty — Hyprland is up but # has zero attached outputs (lid closed on a laptop with no external, # or a transient state). Bail with a clear error. tui_err "$(printf "$TXT_ERR_HYPRCTL_FMT" "$LOG_FILE")" return 1 fi if [ "$count" -eq 1 ]; then printf '%s' "$monitors" return 0 fi local selected show_panel selected=$( { echo "$TXT_BTN_ALL_MONITORS"; printf '%s\n' "$monitors"; } \ | gum choose --header="$(panel_indent)$TXT_HDR_MONITOR") || return 1 if [ "$selected" = "$TXT_BTN_ALL_MONITORS" ]; then printf '%s' '*' else printf '%s' "$selected" fi } browse_filesystem() { # Custom browser built on gum choose so we can hard-confine navigation to # $HOME — gum file exposes a built-in "up" key we can't intercept, which # let users wander above $HOME by mistake. By owning the entry list, the # "Up one folder" item simply isn't offered at $HOME, so escape is impossible. # Side benefit: gum choose stays in the main screen (no alt-screen takeover), # so the status panel remains visible the whole way through. load_state local cur="${LAST_DIR:-}" if [ -z "$cur" ] && [ -n "${LAST_VIDEO:-}" ]; then cur="$(dirname "$LAST_VIDEO")" fi case "$cur" in "$HOME"|"$HOME"/*) : ;; *) cur="$HOME" ;; esac [ -d "$cur" ] || cur="$HOME" local entries name rel label choice while true; do entries=() [ "$cur" != "$HOME" ] && entries+=("$TXT_BTN_UP") # Directories first (skip hidden), then video files (skip hidden). while IFS= read -r -d '' name; do entries+=("$(basename "$name")/") done < <(find "$cur" -mindepth 1 -maxdepth 1 -type d ! -name '.*' \ -print0 2>/dev/null | sort -z) while IFS= read -r -d '' name; do entries+=("$(basename "$name")") done < <(find "$cur" -mindepth 1 -maxdepth 1 -type f ! -name '.*' \ \( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \ -o -iname '*.mov' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z) if [ ${#entries[@]} -eq 0 ]; then gum style --foreground="$COLOR_ERROR" "$TXT_ERR_EMPTY_DIR" >&2 # Bounce back to $HOME instead of getting stuck on an empty leaf. # If $HOME itself is empty we'd loop forever, so bail with an error. if [ "$cur" = "$HOME" ]; then return 1 fi cur="$HOME" continue fi rel="${cur#"$HOME"}"; [ -z "$rel" ] && rel="/" label="$(printf "$TXT_HDR_BROWSE_FMT" "~$rel")" show_panel choice=$(printf '%s\n' "${entries[@]}" \ | gum choose --height=20 --header="$(panel_indent)$label") || return 1 case "$choice" in "$TXT_BTN_UP") cur="$(dirname "$cur")" ;; */) cur="$cur/${choice%/}" ;; *) printf '%s' "$cur/$choice"; return 0 ;; esac done } pick_video() { local library=() basenames=() v if [ -d "$LIBRARY_DIR" ]; then while IFS= read -r -d '' f; do library+=("$f") done < <( find "$LIBRARY_DIR" -maxdepth 1 -type f \ \( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' -o -iname '*.mov' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) fi if [ ${#library[@]} -eq 0 ]; then browse_filesystem || return 1 return 0 fi for v in "${library[@]}"; do basenames+=("$(basename "$v")") done local choice show_panel choice=$( { printf '%s\n' "${basenames[@]}"; echo "$TXT_BTN_BROWSE"; } \ | gum choose --header="$(panel_indent)$(printf "$TXT_HDR_LIBRARY_FMT" "$LIBRARY_DIR")") || return 1 if [ "$choice" = "$TXT_BTN_BROWSE" ]; then browse_filesystem else printf '%s' "$LIBRARY_DIR/$choice" fi } # ===== mpvpaper control ======================================================= kill_static_wallpapers() { pkill -x hyprpaper 2>/dev/null || true pkill -x swaybg 2>/dev/null || true } # When mpvpaper targets a single monitor, the other outputs are left without a # wallpaper layer (kill_static_wallpapers killed swaybg/hyprpaper for all of # them). Re-spawn swaybg bound only to the non-target outputs so they keep # showing the active Omarchy theme background. No-op when target is "*". start_static_on_other_monitors() { local target="$1" [ "$target" = '*' ] && return 0 local omarchy_bg="$HOME/.config/omarchy/current/background" [ -e "$omarchy_bg" ] || { log "no omarchy bg, skipping per-monitor swaybg"; return 0; } local monitors mon monitors="$(get_monitors 2>/dev/null || true)" [ -n "$monitors" ] || return 0 local args=() while IFS= read -r mon; do [ -n "$mon" ] || continue [ "$mon" = "$target" ] && continue args+=(-o "$mon" -i "$omarchy_bg" -m fill) done <<< "$monitors" [ "${#args[@]}" -eq 0 ] && return 0 log "swaybg on non-target monitors (target=$target): ${args[*]}" if command -v uwsm-app >/dev/null 2>&1; then setsid uwsm-app -- swaybg "${args[@]}" >/dev/null 2>&1 & else setsid swaybg "${args[@]}" >/dev/null 2>&1 & fi disown 2>/dev/null || true } kill_watcher() { pkill -f motion-wallpaper-watcher 2>/dev/null || true pkill -f motion-wallpaper-theme-watcher 2>/dev/null || true } # Locate a sibling script — first in PATH, then in this script's dir. Used # for both watchers because launcher-spawned PATH is minimal. locate_sibling() { local name="$1" path path="$(command -v "$name" 2>/dev/null || true)" if [ -z "$path" ]; then local self_dir self_dir="$(dirname "$(readlink -f "$0")")" if [ -x "$self_dir/$name" ]; then path="$self_dir/$name" fi fi printf '%s' "$path" } start_watcher() { local watcher theme_watcher watcher="$(locate_sibling motion-wallpaper-watcher)" theme_watcher="$(locate_sibling motion-wallpaper-theme-watcher)" kill_watcher if [ -n "$watcher" ]; then setsid "$watcher" < /dev/null >> "$LOG_FILE" 2>&1 & disown 2>/dev/null || true log "auto-pause watcher spawned via $watcher" else log "auto-pause watcher binary not found — auto-pause disabled" fi if [ -n "$theme_watcher" ]; then setsid "$theme_watcher" < /dev/null >> "$LOG_FILE" 2>&1 & disown 2>/dev/null || true log "theme watcher spawned via $theme_watcher" else log "theme watcher binary not found — theme-change auto-stop disabled" fi } restore_static_wallpaper() { local omarchy_bg="$HOME/.config/omarchy/current/background" if [ -e "$omarchy_bg" ]; then # Stop may be called twice in quick succession when the TUI stops the # wallpaper and systemd's ExecStop fires right after. Skip if someone # else already put swaybg back so we don't kill-and-respawn (causes a # visible flicker). if pgrep -x swaybg >/dev/null 2>&1 && ! pgrep -x mpvpaper >/dev/null 2>&1; then log "swaybg already running, skipping restore" return 0 fi pkill -x hyprpaper 2>/dev/null || true pkill -x swaybg 2>/dev/null || true if command -v uwsm-app >/dev/null 2>&1; then setsid uwsm-app -- swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 & else setsid swaybg -i "$omarchy_bg" -m fill >/dev/null 2>&1 & fi disown 2>/dev/null || true log "restored swaybg -> $omarchy_bg" elif command -v hyprpaper >/dev/null 2>&1; then hyprctl dispatch exec hyprpaper >/dev/null 2>&1 || true log "restored hyprpaper" else log "no known static wallpaper daemon to restore" fi } start_mpvpaper_fg() { local target="$1" video="$2" kill_static_wallpapers pkill -x mpvpaper 2>/dev/null || true sleep 0.3 log "systemd start target=$target video=$video" start_static_on_other_monitors "$target" start_watcher # shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS exec mpvpaper -o "$MPV_OPTS" "$target" "$video" } start_mpvpaper_bg() { local target="$1" video="$2" kill_static_wallpapers pkill -x mpvpaper 2>/dev/null || true sleep 0.3 log "start target=$target video=$video" start_static_on_other_monitors "$target" # setsid detaches from the controlling terminal so mpvpaper survives the # TUI terminal closing; uwsm-app parents it to the user systemd scope # (matches how Omarchy autostarts swaybg). Without both, mpvpaper was # getting SIGHUP'd when the Walker-spawned terminal exited. # shellcheck disable=SC2086 # intentional word-splitting on MPV_OPTS if command -v uwsm-app >/dev/null 2>&1; then setsid uwsm-app -- mpvpaper -o "$MPV_OPTS" "$target" "$video" \ < /dev/null >> "$LOG_FILE" 2>&1 & else # shellcheck disable=SC2086 setsid mpvpaper -o "$MPV_OPTS" "$target" "$video" \ < /dev/null >> "$LOG_FILE" 2>&1 & fi disown 2>/dev/null || true start_watcher sleep 0.8 if ! is_running; then tui_err "$(printf "$TXT_ERR_MPVP_FMT" "$LOG_FILE")" return 1 fi } stop_mpvpaper() { # Serialize stops so the TUI-initiated path and systemd's ExecStop (which # fires when the TUI kills mpvpaper) can't both be mid-restore at once. # `flock -n` → second caller skips cleanly if the first still holds it. ( flock -n 9 || { log "stop already in progress, skipping"; exit 0; } kill_watcher pkill -x mpvpaper 2>/dev/null || true # mpv cleans up its IPC socket on exit; a hard kill can leave it dangling. rm -f "$MPV_IPC_SOCK" 2>/dev/null || true restore_static_wallpaper log "stopped" ) 9>"$STATE_DIR/.stop.lock" } # ===== actions ================================================================ action_toggle() { require_gum require_tty # Main interactive loop. After every action (stop, change, toggle autostart, # etc.) we `continue` back here and re-render the menu — running vs. stopped # state is recomputed each pass, so the menu adapts. Only Cancel / Esc # breaks the loop, which falls through to the explicit exit (the EXIT trap # restores cursor + main screen on the way out). while true; do if is_running; then local autostart_label if autostart_enabled; then autostart_label="$TXT_BTN_AUTOSTART_OFF" else autostart_label="$TXT_BTN_AUTOSTART_ON" fi local choice show_panel choice=$(gum choose --header="$(panel_indent)$TXT_HDR_MAIN" \ "$TXT_BTN_STOP" "$TXT_BTN_CHANGE" "$autostart_label" "$TXT_BTN_CANCEL") || break case "$choice" in "$TXT_BTN_STOP") stop_mpvpaper tui_ok "$TXT_OK_STOPPED" notify "$TXT_NOTIFY_STOPPED" # If autostart is on, a plain stop will let the wallpaper return on # next reboot. Offer to turn autostart off so "stop" means "stop". if autostart_enabled; then if gum confirm "$TXT_CONFIRM_DISABLE_AUTOSTART"; then autostart_disable tui_ok "$TXT_OK_AUTOSTART_OFF" fi fi continue ;; "$TXT_BTN_CHANGE") # Pick the new video, then the target monitor — pick_target # auto-skips the prompt on single-monitor systems and shows a # picker (with "All monitors") on multi-monitor setups, so the # user can swap monitors at the same time as the video. load_state local video target video=$(pick_video) || continue [ -z "$video" ] && continue [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; continue; } target=$(pick_target) || continue [ -z "$target" ] && continue save_state "$video" "$target" start_mpvpaper_bg "$target" "$video" || continue tui_ok "$(printf "$TXT_OK_SWAPPED_FMT" "$(basename "$video")")" notify "$TXT_NOTIFY_UPDATED" continue ;; "$TXT_BTN_AUTOSTART_ON") autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_REBOOT" continue ;; "$TXT_BTN_AUTOSTART_OFF") autostart_disable tui_ok "$TXT_OK_AUTOSTART_OFF" # Motion wallpaper is still running at this point. Offer to also # stop it now so the label "turn off" matches visible behaviour. if is_running; then if gum confirm "$TXT_CONFIRM_STOP_RUNNING"; then stop_mpvpaper tui_ok "$TXT_OK_STOPPED" notify "$TXT_NOTIFY_STOPPED" fi fi continue ;; "$TXT_BTN_CANCEL") break ;; esac continue fi # Stopped state: show a gum-choose menu first instead of dumping the # user straight into the (alt-screen) file picker. Keeps the status # panel visible and lets them reuse a saved video, toggle autostart, # or back out without committing to a file pick. load_state local options=() local last_label="" if [ -n "${LAST_VIDEO:-}" ] && [ -f "${LAST_VIDEO:-}" ]; then last_label="$(printf "$TXT_BTN_START_FMT" "$(basename "$LAST_VIDEO")")" options+=("$last_label") fi options+=("$TXT_BTN_PICK") if autostart_installed; then if autostart_enabled; then options+=("$TXT_BTN_AUTOSTART_OFF") else options+=("$TXT_BTN_AUTOSTART_ON") fi fi options+=("$TXT_BTN_CANCEL") local choice show_panel choice=$(gum choose --header="$(panel_indent)$TXT_HDR_MAIN" "${options[@]}") || break local target video case "$choice" in "$last_label") [ -n "$last_label" ] || continue target=$(pick_target) || continue [ -z "$target" ] && continue save_state "$LAST_VIDEO" "$target" start_mpvpaper_bg "$target" "$LAST_VIDEO" || continue tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$LAST_VIDEO")" "$target")" notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")" continue ;; "$TXT_BTN_PICK") target=$(pick_target) || continue [ -z "$target" ] && continue video=$(pick_video) || continue [ -z "$video" ] && continue [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; continue; } save_state "$video" "$target" start_mpvpaper_bg "$target" "$video" || continue tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$video")" "$target")" notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")" continue ;; "$TXT_BTN_AUTOSTART_ON") autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_LATER" continue ;; "$TXT_BTN_AUTOSTART_OFF") autostart_disable tui_ok "$TXT_OK_AUTOSTART_OFF" continue ;; "$TXT_BTN_CANCEL"|*) break ;; esac done exit 0 } action_change() { require_gum require_tty if ! is_running; then tui_err "$TXT_ERR_NOT_RUNNING" exit 1 fi load_state local video target="$LAST_TARGET" video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; } # If the saved target no longer exists (monitor unplugged), fall back to # all monitors so the change action still lands somewhere visible. if ! target_is_valid "$target"; then log "change: saved target '$target' not present, falling back to all monitors" target='*' fi save_state "$video" "$target" start_mpvpaper_bg "$target" "$video" || exit 1 tui_ok "$(printf "$TXT_OK_SWAPPED_FMT" "$(basename "$video")")" notify "$TXT_NOTIFY_UPDATED" } action_start() { load_state if [ -z "$LAST_VIDEO" ] || [ -z "$LAST_TARGET" ]; then log "autostart: no saved state, exiting cleanly" exit 0 fi if [ ! -f "$LAST_VIDEO" ]; then log "autostart: saved video missing ($LAST_VIDEO)" exit 1 fi # Validate the saved target against the live Hyprland output list. If the # monitor was unplugged between sessions we'd otherwise hand mpvpaper a # bogus output name and it would fail at startup; falling back to "*" is # the safest default (covers all currently-connected outputs). if ! target_is_valid "$LAST_TARGET"; then log "autostart: saved target '$LAST_TARGET' not present, falling back to all monitors" LAST_TARGET='*' fi start_mpvpaper_fg "$LAST_TARGET" "$LAST_VIDEO" } action_stop() { stop_mpvpaper } action_status() { if command -v gum >/dev/null 2>&1 && [ -t 1 ]; then show_header else load_state if is_running; then echo "status: running"; else echo "status: stopped"; fi if [ -n "${LAST_TARGET:-}" ]; then echo "target: $LAST_TARGET"; fi if [ -n "${LAST_VIDEO:-}" ]; then echo "video: $LAST_VIDEO"; fi fi } # ===== main =================================================================== case "${1:-toggle}" in toggle) action_toggle ;; start) action_start ;; stop) action_stop ;; change) action_change ;; status) action_status ;; -h|--help) cat <&2 echo "Try: ${0##*/} --help" >&2 exit 1 ;; esac