#!/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 ================================================================= # Center the status panel horizontally in the floating window. gum style's # --margin shifts the whole box right; we compute the side margin from the # real terminal width (tput cols) so the panel sits centered regardless of # how the user has sized the floating window. TERM_COLS="$(tput cols 2>/dev/null || echo "${COLUMNS:-80}")" TERM_ROWS="$(tput lines 2>/dev/null || echo "${LINES:-24}")" PANEL_WIDTH=52 # Clamp panel width on narrow terminals so the box can never exceed the # window — gum style would otherwise wrap the border into the next column. [ "$PANEL_WIDTH" -gt "$TERM_COLS" ] && PANEL_WIDTH="$TERM_COLS" PANEL_MARGIN=$(( (TERM_COLS - PANEL_WIDTH) / 2 )) [ "$PANEL_MARGIN" -lt 0 ] && PANEL_MARGIN=0 # Reusable indent string for rows gum doesn't know how to position itself # (menu items, header labels, success/error lines). PANEL_INDENT="$(printf '%*s' "$PANEL_MARGIN" '')" # Vertical centering: estimate the total rows the centered block occupies # (panel ≈ 9, gap 1, label 1, hint 1, ~5 menu rows = ~17). The top pad is # half the slack between the terminal height and that estimate, clamped to # ≥0 so tiny floating windows still show everything. EST_CONTENT_ROWS=17 TOP_PAD_ROWS=$(( (TERM_ROWS - EST_CONTENT_ROWS) / 2 )) [ "$TOP_PAD_ROWS" -lt 0 ] && TOP_PAD_ROWS=0 # Clear and push the cursor down before each interactive gum prompt so the # rendered UI sits vertically centered. Without this, gum draws at the # current cursor position (top of window on first call, then below previous # output as the user navigates) and the panel looks lost in dead space. center_screen() { printf '\033[2J\033[H' local i for ((i = 0; i < TOP_PAD_ROWS; i++)); do printf '\n'; done } # Push the loaded theme into gum's own widget chrome (cursor, selected item, # headers, prompts, confirm buttons). Without this, gum keeps its built-in # pink/cyan defaults regardless of $COLOR_* values, so the menu cursor and # highlighted row never follow the system theme. 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" # Pad gum's cursor with the panel's left margin so the menu items render at # the same column as the centered panel above them. Inactive rows are padded # automatically to match the cursor's printable width. export GUM_CHOOSE_CURSOR="${PANEL_INDENT}> " # Hide gum's built-in help footer — it renders flush-left and can't be # indented. We draw our own centered hint as part of prompt_header instead. export GUM_CHOOSE_SHOW_HELP=false export GUM_FILTER_SHOW_HELP=false export GUM_FILE_SHOW_HELP=false # ===== 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 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…" TXT_HINT_NAV="↑/↓ navigate · enter select · esc cancel" # 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 2>&1 || true fi } tui_ok() { gum style --margin="0 $PANEL_MARGIN" --foreground="$COLOR_OK" "✓ $1" log "$1" } 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")}" umask 077 # Atomic write: avoids leaving a truncated file if the process dies mid-cat. local tmp="$STATE_FILE.tmp" 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 as the --header of gum prompts so the # status stays visible while the user interacts in a small floating window. 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")}" video_line="$TXT_LBL_VIDEO ${LAST_VIDEO:-$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_VAL_NONE")}" 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 # Title centered, data lines left-aligned with consistent label indent — # the box itself is shifted right by PANEL_MARGIN so it sits centered in # the floating window. local title_block title_block="$(gum style --align=center --width=$((PANEL_WIDTH - 4)) \ --bold --foreground="$COLOR_ACCENT" "$TXT_TITLE")" gum style --border=rounded --border-foreground="$COLOR_ACCENT" \ --padding="0 2" --width="$PANEL_WIDTH" \ --margin="0 $PANEL_MARGIN" \ "$title_block" \ "" \ "$status_line" \ "$target_line" \ "$video_line" \ "$autostart_line" } show_header() { header_text } # Build a multi-line --header value combining the persistent status panel # (TUI_HEADER, set once per interactive session) with a per-prompt label # and a centered, muted navigation hint that replaces gum's flush-left # default footer (which can't be indented). All three lines line up with # the panel's left edge via PANEL_INDENT. prompt_header() { local label="$1" local hint hint="$(gum style --foreground="$COLOR_MUTED" --italic "$TXT_HINT_NAV")" if [ -n "${TUI_HEADER:-}" ]; then printf '%s\n\n%s%s\n%s%s' \ "$TUI_HEADER" \ "$PANEL_INDENT" "$label" \ "$PANEL_INDENT" "$hint" else printf '%s%s\n%s%s' \ "$PANEL_INDENT" "$label" \ "$PANEL_INDENT" "$hint" fi } # ===== 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 1 ]; then printf '%s' "$monitors" return 0 fi local selected center_screen selected=$( { echo "$TXT_BTN_ALL_MONITORS"; printf '%s\n' "$monitors"; } \ | gum choose --header="$(prompt_header "$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 --margin="0 $PANEL_MARGIN" --foreground="$COLOR_ERROR" "$TXT_ERR_EMPTY_DIR" # 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")" center_screen choice=$(printf '%s\n' "${entries[@]}" \ | gum choose --height=20 --header="$(prompt_header "$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 center_screen choice=$( { printf '%s\n' "${basenames[@]}"; echo "$TXT_BTN_BROWSE"; } \ | gum choose --header="$(prompt_header "$(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 } 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_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" # 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 # Render the status panel once and reuse it as the --header of every gum # prompt below, so it stays visible while the user makes choices in a # small floating window (instead of flashing past on first paint). TUI_HEADER="$(header_text)" 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 center_screen choice=$(gum choose --header="$(prompt_header "$TXT_HDR_MAIN")" \ "$TXT_BTN_STOP" "$TXT_BTN_CHANGE" "$autostart_label" "$TXT_BTN_CANCEL") || exit 0 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 ;; "$TXT_BTN_CHANGE") load_state local video video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; } save_state "$video" "$LAST_TARGET" start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1 tui_ok "$(printf "$TXT_OK_SWAPPED_FMT" "$(basename "$video")")" notify "$TXT_NOTIFY_UPDATED" ;; "$TXT_BTN_AUTOSTART_ON") autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_REBOOT" ;; "$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 ;; "$TXT_BTN_CANCEL") exit 0 ;; esac return 0 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 center_screen choice=$(gum choose --header="$(prompt_header "$TXT_HDR_MAIN")" "${options[@]}") || exit 0 local target video case "$choice" in "$last_label") [ -n "$last_label" ] || exit 0 target=$(pick_target) || exit 0 [ -z "$target" ] && exit 0 save_state "$LAST_VIDEO" "$target" start_mpvpaper_bg "$target" "$LAST_VIDEO" || exit 1 tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$LAST_VIDEO")" "$target")" notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")" ;; "$TXT_BTN_PICK") target=$(pick_target) || exit 0 [ -z "$target" ] && exit 0 video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; } save_state "$video" "$target" start_mpvpaper_bg "$target" "$video" || exit 1 tui_ok "$(printf "$TXT_OK_STARTED_FMT" "$(basename "$video")" "$target")" notify "$(printf "$TXT_NOTIFY_STARTED_FMT" "$target")" ;; "$TXT_BTN_AUTOSTART_ON") autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON_LATER" return 0 ;; "$TXT_BTN_AUTOSTART_OFF") autostart_disable tui_ok "$TXT_OK_AUTOSTART_OFF" return 0 ;; "$TXT_BTN_CANCEL"|*) exit 0 ;; esac # Offer autostart on first fresh start (skipped silently if already on or # if the systemd unit isn't installed). if autostart_installed && ! autostart_enabled; then if gum confirm "$TXT_CONFIRM_OFFER_AUTOSTART"; then autostart_enable && tui_ok "$TXT_OK_AUTOSTART_ON" fi fi } action_change() { require_gum require_tty if ! is_running; then tui_err "$TXT_ERR_NOT_RUNNING" exit 1 fi load_state TUI_HEADER="$(header_text)" local video video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "$(printf "$TXT_ERR_FILE_FMT" "$video")"; exit 1; } save_state "$video" "$LAST_TARGET" start_mpvpaper_bg "$LAST_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 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