Initial commit: tuistream-play (tsplay) v0.1

Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-30 12:36:49 +01:00
commit 8ee58b905f
20 changed files with 2555 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/dist/
/tuiplay
build.log
*.sock

115
README.md Normal file
View file

@ -0,0 +1,115 @@
# tuistream-play
> Project **tuistream-play**; the command you run is **`tsplay`**.
A terminal Jellyfin client for Arch / Omarchy. Browse your server's libraries in
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*.
## How it works
- **BubbleTea** drives the login form and a stack-based library browser.
- **mpv** does the actual playback. tsplay spawns it with
`--input-ipc-server` and talks to it over a JSON IPC socket: it sends
pause/seek/quit and observes `time-pos`/`duration`/`pause`/`eof-reached`, so
the terminal can render a progress bar and report progress to Jellyfin.
- Media is **direct-played** (`/Videos/{id}/stream?static=true`) — mpv handles
almost every container/codec natively, so the server doesn't transcode.
- Colours follow the active **Omarchy** theme, falling back to ANSI palette
indices over SSH / on non-Omarchy boxes.
## Requirements
- **mpv** — the only run-time dependency
- **Go ≥ 1.26** — build-time only (compiled into the binary)
On Arch / Omarchy the installer fetches both for you via `pacman` if they're
missing, so you don't normally need to install anything by hand.
### GPU drivers (hardware-accelerated playback)
mpv leans on your GPU's video-decode drivers (VA-API on Intel/AMD, NVDEC on
NVIDIA) for smooth, low-CPU playback. A normal Omarchy desktop already has
these, so there's nothing to do. On a **fresh, minimal** Arch box you'd want the
matching stack, e.g.:
- Intel: `intel-media-driver` (or `libva-intel-driver` for older iGPUs)
- AMD: `libva-mesa-driver` `mesa`
- NVIDIA: `nvidia-utils` (provides NVDEC)
The installer deliberately does **not** touch graphics drivers — that's a
system-level choice, not tsplay's to make. Without them mpv still plays, just
with software decoding (higher CPU use).
## Install
```bash
./install.sh
```
Installs missing deps (Arch), builds `~/.local/bin/tsplay`, then just run
`tsplay`.
## Usage
First run shows a login form. tsplay **auto-detects Jellyfin servers on your
LAN** (Jellyfin's UDP discovery on port 7359) and pre-fills the server URL, so
you usually only type your username and password. Credentials and a stable
device id are saved to `~/.config/tsplay/config.json` (mode 0600), so
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.
### Login
| Key | Action |
|-----------|------------------------------------------|
| `tab` | move between fields |
| `ctrl+n` | next discovered server (if more than one)|
| `ctrl+r` | rescan the network |
| `enter` | sign in |
| `esc` | quit |
### Browse
| Key | Action |
|--------------------|---------------------------------|
| `↑`/`↓` `j`/`k` | move |
| `enter` / `l` / `→`| open folder · play item |
| `esc` / `←` / `h` | back |
| `c` | jump to Continue Watching |
| `/` | filter the current list |
| `q` | quit |
### Playing
| Key | Action |
|----------------|-------------------|
| `space` / `k` | play / pause |
| `←` / `→` | seek ∓10s |
| `shift`+`←`/`→`| seek ∓60s |
| `↑` / `↓` | volume ±5 |
| `m` | mute / unmute |
| `q` / `esc` | stop, back to list|
## Status
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".
## Notes
- Self-signed TLS isn't trusted by default — use a proper cert (your Jellyfin is
usually behind Caddy anyway) or a plain `http://…:8096` URL on the LAN.

48
cmd/tsplay/main.go Normal file
View file

@ -0,0 +1,48 @@
// Command tsplay is a terminal Jellyfin client: browse your server's libraries
// in the terminal and play the selected title in an mpv window, with the TUI
// acting as the remote (pause/seek/progress). Config lives in
// ~/.config/tsplay/config.json; colours follow the active Omarchy theme.
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"tuistream-play/internal/config"
"tuistream-play/internal/theme"
"tuistream-play/internal/tui"
)
func main() {
if len(os.Args) > 1 && (os.Args[1] == "--icons" || os.Args[1] == "-icons") {
printIcons()
return
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "tsplay: failed to load config:", err)
os.Exit(1)
}
m := tui.New(cfg, theme.Load())
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "tsplay:", err)
os.Exit(1)
}
}
// printIcons dumps the Nerd Font glyphs tsplay uses so you can check they
// render in your terminal font before relying on them in the browser.
func printIcons() {
fmt.Println("tsplay icon legend (Nerd Font, nf-md-*):")
fmt.Println()
for _, pair := range tui.IconLegend() {
fmt.Printf(" %s %s\n", pair[1], pair[0])
}
fmt.Println()
fmt.Println("If any show as a box/blank, your terminal font lacks Nerd Font glyphs.")
}

35
go.mod Normal file
View file

@ -0,0 +1,35 @@
module tuistream-play
go 1.26.1
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/google/uuid v1.6.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

62
go.sum Normal file
View file

@ -0,0 +1,62 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

56
install.sh Executable file
View file

@ -0,0 +1,56 @@
#!/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.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${HOME}/.local/bin"
BIN="${BIN_DIR}/tsplay"
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
}
# ensure_dep makes sure a command exists, installing it via pacman on Arch if
# missing. $2 = "build" or "run" just tweaks the wording.
ensure_dep() {
local cmd="$1" kind="$2" pkg
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}"
sudo pacman -S --needed --noconfirm "$pkg"
else
err "Missing ${kind} 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
bold "Building tsplay…"
cd "$REPO_DIR"
mkdir -p "$BIN_DIR"
go build -o "$BIN" ./cmd/tsplay
bold "Installed: $BIN"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'tsplay' directly." ;;
esac
echo "Run 'tsplay' to sign in to your Jellyfin server (it auto-detects LAN servers)."

220
internal/api/client.go Normal file
View file

@ -0,0 +1,220 @@
// Package api is a minimal Jellyfin REST client: just enough to authenticate,
// list libraries and their contents, resolve a direct-play stream URL for mpv,
// and report playback progress so "Continue Watching" and resume points work.
//
// All requests carry the Emby-style Authorization header. Before login only the
// client/device fields are set; after login the token is added as Token=.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Version is stamped into the Authorization header and User-Agent.
const Version = "0.1.0"
// Client talks to one Jellyfin server as one user.
type Client struct {
BaseURL string // e.g. https://media.example.com (no trailing slash)
Token string // access token; empty before login
UserID string // logged-in user id; empty before login
DeviceID string // stable per-install id
http *http.Client
}
// New builds a Client. baseURL may have a trailing slash; it is trimmed.
func New(baseURL, token, userID, deviceID string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
UserID: userID,
DeviceID: deviceID,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// authHeader builds the MediaBrowser authorization header. Token is included
// only once we have one (post-login).
func (c *Client) authHeader() string {
h := fmt.Sprintf(`MediaBrowser Client="tsplay", Device="tsplay-tui", DeviceId=%q, Version=%q`,
c.DeviceID, Version)
if c.Token != "" {
h += fmt.Sprintf(`, Token=%q`, c.Token)
}
return h
}
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("User-Agent", "tsplay/"+Version)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// AuthResult is the subset of AuthenticateByName we care about.
type AuthResult struct {
AccessToken string `json:"AccessToken"`
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
}
// Login authenticates by username/password and populates Token/UserID on the
// client. Returns the result so the caller can persist it.
func (c *Client) Login(ctx context.Context, username, password string) (*AuthResult, error) {
var res AuthResult
body := map[string]string{"Username": username, "Pw": password}
if err := c.do(ctx, http.MethodPost, "/Users/AuthenticateByName", body, &res); err != nil {
return nil, err
}
if res.AccessToken == "" {
return nil, fmt.Errorf("login failed: no access token returned")
}
c.Token = res.AccessToken
c.UserID = res.User.ID
return &res, nil
}
// Item is a generic Jellyfin item (library, folder, movie, episode, audio…).
// Only the fields the TUI needs are mapped.
type Item struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"` // Movie, Series, Season, Episode, Audio, MusicAlbum, CollectionFolder…
CollectionType string `json:"CollectionType"` // movies, tvshows, music… (on libraries)
IsFolder bool `json:"IsFolder"`
ProductionYear int `json:"ProductionYear"`
RunTimeTicks int64 `json:"RunTimeTicks"` // 1 tick = 100ns
SeriesName string `json:"SeriesName"`
IndexNumber int `json:"IndexNumber"` // episode/track number
ParentIndexNumber int `json:"ParentIndexNumber"` // season number
UserData struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayedPercentage float64 `json:"PlayedPercentage"`
Played bool `json:"Played"`
} `json:"UserData"`
}
// Playable reports whether selecting this item should start mpv rather than
// drill into a folder.
func (i Item) Playable() bool {
switch i.Type {
case "Movie", "Episode", "Audio", "Video", "MusicVideo":
return true
}
return !i.IsFolder
}
// AudioOnly reports whether this item is music (so mpv should run headless,
// with no video window). MusicVideo is intentionally excluded — it has a
// picture worth showing.
func (i Item) AudioOnly() bool {
switch i.Type {
case "Audio", "MusicAlbum", "MusicArtist":
return true
}
return false
}
type itemsResponse struct {
Items []Item `json:"Items"`
}
// Views returns the user's top-level libraries (Movies, Shows, Music…).
func (c *Client) Views(ctx context.Context) ([]Item, error) {
var r itemsResponse
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Views", nil, &r)
return r.Items, err
}
// Children lists the items directly under parentID for this user, sorted
// sensibly for browsing. recursive=false keeps it one level at a time.
func (c *Client) Children(ctx context.Context, parentID string) ([]Item, error) {
q := url.Values{}
q.Set("ParentId", parentID)
q.Set("SortBy", "IsFolder,SortName")
q.Set("SortOrder", "Ascending")
q.Set("Fields", "ProductionYear,SeriesName,IndexNumber,ParentIndexNumber")
var r itemsResponse
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Items?"+q.Encode(), nil, &r)
return r.Items, err
}
// Resume returns the user's "Continue Watching" list across all libraries.
func (c *Client) Resume(ctx context.Context) ([]Item, error) {
q := url.Values{}
q.Set("Limit", "24")
q.Set("Fields", "ProductionYear,SeriesName,IndexNumber,ParentIndexNumber")
q.Set("MediaTypes", "Video")
var r itemsResponse
err := c.do(ctx, http.MethodGet, "/Users/"+c.UserID+"/Items/Resume?"+q.Encode(), nil, &r)
return r.Items, err
}
// StreamURL returns a direct-play URL mpv can open. static=true asks the server
// not to transcode; mpv handles nearly every container/codec natively. The
// api_key query param authenticates the bare GET that mpv makes.
func (c *Client) StreamURL(itemID string) string {
q := url.Values{}
q.Set("static", "true")
q.Set("api_key", c.Token)
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.BaseURL, itemID, q.Encode())
}
// ReportStart tells the server playback began (creates a session row, marks the
// item as being watched). Best-effort: errors are returned but callers may
// ignore them.
func (c *Client) ReportStart(ctx context.Context, itemID string) error {
body := map[string]any{"ItemId": itemID, "PlayMethod": "DirectStream"}
return c.do(ctx, http.MethodPost, "/Sessions/Playing", body, nil)
}
// ReportProgress updates the resume point (positionTicks) periodically.
func (c *Client) ReportProgress(ctx context.Context, itemID string, positionTicks int64, paused bool) error {
body := map[string]any{"ItemId": itemID, "PositionTicks": positionTicks, "IsPaused": paused, "PlayMethod": "DirectStream"}
return c.do(ctx, http.MethodPost, "/Sessions/Playing/Progress", body, nil)
}
// ReportStop finalises the resume point when playback ends.
func (c *Client) ReportStop(ctx context.Context, itemID string, positionTicks int64) error {
body := map[string]any{"ItemId": itemID, "PositionTicks": positionTicks}
return c.do(ctx, http.MethodPost, "/Sessions/Playing/Stopped", body, nil)
}
// SecondsToTicks converts a position in seconds to Jellyfin's 100ns ticks.
func SecondsToTicks(s float64) int64 { return int64(s * 1e7) }

141
internal/cliamp/cliamp.go Normal file
View file

@ -0,0 +1,141 @@
// Package cliamp mirrors tsplay's Jellyfin credentials into cliamp's config so
// cliamp's Jellyfin music provider works without a second, separate setup.
// cliamp ships by default on Omarchy; when it's present we write a [jellyfin]
// table into ~/.config/cliamp/config.toml at login.
//
// The write is a careful merge: only the [jellyfin] table is replaced (or
// appended); every other section and comment in the user's config is preserved.
package cliamp
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
// Present reports whether cliamp appears to be installed — either the binary is
// on PATH or its config directory already exists. We only write credentials
// when cliamp is actually in use, so tsplay never litters config on systems
// that don't have it.
func Present() bool {
if _, err := exec.LookPath("cliamp"); err == nil {
return true
}
if d, err := configDir(); err == nil {
if _, err := os.Stat(d); err == nil {
return true
}
}
return false
}
func configDir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(base, "cliamp"), nil
}
// Creds are the Jellyfin details to write into cliamp's [jellyfin] block. Empty
// fields are omitted, so callers can write token+user_id or user+password.
type Creds struct {
URL string
User string
Password string
Token string
UserID string
}
// WriteJellyfin merges a [jellyfin] section into cliamp's config.toml, creating
// the file/dir (0700/0600) if needed and preserving every other section and
// comment already in the file. Only the [jellyfin] table is rewritten.
func WriteJellyfin(c Creds) error {
dir, err := configDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
path := filepath.Join(dir, "config.toml")
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
merged := mergeSection(string(existing), "jellyfin", c.block())
return os.WriteFile(path, []byte(merged), 0o600)
}
// block renders the [jellyfin] section (header + non-empty keys), no trailing
// newline.
func (c Creds) block() string {
var b strings.Builder
b.WriteString("[jellyfin]\n")
writeKV(&b, "url", c.URL)
writeKV(&b, "user", c.User)
writeKV(&b, "password", c.Password)
writeKV(&b, "token", c.Token)
writeKV(&b, "user_id", c.UserID)
return strings.TrimRight(b.String(), "\n")
}
func writeKV(b *strings.Builder, key, val string) {
if val == "" {
return
}
b.WriteString(key)
b.WriteString(` = "`)
b.WriteString(tomlEscape(val))
b.WriteString("\"\n")
}
// tomlEscape escapes the characters that matter inside a basic TOML string.
func tomlEscape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
// mergeSection returns doc with the named TOML table replaced by block (which
// itself begins with the table header). If the table is absent, block is
// appended after a blank line. All other content — comments and other tables —
// is preserved verbatim. The result always ends in a single newline.
func mergeSection(doc, name, block string) string {
header := "[" + name + "]"
lines := strings.Split(doc, "\n")
start := -1
for i, ln := range lines {
if strings.TrimSpace(ln) == header {
start = i
break
}
}
if start == -1 {
trimmed := strings.TrimRight(doc, "\n")
if trimmed == "" {
return block + "\n"
}
return trimmed + "\n\n" + block + "\n"
}
// The existing table runs until the next table header line, or EOF.
end := len(lines)
for i := start + 1; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
end = i
break
}
}
var out []string
out = append(out, lines[:start]...)
out = append(out, strings.Split(block, "\n")...)
out = append(out, lines[end:]...)
return strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n"
}

View file

@ -0,0 +1,63 @@
package cliamp
import "testing"
func TestMergeSection_AppendWhenAbsent(t *testing.T) {
existing := "[plex]\nurl = \"http://plex\"\ntoken = \"abc\"\n"
block := Creds{URL: "http://jf", Token: "tok", UserID: "uid"}.block()
got := mergeSection(existing, "jellyfin", block)
// The plex section must survive untouched.
if !contains(got, "[plex]") || !contains(got, `url = "http://plex"`) || !contains(got, `token = "abc"`) {
t.Fatalf("plex section not preserved:\n%s", got)
}
// The jellyfin section must be appended.
if !contains(got, "[jellyfin]") || !contains(got, `token = "tok"`) || !contains(got, `user_id = "uid"`) {
t.Fatalf("jellyfin section not appended:\n%s", got)
}
}
func TestMergeSection_ReplaceExisting(t *testing.T) {
existing := "[jellyfin]\nurl = \"old\"\ntoken = \"OLD\"\n\n[spotify]\nclient = \"sp\"\n"
block := Creds{URL: "http://new", Token: "NEW", UserID: "uid"}.block()
got := mergeSection(existing, "jellyfin", block)
if contains(got, `token = "OLD"`) || contains(got, `url = "old"`) {
t.Fatalf("old jellyfin values not replaced:\n%s", got)
}
if !contains(got, `token = "NEW"`) || !contains(got, `url = "http://new"`) {
t.Fatalf("new jellyfin values missing:\n%s", got)
}
// The spotify section after jellyfin must survive.
if !contains(got, "[spotify]") || !contains(got, `client = "sp"`) {
t.Fatalf("spotify section not preserved:\n%s", got)
}
}
func TestMergeSection_EmptyDoc(t *testing.T) {
block := Creds{URL: "http://jf", Token: "tok", UserID: "uid"}.block()
got := mergeSection("", "jellyfin", block)
if got != block+"\n" {
t.Fatalf("empty-doc merge wrong:\n%q", got)
}
}
func TestBlock_OmitsEmptyFields(t *testing.T) {
got := Creds{URL: "http://jf", Token: "tok", UserID: "uid"}.block()
if contains(got, "user =") || contains(got, "password =") {
t.Fatalf("empty user/password should be omitted:\n%s", got)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

103
internal/config/config.go Normal file
View file

@ -0,0 +1,103 @@
// Package config persists the small amount of state a Jellyfin client needs
// between runs: the server URL, the access token and user id obtained at login,
// a stable per-install device id, and the last-used playback volume. It lives at
// ~/.config/tsplay/config.json (XDG_CONFIG_HOME honoured).
package config
import (
"encoding/json"
"os"
"path/filepath"
"github.com/google/uuid"
)
// Config is the on-disk state. Token/UserID are empty until a successful login;
// the TUI shows the login screen whenever Token is blank.
type Config struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Token string `json:"token"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
// Volume is the last playback volume (mpv's 0-130 scale), remembered across
// tracks and runs. A nil pointer means "never set" — distinct from 0, which
// is a deliberate silent level.
Volume *float64 `json:"volume,omitempty"`
}
// VolumeOr returns the saved volume, or def if none has been stored yet.
func (c *Config) VolumeOr(def float64) float64 {
if c.Volume == nil {
return def
}
return *c.Volume
}
// SetVolume records v as the remembered volume.
func (c *Config) SetVolume(v float64) {
c.Volume = &v
}
// dir returns ~/.config/tsplay, creating it on demand.
func dir() (string, error) {
base, err := os.UserConfigDir()
if err != nil {
return "", err
}
d := filepath.Join(base, "tsplay")
if err := os.MkdirAll(d, 0o700); err != nil {
return "", err
}
return d, nil
}
func path() (string, error) {
d, err := dir()
if err != nil {
return "", err
}
return filepath.Join(d, "config.json"), nil
}
// Load reads the saved config. A missing file is not an error: it returns a
// fresh Config with a newly minted DeviceID so first-run login can proceed.
func Load() (*Config, error) {
p, err := path()
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if os.IsNotExist(err) {
return &Config{DeviceID: uuid.NewString()}, nil
}
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}
if c.DeviceID == "" {
c.DeviceID = uuid.NewString()
}
return &c, nil
}
// Save writes the config back to disk with 0600 perms (it holds a token).
func (c *Config) Save() error {
p, err := path()
if err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o600)
}
// LoggedIn reports whether we have credentials to talk to the server.
func (c *Config) LoggedIn() bool {
return c.ServerURL != "" && c.Token != "" && c.UserID != ""
}

View file

@ -0,0 +1,165 @@
// Package discovery finds Jellyfin servers on the local network using
// Jellyfin's built-in UDP auto-discovery: the client sends the literal string
// "who is JellyfinServer?" to port 7359 and every server replies with a small
// JSON document describing itself. This lets tsplay pre-fill the server URL so
// the user never has to type it (works for any Jellyfin on the LAN, not just
// TUISTREAM-deployed ones).
//
// We probe two ways and read replies on a single socket:
//
// - Broadcast: 255.255.255.255 and each interface's directed broadcast.
// - Unicast sweep: every host address in each interface's IPv4 subnet.
//
// The unicast sweep matters because many home networks (especially over WiFi)
// silently drop directed broadcasts, so a broadcast-only probe finds nothing
// even when the server's discovery port is open and reachable by unicast.
package discovery
import (
"encoding/json"
"net"
"time"
)
// port is Jellyfin's fixed auto-discovery UDP port.
const port = 7359
// query is the exact probe string Jellyfin servers listen for.
const query = "who is JellyfinServer?"
// maxSweep caps how many unicast hosts we probe per interface, so an unusually
// large subnet (e.g. a /16) can't blow up into 65k packets. A typical /24 home
// network is 254 hosts, well under this.
const maxSweep = 1024
// Server is one discovered Jellyfin instance.
type Server struct {
Name string `json:"Name"`
Address string `json:"Address"` // e.g. http://192.168.1.45:8096
ID string `json:"Id"`
}
// Discover sends probes and collects replies until timeout elapses. Results are
// de-duplicated by server Id. A nil slice (no error) simply means nothing
// answered — the caller falls back to manual entry.
func Discover(timeout time.Duration) ([]Server, error) {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
return nil, err
}
defer conn.Close()
payload := []byte(query)
for _, t := range probeTargets() {
_, _ = conn.WriteToUDP(payload, &net.UDPAddr{IP: t, Port: port})
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
seen := make(map[string]bool)
var out []Server
buf := make([]byte, 8192)
for {
n, _, err := conn.ReadFromUDP(buf)
if err != nil {
break // deadline reached or socket closed
}
var s Server
if json.Unmarshal(buf[:n], &s) != nil || s.Address == "" {
continue
}
key := s.ID
if key == "" {
key = s.Address
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, s)
}
return out, nil
}
// probeTargets returns every IP we should send a probe to: the global
// broadcast, each interface's directed broadcast, and a unicast sweep of each
// interface's IPv4 subnet.
func probeTargets() []net.IP {
targets := []net.IP{net.IPv4bcast}
ifaces, err := net.Interfaces()
if err != nil {
return targets
}
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
n, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := n.IP.To4()
if ip == nil || ip.IsLoopback() {
continue
}
// Directed broadcast for this subnet (if the iface supports it).
if ifc.Flags&net.FlagBroadcast != 0 {
targets = append(targets, directedBroadcast(ip, n.Mask))
}
// Unicast sweep of every other host on this subnet.
targets = append(targets, sweepHosts(ip, n.Mask, maxSweep)...)
}
}
return targets
}
// directedBroadcast returns the all-ones host address for ip's subnet.
func directedBroadcast(ip net.IP, mask net.IPMask) net.IP {
b := make(net.IP, 4)
for i := 0; i < 4; i++ {
b[i] = ip[i] | ^mask[i]
}
return b
}
// sweepHosts enumerates the usable host addresses in ip's subnet, excluding the
// network address, the broadcast address, and ip itself. It returns at most max
// addresses; subnets larger than that are skipped entirely (no partial sweep,
// which would silently miss hosts).
func sweepHosts(ip net.IP, mask net.IPMask, max int) []net.IP {
ones, bits := mask.Size()
if bits != 32 {
return nil
}
hostCount := 1<<(uint(bits-ones)) - 2 // minus network + broadcast
if hostCount <= 0 || hostCount > max {
return nil
}
net4 := make(net.IP, 4)
bcast := make(net.IP, 4)
for i := 0; i < 4; i++ {
net4[i] = ip[i] & mask[i]
bcast[i] = ip[i] | ^mask[i]
}
start := ipToU32(net4) + 1
end := ipToU32(bcast) // exclusive
self := ipToU32(ip.To4())
var out []net.IP
for v := start; v < end; v++ {
if v == self {
continue
}
out = append(out, u32ToIP(v))
}
return out
}
func ipToU32(ip net.IP) uint32 {
ip = ip.To4()
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
}
func u32ToIP(v uint32) net.IP {
return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}

252
internal/player/mpv.go Normal file
View file

@ -0,0 +1,252 @@
// Package player drives an external mpv process over its JSON IPC socket. mpv
// renders video in its own (hardware-accelerated) window while this TUI acts as
// the remote: it sends transport commands (pause/seek/quit) and observes mpv's
// properties (position, duration, paused, end-of-file) so the terminal can show
// a live progress bar.
//
// The design is deliberately poll-friendly: a background goroutine keeps a
// mutex-guarded State up to date from mpv's event stream, and the TUI reads a
// snapshot via Snapshot() on a timer. That avoids threading a channel through
// BubbleTea's message loop.
package player
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
// State is an immutable snapshot of mpv's playback at a moment in time.
type State struct {
TimePos float64 // seconds elapsed
Duration float64 // total seconds (0 until known)
Paused bool
Volume float64 // 0-130 (mpv allows >100 for amplification)
Muted bool
Done bool // mpv reached EOF or the process exited
}
// Player owns one mpv process and its IPC connection.
type Player struct {
cmd *exec.Cmd
conn net.Conn
sockPath string
mu sync.Mutex
state State
}
// findMPV returns the mpv binary path or an error the caller can surface.
func findMPV() (string, error) {
return exec.LookPath("mpv")
}
// Start launches mpv on streamURL with the given window title, seeking to
// startSeconds (0 to start from the beginning). It blocks until the IPC socket
// is connectable, then returns with the property observers running.
//
// When audioOnly is true, mpv runs headless (no video output, no window) so a
// music track doesn't pop a useless black window — tsplay's own now-playing
// screen is the UI. Position/duration still flow over IPC exactly as for video.
//
// startVolume sets mpv's initial volume (mpv's 0-130 scale); pass a negative
// value to leave mpv at its own default.
func Start(streamURL, title string, startSeconds float64, audioOnly bool, startVolume float64) (*Player, error) {
bin, err := findMPV()
if err != nil {
return nil, fmt.Errorf("mpv not found in PATH: %w", err)
}
sock := filepath.Join(os.TempDir(), "tsplay-"+uuid.NewString()+".sock")
args := []string{
"--input-ipc-server=" + sock,
"--idle=no",
"--title=" + title,
"--osd-level=1",
}
if audioOnly {
// No window, no video decode — pure audio in the background.
args = append(args, "--no-video", "--force-window=no", "--no-terminal")
} else {
args = append(args, "--force-window=yes")
}
if startSeconds > 0 {
args = append(args, fmt.Sprintf("--start=%.0f", startSeconds))
}
if startVolume >= 0 {
args = append(args, fmt.Sprintf("--volume=%.0f", startVolume))
}
args = append(args, streamURL)
cmd := exec.Command(bin, args...)
// Detach mpv from our stdio so its logging never corrupts the TUI.
cmd.Stdout = nil
cmd.Stderr = nil
// Suppress MangoHud for our playback window: DISABLE_MANGOHUD turns off the
// Vulkan implicit layer and MANGOHUD=0 the OpenGL path. This is scoped to
// the mpv child only — the user's global MangoHud config is untouched.
cmd.Env = append(os.Environ(), "DISABLE_MANGOHUD=1", "MANGOHUD=0")
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start mpv: %w", err)
}
p := &Player{cmd: cmd, sockPath: sock}
// Wait for mpv to create and accept on the IPC socket (it appears a beat
// after launch). Give it a few seconds before giving up.
conn, err := dialWithRetry(sock, 5*time.Second)
if err != nil {
_ = cmd.Process.Kill()
return nil, fmt.Errorf("connect to mpv ipc: %w", err)
}
p.conn = conn
p.subscribe()
go p.readLoop()
go p.waitProcess()
return p, nil
}
func dialWithRetry(sock string, timeout time.Duration) (net.Conn, error) {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
c, err := net.Dial("unix", sock)
if err == nil {
return c, nil
}
lastErr = err
time.Sleep(50 * time.Millisecond)
}
return nil, lastErr
}
// subscribe registers property observers so mpv pushes change events to us.
func (p *Player) subscribe() {
p.send("observe_property", 1, "time-pos")
p.send("observe_property", 2, "duration")
p.send("observe_property", 3, "pause")
p.send("observe_property", 4, "eof-reached")
p.send("observe_property", 5, "volume")
p.send("observe_property", 6, "mute")
}
// send writes one IPC command line. Errors are ignored: a dead socket simply
// means mpv has gone, which waitProcess will report as Done.
func (p *Player) send(command ...any) {
if p.conn == nil {
return
}
payload := map[string]any{"command": command}
b, err := json.Marshal(payload)
if err != nil {
return
}
b = append(b, '\n')
_, _ = p.conn.Write(b)
}
// ipcMessage is the union of command replies and property-change events.
type ipcMessage struct {
Event string `json:"event"`
Name string `json:"name"`
Data json.RawMessage `json:"data"`
}
// readLoop consumes mpv's event stream and keeps State current.
func (p *Player) readLoop() {
sc := bufio.NewScanner(p.conn)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
var m ipcMessage
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
continue
}
if m.Event != "property-change" {
continue
}
p.mu.Lock()
switch m.Name {
case "time-pos":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.TimePos = v
}
case "duration":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.Duration = v
}
case "pause":
var v bool
if json.Unmarshal(m.Data, &v) == nil {
p.state.Paused = v
}
case "eof-reached":
var v bool
if json.Unmarshal(m.Data, &v) == nil && v {
p.state.Done = true
}
case "volume":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.Volume = v
}
case "mute":
var v bool
if json.Unmarshal(m.Data, &v) == nil {
p.state.Muted = v
}
}
p.mu.Unlock()
}
}
// waitProcess marks the player Done when mpv exits (e.g. the user closes the
// window) and cleans up the socket file.
func (p *Player) waitProcess() {
_ = p.cmd.Wait()
p.mu.Lock()
p.state.Done = true
p.mu.Unlock()
_ = os.Remove(p.sockPath)
}
// Snapshot returns the current playback state.
func (p *Player) Snapshot() State {
p.mu.Lock()
defer p.mu.Unlock()
return p.state
}
// TogglePause flips play/pause.
func (p *Player) TogglePause() { p.send("cycle", "pause") }
// SeekRelative jumps by delta seconds (negative to rewind).
func (p *Player) SeekRelative(delta float64) { p.send("seek", delta, "relative") }
// AdjustVolume changes volume by delta (e.g. +5/-5), clamped by mpv to its
// 0-130 range. mpv echoes the new value back via the volume observer.
func (p *Player) AdjustVolume(delta float64) { p.send("add", "volume", delta) }
// ToggleMute flips mute on/off.
func (p *Player) ToggleMute() { p.send("cycle", "mute") }
// Stop asks mpv to quit and closes the connection. Safe to call more than once.
func (p *Player) Stop() {
p.send("quit")
if p.conn != nil {
_ = p.conn.Close()
}
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
}

115
internal/theme/theme.go Normal file
View file

@ -0,0 +1,115 @@
// Package theme loads the active Omarchy colour scheme so the TUI matches the
// rest of the desktop. Omarchy writes a per-theme colors.toml (accent,
// foreground, background, color0-15); we read the active one and fall back to a
// sensible built-in palette on headless / non-Omarchy systems.
package theme
import (
"os"
"os/user"
"path/filepath"
"strings"
)
// Theme is the small set of colours the TUI needs, as "#rrggbb" strings.
type Theme struct {
Accent string
Fg string
Bg string
Dim string
Muted string
Good string
Bad string
}
// Default is the palette used when no Omarchy theme is found (e.g. a headless /
// omaterm box reached over SSH). It uses ANSI palette indices rather than fixed
// hex, so the colours track whatever theme the connecting terminal uses.
func Default() Theme {
return Theme{
Accent: "4", // blue
Fg: "7", // foreground / white
Bg: "0", // background / black
Dim: "7",
Muted: "8", // bright black / grey
Good: "2", // green
Bad: "1", // red
}
}
// Load returns the active Omarchy theme's colours, or Default() if unavailable.
func Load() Theme {
// Under `sudo` HOME is /root, which never has an Omarchy theme. Prefer
// the calling user's home (via SUDO_USER) so the TUI matches the
// desktop you actually launched it from.
home := callerHome()
if home == "" {
return Default()
}
path := filepath.Join(home, ".config", "omarchy", "current", "theme", "colors.toml")
kv, ok := parse(path)
if !ok {
return Default()
}
d := Default()
pick := func(def string, keys ...string) string {
for _, k := range keys {
if v := kv[k]; v != "" {
return v
}
}
return def
}
return Theme{
Accent: pick(d.Accent, "accent", "color4"),
Fg: pick(d.Fg, "foreground", "color7"),
Bg: pick(d.Bg, "background", "color0"),
Dim: pick(d.Dim, "color7", "foreground"),
Muted: pick(d.Muted, "color8", "color7"),
Good: pick(d.Good, "color2"),
Bad: pick(d.Bad, "color1"),
}
}
// callerHome resolves the home directory of the user who launched tuistream,
// looking through `sudo` if necessary. Returns "" if nothing resolves.
func callerHome() string {
if su := os.Getenv("SUDO_USER"); su != "" && su != "root" {
if u, err := user.Lookup(su); err == nil && u.HomeDir != "" {
return u.HomeDir
}
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return ""
}
// parse reads simple `key = "#hex"` lines from an Omarchy colors.toml. It is a
// minimal parser (no TOML dependency) sufficient for that flat file.
func parse(path string) (map[string]string, bool) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
kv := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.Trim(strings.TrimSpace(line[eq+1:]), `"`)
if strings.HasPrefix(val, "#") {
kv[key] = val
}
}
if len(kv) == 0 {
return nil, false
}
return kv, true
}

160
internal/tui/browse.go Normal file
View file

@ -0,0 +1,160 @@
package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/api"
)
func (m Model) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) {
// Nothing loaded yet (initial Views fetch in flight).
if len(m.stack) == 0 {
if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "ctrl+c" || key.String() == "q") {
return m, tea.Quit
}
return m, nil
}
cur := &m.stack[len(m.stack)-1]
filtering := cur.FilterState() == list.Filtering
if key, ok := msg.(tea.KeyMsg); ok && !filtering {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc", "backspace", "h", "left":
if len(m.stack) > 1 {
m.stack = m.stack[:len(m.stack)-1]
}
return m, nil
case "c":
return m, m.loadResume()
case "enter", "l", "right":
sel, ok := cur.SelectedItem().(listItem)
if !ok {
return m, nil
}
it := sel.item
if it.Playable() {
m.status = "Starting mpv…"
// Capture the sibling list as the play queue so audio can
// auto-advance. Built from the current level's items in their
// displayed order.
m.queue, m.queueIdx = currentItems(*cur), cur.Index()
return m, m.startPlayback(it)
}
return m, m.loadChildren(displayTitle(it), it.ID)
}
}
var cmd tea.Cmd
m.stack[len(m.stack)-1], cmd = cur.Update(msg)
return m, cmd
}
func (m Model) viewBrowse() string {
if len(m.stack) == 0 {
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(), "", m.st.muted.Render(" Loading libraries…"))
}
cur := m.stack[len(m.stack)-1]
help := "enter play/open · c continue · / filter · esc back · q quit"
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(),
"",
cur.View(),
m.footerBar(help, m.status),
)
}
// currentItems extracts the api.Items from a list, in display order, so the
// playback queue mirrors exactly what the user sees.
func currentItems(l list.Model) []api.Item {
rows := l.Items()
out := make([]api.Item, 0, len(rows))
for _, r := range rows {
if li, ok := r.(listItem); ok {
out = append(out, li.item)
}
}
return out
}
// ---- display helpers ----
func displayTitle(it api.Item) string {
switch it.Type {
case "Episode":
if it.ParentIndexNumber > 0 && it.IndexNumber > 0 {
return fmt.Sprintf("S%02dE%02d %s", it.ParentIndexNumber, it.IndexNumber, it.Name)
}
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
case "Audio":
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
case "Movie", "Series":
if it.ProductionYear > 0 {
return fmt.Sprintf("%s (%d)", it.Name, it.ProductionYear)
}
}
return it.Name
}
func displayDesc(it api.Item) string {
var parts []string
switch it.Type {
case "CollectionFolder", "UserView":
if it.CollectionType != "" {
parts = append(parts, it.CollectionType)
} else {
parts = append(parts, "library")
}
case "Episode":
if it.SeriesName != "" {
parts = append(parts, it.SeriesName)
}
case "Series", "Season":
parts = append(parts, "folder")
default:
if it.IsFolder {
parts = append(parts, "folder")
}
}
if d := formatDuration(it.RunTimeTicks); d != "" {
parts = append(parts, d)
}
if it.UserData.Played {
parts = append(parts, "✓ watched")
} else if it.UserData.PlayedPercentage > 1 {
parts = append(parts, fmt.Sprintf("%.0f%%", it.UserData.PlayedPercentage))
}
if len(parts) == 0 {
return " "
}
out := parts[0]
for _, p := range parts[1:] {
out += " · " + p
}
return out
}
// formatDuration turns RunTimeTicks (100ns units) into "1h 23m" / "12m".
func formatDuration(ticks int64) string {
if ticks <= 0 {
return ""
}
secs := ticks / 1e7
h := secs / 3600
mn := (secs % 3600) / 60
if h > 0 {
return fmt.Sprintf("%dh %02dm", h, mn)
}
return fmt.Sprintf("%dm", mn)
}

197
internal/tui/delegate.go Normal file
View file

@ -0,0 +1,197 @@
// itemDelegate is a compact single-line list renderer for media items: a
// media-type icon, the title, and a right-aligned meta column (year · duration ·
// watched). It replaces bubbles' two-line default delegate so the browser reads
// like a tidy table.
package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/api"
)
type itemDelegate struct{ st styles }
func newItemDelegate(st styles) itemDelegate { return itemDelegate{st: st} }
func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, raw list.Item) {
it, ok := raw.(listItem)
if !ok {
return
}
selected := index == m.Index()
icon := cell(mediaIcon(it.item), 2)
title := rowTitle(it.item)
meta := rowMeta(it.item)
cursor := " "
if selected {
cursor = d.st.accent.Render("▸ ")
}
width := m.Width()
if width <= 0 {
width = 80
}
// Layout: cursor(2) + icon(2) + " " + title + gap + meta + 1 right margin.
fixed := 2 + lipgloss.Width(icon) + 1 + lipgloss.Width(meta) + 1
titleBudget := width - fixed
if lipgloss.Width(title) > titleBudget {
title = truncate(title, titleBudget)
}
gap := width - fixed - lipgloss.Width(title)
if gap < 0 {
gap = 0
}
titleR := title
if selected {
titleR = d.st.accent.Render(title)
}
row := cursor + icon + " " + titleR + strings.Repeat(" ", gap) + d.st.muted.Render(meta)
fmt.Fprint(w, row)
}
// Nerd Font Material Design icon glyphs (nf-md-*), matching the family Omarchy
// uses in waybar/walker. Defined by codepoint so the source bytes can't be
// mangled by an editor or terminal. These are single display-width.
const (
iconMovie = "\U000f07de" // nf-md-movie_open
iconTV = "\U000f082b" // nf-md-television_classic
iconFolder = "\U000f024b" // nf-md-folder
iconEpisode = "\U000f0fce" // nf-md-movie_play
iconMusic = "\U000f075a" // nf-md-music
iconAlbum = "\U000f0025" // nf-md-album
iconArtist = "\U000f0803" // nf-md-account_music
iconMusicBox = "\U000f0223" // nf-md-music_box (music library)
iconLibrary = "\U000f0253" // nf-md-folder_multiple (generic library)
iconFile = "\U000f0224" // nf-md-file (plain leaf)
)
// IconLegend returns the (label, glyph) pairs tsplay uses, for the `--icons`
// debug command so the user can confirm their terminal font renders them.
func IconLegend() [][2]string {
return [][2]string{
{"movie", iconMovie},
{"tv / series", iconTV},
{"season / folder", iconFolder},
{"episode", iconEpisode},
{"audio track", iconMusic},
{"album", iconAlbum},
{"artist", iconArtist},
{"music library", iconMusicBox},
{"generic library", iconLibrary},
{"file", iconFile},
}
}
// mediaIcon picks a Nerd Font glyph for an item's type (or its library's
// collection type, or a generic folder/leaf glyph).
func mediaIcon(it api.Item) string {
switch it.Type {
case "Movie":
return iconMovie
case "Series":
return iconTV
case "Season":
return iconFolder
case "Episode":
return iconEpisode
case "Audio":
return iconMusic
case "MusicAlbum":
return iconAlbum
case "MusicArtist":
return iconArtist
case "CollectionFolder", "UserView":
switch it.CollectionType {
case "movies":
return iconMovie
case "tvshows":
return iconTV
case "music":
return iconMusicBox
default:
return iconLibrary
}
}
if it.IsFolder {
return iconFolder
}
return iconFile
}
// rowTitle is the left text for a row: episode/track numbering where it helps,
// otherwise the bare name (year lives in the meta column).
func rowTitle(it api.Item) string {
switch it.Type {
case "Episode":
if it.ParentIndexNumber > 0 && it.IndexNumber > 0 {
return fmt.Sprintf("S%02dE%02d %s", it.ParentIndexNumber, it.IndexNumber, it.Name)
}
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
case "Audio":
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
}
return it.Name
}
// rowMeta is the right-aligned column: "(year) 1h 23m ✓".
func rowMeta(it api.Item) string {
var parts []string
if it.ProductionYear > 0 && (it.Type == "Movie" || it.Type == "Series") {
parts = append(parts, fmt.Sprintf("(%d)", it.ProductionYear))
}
if d := formatDuration(it.RunTimeTicks); d != "" {
parts = append(parts, d)
}
if it.UserData.Played {
parts = append(parts, "✓")
} else if it.UserData.PlayedPercentage > 1 {
parts = append(parts, fmt.Sprintf("%.0f%%", it.UserData.PlayedPercentage))
}
return strings.Join(parts, " ")
}
// cell pads s on the right so it occupies exactly w display columns. This keeps
// the title column aligned even when a terminal measures an emoji as one cell
// instead of two.
func cell(s string, w int) string {
if gap := w - lipgloss.Width(s); gap > 0 {
return s + strings.Repeat(" ", gap)
}
return s
}
// truncate shortens s to at most max display columns, appending an ellipsis.
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
if lipgloss.Width(s) <= max {
return s
}
var b strings.Builder
for _, r := range s {
if lipgloss.Width(b.String()+string(r))+1 > max {
break
}
b.WriteRune(r)
}
return b.String() + "…"
}

67
internal/tui/frame.go Normal file
View file

@ -0,0 +1,67 @@
// Shared screen chrome: a header rule (app name + server) and a footer rule
// (status + help) used by the browse and playing screens so they feel like one
// app rather than three separate views.
package tui
import (
"net/url"
"strings"
"github.com/charmbracelet/lipgloss"
)
// hostLabel extracts a friendly host from a server URL — no scheme or port, and
// a trailing ".local" trimmed (so "http://omabox.local:8096" → "omabox").
func hostLabel(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
return strings.TrimSuffix(u.Hostname(), ".local")
}
// serverLabel prefers a discovered server's advertised name (matched by URL),
// otherwise falls back to the host portion of the configured URL.
func (m Model) serverLabel() string {
for _, s := range m.discovered {
if s.Address == m.cfg.ServerURL && s.Name != "" {
return s.Name
}
}
return hostLabel(m.cfg.ServerURL)
}
// frameWidth returns the usable width, with a sane fallback before the first
// WindowSizeMsg arrives.
func (m Model) frameWidth() int {
if m.width <= 0 {
return 80
}
return m.width
}
// headerBar renders the top rule: " tsplay ───────────────── server ".
func (m Model) headerBar() string {
title := m.st.title.Render(" tsplay ")
srv := ""
if s := m.serverLabel(); s != "" {
srv = m.st.muted.Render(" " + s + " ")
}
dash := m.frameWidth() - lipgloss.Width(title) - lipgloss.Width(srv)
if dash < 1 {
dash = 1
}
return title + m.st.barEmpty.Render(strings.Repeat("─", dash)) + srv
}
// footerBar renders a thin rule, an optional accent status line, then the help
// keys — the consistent bottom of every framed screen.
func (m Model) footerBar(help, status string) string {
rule := m.st.barEmpty.Render(strings.Repeat("─", m.frameWidth()))
lines := []string{rule}
if status != "" {
lines = append(lines, " "+m.st.accent.Render(status))
}
lines = append(lines, " "+m.st.help.Render(help))
return strings.Join(lines, "\n")
}

118
internal/tui/login.go Normal file
View file

@ -0,0 +1,118 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/discovery"
)
func (m Model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+n":
// Cycle through discovered servers, filling the URL field.
if len(m.discovered) > 1 {
m.discIdx = (m.discIdx + 1) % len(m.discovered)
m.inputs[0].SetValue(m.discovered[m.discIdx].Address)
m.status = discoveryStatus(m.discovered, m.discIdx)
}
return m, nil
case "ctrl+r":
m.status = "Scanning the network for Jellyfin servers…"
return m, discoverCmd()
case "tab", "down":
m.focusField(m.focus + 1)
return m, textinput.Blink
case "shift+tab", "up":
m.focusField(m.focus - 1)
return m, textinput.Blink
case "enter":
server := strings.TrimSpace(m.inputs[0].Value())
user := strings.TrimSpace(m.inputs[1].Value())
pass := m.inputs[2].Value()
if server == "" || user == "" {
m.status = "Server URL and username are required."
return m, nil
}
if m.logging {
return m, nil
}
m.logging = true
m.status = "Connecting…"
return m, m.doLogin(server, user, pass)
}
}
// Feed the event to every input so the cursor blinks and the focused one
// receives text.
var cmds []tea.Cmd
for i := range m.inputs {
var c tea.Cmd
m.inputs[i], c = m.inputs[i].Update(msg)
cmds = append(cmds, c)
}
return m, tea.Batch(cmds...)
}
func (m *Model) focusField(i int) {
n := len(m.inputs)
i = ((i % n) + n) % n // wrap
for j := range m.inputs {
if j == i {
m.inputs[j].Focus()
} else {
m.inputs[j].Blur()
}
}
m.focus = i
}
// discoveryStatus formats the "found N servers" line shown under the form.
func discoveryStatus(servers []discovery.Server, idx int) string {
s := servers[idx]
name := s.Name
if name == "" {
name = s.Address
}
if len(servers) == 1 {
return "Found: " + name
}
return fmt.Sprintf("Found %d servers — using %s [%d/%d]", len(servers), name, idx+1, len(servers))
}
func (m Model) viewLogin() string {
var b strings.Builder
b.WriteString(m.st.title.Render("tsplay") + m.st.muted.Render(" — Jellyfin terminal player"))
b.WriteString("\n\n")
for i := range m.inputs {
b.WriteString(m.inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
help := "tab move · enter sign in · esc quit"
if len(m.discovered) > 1 {
help = "tab move · ctrl+n next server · enter sign in · esc quit"
} else {
help += " · ctrl+r rescan"
}
b.WriteString(m.st.help.Render(help))
body := m.st.box.Render(b.String())
// Discovery messages are informational (accent); real errors stay red.
footer := ""
if m.status != "" {
style := m.st.accent
if strings.HasPrefix(m.status, "Login failed") || strings.HasPrefix(m.status, "No servers") {
style = m.st.bad
}
footer = "\n" + style.Render(m.status)
}
return lipgloss.JoinVertical(lipgloss.Left, body, footer)
}

426
internal/tui/model.go Normal file
View file

@ -0,0 +1,426 @@
// Package tui is the BubbleTea front-end: a login form, a stack-based library
// browser, and a now-playing view that drives mpv. It owns the api.Client and
// the active player.Player.
package tui
import (
"context"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream-play/internal/api"
"tuistream-play/internal/cliamp"
"tuistream-play/internal/config"
"tuistream-play/internal/discovery"
"tuistream-play/internal/player"
"tuistream-play/internal/theme"
)
type screen int
const (
screenLogin screen = iota
screenBrowse
screenPlaying
)
// listItem adapts an api.Item to the bubbles/list interfaces.
type listItem struct {
item api.Item
}
func (l listItem) Title() string { return displayTitle(l.item) }
func (l listItem) Description() string { return displayDesc(l.item) }
func (l listItem) FilterValue() string { return l.item.Name }
// Model is the whole application state.
type Model struct {
cfg *config.Config
client *api.Client
st styles
screen screen
width, height int
status string // transient message / error shown in the footer
// login form
inputs []textinput.Model
focus int
logging bool
discovered []discovery.Server // Jellyfin servers found on the LAN
discIdx int // which discovered server is selected
// browse: a stack of list levels; the last is the visible one
stack []list.Model
// playing
plr *player.Player
nowPlaying api.Item
lastReport time.Time
// queue is the sibling list the current item was launched from, so audio
// can auto-advance to the next playable track. queueIdx is the position of
// nowPlaying within it. Empty when playback wasn't started from a list.
queue []api.Item
queueIdx int
}
// New builds the initial model from saved config.
func New(cfg *config.Config, t theme.Theme) Model {
m := Model{
cfg: cfg,
st: newStyles(t),
}
if cfg.LoggedIn() {
m.client = api.New(cfg.ServerURL, cfg.Token, cfg.UserID, cfg.DeviceID)
m.screen = screenBrowse
} else {
m.screen = screenLogin
m.initLogin()
}
return m
}
func (m *Model) initLogin() {
labels := []string{"Server URL (https://media.example.com)", "Username", "Password"}
m.inputs = make([]textinput.Model, 3)
for i := range m.inputs {
ti := textinput.New()
ti.Placeholder = labels[i]
ti.Prompt = " "
if i == 2 {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
}
m.inputs[i] = ti
}
if m.cfg.ServerURL != "" {
m.inputs[0].SetValue(m.cfg.ServerURL)
}
if m.cfg.Username != "" {
m.inputs[1].SetValue(m.cfg.Username)
}
m.focus = 0
m.inputs[0].Focus()
m.status = "Scanning the network for Jellyfin servers…"
}
// Init kicks off the first load: libraries if logged in, else LAN discovery.
func (m Model) Init() tea.Cmd {
if m.screen == screenBrowse {
return m.loadViews()
}
return tea.Batch(textinput.Blink, discoverCmd())
}
// ---- messages ----
type loginResultMsg struct {
res *api.AuthResult
err error
}
type levelMsg struct {
title string
items []api.Item
err error
}
type playStartedMsg struct {
plr *player.Player
item api.Item
err error
}
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
type discoveredMsg struct {
servers []discovery.Server
}
// discoverCmd runs LAN auto-discovery off the UI thread.
func discoverCmd() tea.Cmd {
return func() tea.Msg {
servers, _ := discovery.Discover(2 * time.Second)
return discoveredMsg{servers: servers}
}
}
// ---- async commands ----
func (m Model) doLogin(server, user, pass string) tea.Cmd {
cfg := m.cfg
return func() tea.Msg {
c := api.New(server, "", "", cfg.DeviceID)
res, err := c.Login(context.Background(), user, pass)
return loginResultMsg{res: res, err: err}
}
}
func (m Model) loadViews() tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Views(context.Background())
return levelMsg{title: "Libraries", items: items, err: err}
}
}
func (m Model) loadChildren(title, parentID string) tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Children(context.Background(), parentID)
return levelMsg{title: title, items: items, err: err}
}
}
func (m Model) loadResume() tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Resume(context.Background())
return levelMsg{title: "Continue Watching", items: items, err: err}
}
}
func (m Model) startPlayback(it api.Item) tea.Cmd {
c := m.client
vol := m.cfg.VolumeOr(100) // resume at the last-used volume
return func() tea.Msg {
url := c.StreamURL(it.ID)
startSec := float64(it.UserData.PlaybackPositionTicks) / 1e7
p, err := player.Start(url, displayTitle(it), startSec, it.AudioOnly(), vol)
if err == nil {
_ = c.ReportStart(context.Background(), it.ID)
}
return playStartedMsg{plr: p, item: it, err: err}
}
}
// nextAudioIndex returns the index of the next playable audio sibling after
// `from` in the queue, or -1 if there isn't one. Used for auto-advance, which
// is audio-only by design — a finished movie/episode just returns to the list.
func (m Model) nextAudioIndex(from int) int {
for i := from + 1; i < len(m.queue); i++ {
if m.queue[i].Playable() && m.queue[i].AudioOnly() {
return i
}
}
return -1
}
// prevAudioIndex returns the index of the previous playable audio sibling
// before `from` in the queue, or -1 if there isn't one. Used for the
// previous-track control on the now-playing screen.
func (m Model) prevAudioIndex(from int) int {
for i := from - 1; i >= 0; i-- {
if i < len(m.queue) && m.queue[i].Playable() && m.queue[i].AudioOnly() {
return i
}
}
return -1
}
// newLevel builds a list.Model populated with the given items.
func (m Model) newLevel(title string, items []api.Item) list.Model {
rows := make([]list.Item, len(items))
for i, it := range items {
rows[i] = listItem{item: it}
}
w, h := m.listSize()
l := list.New(rows, newItemDelegate(m.st), w, h)
l.Title = title
l.Styles.Title = m.st.title // plain bold accent, no default colour block
l.SetShowStatusBar(false) // count/filter state lives in our footer instead
l.SetShowHelp(false) // footer renders the key hints
l.SetFilteringEnabled(true)
return l
}
func (m Model) listSize() (int, int) {
w := m.width
// Reserve rows for the header rule, a blank line, and the footer
// (rule + help), so the list never overflows the screen frame.
h := m.height - 5
if w <= 0 {
w = 80
}
if h <= 0 {
h = 18
}
return w, h
}
// Update is the BubbleTea reducer; it dispatches by screen.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
w, h := m.listSize()
for i := range m.stack {
m.stack[i].SetSize(w, h)
}
return m, nil
case discoveredMsg:
m.discovered = msg.servers
m.discIdx = 0
if len(msg.servers) == 0 {
if m.status == "Scanning the network for Jellyfin servers…" {
m.status = "No servers found — enter the URL manually."
}
return m, nil
}
// Pre-fill the URL only if the user hasn't typed one yet.
if m.inputs[0].Value() == "" {
m.inputs[0].SetValue(msg.servers[0].Address)
}
m.status = discoveryStatus(msg.servers, 0)
return m, nil
case loginResultMsg:
m.logging = false
if msg.err != nil {
m.status = "Login failed: " + msg.err.Error()
return m, nil
}
// Persist and switch to browsing.
m.cfg.ServerURL = m.inputs[0].Value()
m.cfg.Username = m.inputs[1].Value()
m.cfg.Token = msg.res.AccessToken
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)
m.screen = screenBrowse
m.status = ""
// Best-effort: mirror the Jellyfin credentials into cliamp's config so
// its Jellyfin music provider works without a second setup. Only when
// cliamp is present; never blocks or fails login.
if cliamp.Present() {
if err := cliamp.WriteJellyfin(cliamp.Creds{
URL: m.cfg.ServerURL,
Token: m.cfg.Token,
UserID: m.cfg.UserID,
}); err == nil {
m.status = "cliamp Jellyfin provider configured"
}
}
return m, m.loadViews()
case levelMsg:
if msg.err != nil {
m.status = "Load failed: " + msg.err.Error()
return m, nil
}
m.status = ""
m.stack = append(m.stack, m.newLevel(msg.title, msg.items))
return m, nil
case playStartedMsg:
if msg.err != nil {
m.status = "Playback failed: " + msg.err.Error()
return m, nil
}
m.plr = msg.plr
m.nowPlaying = msg.item
m.screen = screenPlaying
m.lastReport = time.Time{}
return m, tick()
case tickMsg:
if m.screen != screenPlaying || m.plr == nil {
return m, nil
}
snap := m.plr.Snapshot()
if snap.Done {
// Auto-advance to the next audio track in the queue (music only);
// anything else just ends and returns to the browser.
if m.nowPlaying.AudioOnly() {
if next := m.nextAudioIndex(m.queueIdx); next >= 0 {
return m.advanceTo(next)
}
}
cmd := m.endPlayback(snap)
return m, cmd
}
// Report progress to the server roughly every 5s, and persist the
// volume if the user has changed it (so the next track resumes at the
// same level). snap.Volume is only trusted once mpv has reported it
// (>0), which avoids saving a spurious 0 before the first observe.
if t := time.Time(msg); t.Sub(m.lastReport) > 5*time.Second {
m.lastReport = t
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
paused := snap.Paused
c := m.client
go func() { _ = c.ReportProgress(context.Background(), id, pos, paused) }()
if snap.Volume > 0 && snap.Volume != m.cfg.VolumeOr(-1) {
m.cfg.SetVolume(snap.Volume)
_ = m.cfg.Save()
}
}
return m, tick()
}
switch m.screen {
case screenLogin:
return m.updateLogin(msg)
case screenBrowse:
return m.updateBrowse(msg)
case screenPlaying:
return m.updatePlaying(msg)
}
return m, nil
}
// advanceTo stops the current track (reporting its stop) and starts the queue
// item at index next, keeping the now-playing screen up for a seamless album
// playthrough. It returns the updated model so the caller uses
// `return m.advanceTo(next)` — a value receiver is deliberate: the
// `return m, m.advanceTo(...)` form would evaluate the first `m` before this
// method mutates it, discarding the plr=nil/queueIdx changes and orphaning the
// old player (two tracks playing at once).
func (m Model) advanceTo(next int) (Model, tea.Cmd) {
if m.plr != nil {
snap := m.plr.Snapshot()
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
c := m.client
go func() { _ = c.ReportStop(context.Background(), id, pos) }()
m.plr.Stop()
m.plr = nil
}
m.queueIdx = next
return m, m.startPlayback(m.queue[next])
}
// endPlayback stops mpv, reports the final position, and returns to browsing.
func (m *Model) endPlayback(snap player.State) tea.Cmd {
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
c := m.client
go func() { _ = c.ReportStop(context.Background(), id, pos) }()
if m.plr != nil {
m.plr.Stop()
m.plr = nil
}
m.screen = screenBrowse
return nil
}
// View renders the active screen.
func (m Model) View() string {
switch m.screen {
case screenLogin:
return m.viewLogin()
case screenPlaying:
return m.viewPlaying()
default:
return m.viewBrowse()
}
}

