commit 8ee58b905f4bbb4fe16e839456947e194e5b95b7 Author: 28allday Date: Sat May 30 12:36:49 2026 +0100 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6a4da1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/tuiplay +build.log +*.sock diff --git a/README.md b/README.md new file mode 100644 index 0000000..2971eb2 --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/tsplay/main.go b/cmd/tsplay/main.go new file mode 100644 index 0000000..7ef60ef --- /dev/null +++ b/cmd/tsplay/main.go @@ -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.") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ec996f0 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5afa96c --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a648184 --- /dev/null +++ b/install.sh @@ -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)." diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..e836d86 --- /dev/null +++ b/internal/api/client.go @@ -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) } diff --git a/internal/cliamp/cliamp.go b/internal/cliamp/cliamp.go new file mode 100644 index 0000000..f631b49 --- /dev/null +++ b/internal/cliamp/cliamp.go @@ -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" +} diff --git a/internal/cliamp/cliamp_test.go b/internal/cliamp/cliamp_test.go new file mode 100644 index 0000000..f0addf3 --- /dev/null +++ b/internal/cliamp/cliamp_test.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e342ab7 --- /dev/null +++ b/internal/config/config.go @@ -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 != "" +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..a8d57c2 --- /dev/null +++ b/internal/discovery/discovery.go @@ -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)) +} diff --git a/internal/player/mpv.go b/internal/player/mpv.go new file mode 100644 index 0000000..dd5a8e2 --- /dev/null +++ b/internal/player/mpv.go @@ -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() + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..6b247a9 --- /dev/null +++ b/internal/theme/theme.go @@ -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 +} diff --git a/internal/tui/browse.go b/internal/tui/browse.go new file mode 100644 index 0000000..6013c97 --- /dev/null +++ b/internal/tui/browse.go @@ -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) +} diff --git a/internal/tui/delegate.go b/internal/tui/delegate.go new file mode 100644 index 0000000..3aa6a9c --- /dev/null +++ b/internal/tui/delegate.go @@ -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() + "…" +} diff --git a/internal/tui/frame.go b/internal/tui/frame.go new file mode 100644 index 0000000..a195879 --- /dev/null +++ b/internal/tui/frame.go @@ -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") +} diff --git a/internal/tui/login.go b/internal/tui/login.go new file mode 100644 index 0000000..7a7e65e --- /dev/null +++ b/internal/tui/login.go @@ -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) +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..8660aae --- /dev/null +++ b/internal/tui/model.go @@ -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() + } +} diff --git a/internal/tui/playing.go b/internal/tui/playing.go new file mode 100644 index 0000000..a4b98cb --- /dev/null +++ b/internal/tui/playing.go @@ -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) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..b0e761b --- /dev/null +++ b/internal/tui/styles.go @@ -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), + } +}