Release v1.0.0: server switching, Walker launcher, curl install, user guide
- Switch servers from the browser (s) with auto-discovery; auto-fallback to discovery when a saved server is unreachable, so you're never locked to a rebuilt box. cliamp's Jellyfin provider is re-synced on every (re)login. - Ignore late LAN-discovery results once off the login screen (footer/input clobber fix). - install.sh: curl|bash one-liner that downloads the per-arch release binary, still builds from a clone when Go is present; adds a floating Walker entry (TUI.float app-id) + icon on Omarchy desktops. - Docs: USER_GUIDE.md plain-English walkthrough; README install one-liner, accurate keybindings, features section. - Bump version to 1.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c64f13b92
commit
b3bf49df05
8 changed files with 435 additions and 54 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
/dist/
|
/dist/
|
||||||
/tuiplay
|
/tsplay
|
||||||
build.log
|
build.log
|
||||||
*.sock
|
*.sock
|
||||||
|
|
|
||||||
67
README.md
67
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
|
accelerated) while the TUI acts as the remote — pause, seek, and a live progress
|
||||||
bar, with resume points reported back to the server.
|
bar, with resume points reported back to the server.
|
||||||
|
|
||||||
It's the watch-side companion to [TUISTREAM](../TUISTREAM) (which sets up and
|
It's the watch-side companion to
|
||||||
manages the Jellyfin *server*). tsplay only *plays*.
|
[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
|
## How it works
|
||||||
|
|
||||||
|
|
@ -45,12 +50,28 @@ with software decoding (higher CPU use).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
One line, nothing to clone:
|
||||||
./install.sh
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Installs missing deps (Arch), builds `~/.local/bin/tsplay`, then just run
|
This installs `mpv` if it's missing (Arch), downloads the right binary for your
|
||||||
`tsplay`.
|
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
|
## Usage
|
||||||
|
|
||||||
|
|
@ -63,6 +84,14 @@ subsequent runs go straight to the library.
|
||||||
> Discovery probes both broadcast **and** a unicast sweep of your subnet,
|
> Discovery probes both broadcast **and** a unicast sweep of your subnet,
|
||||||
> because many home networks (especially WiFi) silently drop broadcast packets.
|
> 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
|
### Login
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
@ -71,7 +100,7 @@ subsequent runs go straight to the library.
|
||||||
| `ctrl+n` | next discovered server (if more than one)|
|
| `ctrl+n` | next discovered server (if more than one)|
|
||||||
| `ctrl+r` | rescan the network |
|
| `ctrl+r` | rescan the network |
|
||||||
| `enter` | sign in |
|
| `enter` | sign in |
|
||||||
| `esc` | quit |
|
| `esc` | cancel — back to the library, or quit if not yet signed in |
|
||||||
|
|
||||||
### Browse
|
### Browse
|
||||||
|
|
||||||
|
|
@ -81,6 +110,7 @@ subsequent runs go straight to the library.
|
||||||
| `enter` / `l` / `→`| open folder · play item |
|
| `enter` / `l` / `→`| open folder · play item |
|
||||||
| `esc` / `←` / `h` | back |
|
| `esc` / `←` / `h` | back |
|
||||||
| `c` | jump to Continue Watching |
|
| `c` | jump to Continue Watching |
|
||||||
|
| `s` | switch server (back to discovery)|
|
||||||
| `/` | filter the current list |
|
| `/` | filter the current list |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
|
|
@ -91,21 +121,24 @@ subsequent runs go straight to the library.
|
||||||
| `space` / `k` | play / pause |
|
| `space` / `k` | play / pause |
|
||||||
| `←` / `→` | seek ∓10s |
|
| `←` / `→` | seek ∓10s |
|
||||||
| `shift`+`←`/`→`| seek ∓60s |
|
| `shift`+`←`/`→`| seek ∓60s |
|
||||||
|
| `n` / `p` | next / previous track (music) |
|
||||||
| `↑` / `↓` | volume ±5 |
|
| `↑` / `↓` | volume ±5 |
|
||||||
| `m` | mute / unmute |
|
| `m` | mute / unmute |
|
||||||
| `q` / `esc` | stop, back to list|
|
| `q` / `esc` | stop, back to list|
|
||||||
|
|
||||||
## Status
|
## Features
|
||||||
|
|
||||||
v0.1 — LAN auto-discovery, movies/shows (season/episode drill-down)/music browse
|
- **LAN auto-discovery** of Jellyfin servers, with one-key **server switching**
|
||||||
+ direct-play, resume on start, progress/stop reporting, volume control with
|
and automatic fallback if a saved server goes away.
|
||||||
remembered level, and **audio auto-advance** (music plays through the album/
|
- Browse **movies, shows** (season/episode drill-down) **and music**, with a
|
||||||
folder, then returns to the browser). The UI uses a shared header/footer frame
|
compact one-line list (media-type icon, title, and a right-aligned year ·
|
||||||
and a compact one-line list (media-type icon, title, and a right-aligned year ·
|
duration · watched column) and `/` to filter.
|
||||||
duration · watched column).
|
- **Direct-play** to mpv (no server-side transcoding), **resume from your last
|
||||||
|
position**, and progress/stop reporting so **Continue Watching** stays in sync
|
||||||
Roadmap: search, subtitle/audio-track selection, quick-connect login, video
|
across all your devices.
|
||||||
"play next episode".
|
- **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
|
## Notes
|
||||||
|
|
||||||
|
|
|
||||||
209
USER_GUIDE.md
Normal file
209
USER_GUIDE.md
Normal file
|
|
@ -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.
|
||||||
150
install.sh
150
install.sh
|
|
@ -1,56 +1,146 @@
|
||||||
#!/usr/bin/env bash
|
#!/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
|
# install.sh — install tsplay, a terminal Jellyfin player for Omarchy / Arch.
|
||||||
# needs (mpv at run-time, go at build-time) via pacman; on other distros it
|
#
|
||||||
# falls back to telling you what to install.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO="28allday/tsplay"
|
||||||
BIN_DIR="${HOME}/.local/bin"
|
NAME="tsplay"
|
||||||
BIN="${BIN_DIR}/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"; }
|
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||||
warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; }
|
warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; }
|
||||||
err() { printf '\033[31m%s\033[0m\n' "$1" >&2; }
|
err() { printf '\033[31m%s\033[0m\n' "$1" >&2; }
|
||||||
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
# ensure_dep makes sure a command exists, installing it via pacman on Arch if
|
# 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() {
|
ensure_dep() {
|
||||||
local cmd="$1" kind="$2" pkg
|
local cmd="$1" pkg="$2"
|
||||||
command -v "$cmd" >/dev/null 2>&1 && return 0
|
command -v "$cmd" >/dev/null 2>&1 && return 0
|
||||||
pkg="$(pkg_for "$cmd")"
|
|
||||||
if command -v pacman >/dev/null 2>&1; then
|
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"
|
sudo pacman -S --needed --noconfirm "$pkg"
|
||||||
else
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build-time and run-time deps. mpv is the only thing tsplay needs at run time;
|
# mpv is required however we obtain the binary.
|
||||||
# everything else is compiled into the Go binary.
|
ensure_dep mpv mpv
|
||||||
ensure_dep go build
|
|
||||||
ensure_dep mpv run
|
|
||||||
|
|
||||||
bold "Building tsplay…"
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
mkdir -p "$BIN_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"
|
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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#16161e"/>
|
||||||
|
<path d="M104 78 L104 158 L172 118 Z" fill="#7aa2f7"/>
|
||||||
|
<rect x="64" y="186" width="128" height="10" rx="5" fill="#2a2e42"/>
|
||||||
|
<rect x="64" y="186" width="80" height="10" rx="5" fill="#7aa2f7"/>
|
||||||
|
</svg>
|
||||||
|
SVG
|
||||||
|
gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
|
||||||
|
|
||||||
|
cat > "$APP_DIR/tsplay.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=tsplay
|
||||||
|
Comment=Terminal Jellyfin player — browse your server and play in mpv
|
||||||
|
Exec=xdg-terminal-exec --app-id=TUI.float -e $BIN
|
||||||
|
Icon=tsplay
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=AudioVideo;Player;Video;Network;
|
||||||
|
Keywords=jellyfin;media;player;stream;mpv;movies;tv;music;
|
||||||
|
EOF
|
||||||
|
bold "Omarchy detected — added floating Walker entry (with icon)."
|
||||||
|
echo " Launch it from Walker by searching 'tsplay'."
|
||||||
|
fi
|
||||||
|
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
*":$BIN_DIR:"*) ;;
|
*":$BIN_DIR:"*) : ;;
|
||||||
*) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'tsplay' directly." ;;
|
*) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'tsplay' directly." ;;
|
||||||
esac
|
esac
|
||||||
echo "Run 'tsplay' to sign in to your Jellyfin server (it auto-detects LAN servers)."
|
echo "Run 'tsplay' to sign in to your Jellyfin server (it auto-detects LAN servers)."
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is stamped into the Authorization header and User-Agent.
|
// Version is stamped into the Authorization header and User-Agent.
|
||||||
const Version = "0.1.0"
|
const Version = "1.0.0"
|
||||||
|
|
||||||
// Client talks to one Jellyfin server as one user.
|
// Client talks to one Jellyfin server as one user.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ func (m Model) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
case "c":
|
case "c":
|
||||||
return m, m.loadResume()
|
return m, m.loadResume()
|
||||||
|
case "s":
|
||||||
|
// Switch to a different Jellyfin server: back to discovery/login.
|
||||||
|
return m.toLogin("")
|
||||||
case "enter", "l", "right":
|
case "enter", "l", "right":
|
||||||
sel, ok := cur.SelectedItem().(listItem)
|
sel, ok := cur.SelectedItem().(listItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -62,7 +65,7 @@ func (m Model) viewBrowse() string {
|
||||||
m.headerBar(), "", m.st.muted.Render(" Loading libraries…"))
|
m.headerBar(), "", m.st.muted.Render(" Loading libraries…"))
|
||||||
}
|
}
|
||||||
cur := m.stack[len(m.stack)-1]
|
cur := m.stack[len(m.stack)-1]
|
||||||
help := "enter play/open · c continue · / filter · esc back · q quit"
|
help := "enter play/open · c continue · / filter · s server · esc back · q quit"
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.headerBar(),
|
m.headerBar(),
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,18 @@ import (
|
||||||
func (m Model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "esc":
|
||||||
|
// If we reached login from an active session (switch server) and the
|
||||||
|
// old browse view is still around, esc cancels and returns to it. On
|
||||||
|
// genuine first-run, or after a failed-server fallback (empty stack),
|
||||||
|
// there's nothing to go back to, so esc quits.
|
||||||
|
if m.client != nil && len(m.stack) > 0 {
|
||||||
|
m.screen = screenBrowse
|
||||||
|
m.status = ""
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "ctrl+n":
|
case "ctrl+n":
|
||||||
// Cycle through discovered servers, filling the URL field.
|
// Cycle through discovered servers, filling the URL field.
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,30 @@ func New(cfg *config.Config, t theme.Theme) Model {
|
||||||
m.screen = screenBrowse
|
m.screen = screenBrowse
|
||||||
} else {
|
} else {
|
||||||
m.screen = screenLogin
|
m.screen = screenLogin
|
||||||
m.initLogin()
|
m.initLogin(true)
|
||||||
}
|
}
|
||||||
return m
|
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"}
|
labels := []string{"Server URL (https://media.example.com)", "Username", "Password"}
|
||||||
m.inputs = make([]textinput.Model, 3)
|
m.inputs = make([]textinput.Model, 3)
|
||||||
for i := range m.inputs {
|
for i := range m.inputs {
|
||||||
|
|
@ -97,7 +115,7 @@ func (m *Model) initLogin() {
|
||||||
}
|
}
|
||||||
m.inputs[i] = ti
|
m.inputs[i] = ti
|
||||||
}
|
}
|
||||||
if m.cfg.ServerURL != "" {
|
if prefillURL && m.cfg.ServerURL != "" {
|
||||||
m.inputs[0].SetValue(m.cfg.ServerURL)
|
m.inputs[0].SetValue(m.cfg.ServerURL)
|
||||||
}
|
}
|
||||||
if m.cfg.Username != "" {
|
if m.cfg.Username != "" {
|
||||||
|
|
@ -268,6 +286,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case discoveredMsg:
|
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.discovered = msg.servers
|
||||||
m.discIdx = 0
|
m.discIdx = 0
|
||||||
if len(msg.servers) == 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.UserID = msg.res.User.ID
|
||||||
_ = m.cfg.Save()
|
_ = m.cfg.Save()
|
||||||
m.client = api.New(m.cfg.ServerURL, m.cfg.Token, m.cfg.UserID, m.cfg.DeviceID)
|
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.screen = screenBrowse
|
||||||
m.status = ""
|
m.status = ""
|
||||||
// Best-effort: mirror the Jellyfin credentials into cliamp's config so
|
// 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:
|
case levelMsg:
|
||||||
if msg.err != nil {
|
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()
|
m.status = "Load failed: " + msg.err.Error()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue