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:
commit
8ee58b905f
20 changed files with 2555 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/dist/
|
||||
/tuiplay
|
||||
build.log
|
||||
*.sock
|
||||
115
README.md
Normal file
115
README.md
Normal 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
48
cmd/tsplay/main.go
Normal 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
35
go.mod
Normal 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
62
go.sum
Normal 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
56
install.sh
Executable 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
220
internal/api/client.go
Normal 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
141
internal/cliamp/cliamp.go
Normal 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"
|
||||
}
|
||||
63
internal/cliamp/cliamp_test.go
Normal file
63
internal/cliamp/cliamp_test.go
Normal 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
103
internal/config/config.go
Normal 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 != ""
|
||||
}
|
||||
165
internal/discovery/discovery.go
Normal file
165
internal/discovery/discovery.go
Normal 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
252
internal/player/mpv.go
Normal 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
115
internal/theme/theme.go
Normal 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
160
internal/tui/browse.go
Normal 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
197
internal/tui/delegate.go
Normal 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
67
internal/tui/frame.go
Normal 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
118
internal/tui/login.go
Normal 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
426
internal/tui/model.go
Normal 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
171
internal/tui/playing.go
Normal 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
37
internal/tui/styles.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue