Scripts, scenes, bridges, bots and more
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# loading/convert.sh — strip video tracks out of audio files in
|
||||
# ~/.config/obs-studio/playlist/ and re-encode to AAC m4a in place.
|
||||
#
|
||||
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
|
||||
# element decodes the video stream too (just doesn't render it) — pegs
|
||||
# CPU and stalls playback mid-track. Audio-only m4a is ~95% smaller
|
||||
# and decodes ~20× faster.
|
||||
#
|
||||
# ./convert.sh — convert all video-bearing files
|
||||
# ./convert.sh --dry-run — show what would happen, do nothing
|
||||
#
|
||||
# DO NOT run this while OBS has a playlist track open (between streams
|
||||
# is fine; mid-stream you'll lock the file ffmpeg is reading from).
|
||||
#
|
||||
# After conversion, re-index: ./playlist.sh
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
AUDIO_DIR="$OBS_DIR/playlist"
|
||||
|
||||
DRY=0
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY=1
|
||||
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo " ✗ $AUDIO_DIR does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ffprobe >/dev/null 2>&1 || ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo " ✗ ffmpeg / ffprobe required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob nocaseglob
|
||||
|
||||
# Files that contain a video stream (and aren't already m4a)
|
||||
declare -a TODO=()
|
||||
for f in "$AUDIO_DIR"/*.{webm,mp4,mkv,mov,avi}; do
|
||||
[[ -f "$f" ]] || continue
|
||||
# Skip yt-dlp intermediate files
|
||||
case "$(basename "$f")" in
|
||||
*.f[0-9]*.*|*.temp.*|*.part) continue ;;
|
||||
esac
|
||||
# Only consider files that actually contain a video stream
|
||||
if ffprobe -v error -select_streams v -show_entries stream=codec_type \
|
||||
-of csv=p=0 "$f" 2>/dev/null | grep -q video; then
|
||||
TODO+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#TODO[@]} -eq 0 ]]; then
|
||||
echo " ✓ nothing to convert (no video-bearing files in $AUDIO_DIR)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "── Converting ${#TODO[@]} file(s) → AAC m4a ──"
|
||||
for f in "${TODO[@]}"; do
|
||||
base="${f%.*}"
|
||||
out="$base.m4a"
|
||||
if [[ -f "$out" ]]; then
|
||||
echo " ⊘ skip (m4a exists): $(basename "$f")"
|
||||
continue
|
||||
fi
|
||||
echo " → $(basename "$f")"
|
||||
if [[ $DRY -eq 1 ]]; then
|
||||
echo " (dry-run: would write $(basename "$out") and remove the source)"
|
||||
continue
|
||||
fi
|
||||
if ffmpeg -nostdin -loglevel error -i "$f" -vn -c:a aac -b:a 192k "$out"; then
|
||||
rm -f "$f"
|
||||
echo " ✓ ${out##*/} ($(du -h "$out" | cut -f1))"
|
||||
else
|
||||
echo " ✗ ffmpeg failed; leaving $(basename "$f") in place" >&2
|
||||
rm -f "$out"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo " → next: ./playlist.sh (re-index for the new filenames)"
|
||||
@@ -17,7 +17,7 @@
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.30);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
@@ -58,16 +58,25 @@ body {
|
||||
.hud > .group:nth-child(2) { justify-self: center; }
|
||||
.hud > .group:nth-child(3) { justify-self: end; }
|
||||
.hud .dim { color: var(--hud-dim); }
|
||||
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
/* Small upward nudge: flex centers on line-box mid, but caps-only text
|
||||
reads centered around cap-height mid which sits a touch higher. */
|
||||
margin-top: -0.08em;
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
|
||||
not cap-height, so against all-caps SIGNAL their visual middle sits low.
|
||||
Lift by ~(cap-mid − x-mid) ≈ 0.10em to put the glyph center on cap center. */
|
||||
#signal {
|
||||
display: inline-block;
|
||||
transform: translateY(-0.10em);
|
||||
}
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.2; }
|
||||
@@ -81,16 +90,17 @@ body {
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 64vw;
|
||||
max-width: 1500px;
|
||||
height: 100%;
|
||||
min-height: 380px;
|
||||
width: 60vw;
|
||||
max-width: 1380px;
|
||||
height: 58vh;
|
||||
max-height: 740px;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 1px solid var(--term-edge);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 32px 40px;
|
||||
padding: 28px 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
line-height: 1.55;
|
||||
@@ -158,11 +168,15 @@ body {
|
||||
}
|
||||
|
||||
/* ───────── Big countdown ───────── */
|
||||
/* text-indent compensates for trailing letter-spacing on the last char,
|
||||
which would otherwise be included in the centered inline box and shift
|
||||
visible text left by half the letter-spacing value. */
|
||||
.countdown { text-align: center; }
|
||||
.countdown .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -171,6 +185,7 @@ body {
|
||||
font-size: 132px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.10em;
|
||||
text-indent: 0.10em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
|
||||
@@ -181,6 +196,7 @@ body {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes cd-pulse {
|
||||
@@ -191,6 +207,7 @@ body {
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 32px var(--term-glow);
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
}
|
||||
.countdown.ready .sub { color: var(--term-fg-bright); }
|
||||
.countdown.ready .label { color: var(--term-fg-dim); }
|
||||
@@ -301,9 +318,12 @@ body {
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<!-- Manifest data written by loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
|
||||
<!-- Rig + hardware telemetry written by scripts/telemetry.sh — exposes window.__TEL.rig
|
||||
for the "detected rig :: …" terminal line below. Same wrapper trick. -->
|
||||
<script src="../landing/telemetry.js"></script>
|
||||
<!-- Manifest data written by scripts/loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
|
||||
<script src="loading.js"></script>
|
||||
<!-- Playlist manifest written by playlist.sh — same wrapper trick. Used here
|
||||
<!-- Playlist manifest written by scripts/playlist.sh — same wrapper trick. Used here
|
||||
just for the track count in the terminal output; actual playback runs
|
||||
in the separate Music Daemon source. -->
|
||||
<script src="playlist.js"></script>
|
||||
@@ -356,9 +376,10 @@ body {
|
||||
const cdMin = data.countdownMin ?? 5;
|
||||
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'loadproject --manifest' },
|
||||
{ kind: 'out', text: 'initializing transmission envelope...' },
|
||||
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
||||
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
||||
@@ -450,7 +471,7 @@ body {
|
||||
let lastTrackIndex = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforeNp('[audio] vendor/obs-config.js missing — run setup.sh', 'term-dim');
|
||||
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -482,7 +503,7 @@ body {
|
||||
await typeCommand('mpd --queue ~/playlists/stream-mix');
|
||||
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued — run loading/playlist.sh', 'term-dim');
|
||||
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Interactive manifest builder for the Project Loading scene.
|
||||
#
|
||||
# ./loading.sh
|
||||
#
|
||||
# Walks you through 5 prompts, writes loading.json + loading.js, then
|
||||
# refresh the Project Loading browser source in OBS. Re-running uses
|
||||
# previous answers as defaults — press Enter to keep them.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
JSON="$DIR/loading.json"
|
||||
JS="$DIR/loading.js"
|
||||
|
||||
# Reads a top-level field from the previous loading.json. Always exits 0;
|
||||
# prints empty string if file/field is missing.
|
||||
prev() {
|
||||
[[ -f "$JSON" ]] || return 0
|
||||
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
v = d.get(sys.argv[2])
|
||||
if v is None: print('')
|
||||
elif isinstance(v, bool): print('y' if v else 'n')
|
||||
else: print(v)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
|
||||
ask() {
|
||||
# ask "question" "default" → echoes user input or default if blank
|
||||
local q="$1" def="${2:-}"
|
||||
local prompt=" $q"
|
||||
[[ -n "$def" ]] && prompt+=" [$def]"
|
||||
prompt+=": "
|
||||
local v
|
||||
read -rp "$prompt" v
|
||||
[[ -z "$v" && -n "$def" ]] && v="$def"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
ask_yn() {
|
||||
local v; v=$(ask "$1" "${2:-y}")
|
||||
case "${v,,}" in
|
||||
y|yes|on|true|1) printf 'true' ;;
|
||||
n|no|off|false|0) printf 'false' ;;
|
||||
*) printf 'true' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ask_int() {
|
||||
local v
|
||||
while :; do
|
||||
v=$(ask "$1" "${2:-}")
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
|
||||
echo " ✗ must be a whole number" >&2
|
||||
done
|
||||
}
|
||||
|
||||
sanitize() {
|
||||
printf '%s' "${1:-}" \
|
||||
| tr -d '"\\' \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
|
||||
|
||||
# ── Pull previous answers as defaults ───────────────
|
||||
prev_game=$(prev game)
|
||||
prev_subtitle=$(prev subtitle)
|
||||
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
||||
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
|
||||
prev_mic=$(prev microphone); [[ -z "$prev_mic" ]] && prev_mic="y"
|
||||
|
||||
echo "── Project Loading manifest ──"
|
||||
echo " (press Enter to keep [defaults])"
|
||||
echo
|
||||
|
||||
game=$(ask "Game" "$prev_game")
|
||||
while [[ -z "$game" ]]; do
|
||||
echo " ✗ Game is required" >&2
|
||||
game=$(ask "Game" "")
|
||||
done
|
||||
|
||||
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
|
||||
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
|
||||
camera=$(ask_yn "Camera" "$prev_camera")
|
||||
mic=$(ask_yn "Microphone" "$prev_mic")
|
||||
|
||||
game=$(sanitize "$game")
|
||||
subtitle=$(sanitize "$subtitle")
|
||||
|
||||
now_iso=$(date -u +%FT%TZ)
|
||||
tmp="$JSON.tmp.$$"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"compiledAt": "$now_iso",
|
||||
"game": $(emit_str "$game"),
|
||||
"subtitle": $(emit_str "$subtitle"),
|
||||
"countdownMin": $countdown,
|
||||
"camera": $camera,
|
||||
"microphone": $mic
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
{ printf 'window.__LOADING = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
echo
|
||||
echo " ✓ wrote $JSON"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Project Loading browser source in OBS."
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# loading/playlist.sh — playlist sync + control API for the music daemon.
|
||||
#
|
||||
# Usage:
|
||||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||||
# ./playlist.sh sync → explicit sync
|
||||
# ./playlist.sh skip → next track
|
||||
# ./playlist.sh prev → previous track
|
||||
# ./playlist.sh pause → pause playback
|
||||
# ./playlist.sh resume → resume playback
|
||||
# ./playlist.sh status → show last queued command
|
||||
# ./playlist.sh help → this help
|
||||
#
|
||||
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
|
||||
# Music Daemon polls every 250ms. The daemon picks up the new command,
|
||||
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
|
||||
# loading overlay updates live.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
AUDIO_DIR="$OBS_DIR/playlist"
|
||||
JSON="$DIR/playlist.json"
|
||||
JS="$DIR/playlist.js"
|
||||
CMD_JS="$DIR/cmd.js"
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# \?//p' "$0" | head -20
|
||||
}
|
||||
|
||||
# ─────────────── sync ───────────────
|
||||
cmd_sync() {
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo " ✗ $AUDIO_DIR does not exist" >&2
|
||||
echo " Drop your audio files there first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── Indexing $AUDIO_DIR ──"
|
||||
|
||||
local TMP="$JSON.tmp.$$"
|
||||
python3 - "$AUDIO_DIR" "$TMP" <<'PY'
|
||||
import json, subprocess, sys, os, datetime, re
|
||||
|
||||
audio_dir, out = sys.argv[1], sys.argv[2]
|
||||
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||||
|
||||
def ffprobe_meta(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json', path],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0: return {}
|
||||
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
|
||||
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
|
||||
return {
|
||||
'title': tags.get('title'),
|
||||
'artist': tags.get('artist'),
|
||||
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def clean_title(t):
|
||||
if not t: return t
|
||||
# PRIMARY rule: keep only what's before the first vertical bar
|
||||
# (fullwidth | U+FF5C or ASCII |). Everything after is genre/label noise.
|
||||
t = re.sub(r'\s*[||].*$', '', t)
|
||||
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
|
||||
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
|
||||
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
|
||||
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
|
||||
return t.strip()
|
||||
|
||||
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
|
||||
def is_audio_file(name):
|
||||
base, ext = os.path.splitext(name)
|
||||
if ext.lower() not in exts: return False
|
||||
if INTERMEDIATE.search(base): return False
|
||||
return True
|
||||
|
||||
YT_ID_RX = re.compile(r'\[([A-Za-z0-9_-]{11})\]')
|
||||
files = sorted(f for f in os.listdir(audio_dir) if is_audio_file(f))
|
||||
|
||||
tracks, seen_ids = [], set()
|
||||
for fname in files:
|
||||
m = YT_ID_RX.search(fname)
|
||||
yt_id = m.group(1) if m else None
|
||||
if yt_id:
|
||||
if yt_id in seen_ids: continue
|
||||
seen_ids.add(yt_id)
|
||||
path = os.path.join(audio_dir, fname)
|
||||
meta = ffprobe_meta(path)
|
||||
display = clean_title(os.path.splitext(fname)[0])
|
||||
tracks.append({
|
||||
'title': display,
|
||||
'file': f"../playlist/{fname}",
|
||||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||||
})
|
||||
|
||||
doc = {
|
||||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'sourceDir': audio_dir,
|
||||
'trackCount': len(tracks),
|
||||
'tracks': tracks,
|
||||
}
|
||||
with open(out, 'w') as f:
|
||||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
|
||||
echo " ✗ manifest invalid, leaving $TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$TMP" "$JSON"
|
||||
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
local count
|
||||
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
|
||||
echo
|
||||
echo " ✓ wrote $JSON ($count tracks)"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
|
||||
}
|
||||
|
||||
# ─────────────── control commands ───────────────
|
||||
# Writes a unique-per-call cmd.js that the daemon polls. The id is
|
||||
# nanosecond-precise so the daemon's "did this id change?" check always passes
|
||||
# even on rapid-fire commands.
|
||||
send_cmd() {
|
||||
local type="$1"
|
||||
local id ts
|
||||
id=$(date +%s%N)
|
||||
ts=$(date +%s)
|
||||
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
|
||||
echo " → ${type} (id=$id)"
|
||||
}
|
||||
|
||||
cmd_skip() { send_cmd skip; }
|
||||
cmd_prev() { send_cmd prev; }
|
||||
cmd_pause() { send_cmd pause; }
|
||||
cmd_resume() { send_cmd resume; }
|
||||
|
||||
cmd_status() {
|
||||
if [[ -f "$CMD_JS" ]]; then
|
||||
echo "── last queued command ──"
|
||||
cat "$CMD_JS"
|
||||
else
|
||||
echo " (no command file yet — daemon hasn't been signalled this session)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────── dispatch ───────────────
|
||||
case "${1:-sync}" in
|
||||
sync) cmd_sync ;;
|
||||
skip|next) cmd_skip ;;
|
||||
prev|back) cmd_prev ;;
|
||||
pause) cmd_pause ;;
|
||||
resume|play) cmd_resume ;;
|
||||
status) cmd_status ;;
|
||||
help|-h|--help) usage ;;
|
||||
*)
|
||||
echo " ✗ unknown command: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user