tsplay/internal/tui/playing.go
28allday 3c64f13b92 Rename project to tsplay
Collapse the project/module/directory name to match the binary, so there
is a single name everywhere (tuistream-play -> tsplay). The binary, config
dir and Jellyfin client identity were already tsplay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:47:37 +01:00

171 lines
4 KiB
Go

package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tsplay/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)
}