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>
171 lines
4 KiB
Go
171 lines
4 KiB
Go
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)
|
|
}
|