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) }