#!/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" # Catppuccin Mocha-ish accents COLOR_ACCENT="#cba6f7" COLOR_ERROR="#f38ba8" COLOR_OK="#a6e3a1" COLOR_MUTED="#6c7086" BROWSE_SENTINEL="── Browse filesystem… ──" 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 --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 "motion-wallpaper.service is not installed. Re-run wallpaper.sh." return 1 fi if ! systemctl --user enable motion-wallpaper.service >/dev/null 2>&1; then tui_err "Failed to enable autostart (systemctl error)." return 1 fi log "autostart enabled" return 0 } autostart_disable() { systemctl --user disable motion-wallpaper.service >/dev/null 2>&1 || true log "autostart disabled" } show_header() { load_state local status_line target_line video_line autostart_line if is_running; then status_line="status: $(gum style --foreground="$COLOR_OK" running)" else status_line="status: $(gum style --foreground="$COLOR_MUTED" stopped)" fi target_line="target: ${LAST_TARGET:-$(gum style --foreground="$COLOR_MUTED" --italic '(none)')}" video_line="video: ${LAST_VIDEO:-$(gum style --foreground="$COLOR_MUTED" --italic '(none)')}" if autostart_enabled; then autostart_line="autostart: $(gum style --foreground="$COLOR_OK" enabled)" else autostart_line="autostart: $(gum style --foreground="$COLOR_MUTED" disabled)" fi gum style --border=rounded --border-foreground="$COLOR_ACCENT" \ --padding="1 2" --margin="1 0" \ "$(gum style --bold --foreground="$COLOR_ACCENT" "◐ $APP_NAME")" \ "" \ "$status_line" \ "$target_line" \ "$video_line" \ "$autostart_line" } # ===== selection ============================================================== ensure_hyprland_env() { # The TUI can be launched from contexts whose env doesn't carry a valid # HYPRLAND_INSTANCE_SIGNATURE. Two failure modes: # 1) HIS unset (fresh login shell from a non-graphical context) # 2) HIS set but STALE — points to a previous Hyprland session whose # socket no longer exists (seen when Walker's env carries the # signature from an earlier login). The second case is silent but # equally broken: hyprctl dumps a plain-text error on stdout. # `hyprctl instances` always reports the *live* signatures, so recover # from that. Leave HIS unchanged if we can't find a live instance. local his="${HYPRLAND_INSTANCE_SIGNATURE:-}" if [ -n "$his" ] && [ -d "$RUNTIME_DIR/hypr/$his" ]; then return 0 fi local sig sig="$(hyprctl instances 2>/dev/null | awk '/^instance /{sub(/:$/,"",$2); print $2; exit}')" if [ -n "$sig" ] && [ "$sig" != "$his" ]; then 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 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 "hyprctl not found. Are you in Hyprland?"; return 1; } command -v jq >/dev/null || { tui_err "jq is not installed."; return 1; } local monitors if ! monitors="$(get_monitors)" || [ -z "$monitors" ]; then if [ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]; then tui_err "HYPRLAND_INSTANCE_SIGNATURE is not set — the launcher didn't inherit the Hyprland session. Try running motion-wallpaper-toggle from a regular terminal." else tui_err "Could not read monitors from hyprctl. See $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 selected=$( { echo "All monitors"; printf '%s\n' "$monitors"; } \ | gum choose --header="Select a monitor") || return 1 if [ "$selected" = "All monitors" ]; then printf '%s' '*' else printf '%s' "$selected" fi } browse_filesystem() { # Start gum file at the last directory we successfully picked from, so # changing videos returns the user to where they were. Falls back to the # last video's dirname (for state files pre-dating LAST_DIR), then $HOME. load_state local start_dir="${LAST_DIR:-}" if [ -z "$start_dir" ] && [ -n "${LAST_VIDEO:-}" ]; then start_dir="$(dirname "$LAST_VIDEO")" fi if [ -z "$start_dir" ] || [ ! -d "$start_dir" ]; then start_dir="$HOME" fi gum file --height=20 "$start_dir" } 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 choice=$( { printf '%s\n' "${basenames[@]}"; echo "$BROWSE_SENTINEL"; } \ | gum choose --header="Choose a video from $LIBRARY_DIR") || return 1 if [ "$choice" = "$BROWSE_SENTINEL" ]; 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 } start_watcher() { local watcher # PATH in launcher-spawned terminals is unreliable (minimal systemd env etc), # so fall back to the script's own directory where the installer puts both. watcher="$(command -v motion-wallpaper-watcher 2>/dev/null || true)" if [ -z "$watcher" ]; then local self_dir self_dir="$(dirname "$(readlink -f "$0")")" if [ -x "$self_dir/motion-wallpaper-watcher" ]; then watcher="$self_dir/motion-wallpaper-watcher" fi fi if [ -z "$watcher" ]; then log "watcher binary not found — auto-pause disabled" return 0 fi kill_watcher setsid "$watcher" < /dev/null >> "$LOG_FILE" 2>&1 & disown 2>/dev/null || true log "watcher spawned via $watcher" } 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 "mpvpaper failed to start. See $LOG_FILE for details." 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 if is_running; then show_header local autostart_label if autostart_enabled; then autostart_label="Turn autostart OFF" else autostart_label="Turn autostart ON" fi local choice choice=$(gum choose --header="What would you like to do?" \ "Stop motion wallpaper" "Change video" "$autostart_label" "Cancel") || exit 0 case "$choice" in "Stop motion wallpaper") stop_mpvpaper tui_ok "Stopped. Normal wallpaper restored." notify "Motion wallpaper 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 "Autostart is still enabled — also disable it so the wallpaper doesn't resume after reboot?"; then autostart_disable tui_ok "Autostart disabled." fi fi ;; "Change video") load_state local video video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "File not found: $video"; exit 1; } save_state "$video" "$LAST_TARGET" start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1 tui_ok "Swapped to $(basename "$video")." notify "Motion wallpaper updated." ;; "Turn autostart ON") autostart_enable && tui_ok "Autostart enabled — wallpaper will resume after reboot." ;; "Turn autostart OFF") autostart_disable tui_ok "Autostart disabled." # 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 "Motion wallpaper is still running — stop it now too?"; then stop_mpvpaper tui_ok "Stopped. Normal wallpaper restored." notify "Motion wallpaper stopped." fi fi ;; Cancel) exit 0 ;; esac return 0 fi show_header local target video target=$(pick_target) || exit 0 [ -z "$target" ] && exit 0 video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "File not found: $video"; exit 1; } save_state "$video" "$target" start_mpvpaper_bg "$target" "$video" || exit 1 tui_ok "Started $(basename "$video") on $target." notify "Motion wallpaper started on $target." # 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 "Start motion wallpaper automatically after login / reboot?"; then autostart_enable && tui_ok "Autostart enabled." fi fi } action_change() { require_gum require_tty if ! is_running; then tui_err "Motion wallpaper is not running." exit 1 fi load_state local video video=$(pick_video) || exit 0 [ -z "$video" ] && exit 0 [ -f "$video" ] || { tui_err "File not found: $video"; exit 1; } save_state "$video" "$LAST_TARGET" start_mpvpaper_bg "$LAST_TARGET" "$video" || exit 1 tui_ok "Swapped to $(basename "$video")." notify "Motion wallpaper 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