Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

1131 lines
30 KiB
Go

package model
import (
"errors"
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/ipc"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// Update handles messages: key presses, ticks, and window resizes.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
wasScreen := m.activeScreen()
wasMode := ui.VisNone
if m.vis != nil {
wasMode = m.vis.Mode
}
wasPlaying := false
wasPaused := false
if m.player != nil {
wasPlaying = m.player.IsPlaying()
wasPaused = m.player.IsPaused()
}
defer func() {
m.maybeRequestVisualizerRefresh(msg, wasScreen, wasMode, wasPlaying, wasPaused)
m.emitPluginEvents()
}()
switch msg := msg.(type) {
case tea.PasteMsg:
cmd := m.handlePaste(msg.Content)
return m, cmd
case tea.KeyPressMsg:
cmd := m.handleKey(msg)
if m.quitting {
return m, tea.Quit
}
m.applyHeightMode()
m.adjustScroll()
return m, cmd
case autoPlayMsg:
if m.playlist.Len() > 0 && !m.player.IsPlaying() {
cmd := m.playCurrentTrack()
m.notifyAll()
return m, cmd
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Dynamic frame width: use full terminal width, or cap at 80 in compact mode.
frameW := msg.Width
if m.compact {
frameW = min(frameW, 80)
}
ui.FrameStyle = ui.FrameStyle.Width(frameW)
m.restorePanelWidth()
if m.fullVis {
m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5)
ui.PanelWidth = max(0, m.width-2*ui.PaddingH)
}
m.recomputeChrome()
m.applyHeightMode()
m.adjustScroll()
if m.focus == focusProvider {
m.providerMaybeAdjustScroll()
}
if m.fileBrowser.visible {
m.fbMaybeAdjustScroll(m.fbVisible())
}
if m.plPicker.visible {
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
}
if m.keymap.visible {
m.keymapMaybeAdjustScroll(m.keymapVisible())
}
if m.plManager.visible {
if m.plManager.screen == plMgrScreenList {
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
} else if m.plManager.screen == plMgrScreenTracks {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
}
return m, nil
case seekTickMsg:
// Async seek completed.
// Only clear seekActive if no new seek keypresses arrived during loading.
if m.seek.timer <= 0 {
m.seek.active = false
}
// Grace period: suppress reconnect for a few ticks after seek completes.
m.seek.grace = 10
m.seek.graceFor = 0
m.finishSeek()
return m, nil
case ytdlUnpauseReconnectMsg:
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 10
m.seek.graceFor = 0
if msg.err != nil {
m.err = msg.err
} else {
m.err = nil
m.pausedAt = time.Time{}
}
m.notifyAll()
return m, nil
case tickMsg:
now := time.Time(msg)
dt := m.tickDelta(now)
// Cache expensive player state once per tick so View() render
// functions don't re-acquire speaker.Lock() multiple times.
// PositionAndDuration() batches both reads under one speaker lock.
if !m.buffering {
if m.seek.active {
m.cachedPos = m.seek.targetPos
m.cachedDur = m.player.Duration()
} else {
m.cachedPos, m.cachedDur = m.player.PositionAndDuration()
// Piped SSH streams report 0 duration — use metadata fallback.
if m.cachedDur == 0 {
if track, _ := m.currentPlaybackTrack(); track.DurationSecs > 0 && strings.HasPrefix(track.Path, "ssh://") {
m.cachedDur = time.Duration(track.DurationSecs) * time.Second
}
}
}
} else {
track, _ := m.currentPlaybackTrack()
m.cachedDur = time.Duration(track.DurationSecs) * time.Second
m.cachedPos = 0
}
m.tickVisualizer(now)
// Process debounced yt-dlp seek.
var seekCmd tea.Cmd
if cmd := m.tickSeek(dt); cmd != nil {
seekCmd = cmd
}
// Expire temporary status messages.
wasStatus := m.status.text != ""
if !m.status.expiresAt.IsZero() && !now.Before(m.status.expiresAt) {
m.status.Clear()
}
// Drain app log buffer and expire old entries.
wasLogs := len(m.logLines)
m.tickLogLines(now)
if (wasStatus && m.status.text == "") || len(m.logLines) != wasLogs {
m.applyHeightMode()
m.adjustScroll()
}
m.tickPendingSpeedSave(dt)
// Decrement seek grace period.
advanceTickUnits(&m.seek.grace, &m.seek.graceFor, dt, ui.TickFast)
// Surface stream errors (e.g., connection drops) and auto-reconnect streams.
// Suppress during yt-dlp seek and grace period — killing the old pipeline
// triggers a transient error that can persist for a few ticks.
if err := m.player.StreamErr(); err != nil && !m.seek.active && m.seek.grace == 0 {
track, idx := m.currentPlaybackTrack()
isStream := idx >= 0 && (track.Stream || playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path))
if isStream && m.reconnect.attempts < 5 {
// Schedule reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s
if m.reconnect.at.IsZero() {
delay := time.Second << m.reconnect.attempts
m.reconnect.at = now.Add(delay)
m.reconnect.attempts++
m.err = fmt.Errorf("reconnecting in %s", delay)
}
} else {
m.err = err
m.reconnect.at = time.Time{}
}
}
var lyricCmd tea.Cmd
// Poll ICY stream title for live radio display.
if title := m.player.StreamTitle(); title != "" && title != m.streamTitle {
m.streamTitle = title
m.applyHeightMode()
m.adjustScroll()
m.notifyAll()
// Auto-fetch lyrics when the stream song changes and lyrics overlay is open.
if m.lyrics.visible && !m.lyrics.loading {
if artist, song, ok := strings.Cut(title, " - "); ok {
q := artist + "\n" + song
if q != m.lyrics.query {
m.lyrics.query = q
m.lyrics.loading = true
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.scroll = 0
lyricCmd = fetchLyricsCmd(artist, song)
}
}
}
}
m.network.sampleFor += dt
if m.network.sampleFor >= time.Second {
downloaded, _ := m.player.StreamBytes()
if downloaded > 0 || m.player.IsPlaying() {
m.notifyAll()
}
delta := downloaded - m.network.lastBytes
if delta > 0 {
// Exponential moving average for smooth display.
instant := float64(delta) / m.network.sampleFor.Seconds() // bytes/sec
if m.network.speed == 0 {
m.network.speed = instant
} else {
m.network.speed = m.network.speed*0.6 + instant*0.4
}
} else if downloaded == 0 {
m.network.speed = 0
}
m.network.lastBytes = downloaded
m.network.sampleFor = 0
}
// Fire scheduled reconnect when the timer expires.
if !m.reconnect.at.IsZero() && now.After(m.reconnect.at) {
m.reconnect.at = time.Time{}
track, idx := m.currentPlaybackTrack()
m.player.Stop()
if idx >= 0 {
// Preserve any seek/lyric commands already queued this tick
// rather than dropping them on the early return.
batch := []tea.Cmd{m.playTrack(track), tickCmdAt(ui.TickFast)}
if seekCmd != nil {
batch = append(batch, seekCmd)
}
if lyricCmd != nil {
batch = append(batch, lyricCmd)
}
return m, tea.Batch(batch...)
}
}
var cmds []tea.Cmd
if seekCmd != nil {
cmds = append(cmds, seekCmd)
}
if lyricCmd != nil {
cmds = append(cmds, lyricCmd)
}
// Check gapless transition (audio already playing next track)
if m.player.GaplessAdvanced() {
// Capture the track that just finished before advancing the playlist.
// For gapless, the track played fully (100% ≥ 50%), so elapsed = duration.
finishedTrack, _ := m.currentPlaybackTrack()
fullDur := time.Duration(finishedTrack.DurationSecs) * time.Second
m.maybeScrobble(finishedTrack, fullDur, fullDur)
var newTrack playlist.Track
var ok bool
if m.playbackDetached {
var idx int
newTrack, idx = m.playlist.Current()
ok = idx >= 0
m.playbackDetached = false
} else {
newTrack, ok = m.playlist.Next()
}
if !ok {
m.player.Stop()
m.clearPlaybackTrack()
m.notifyAll()
cmds = append(cmds, tickCmdAt(m.tickInterval()))
return m, tea.Batch(cmds...)
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
m.titleOff = 0
var gaplessLyricCmd tea.Cmd
newTrack, gaplessLyricCmd = m.beginPlaybackTrack(newTrack)
if gaplessLyricCmd != nil {
cmds = append(cmds, gaplessLyricCmd)
}
// The preload that just fired is consumed — clear the in-flight flag
// so the next track can be preloaded.
m.preloading = false
// A stream decoder error at the track boundary (e.g., server closing
// the connection when the preload HTTP request opens) is expected and
// not a user-visible problem. Clear any pending error so the red
// message doesn't flash at every track transition.
m.err = nil
// Gapless advances without calling playTrack(), so emit now-playing here.
m.nowPlaying(newTrack)
cmds = append(cmds, m.preloadNext())
m.notifyAll()
}
// Check if gapless drained (end of playlist, no preloaded next).
// Skip if already buffering a yt-dlp download to avoid advancing
// the playlist on every tick while waiting for the resolve.
if m.player.IsPlaying() && !m.player.IsPaused() && m.player.Drained() && !m.buffering && m.reconnect.at.IsZero() {
// Track drained to end — always ≥ 50%.
finishedTrack, _ := m.currentPlaybackTrack()
drainDur := time.Duration(finishedTrack.DurationSecs) * time.Second
m.maybeScrobble(finishedTrack, drainDur, drainDur)
// Stop the player before dispatching the async nextTrack command.
// This clears the gapless streamer so the finished track cannot
// replay while waiting for a yt-dlp pipe chain to spin up.
m.player.Stop()
cmds = append(cmds, m.nextTrack())
m.notifyAll()
}
if m.player.IsPlaying() && !m.player.IsPaused() {
if now.Sub(m.titleLastScroll) >= 200*time.Millisecond {
m.titleOff++
m.titleLastScroll = now
}
}
// Retry deferred stream preload: preloadNext() returns nil (defers) when
// the current stream has >streamPreloadLeadTime remaining. Poll every tick
// until we're within the window and the preload gets armed.
// Guard with !m.preloading so we don't fire a second concurrent HTTP
// connection while the first preloadStreamCmd goroutine is still running.
if m.player.IsPlaying() && !m.player.IsPaused() && !m.buffering && !m.preloading && !m.player.HasPreload() {
if cmd := m.preloadNext(); cmd != nil {
cmds = append(cmds, cmd)
}
}
m.advanceTerminalTitle()
cmds = append(cmds, tickCmdAt(m.tickInterval()))
return m, tea.Batch(cmds...)
case []playlist.PlaylistInfo:
m.providerLists = msg
m.provLoading = false
// Start loading catalog when the provider supports lazy catalog loading.
if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.catalogBatch.loading && !m.catalogBatch.done {
m.catalogBatch.loading = true
return m, fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize)
}
return m, nil
case tracksLoadedMsg:
if m.player.IsPlaying() || m.buffering {
m.detachPlaybackTrack()
m.player.ClearPreload()
m.preloading = false
} else {
m.player.Stop()
m.player.ClearPreload()
m.clearPlaybackTrack()
}
m.resetYTDLBatch()
m.playlist.Replace(msg.tracks)
m.setHeaderStateFromTracks(msg.tracks)
if msg.playlistExact && m.localProvider != nil && msg.providerName == m.localProvider.Name() && msg.playlistID != history.PlaylistName {
m.loadedPlaylist = msg.playlistID
} else {
m.loadedPlaylist = ""
}
m.plCursor = 0
m.plScroll = 0
m.focus = focusPlaylist
m.applyHeightMode()
m.adjustScroll()
m.provLoading = false
m.notifyAll()
return m, nil
case navArtistsLoadedMsg:
m.navBrowser.artists = []provider.ArtistInfo(msg)
m.navBrowser.loading = false
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
return m, nil
case navAlbumsLoadedMsg:
if msg.offset == 0 {
// Fresh load (new sort or drill-in): replace the list.
m.navBrowser.albums = msg.albums
m.navBrowser.albumDone = false
} else {
// Lazy-load page: append.
m.navBrowser.albums = append(m.navBrowser.albums, msg.albums...)
}
if msg.isLast {
m.navBrowser.albumDone = true
}
m.navBrowser.albumLoading = false
if msg.offset == 0 {
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
}
// If we just loaded the first page and it was a full menu → list transition,
// also clear the general loading flag.
m.navBrowser.loading = false
return m, nil
case navTracksLoadedMsg:
m.navBrowser.tracks = []playlist.Track(msg)
m.setHeaderStateFromTracks(m.navBrowser.tracks)
m.navBrowser.loading = false
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.screen = navBrowseScreenTracks
return m, nil
case catalogBatchMsg:
m.catalogBatch.loading = false
if msg.err != nil {
m.catalogBatch.done = true
m.status.Show("Catalog load failed", statusTTLDefault)
return m, nil
}
if msg.added == 0 {
m.catalogBatch.done = true
return m, nil
}
if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists
}
m.catalogBatch.offset += msg.added
if msg.added < catalogBatchSize {
m.catalogBatch.done = true
}
return m, nil
case catalogSearchMsg:
m.provLoading = false
if msg.err != nil {
m.status.Show("Search failed", statusTTLDefault)
} else {
if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists
}
m.provCursor = 0
m.provScroll = 0
if msg.count == 0 {
m.status.Show("No stations found", statusTTLDefault)
}
}
return m, nil
case ytdlBatchMsg:
// Discard stale responses from a previous batch session.
if msg.gen != m.ytdlBatch.gen {
return m, nil
}
m.ytdlBatch.loading = false
if msg.err != nil {
m.ytdlBatch.done = true
m.status.Showf(statusTTLBatch, "Radio batch load failed: %v", msg.err)
return m, nil
}
if len(msg.tracks) == 0 {
m.ytdlBatch.done = true
return m, nil
}
m.playlist.Add(msg.tracks...)
m.loadedPlaylist = ""
m.addToHeaderState(msg.tracks)
m.ytdlBatch.offset += len(msg.tracks)
if len(msg.tracks) < ytdlBatchSize {
m.ytdlBatch.done = true
return m, nil
}
// Immediately fetch the next batch.
m.ytdlBatch.loading = true
return m, fetchYTDLBatchCmd(m.ytdlBatch.gen, m.ytdlBatch.url, m.ytdlBatch.offset, ytdlBatchSize)
case feedTrackResolvedMsg:
m.feedLoading = false
if len(msg.tracks) == 0 {
m.status.Show("No episodes found in feed.", statusTTLDefault)
return m, nil
}
m.playlist.Replace(msg.tracks)
m.loadedPlaylist = ""
m.setHeaderStateFromTracks(msg.tracks)
m.plCursor = 0
m.plScroll = 0
m.applyHeightMode()
m.adjustScroll()
m.status.Showf(statusTTLDefault, "Loaded %d episode(s)", len(msg.tracks))
playCmd := m.playCurrentTrack()
m.notifyAll()
return m, playCmd
case feedsLoadedMsg:
m.feedLoading = false
if len(msg.tracks) > 0 {
m.playlist.Add(msg.tracks...)
m.loadedPlaylist = ""
m.addToHeaderState(msg.tracks)
m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks))
} else {
m.status.Show("No tracks found at URL.", statusTTLDefault)
}
if len(msg.tracks) > 0 {
// Set up incremental loading for YouTube Radio playlists.
// The source URLs are carried in the message so we don't
// need to re-scan pendingURLs (which misses interactive loads).
batchCmd := m.initYTDLBatch(msg.urls)
if msg.autoPlay && m.playlist.Len() > 0 && !m.player.IsPlaying() {
playCmd := m.playCurrentTrack()
m.notifyAll()
if batchCmd != nil {
return m, tea.Batch(playCmd, batchCmd)
}
return m, playCmd
}
if batchCmd != nil {
return m, batchCmd
}
}
return m, nil
case netSearchResultsMsg:
m.netSearch.loading = false
m.netSearch.cursor = 0
m.netSearch.scroll = 0
if msg.err != nil {
m.netSearch.err = msg.err.Error()
return m, nil
}
m.netSearch.results = msg.tracks
m.netSearch.cursor = 0
m.netSearch.screen = netSearchResults
if len(msg.tracks) == 0 {
m.netSearch.err = "No results found"
}
return m, nil
case lyricsLoadedMsg:
m.lyrics.loading = false
m.lyrics.err = msg.err
m.lyrics.scroll = 0
if msg.err == nil {
m.lyrics.lines = msg.lines
}
return m, nil
case fbTracksResolvedMsg:
if len(msg.tracks) == 0 {
m.status.Show("No audio files found", statusTTLDefault)
return m, nil
}
if msg.targetPlaylist != "" {
added, skipped, err := m.writeTracksToPlaylist(msg.targetPlaylist, msg.tracks)
if err != nil {
m.status.Showf(statusTTLDefault, "Add failed: %s", err)
} else if skipped > 0 {
m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped)
} else {
m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist)
}
m.refreshPlaylistManagerAfterWrite(msg.targetPlaylist)
return m, nil
}
if msg.toPlaylist {
m.openPlaylistPicker(msg.tracks, fmt.Sprintf("%d tracks selected", len(msg.tracks)))
return m, nil
}
if msg.replace {
m.player.Stop()
m.player.ClearPreload()
m.resetYTDLBatch()
m.playlist.Replace(msg.tracks)
m.loadedPlaylist = ""
m.setHeaderStateFromTracks(msg.tracks)
m.plCursor = 0
m.plScroll = 0
} else {
m.playlist.Add(msg.tracks...)
m.loadedPlaylist = ""
m.addToHeaderState(msg.tracks)
}
m.focus = focusPlaylist
m.applyHeightMode()
m.adjustScroll()
m.status.Showf(statusTTLDefault, "Added %d track(s)", len(msg.tracks))
if !m.player.IsPlaying() && m.playlist.Len() > 0 {
if msg.replace {
m.playlist.SetIndex(0)
}
cmd := m.playCurrentTrack()
m.notifyAll()
return m, cmd
}
return m, nil
case streamPlayedMsg:
m.buffering = false
if msg.err != nil {
m.err = msg.err
if track, idx := m.currentPlaybackTrack(); idx >= 0 {
m.status.Showf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName())
}
} else {
m.err = nil
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.applyResume()
}
m.notifyAll()
return m, m.preloadNext()
case streamPreloadedMsg:
m.preloading = false
return m, nil
case ytdlSavedMsg:
m.save.finishDownload()
if msg.err != nil {
m.status.Showf(statusTTLMedium, "Download failed: %s", msg.err)
} else {
m.status.Showf(statusTTLMedium, "Saved to %s", msg.path)
}
return m, nil
case ytdlResolvedMsg:
m.buffering = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
// Update the track with the downloaded local file and metadata.
m.playlist.SetTrack(msg.index, msg.track)
// Play the local file (seekable).
cmd := m.playTrack(msg.track)
m.notifyAll()
return m, cmd
case error:
if errors.Is(msg, playlist.ErrNeedsAuth) {
m.provLoading = false
m.provSignIn = true
m.err = nil
return m, nil
}
m.err = msg
m.provLoading = false
m.feedLoading = false
m.buffering = false
return m, nil
case spotSearchResultsMsg:
m.spotSearch.loading = false
m.spotSearch.cursor = 0
m.spotSearch.scroll = 0
if msg.err != nil {
m.spotSearch.err = msg.err.Error()
return m, nil
}
m.spotSearch.results = msg.tracks
m.spotSearch.cursor = 0
m.spotSearch.screen = spotSearchResults
if len(msg.tracks) == 0 {
m.spotSearch.err = "No results found"
}
return m, nil
case spotPlaylistsMsg:
m.spotSearch.loading = false
m.spotSearch.cursor = 0
m.spotSearch.scroll = 0
if msg.err != nil {
m.spotSearch.err = msg.err.Error()
return m, nil
}
m.spotSearch.playlists = msg.playlists
m.spotSearch.cursor = 0
m.spotSearch.screen = spotSearchPlaylist
return m, nil
case spotAddedMsg:
m.spotSearch.loading = false
if msg.err != nil {
m.spotSearch.err = "Add failed: " + msg.err.Error()
return m, nil
}
m.status.Showf(statusTTLDefault, "Added to %q", msg.name)
m.closeSpotSearch()
return m, nil
case spotCreatedMsg:
m.spotSearch.loading = false
if msg.err != nil {
m.spotSearch.err = "Create failed: " + msg.err.Error()
return m, nil
}
m.status.Showf(statusTTLDefault, "Created %q & added track", msg.name)
m.closeSpotSearch()
return m, nil
case provAuthDoneMsg:
m.provAuthURL = ""
if msg.err != nil {
m.err = msg.err
m.provLoading = false
m.provSignIn = false
return m, nil
}
m.provSignIn = false
m.provLoading = true
return m, fetchPlaylistsCmd(m.provider)
case ProvAuthURLMsg:
m.provAuthURL = msg.URL
return m, nil
case devicesListedMsg:
m.devicePicker.loading = false
if msg.err != nil {
m.status.Showf(statusTTLDefault, "Device list failed: %s", msg.err)
m.devicePicker.visible = false
} else {
m.devicePicker.devices = msg.devices
}
return m, nil
case deviceSwitchedMsg:
if msg.err != nil {
m.status.Showf(statusTTLDefault, "Switch failed: %s", msg.err)
} else {
m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name)
_ = m.configSaver.Save("audio_device", msg.name)
}
// Invalidate cached list so the next open refreshes Active markers.
m.devicePicker.devices = nil
return m, nil
case attachNotifierMsg:
m.attachNotifier(msg.notifier)
return m, nil
case playback.PlayPauseMsg:
cmd := m.togglePlayPause()
m.notifyAll()
return m, cmd
case playback.PlayMsg:
if !m.player.IsPlaying() || m.player.IsPaused() {
cmd := m.togglePlayPause()
m.notifyAll()
return m, cmd
}
return m, nil
case playback.PauseMsg:
if m.player.IsPlaying() && !m.player.IsPaused() {
m.togglePlayerPause()
m.notifyAll()
}
return m, nil
case playback.NextMsg:
m.scrobbleCurrent()
cmd := m.nextTrack()
m.notifyAll()
return m, cmd
case playback.PrevMsg:
m.scrobbleCurrent()
cmd := m.prevTrack()
m.notifyAll()
return m, cmd
case playback.SeekMsg:
return m, m.seekRelative(msg.Offset, 0)
case playback.SetPositionMsg:
return m, m.seekAbsolute(msg.Position)
case playback.SetVolumeMsg:
m.player.SetVolume(msg.VolumeDB)
m.notifyAll()
return m, nil
case playback.StopMsg:
m.player.Stop()
m.clearPlaybackTrack()
m.notifyAll()
return m, nil
case playback.QuitMsg:
m.flushPendingSpeedSave()
m.player.Close()
m.clearPlaybackTrack()
m.quitting = true
return m, tea.Quit
case SetEQPresetMsg:
m.SetEQPreset(msg.Name, msg.Bands)
return m, nil
case PluginQueueMsg:
return m, m.handlePluginQueue(msg)
case pluginQueueAddedMsg:
if len(msg.tracks) > 0 {
m.playlist.Add(msg.tracks...)
m.loadedPlaylist = ""
m.notifyPlayback()
}
return m, nil
case ShowStatusMsg:
ttl := statusTTLDefault
if msg.Duration > 0 {
ttl = statusTTL(msg.Duration)
}
m.status.Show(msg.Text, ttl)
return m, nil
// IPC-specific messages (PlayMsg, PauseMsg have different semantics from toggle).
// Shared types (NextMsg, PrevMsg, StopMsg, PlayPauseMsg) are handled above via
// playback.* types.
case ipc.PlayMsg:
if m.player.IsPaused() {
cmd := m.togglePlayPause()
m.notifyAll()
return m, cmd
}
return m, nil
case ipc.PauseMsg:
if m.player.IsPlaying() && !m.player.IsPaused() {
cmd := m.togglePlayPause()
m.notifyAll()
return m, cmd
}
return m, nil
case ipc.VolumeMsg:
m.player.SetVolume(msg.DB)
m.notifyAll()
return m, nil
case ipc.SeekMsg:
_ = m.player.Seek(msg.Offset)
m.notifyAll()
return m, nil
case ipc.LoadMsg:
tracks, err := m.localProvider.Tracks(msg.Playlist)
if err != nil {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("playlist %q: %v", msg.Playlist, err)}
}
return m, nil
}
m.playlist.Replace(tracks)
m.setHeaderStateFromTracks(tracks)
if msg.Playlist != history.PlaylistName {
m.loadedPlaylist = msg.Playlist
} else {
m.loadedPlaylist = ""
}
cmd := m.playCurrentTrack()
m.notifyAll()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Playlist: msg.Playlist, Total: len(tracks)}
}
return m, cmd
case ipc.QueueMsg:
t := playlist.TrackFromPath(msg.Path)
m.playlist.Add(t)
m.loadedPlaylist = ""
m.addToHeaderState([]playlist.Track{t})
m.notifyAll()
return m, nil
case ipc.ThemeMsg:
// Reload themes from disk to pick up new custom themes.
// Same pattern as openThemePicker() — LoadAll is fast (<1ms for local TOML files).
m.themes = theme.LoadAll()
if m.SetTheme(msg.Name) {
// Persist immediately so the setting survives ungraceful exits.
themeName := msg.Name
if strings.EqualFold(themeName, "default") {
themeName = ""
}
_ = m.configSaver.Save("theme", fmt.Sprintf("%q", themeName))
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true}
}
} else {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("theme %q not found", msg.Name)}
}
}
return m, nil
case ipc.VisMsg:
if m.vis == nil {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: "visualizer not available"}
}
return m, nil
}
var resp ipc.Response
if strings.EqualFold(msg.Name, "next") {
m.vis.CycleMode()
m.vis.RequestRefresh()
m.refreshChrome()
resp = ipc.Response{OK: true, Visualizer: m.vis.ModeName()}
} else if m.SetVisualizer(msg.Name) {
resp = ipc.Response{OK: true, Visualizer: m.vis.ModeName()}
} else {
resp = ipc.Response{OK: false, Error: fmt.Sprintf("visualizer %q not found", msg.Name)}
}
if msg.Reply != nil {
msg.Reply <- resp
}
return m, nil
case ipc.ShuffleMsg:
switch strings.ToLower(msg.Name) {
case "on":
if !m.playlist.Shuffled() {
m.playlist.ToggleShuffle()
}
case "off":
if m.playlist.Shuffled() {
m.playlist.ToggleShuffle()
}
default: // "toggle" or empty
m.playlist.ToggleShuffle()
}
shuffled := m.playlist.Shuffled()
if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", shuffled)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
m.player.ClearPreload()
cmd := m.preloadNext()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Shuffle: &shuffled}
}
return m, cmd
case ipc.RepeatMsg:
switch strings.ToLower(msg.Name) {
case "off":
m.playlist.SetRepeat(playlist.RepeatOff)
case "all":
m.playlist.SetRepeat(playlist.RepeatAll)
case "one":
m.playlist.SetRepeat(playlist.RepeatOne)
default: // "cycle" or empty
m.playlist.CycleRepeat()
}
mode := m.playlist.Repeat()
if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", mode.String())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
m.player.ClearPreload()
cmd := m.preloadNext()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Repeat: mode.String()}
}
return m, cmd
case ipc.MonoMsg:
switch strings.ToLower(msg.Name) {
case "on":
if !m.player.Mono() {
m.player.ToggleMono()
}
case "off":
if m.player.Mono() {
m.player.ToggleMono()
}
default: // "toggle" or empty
m.player.ToggleMono()
}
mono := m.player.Mono()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Mono: &mono}
}
return m, nil
case ipc.SpeedMsg:
m.player.SetSpeed(msg.Speed)
m.saveSpeed()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Speed: m.player.Speed()}
}
return m, nil
case ipc.EQMsg:
if msg.Band > 0 || (msg.Band == 0 && msg.Name == "") {
// Set a specific band (0-9).
m.player.SetEQBand(msg.Band, msg.Value)
m.saveEQ()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, EQPreset: m.EQPresetName()}
}
} else if msg.Name != "" {
// Apply a preset by name.
m.SetEQPreset(msg.Name, nil)
m.saveEQ()
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, EQPreset: m.EQPresetName()}
}
} else {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: "eq requires a preset name or --band"}
}
}
return m, nil
case ipc.DeviceMsg:
if strings.EqualFold(msg.Name, "list") {
devices, err := player.ListAudioDevices()
if err != nil {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("list devices: %v", err)}
}
return m, nil
}
// Encode device list as newline-separated string in the Device field.
var lines []string
for _, d := range devices {
marker := " "
if d.Active {
marker = "* "
}
lines = append(lines, fmt.Sprintf("%s%s", marker, d.Name))
}
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Device: strings.Join(lines, "\n")}
}
return m, nil
}
err := player.SwitchAudioDevice(msg.Name)
if err != nil {
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("switch device: %v", err)}
}
return m, nil
}
_ = m.configSaver.Save("audio_device", msg.Name)
m.status.Showf(statusTTLDefault, "Audio output: %s", msg.Name)
// Invalidate cached list so the next open refreshes Active markers.
m.devicePicker.devices = nil
if msg.Reply != nil {
msg.Reply <- ipc.Response{OK: true, Device: msg.Name}
}
return m, nil
case ipc.StatusRequestMsg:
resp := ipc.Response{OK: true}
switch {
case m.player.IsPlaying() && !m.player.IsPaused():
resp.State = "playing"
case m.player.IsPaused():
resp.State = "paused"
default:
resp.State = "stopped"
}
if cur, _ := m.currentPlaybackTrack(); cur.Path != "" {
resp.Track = &ipc.TrackInfo{
Title: cur.Title,
Artist: cur.Artist,
Path: cur.Path,
}
}
resp.Position = m.player.Position().Seconds()
resp.Duration = m.player.Duration().Seconds()
resp.Volume = m.player.Volume()
resp.Index = m.playlist.Index()
resp.Total = m.playlist.Len()
resp.Visualizer = m.vis.ModeName()
shuffled := m.playlist.Shuffled()
resp.Shuffle = &shuffled
resp.Repeat = m.playlist.Repeat().String()
mono := m.player.Mono()
resp.Mono = &mono
resp.Speed = m.player.Speed()
resp.EQPreset = m.EQPresetName()
if m.themeIdx >= 0 && m.themeIdx < len(m.themes) {
t := m.themes[m.themeIdx]
resp.Theme = &ipc.ThemeInfo{
Name: t.Name,
Accent: t.Accent,
Fg: t.FG,
BrightFg: t.BrightFG,
Green: t.Green,
Yellow: t.Yellow,
Red: t.Red,
}
} else {
resp.Theme = &ipc.ThemeInfo{Name: theme.DefaultName}
}
if msg.Reply != nil {
msg.Reply <- resp
}
return m, nil
case ipc.BandsRequestMsg:
resp := ipc.Response{OK: true}
if m.vis != nil {
resp.Visualizer = m.vis.ModeName()
b := m.vis.SmoothedBands()
if len(b) > 0 {
out := make([]float64, len(b))
copy(out, b)
resp.Bands = out
}
}
if msg.Reply != nil {
msg.Reply <- resp
}
return m, nil
}
return m, nil
}
// restorePanelWidth resets PanelWidth to the correct value based on compact mode.
func (m *Model) restorePanelWidth() {
frameW := m.width
if m.compact {
frameW = min(frameW, 80)
}
ui.PanelWidth = max(0, frameW-2*ui.PaddingH)
}