171
internal/tui/playing.go Normal file
View file

@ -0,0 +1,171 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/player"
)
func (m Model) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok || m.plr == nil {
return m, nil
}
switch key.String() {
case "ctrl+c":
m.plr.Stop()
m.plr = nil
return m, tea.Quit
case "q", "esc":
cmd := m.endPlayback(m.plr.Snapshot())
return m, cmd
case " ", "k":
m.plr.TogglePause()
case "left":
m.plr.SeekRelative(-10)
case "right":
m.plr.SeekRelative(10)
case "shift+left":
m.plr.SeekRelative(-60)
case "shift+right":
m.plr.SeekRelative(60)
case "up", "+", "=":
m.plr.AdjustVolume(5)
case "down", "-":
m.plr.AdjustVolume(-5)
case "m":
m.plr.ToggleMute()
case "n", ">", ".":
// Next track (audio queues only). Falls through silently if there's
// no next track or the current item isn't part of an audio queue.
if m.nowPlaying.AudioOnly() {
if next := m.nextAudioIndex(m.queueIdx); next >= 0 {
return m.advanceTo(next)
}
}
case "p", "<", ",":
// Previous track (audio queues only).
if m.nowPlaying.AudioOnly() {
if prev := m.prevAudioIndex(m.queueIdx); prev >= 0 {
return m.advanceTo(prev)
}
}
}
return m, nil
}
func (m Model) viewPlaying() string {
// During a track change advanceTo briefly nils the player before the next
// one starts; render a placeholder instead of dereferencing nil.
if m.plr == nil {
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(),
" "+m.st.muted.Render("Loading…"),
"",
m.footerBar("q stop", ""),
)
}
snap := m.plr.Snapshot()
state := m.st.good.Render("▶ playing")
if snap.Paused {
state = m.st.accent.Render("⏸ paused")
}
title := m.st.title.Render(displayTitle(m.nowPlaying))
sub := m.st.muted.Render(displayDesc(m.nowPlaying))
times := m.st.muted.Render(fmt.Sprintf("%s / %s", formatTime(snap.TimePos), formatTime(snap.Duration)))
bar := m.progressBar(snap.TimePos, snap.Duration)
vol := m.volumeLine(snap)
help := "space pause · ←/→ ±10s · ↑/↓ vol · m mute · q stop"
if m.nowPlaying.AudioOnly() {
help = "space pause · ←/→ ±10s · n/p track · ↑/↓ vol · m mute · q stop"
}
body := strings.Join([]string{
"",
" " + title,
" " + sub,
"",
" " + state + " " + times,
" " + bar,
"",
" " + vol,
"",
}, "\n")
label := "Now Playing"
if m.nowPlaying.AudioOnly() {
label = "♪ Now Playing (audio)"
}
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(),
" "+m.st.muted.Render(label),
body,
m.footerBar(help, ""),
)
}
// volumeLine renders a compact volume indicator: a short bar plus the percent,
// or a muted marker. mpv's range is 0-130; the bar is scaled to 0-100 so the
// common case fills it, with amplification (>100) simply pinning it full.
func (m Model) volumeLine(s player.State) string {
if s.Muted {
return m.st.muted.Render("vol ") + m.st.bad.Render("muted")
}
const width = 16
ratio := s.Volume / 100.0
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
fill := int(ratio * float64(width))
bar := m.st.barFill.Render(strings.Repeat("▮", fill)) +
m.st.barEmpty.Render(strings.Repeat("▯", width-fill))
return m.st.muted.Render("vol ") + bar + m.st.muted.Render(fmt.Sprintf(" %.0f%%", s.Volume))
}
// progressBar renders a fixed-width bar from the elapsed/total ratio.
func (m Model) progressBar(pos, dur float64) string {
width := m.width - 10
if width < 10 {
width = 40
}
if width > 80 {
width = 80
}
ratio := 0.0
if dur > 0 {
ratio = pos / dur
}
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
fill := int(ratio * float64(width))
return m.st.barFill.Render(strings.Repeat("━", fill)) +
m.st.barEmpty.Render(strings.Repeat("─", width-fill))
}
// formatTime renders seconds as H:MM:SS or M:SS.
func formatTime(sec float64) string {
if sec < 0 {
sec = 0
}
s := int(sec)
h := s / 3600
mn := (s % 3600) / 60
ss := s % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, mn, ss)
}
return fmt.Sprintf("%d:%02d", mn, ss)
}

37
internal/tui/styles.go Normal file
View file

@ -0,0 +1,37 @@
// Styles derived from the active Omarchy theme (or the ANSI fallback). Kept in
// one place so the login, browse and playing views share a consistent look.
package tui
import (
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/theme"
)
type styles struct {
title lipgloss.Style
accent lipgloss.Style
muted lipgloss.Style
good lipgloss.Style
bad lipgloss.Style
help lipgloss.Style
barFill lipgloss.Style
barEmpty lipgloss.Style
box lipgloss.Style
}
func newStyles(t theme.Theme) styles {
return styles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(t.Accent)),
accent: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Accent)),
muted: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Muted)),
good: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Good)),
bad: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Bad)),
help: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Muted)),
barFill: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Accent)),
barEmpty: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Muted)),
box: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(t.Accent)).
Padding(1, 2),
}
}