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 }