package model import ( "time" tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/luaplugin" "github.com/bjarneo/cliamp/ui" ) // seekDebounceTicks is how many ticks to wait after the last seek keypress // before actually executing the yt-dlp seek (restart). const seekDebounceTicks = 8 // ~800ms at 100ms tick interval // seekTickMsg fires when the async seek completes. type seekTickMsg struct{} type ytdlUnpauseReconnectMsg struct{ err error } // doSeek handles a seek keypress. For yt-dlp streams, accumulates into a // single target position and debounces. For HTTP seekable streams, dispatches // the seek asynchronously to avoid blocking the UI. For local files, seeks // immediately. func (m *Model) doSeek(d time.Duration) tea.Cmd { return m.seekRelative(d, seekDebounceTicks) } func (m *Model) streamSeekRelative(delta time.Duration) tea.Cmd { p := m.player return func() tea.Msg { p.Seek(delta) return seekTickMsg{} } } func (m *Model) streamSeekAbsolute(target time.Duration) tea.Cmd { p := m.player return func() tea.Msg { p.Seek(target - p.Position()) return seekTickMsg{} } } func (m *Model) seekRelative(d time.Duration, debounceTicks int) tea.Cmd { if m.player.IsStreamSeek() { return m.streamSeekRelative(d) } if !m.player.IsYTDLSeek() { m.player.Seek(d) m.finishSeek() return nil } target := m.player.Position() if m.seek.active && debounceTicks > 0 { target = m.seek.targetPos } return m.queueYTDLSeekTarget(target+d, debounceTicks) } func (m *Model) seekAbsolute(target time.Duration) tea.Cmd { if m.player.IsStreamSeek() { return m.streamSeekAbsolute(target) } if !m.player.IsYTDLSeek() { m.player.Seek(target - m.player.Position()) m.finishSeek() return nil } return m.queueYTDLSeekTarget(target, 0) } func (m *Model) queueYTDLSeekTarget(target time.Duration, debounceTicks int) tea.Cmd { m.seek.active = true m.seek.targetPos = m.clampPosition(target) m.player.CancelSeekYTDL() if debounceTicks > 0 { m.seek.timer = debounceTicks m.seek.timerFor = 0 return nil } m.seek.timer = 0 m.seek.timerFor = 0 return m.commitPendingYTDLSeek() } func (m *Model) finishSeek() { m.notifyAll() if m.notifier != nil { m.notifier.Seeked(m.player.Position()) } m.emitPlugin(luaplugin.EventPlayerSeek, map[string]any{ "position": m.player.Position().Seconds(), "duration": m.player.Duration().Seconds(), }) } func (m *Model) commitPendingYTDLSeek() tea.Cmd { target := m.seek.targetPos curPos := m.player.Position() d := target - curPos p := m.player return func() tea.Msg { p.SeekYTDL(d) return seekTickMsg{} } } func (m *Model) clampPosition(pos time.Duration) time.Duration { if pos < 0 { return 0 } dur := m.player.Duration() if dur > 0 && pos >= dur { return dur - time.Second } return pos } // tickSeek is called from the main tick loop. It advances the debounce timer with elapsed // time and runs the yt-dlp seek when the countdown reaches zero. func (m *Model) tickSeek(dt time.Duration) tea.Cmd { if !m.seek.active || m.seek.timer <= 0 { m.seek.timerFor = 0 return nil } if advanceTickUnits(&m.seek.timer, &m.seek.timerFor, dt, ui.TickFast) == 0 || m.seek.timer > 0 { return nil } // Timer expired — fire the seek to the target position. return m.commitPendingYTDLSeek() }