- Multi-monitor: keep theme background on non-target outputs via swaybg -o - TUI loops back to main menu after every action; only Cancel/Esc exits - Change video now also lets you pick the monitor - Validate saved target against live hyprctl monitors; fall back to "*" if the monitor was unplugged between sessions - Restore terminal state on exit (cnorm/rmcup) so gum can't leave the floating window with a frozen cursor - show_panel writes to stderr so command-substitution callers (pick_target, pick_video, browse_filesystem) can't capture panel bytes into their result - Center the panel + indent --header label and gum cursor to match - Align colons in the status panel by left-padding labels - Handle 0-monitor edge case in pick_target with a clear error - Wrap save_state's umask 077 in a subshell so it doesn't leak Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
995 lines
34 KiB
Bash
995 lines
34 KiB
Bash
#!/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 <<MSG
|
|
$APP_NAME: interactive mode requires a terminal.
|
|
Run from a terminal, or use a non-interactive action:
|
|
$0 start | stop | status
|
|
MSG
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Restore terminal state on exit. gum widgets occasionally leave the terminal
|
|
# in alt-screen / hidden-cursor mode (especially when interrupted or when the
|
|
# script exits before gum has fully torn down its UI), which can leave the
|
|
# launcher-spawned floating window blank with a frozen cursor that won't
|
|
# accept input. Resetting the cursor + screen on every exit eliminates that.
|
|
restore_terminal() {
|
|
tput cnorm 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" <<STATE
|
|
LAST_VIDEO="$1"
|
|
LAST_TARGET="$2"
|
|
LAST_DIR="$dir"
|
|
STATE
|
|
)
|
|
mv -f "$tmp" "$STATE_FILE"
|
|
}
|
|
|
|
# Return 0 if $1 is "*" or matches a currently-attached output. Used to
|
|
# detect when a saved LAST_TARGET no longer exists (e.g. external monitor
|
|
# unplugged between sessions) so callers can re-prompt or bail out cleanly.
|
|
target_is_valid() {
|
|
local target="$1"
|
|
[ -z "$target" ] && return 1
|
|
[ "$target" = '*' ] && return 0
|
|
local monitors mon
|
|
monitors="$(get_monitors 2>/dev/null || true)"
|
|
[ -n "$monitors" ] || return 1
|
|
while IFS= read -r mon; do
|
|
[ "$mon" = "$target" ] && return 0
|
|
done <<< "$monitors"
|
|
return 1
|
|
}
|
|
|
|
is_running() {
|
|
pgrep -x mpvpaper >/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/<sig>/` 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 <<USAGE
|
|
$APP_NAME
|
|
Usage: ${0##*/} [toggle|start|stop|change|status]
|
|
|
|
toggle Interactive gum TUI (default). If running, offers Stop / Change.
|
|
start Start from saved state without prompting (for systemd).
|
|
stop Stop mpvpaper and restore the normal wallpaper.
|
|
change Pick a new video while already running.
|
|
status Print current state.
|
|
USAGE
|
|
;;
|
|
*)
|
|
echo "Unknown action: $1" >&2
|
|
echo "Try: ${0##*/} --help" >&2
|
|
exit 1
|
|
;;
|
|
esac
|