199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package model
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/ui"
|
|
)
|
|
|
|
type tickMsg time.Time
|
|
type autoPlayMsg struct{}
|
|
|
|
var teaTick = tea.Tick
|
|
|
|
func tickCmd() tea.Cmd {
|
|
return tickCmdAt(ui.TickFast)
|
|
}
|
|
|
|
func tickCmdAt(d time.Duration) tea.Cmd {
|
|
return teaTick(d, func(t time.Time) tea.Msg {
|
|
return tickMsg(t)
|
|
})
|
|
}
|
|
|
|
func (m *Model) visualizerPlaying() bool {
|
|
return m.player != nil && m.vis != nil && m.vis.Mode != ui.VisNone &&
|
|
!m.isOverlayActive() && m.player.IsPlaying() && !m.player.IsPaused()
|
|
}
|
|
|
|
func (m *Model) visualizerPaused() bool {
|
|
return m.player != nil && m.vis != nil && m.vis.Mode != ui.VisNone &&
|
|
!m.isOverlayActive() && m.player.IsPlaying() && m.player.IsPaused()
|
|
}
|
|
|
|
func (m *Model) visualizerTickContext(now time.Time) ui.VisTickContext {
|
|
sampled := false
|
|
samplesRead := 0
|
|
sampledSize := 0
|
|
cache := map[ui.VisAnalysisSpec][]float64{}
|
|
|
|
return ui.VisTickContext{
|
|
Now: now,
|
|
Playing: m.visualizerPlaying(),
|
|
Paused: m.visualizerPaused(),
|
|
OverlayActive: m.isOverlayActive(),
|
|
Analyze: func(spec ui.VisAnalysisSpec) []float64 {
|
|
spec = ui.NormalizeAnalysisSpec(spec)
|
|
if m.player == nil || m.vis == nil || m.vis.Mode == ui.VisNone {
|
|
return nil
|
|
}
|
|
if bands, ok := cache[spec]; ok {
|
|
return bands
|
|
}
|
|
buf := m.vis.EnsureSampleBuf(spec.FFTSize)
|
|
if !sampled || spec.FFTSize > sampledSize {
|
|
samplesRead = m.player.SamplesInto(buf)
|
|
if m.visVolumeLinked {
|
|
gain := math.Pow(10, m.player.Volume()/20)
|
|
for i := range samplesRead {
|
|
buf[i] *= gain
|
|
}
|
|
}
|
|
sampled = true
|
|
sampledSize = spec.FFTSize
|
|
}
|
|
start := max(0, samplesRead-spec.FFTSize)
|
|
bands := m.vis.Analyze(buf[start:samplesRead], spec)
|
|
cache[spec] = bands
|
|
return bands
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *Model) tickDelta(now time.Time) time.Duration {
|
|
dt := m.tickInterval()
|
|
if !now.IsZero() && !m.lastTickAt.IsZero() {
|
|
dt = now.Sub(m.lastTickAt)
|
|
}
|
|
if dt <= 0 {
|
|
dt = ui.TickFast
|
|
}
|
|
if !now.IsZero() {
|
|
m.lastTickAt = now
|
|
}
|
|
return dt
|
|
}
|
|
|
|
func advanceTickUnits(counter *int, elapsed *time.Duration, dt, quantum time.Duration) int {
|
|
if *counter <= 0 {
|
|
*elapsed = 0
|
|
return 0
|
|
}
|
|
*elapsed += dt
|
|
if *elapsed < quantum {
|
|
return 0
|
|
}
|
|
steps := min(int(*elapsed/quantum), *counter)
|
|
*counter -= steps
|
|
if *counter == 0 {
|
|
*elapsed = 0
|
|
return steps
|
|
}
|
|
*elapsed -= time.Duration(steps) * quantum
|
|
return steps
|
|
}
|
|
|
|
func (m *Model) tickInterval() time.Duration {
|
|
if m.termTitle.introActive {
|
|
return ui.TickFast
|
|
}
|
|
// Fully idle: stopped or paused with nothing self-animating. Drop to the
|
|
// idle cadence so the CPU can sit in a low P-state between user actions.
|
|
// Bubbletea still wakes immediately on key / IPC / MPRIS / plugin events.
|
|
if m.isFullyIdle() {
|
|
return ui.TickIdle
|
|
}
|
|
d := ui.TickSlow
|
|
if m.vis != nil {
|
|
d = m.vis.TickInterval(m.visualizerTickContext(time.Time{}))
|
|
}
|
|
// Keep the seek bar / time counter smooth while audio is playing, even
|
|
// when the visualizer driver wants a slow cadence (VisNone, classic peak
|
|
// idle, etc.). Overlays, paused, and stopped playback keep the slower
|
|
// cadence to save CPU.
|
|
if !m.isOverlayActive() && !m.buffering && m.player != nil &&
|
|
m.player.IsPlaying() && !m.player.IsPaused() && d > ui.TickFast {
|
|
if m.lowPower {
|
|
return ui.TickLowPowerPlaying
|
|
}
|
|
d = ui.TickFast
|
|
}
|
|
return d
|
|
}
|
|
|
|
// isFullyIdle reports whether the model has nothing changing on its own.
|
|
// When true, the tick can run at ui.TickIdle since any state change will
|
|
// arrive as an explicit message (key press, IPC, MPRIS, plugin send).
|
|
func (m *Model) isFullyIdle() bool {
|
|
if m.player == nil {
|
|
return false
|
|
}
|
|
if m.player.IsPlaying() && !m.player.IsPaused() {
|
|
return false
|
|
}
|
|
if m.isOverlayActive() || m.buffering || m.termTitle.introActive {
|
|
return false
|
|
}
|
|
if !m.status.expiresAt.IsZero() || len(m.logLines) > 0 {
|
|
return false
|
|
}
|
|
if !m.reconnect.at.IsZero() {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *Model) tickVisualizer(now time.Time) {
|
|
if m.vis == nil || m.vis.Mode == ui.VisNone {
|
|
return
|
|
}
|
|
m.vis.Tick(m.visualizerTickContext(now))
|
|
}
|
|
|
|
func (m Model) refreshVisualizerIfPending() {
|
|
if m.vis == nil || m.vis.Mode == ui.VisNone || m.activeScreen().hidesVisualizer() || !m.vis.ConsumeRefresh() {
|
|
return
|
|
}
|
|
m.tickVisualizer(time.Now())
|
|
}
|
|
|
|
func (m Model) maybeRequestVisualizerRefresh(msg tea.Msg, wasScreen topLevelScreen, wasMode ui.VisMode, wasPlaying, wasPaused bool) {
|
|
if m.vis == nil {
|
|
return
|
|
}
|
|
if _, ok := msg.(tickMsg); ok {
|
|
return
|
|
}
|
|
screen := m.activeScreen()
|
|
if screen.hidesVisualizer() || m.vis.Mode == ui.VisNone {
|
|
return
|
|
}
|
|
|
|
playing := false
|
|
paused := false
|
|
if m.player != nil {
|
|
playing = m.player.IsPlaying()
|
|
paused = m.player.IsPaused()
|
|
}
|
|
|
|
if wasScreen != screen ||
|
|
wasMode != m.vis.Mode ||
|
|
(!wasPlaying && playing) ||
|
|
(wasPaused && !paused) {
|
|
m.vis.RequestRefresh()
|
|
}
|
|
}
|