diff --git a/.gitignore b/.gitignore
index e6a4da1..d403830 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
/dist/
-/tuiplay
+/tsplay
build.log
*.sock
diff --git a/README.md b/README.md
index 3d1340c..a2b6f1e 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,13 @@ the terminal; the selected title plays in an **mpv window** (hardware
accelerated) while the TUI acts as the remote — pause, seek, and a live progress
bar, with resume points reported back to the server.
-It's the watch-side companion to [TUISTREAM](../TUISTREAM) (which sets up and
-manages the Jellyfin *server*). tsplay only *plays*.
+It's the watch-side companion to
+[TUISTREAM](https://github.com/28allday/TUISTREAM) (which sets up and manages the
+Jellyfin *server*). tsplay only *plays*.
+
+> **New here?** The [User Guide](USER_GUIDE.md) is a plain-English, step-by-step
+> walkthrough of installing, signing in, and watching. This README is the
+> technical overview.
## How it works
@@ -45,12 +50,28 @@ with software decoding (higher CPU use).
## Install
-```bash
-./install.sh
+One line, nothing to clone:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
```
-Installs missing deps (Arch), builds `~/.local/bin/tsplay`, then just run
-`tsplay`.
+This installs `mpv` if it's missing (Arch), downloads the right binary for your
+architecture into `~/.local/bin`, then you just run `tsplay`. Pin a version with
+`TSPLAY_VERSION=v1.0.0`, or change the prefix with `PREFIX=/opt`.
+
+From a clone it builds from source instead (if Go is present), otherwise it falls
+back to downloading the released binary:
+
+```sh
+git clone https://github.com/28allday/tsplay.git
+cd tsplay && ./install.sh
+```
+
+On an **Omarchy desktop** the installer also adds a Walker entry (with icon) that
+launches tsplay as a floating terminal window — search **`tsplay`** in Walker —
+using the stock `TUI.float` app-id, so no Hyprland config is needed. On headless
+boxes this step is skipped and tsplay stays a plain shell TUI.
## Usage
@@ -63,6 +84,14 @@ subsequent runs go straight to the library.
> Discovery probes both broadcast **and** a unicast sweep of your subnet,
> because many home networks (especially WiFi) silently drop broadcast packets.
+**Switching servers.** Once signed in, tsplay locks onto that server and goes
+straight to the library on every later run — it won't ask again. If you move to
+a different Jellyfin box, press **`s`** in the browser to drop back to discovery,
+pick the new server, and sign in; the new one is then saved and locked in. If a
+saved server has gone away (e.g. you rebuilt it elsewhere), tsplay notices the
+failed connection on startup and falls back to discovery automatically so you're
+never stuck pointing at a dead server.
+
### Login
| Key | Action |
@@ -71,7 +100,7 @@ subsequent runs go straight to the library.
| `ctrl+n` | next discovered server (if more than one)|
| `ctrl+r` | rescan the network |
| `enter` | sign in |
-| `esc` | quit |
+| `esc` | cancel — back to the library, or quit if not yet signed in |
### Browse
@@ -81,6 +110,7 @@ subsequent runs go straight to the library.
| `enter` / `l` / `→`| open folder · play item |
| `esc` / `←` / `h` | back |
| `c` | jump to Continue Watching |
+| `s` | switch server (back to discovery)|
| `/` | filter the current list |
| `q` | quit |
@@ -91,21 +121,24 @@ subsequent runs go straight to the library.
| `space` / `k` | play / pause |
| `←` / `→` | seek ∓10s |
| `shift`+`←`/`→`| seek ∓60s |
+| `n` / `p` | next / previous track (music) |
| `↑` / `↓` | volume ±5 |
| `m` | mute / unmute |
| `q` / `esc` | stop, back to list|
-## Status
+## Features
-v0.1 — LAN auto-discovery, movies/shows (season/episode drill-down)/music browse
-+ direct-play, resume on start, progress/stop reporting, volume control with
-remembered level, and **audio auto-advance** (music plays through the album/
-folder, then returns to the browser). The UI uses a shared header/footer frame
-and a compact one-line list (media-type icon, title, and a right-aligned year ·
-duration · watched column).
-
-Roadmap: search, subtitle/audio-track selection, quick-connect login, video
-"play next episode".
+- **LAN auto-discovery** of Jellyfin servers, with one-key **server switching**
+ and automatic fallback if a saved server goes away.
+- Browse **movies, shows** (season/episode drill-down) **and music**, with a
+ compact one-line list (media-type icon, title, and a right-aligned year ·
+ duration · watched column) and `/` to filter.
+- **Direct-play** to mpv (no server-side transcoding), **resume from your last
+ position**, and progress/stop reporting so **Continue Watching** stays in sync
+ across all your devices.
+- **Audio auto-advance** — music plays through the album/folder and returns to
+ the browser when it's done — with volume remembered across tracks and runs.
+- Colours follow the active **Omarchy** theme, with an ANSI fallback over SSH.
## Notes
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
new file mode 100644
index 0000000..dbff8d0
--- /dev/null
+++ b/USER_GUIDE.md
@@ -0,0 +1,209 @@
+# tsplay — User Guide
+
+tsplay lets you watch everything on your Jellyfin server straight from the
+terminal. You browse your libraries in a tidy text interface; when you pick
+something, it opens in a proper **mpv** video window (hardware-accelerated, full
+quality) while the terminal becomes the remote control — pause, seek, volume,
+and a live progress bar. Where you stop is remembered on the server, so you can
+pick up later from any device.
+
+This guide is for everyday use. If you want the technical details, see the
+[README](README.md).
+
+---
+
+## 1. Installing
+
+On an **Omarchy / Arch** machine, one line does everything:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
+```
+
+The installer:
+
+- Installs the only thing it needs — **mpv** — if it isn't already present.
+- Downloads the `tsplay` program and puts it in `~/.local/bin`.
+- On an **Omarchy desktop**, it also adds tsplay to the **Walker** launcher
+ (with an icon), so you can start it like any other app.
+
+That's it — nothing else to set up.
+
+> Prefer to build it yourself? Clone the repo and run the same script — with Go
+> installed it builds from source:
+>
+> ```bash
+> git clone https://github.com/28allday/tsplay.git
+> cd tsplay && ./install.sh
+> ```
+
+> **On a non-Arch system?** The installer won't install packages for you; it
+> will just tell you to install `mpv` first, then re-run it.
+
+---
+
+## 2. Starting tsplay
+
+Three ways, pick whichever suits you:
+
+- **Walker (Omarchy desktop):** press your launcher key (usually `SUPER`), type
+ **`tsplay`**, and hit enter. It opens in a floating terminal window.
+- **Terminal:** just type `tsplay`.
+- **Over SSH** (e.g. on a headless box): type `tsplay`. The video window then
+ appears wherever mpv can open one; on a pure server with no screen, tsplay is
+ mainly used for music.
+
+---
+
+## 3. Signing in (first run)
+
+The first time you run tsplay you'll see a short login form.
+
+tsplay **automatically finds Jellyfin servers on your network**, so the server
+address is usually filled in for you. You normally only need to type your
+**username** and **password**, then press `enter`.
+
+| Key | What it does |
+|----------|------------------------------------------------|
+| `tab` | Move between the boxes (server / username / password) |
+| `ctrl+n` | Pick the next server, if more than one was found |
+| `ctrl+r` | Scan the network again |
+| `enter` | Sign in |
+| `esc` | Quit |
+
+A few notes:
+
+- If nothing is found automatically, just type the address yourself, e.g.
+ `https://media.example.com` or `http://192.168.1.50:8096`.
+- Your login is **saved securely** on your own machine, so you won't be asked
+ again — every later run goes straight to your library.
+- If you don't see your server, make sure tsplay and the server are on the **same
+ network**, then press `ctrl+r` to rescan.
+
+---
+
+## 4. Finding and playing something
+
+Once you're in, you'll see your libraries (Movies, Shows, Music, …). Move around
+with the arrow keys and open things with `enter`.
+
+| Key | What it does |
+|-------------------------|---------------------------------------|
+| `↑` / `↓` (or `j` / `k`)| Move up and down the list |
+| `enter` (or `→`) | Open a folder, **or play** a title |
+| `esc` (or `←`) | Go back one level |
+| `/` | Search/filter the list you're looking at |
+| `c` | Jump to **Continue Watching** |
+| `s` | Switch to a different server |
+| `q` | Quit tsplay |
+
+- Drill down naturally: **Shows → a series → a season → an episode**, then
+ `enter` to play.
+- Press `/` to filter a long list — start typing and the list narrows; press
+ `esc` to clear the filter.
+
+### Continue Watching
+
+Press **`c`** anywhere in the browser to see everything you started but didn't
+finish, newest first. Pick one and it **resumes from where you left off** — no
+need to scrub to the right spot. This is the same "Continue Watching" you see in
+the Jellyfin app, kept in sync with your phone, TV, and browser.
+
+---
+
+## 5. While something is playing
+
+The video plays in its own mpv window. Keep the **terminal focused** to use these
+controls (you'll also see a live progress bar and the time):
+
+| Key | What it does |
+|-----------------|-------------------------|
+| `space` (or `k`)| Play / pause |
+| `←` / `→` | Skip back / forward 10s |
+| `shift`+`←`/`→` | Skip back / forward 60s |
+| `↑` / `↓` | Volume up / down |
+| `m` | Mute / unmute |
+| `n` / `p` | Next / previous track (music only) |
+| `q` (or `esc`) | Stop and return to the library |
+
+Your position is reported back to the server as you watch, so it shows up in
+**Continue Watching** next time — and disappears once you finish.
+
+---
+
+## 6. Music
+
+Music works just like video, but with no black window — the audio simply plays in
+the background while the terminal shows what's on.
+
+- Play an album or a track and it keeps going: when one track ends, tsplay
+ **automatically moves to the next** one in the album/folder, then returns to
+ the browser when the album finishes.
+- Use `n` and `p` to jump between tracks.
+- Your last volume is remembered between tracks and between runs.
+
+---
+
+## 7. Switching to a different server
+
+If you set up a new Jellyfin box (or moved house and rebuilt it elsewhere), you
+don't need to reinstall or wipe anything:
+
+- Press **`s`** while browsing. tsplay drops back to the network scan, finds the
+ new server, and lets you sign in. From then on it locks onto the new one and
+ goes straight there on every run.
+- If your old server has simply **gone away**, tsplay notices it can't connect
+ when it starts and **takes you to the scan automatically** — so you're never
+ stuck staring at a server that no longer exists.
+
+(If you have the **cliamp** music player installed, switching servers updates its
+Jellyfin settings too, so your music stays in sync.)
+
+---
+
+## 8. Troubleshooting
+
+**"No servers found" on the login screen.**
+Make sure your computer and the Jellyfin server are on the same Wi-Fi/network,
+then press `ctrl+r` to scan again. You can always type the address by hand —
+e.g. `http://192.168.1.50:8096`.
+
+**"Login failed".**
+Double-check the username and password. If the server address is wrong, press
+`tab` back up to it and correct it (or `ctrl+r` to rediscover).
+
+**It can't connect / the library won't load.**
+tsplay will usually send you back to the server scan on its own. If you're typing
+the address, prefer a plain `http://…:8096` address on your home network, or a
+proper `https://` address with a real certificate. Self-signed certificates are
+**not** trusted.
+
+**Video plays but is choppy / uses lots of CPU.**
+That's the graphics drivers, not tsplay. On a normal Omarchy desktop this is
+already sorted. On a brand-new, minimal install you may need your GPU's
+video-decode drivers (the [README](README.md#gpu-drivers-hardware-accelerated-playback)
+lists them per graphics card).
+
+**I want to start over / log in as someone else.**
+Delete the saved settings and relaunch:
+
+```bash
+rm ~/.config/tsplay/config.json
+tsplay
+```
+
+You'll get the fresh login screen again.
+
+---
+
+## 9. Quick reference
+
+```
+Browsing ↑/↓ move · enter open/play · esc back · / search
+ c continue watching · s switch server · q quit
+
+Playing space pause · ←/→ ±10s · shift+←/→ ±60s
+ ↑/↓ volume · m mute · n/p track (music) · q stop
+```
+
+Happy watching.
diff --git a/install.sh b/install.sh
index a648184..d268bde 100755
--- a/install.sh
+++ b/install.sh
@@ -1,56 +1,146 @@
#!/usr/bin/env bash
-# tsplay installer — builds the terminal Jellyfin player from source and drops
-# the binary in ~/.local/bin. On Arch/Omarchy it installs the dependencies it
-# needs (mpv at run-time, go at build-time) via pacman; on other distros it
-# falls back to telling you what to install.
+#
+# install.sh — install tsplay, a terminal Jellyfin player for Omarchy / Arch.
+#
+# Quick install (nothing to clone):
+#
+# curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
+#
+# When run from a git clone it builds from source instead (if Go is present),
+# otherwise it downloads the latest released binary for your architecture.
+#
+# ./install.sh
+#
+# Environment overrides:
+# PREFIX=/somewhere install prefix (default ~/.local → ~/.local/bin)
+# TSPLAY_VERSION=v1.0.0 pin a release (default: latest)
+#
+# tsplay needs mpv at run time; the installer fetches it via pacman on Arch if
+# it's missing. On an Omarchy desktop it also adds a floating Walker entry.
set -euo pipefail
-REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-BIN_DIR="${HOME}/.local/bin"
-BIN="${BIN_DIR}/tsplay"
+REPO="28allday/tsplay"
+NAME="tsplay"
+PREFIX="${PREFIX:-$HOME/.local}"
+BIN_DIR="$PREFIX/bin"
+BIN="$BIN_DIR/$NAME"
+APP_DIR="$HOME/.local/share/applications"
+VERSION="${TSPLAY_VERSION:-latest}"
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; }
err() { printf '\033[31m%s\033[0m\n' "$1" >&2; }
-
-# pkg_for maps a required command to its Arch package name (same here, but kept
-# explicit so it's obvious what gets installed).
-pkg_for() {
- case "$1" in
- go) echo "go" ;;
- mpv) echo "mpv" ;;
- *) echo "$1" ;;
- esac
-}
+log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
# ensure_dep makes sure a command exists, installing it via pacman on Arch if
-# missing. $2 = "build" or "run" just tweaks the wording.
+# missing. mpv is tsplay's only run-time dependency.
ensure_dep() {
- local cmd="$1" kind="$2" pkg
+ local cmd="$1" pkg="$2"
command -v "$cmd" >/dev/null 2>&1 && return 0
- pkg="$(pkg_for "$cmd")"
if command -v pacman >/dev/null 2>&1; then
- bold "Installing missing ${kind} dependency: ${pkg}"
+ bold "Installing missing dependency: ${pkg}"
sudo pacman -S --needed --noconfirm "$pkg"
else
- err "Missing ${kind} dependency '${cmd}'. Install the '${pkg}' package and re-run."
+ err "Missing dependency '${cmd}'. Install the '${pkg}' package and re-run."
exit 1
fi
}
-# Build-time and run-time deps. mpv is the only thing tsplay needs at run time;
-# everything else is compiled into the Go binary.
-ensure_dep go build
-ensure_dep mpv run
+# mpv is required however we obtain the binary.
+ensure_dep mpv mpv
-bold "Building tsplay…"
-cd "$REPO_DIR"
mkdir -p "$BIN_DIR"
-go build -o "$BIN" ./cmd/tsplay
+# If the script lives next to the source tree, we're in a clone.
+SCRIPT_DIR=""
+if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+fi
+
+# Detect OS/arch for prebuilt asset names.
+os="$(uname -s | tr '[:upper:]' '[:lower:]')"
+case "$(uname -m)" in
+ x86_64 | amd64) arch=amd64 ;;
+ aarch64 | arm64) arch=arm64 ;;
+ *) arch="" ;;
+esac
+
+# ---- obtain the binary ----------------------------------------------------
+TMPBIN=""
+if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
+ bold "Building $NAME from source…"
+ TMPBIN="$(mktemp)"
+ trap 'rm -f "$TMPBIN"' EXIT
+ ( cd "$SCRIPT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' \
+ -o "$TMPBIN" "./cmd/$NAME" )
+elif [ -n "$SCRIPT_DIR" ] && [ -n "$arch" ] && [ -x "$SCRIPT_DIR/dist/${NAME}-${os}-${arch}" ]; then
+ log "Using prebuilt binary from dist/"
+ TMPBIN="$SCRIPT_DIR/dist/${NAME}-${os}-${arch}"
+else
+ # Download the released binary for this OS/arch (curl-style install).
+ [ "$os" = "linux" ] || err "tsplay ships Linux binaries only (detected: $os). Clone the repo and build with Go."
+ [ "$os" = "linux" ] || exit 1
+ [ -n "$arch" ] || { err "unsupported architecture: $(uname -m)"; exit 1; }
+ asset="${NAME}-${os}-${arch}"
+ if [ "$VERSION" = "latest" ]; then
+ url="https://github.com/$REPO/releases/latest/download/$asset"
+ else
+ url="https://github.com/$REPO/releases/download/$VERSION/$asset"
+ fi
+ log "Downloading $asset ($VERSION)…"
+ TMPBIN="$(mktemp)"
+ trap 'rm -f "$TMPBIN"' EXIT
+ if command -v curl >/dev/null 2>&1; then
+ curl -fSL --proto '=https' --tlsv1.2 -o "$TMPBIN" "$url"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$TMPBIN" "$url"
+ else
+ err "need curl or wget to download the binary."; exit 1
+ fi
+fi
+
+# ---- install --------------------------------------------------------------
+install -Dm755 "$TMPBIN" "$BIN"
bold "Installed: $BIN"
+
+# ---- Omarchy desktop integration -----------------------------------------
+# Only on Omarchy: add a Walker entry that opens tsplay in a floating window.
+# The TUI.float app-id is matched by Omarchy's stock floating-window rule, so
+# no Hyprland configuration is required (same approach as omarchy-send).
+if command -v omarchy-launch-tui >/dev/null 2>&1 || [ -d "$HOME/.local/share/omarchy" ]; then
+ mkdir -p "$APP_DIR"
+
+ # Install the bundled icon into the user's hicolor theme — a play triangle
+ # over a progress bar, in terminal-accent blue.
+ icon_dir="$HOME/.local/share/icons/hicolor/scalable/apps"
+ mkdir -p "$icon_dir"
+ cat > "$icon_dir/tsplay.svg" <<'SVG'
+
+SVG
+ gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
+
+ cat > "$APP_DIR/tsplay.desktop" < 0 {
+ m.screen = screenBrowse
+ m.status = ""
+ return m, nil
+ }
return m, tea.Quit
case "ctrl+n":
// Cycle through discovered servers, filling the URL field.
diff --git a/internal/tui/model.go b/internal/tui/model.go
index afa4b49..7424481 100644
--- a/internal/tui/model.go
+++ b/internal/tui/model.go
@@ -79,12 +79,30 @@ func New(cfg *config.Config, t theme.Theme) Model {
m.screen = screenBrowse
} else {
m.screen = screenLogin
- m.initLogin()
+ m.initLogin(true)
}
return m
}
-func (m *Model) initLogin() {
+// toLogin returns to the login/discovery screen to (re)connect to a server and
+// kicks off a fresh LAN scan. The URL field is left blank so discovery can
+// pre-fill the *new* server; the saved username is kept. status seeds the
+// footer (empty → the default "scanning…" line). The old browse stack is left
+// intact so esc can return to it; a successful login replaces it.
+//
+// Used both for an explicit "switch server" from the browser and as the
+// fallback when the saved server can't be reached on startup (e.g. the Jellyfin
+// box was rebuilt), so the user is never permanently locked to a dead server.
+func (m Model) toLogin(status string) (Model, tea.Cmd) {
+ m.screen = screenLogin
+ m.initLogin(false)
+ if status != "" {
+ m.status = status
+ }
+ return m, tea.Batch(textinput.Blink, discoverCmd())
+}
+
+func (m *Model) initLogin(prefillURL bool) {
labels := []string{"Server URL (https://media.example.com)", "Username", "Password"}
m.inputs = make([]textinput.Model, 3)
for i := range m.inputs {
@@ -97,7 +115,7 @@ func (m *Model) initLogin() {
}
m.inputs[i] = ti
}
- if m.cfg.ServerURL != "" {
+ if prefillURL && m.cfg.ServerURL != "" {
m.inputs[0].SetValue(m.cfg.ServerURL)
}
if m.cfg.Username != "" {
@@ -268,6 +286,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case discoveredMsg:
+ // Ignore late scan results that arrive after we've left the login screen
+ // (e.g. the user signed in before the 2s scan finished). The discovered
+ // list and URL pre-fill are only meaningful on the login form, and
+ // applying them on the browser would clobber its footer/inputs.
+ if m.screen != screenLogin {
+ return m, nil
+ }
m.discovered = msg.servers
m.discIdx = 0
if len(msg.servers) == 0 {
@@ -296,6 +321,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cfg.UserID = msg.res.User.ID
_ = m.cfg.Save()
m.client = api.New(m.cfg.ServerURL, m.cfg.Token, m.cfg.UserID, m.cfg.DeviceID)
+ // Drop any browse stack from a previous server so the new server's
+ // libraries replace it instead of stacking on top.
+ m.stack = nil
m.screen = screenBrowse
m.status = ""
// Best-effort: mirror the Jellyfin credentials into cliamp's config so
@@ -314,6 +342,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case levelMsg:
if msg.err != nil {
+ // A failed *initial* load (empty stack) means the saved server is
+ // unreachable or our token expired — typically the Jellyfin box was
+ // rebuilt elsewhere. Drop back to discovery so the user can reconnect,
+ // rather than dead-ending on an error footer.
+ if len(m.stack) == 0 {
+ return m.toLogin("Couldn't reach " + m.cfg.ServerURL + " — pick a server to reconnect.")
+ }
m.status = "Load failed: " + msg.err.Error()
return m, nil
}