chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
)
const speedSaveDebounce = time.Second
// SetEQPreset sets the preset by name. If it matches a built-in preset,
// those bands are applied. Otherwise the name is used as a custom label.
// If bands is non-nil, they are applied regardless of whether the name matches.
func (m *Model) SetEQPreset(name string, bands *[10]float64) {
m.eqCustomLabel = ""
// Check built-in presets first.
for i, p := range eqPresets {
if strings.EqualFold(p.Name, name) {
m.eqPresetIdx = i
if bands != nil {
for j, gain := range bands {
m.player.SetEQBand(j, gain)
}
} else {
m.applyEQPreset()
}
return
}
}
// Custom label — set bands if provided, otherwise keep current.
m.eqPresetIdx = -1
m.eqCustomLabel = name
if bands != nil {
for i, gain := range bands {
m.player.SetEQBand(i, gain)
}
}
}
// EQPresetName returns the current preset name, or "Custom".
func (m Model) EQPresetName() string {
if m.eqPresetIdx >= 0 && m.eqPresetIdx < len(eqPresets) {
return eqPresets[m.eqPresetIdx].Name
}
if m.eqCustomLabel != "" {
return m.eqCustomLabel
}
return "Custom"
}
// applyEQPreset writes the current preset's bands to the player.
func (m *Model) applyEQPreset() {
if m.eqPresetIdx < 0 || m.eqPresetIdx >= len(eqPresets) {
return
}
bands := eqPresets[m.eqPresetIdx].Bands
for i, gain := range bands {
m.player.SetEQBand(i, gain)
}
}
// saveEQ persists the current EQ state (preset name and band values) to config.
func (m *Model) saveEQ() {
name := m.EQPresetName()
if err := m.configSaver.Save("eq_preset", fmt.Sprintf("%q", name)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
bands := m.player.EQBands()
parts := make([]string, len(bands))
for i, g := range bands {
parts[i] = strconv.FormatFloat(g, 'f', -1, 64)
}
eqVal := "[" + strings.Join(parts, ", ") + "]"
if err := m.configSaver.Save("eq", eqVal); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
}
// saveSpeed persists the current playback speed to the config file.
func (m *Model) saveSpeed() {
speed := m.player.Speed()
if err := m.configSaver.Save("speed", fmt.Sprintf("%.2f", speed)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
}
func (m *Model) changeSpeed(delta float64) {
m.player.SetSpeed(m.player.Speed() + delta)
m.speedSaveAfter = speedSaveDebounce
}
func (m *Model) tickPendingSpeedSave(dt time.Duration) {
if m.speedSaveAfter <= 0 {
return
}
m.speedSaveAfter -= dt
if m.speedSaveAfter > 0 {
return
}
m.speedSaveAfter = 0
m.saveSpeed()
}
func (m *Model) flushPendingSpeedSave() {
if m.speedSaveAfter <= 0 {
return
}
m.speedSaveAfter = 0
m.saveSpeed()
}
+450
View File
@@ -0,0 +1,450 @@
package model
import (
"context"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/lyrics"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/resolve"
)
// — Message types used by tea.Cmd constructors —
// devicesListedMsg carries the result of listing audio output devices.
type devicesListedMsg struct {
devices []player.AudioDevice
err error
}
// deviceSwitchedMsg signals that an audio device switch attempt completed.
type deviceSwitchedMsg struct {
name string
err error
}
// SetEQPresetMsg is sent by Lua plugins to change the EQ preset by name.
// If Bands is non-nil, the bands are applied and the name becomes a custom label.
type SetEQPresetMsg struct {
Name string
Bands *[10]float64 // nil = use built-in preset bands or keep current
}
// ShowStatusMsg is sent by Lua plugins to display a message in the status bar.
// Duration <= 0 falls back to the default status TTL.
type ShowStatusMsg struct {
Text string
Duration time.Duration
}
type tracksLoadedMsg struct {
tracks []playlist.Track
playlistID string
providerName string
playlistExact bool
}
// feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs,
// along with the original source URLs so downstream handlers can identify
// the source (e.g. YouTube Radio) without re-scanning external state.
type feedsLoadedMsg struct {
tracks []playlist.Track
urls []string // original source URLs that produced these tracks
autoPlay bool // whether to start playback automatically
}
// feedTrackResolvedMsg carries episodes resolved from a feed track in the playlist.
type feedTrackResolvedMsg struct {
tracks []playlist.Track
}
// lyricsLoadedMsg carries parsed LRC output.
type lyricsLoadedMsg struct {
lines []lyrics.Line
err error
}
// netSearchResultsMsg carries the result set of a yt-dlp/sc-dlp search query
// so the UI can present a picker rather than auto-queuing.
type netSearchResultsMsg struct {
tracks []playlist.Track
err error
}
// streamPlayedMsg signals that async stream Play() completed.
type streamPlayedMsg struct{ err error }
// streamPreloadedMsg signals that async stream Preload() completed.
type streamPreloadedMsg struct{}
type attachNotifierMsg struct{ notifier playback.Notifier }
// ytdlResolvedMsg carries a lazily resolved yt-dlp track (direct audio URL).
type ytdlResolvedMsg struct {
index int
track playlist.Track
err error
}
// ytdlBatchMsg carries an incrementally loaded batch of yt-dlp tracks.
// The gen field ties the response to a specific batch session so stale
// responses from a previous or reloaded playlist are discarded.
type ytdlBatchMsg struct {
gen uint64 // batch session generation
tracks []playlist.Track
err error
}
// ytdlSavedMsg signals that an async yt-dlp download-to-disk completed.
type ytdlSavedMsg struct {
path string
err error
}
// — Navidrome browser message types —
// navArtistsLoadedMsg carries the full artist list from a provider browser.
type navArtistsLoadedMsg []provider.ArtistInfo
// navAlbumsLoadedMsg carries one page of albums and the fetch offset.
type navAlbumsLoadedMsg struct {
albums []provider.AlbumInfo
offset int // the offset this page was requested at
isLast bool // true when the server returned fewer than the requested page size
}
// navTracksLoadedMsg carries the track list from a provider.AlbumTrackLoader.
type navTracksLoadedMsg []playlist.Track
// provAuthDoneMsg signals that interactive provider authentication completed.
type provAuthDoneMsg struct{ err error }
// ProvAuthURLMsg carries the OAuth URL produced by a provider's interactive
// auth flow so the TUI can display it. Used as a fallback when the launched
// browser doesn't reach the user (e.g. inside containers or headless envs).
type ProvAuthURLMsg struct{ URL string }
// — Command constructors —
func AttachNotifier(notifier playback.Notifier) tea.Msg {
return attachNotifierMsg{notifier: notifier}
}
func listDevicesCmd() tea.Cmd {
return func() tea.Msg {
devices, err := player.ListAudioDevices()
return devicesListedMsg{devices: devices, err: err}
}
}
func switchDeviceCmd(name string) tea.Cmd {
return func() tea.Msg {
err := player.SwitchAudioDevice(name)
return deviceSwitchedMsg{name: name, err: err}
}
}
// authenticateProviderCmd runs the interactive auth flow for a provider.
func authenticateProviderCmd(auth playlist.Authenticator) tea.Cmd {
return func() tea.Msg {
return provAuthDoneMsg{err: auth.Authenticate()}
}
}
func fetchPlaylistsCmd(prov playlist.Provider) tea.Cmd {
return func() tea.Msg {
pls, err := prov.Playlists()
if err != nil {
return err
}
return pls
}
}
func fetchYTDLBatchCmd(gen uint64, pageURL string, start, count int) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.ResolveYTDLBatch(pageURL, start, count)
return ytdlBatchMsg{gen: gen, tracks: tracks, err: err}
}
}
func resolveFeedTrackCmd(feedURL string) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.Remote([]string{feedURL})
if err != nil {
return err
}
return feedTrackResolvedMsg{tracks: tracks}
}
}
func resolveRemoteCmd(urls []string, autoPlay bool) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.Remote(urls)
if err != nil {
return err
}
return feedsLoadedMsg{tracks: tracks, urls: urls, autoPlay: autoPlay}
}
}
func fetchLyricsCmd(artist, title string) tea.Cmd {
return func() tea.Msg {
lines, err := lyrics.Fetch(artist, title)
return lyricsLoadedMsg{lines: lines, err: err}
}
}
func fetchTrackLyricsCmd(track playlist.Track, artist, title string) tea.Cmd {
return func() tea.Msg {
if lines := lyrics.ParseEmbedded(track.EmbeddedLyrics); len(lines) > 0 {
return lyricsLoadedMsg{lines: lines}
}
lines, err := lyrics.Fetch(artist, title)
return lyricsLoadedMsg{lines: lines, err: err}
}
}
func fetchNetSearchCmd(query string) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.Remote([]string{query})
return netSearchResultsMsg{tracks: tracks, err: err}
}
}
func playStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
return func() tea.Msg {
return streamPlayedMsg{err: p.Play(path, knownDuration)}
}
}
func preloadStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
return func() tea.Msg {
p.Preload(path, knownDuration) // errors silently ignored
return streamPreloadedMsg{}
}
}
func preloadLocalCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
return func() tea.Msg {
p.Preload(path, knownDuration)
return streamPreloadedMsg{}
}
}
func playYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd {
return func() tea.Msg {
return streamPlayedMsg{err: p.PlayYTDL(pageURL, knownDuration)}
}
}
func preloadYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd {
return func() tea.Msg {
p.PreloadYTDL(pageURL, knownDuration) // errors silently ignored
return streamPreloadedMsg{}
}
}
func saveYTDLCmd(pageURL string, saveDir string) tea.Cmd {
return func() tea.Msg {
path, err := resolve.DownloadYTDL(pageURL, saveDir)
return ytdlSavedMsg{path: path, err: err}
}
}
func fetchTracksCmd(prov playlist.Provider, playlistID string) tea.Cmd {
return func() tea.Msg {
tracks, err := prov.Tracks(playlistID)
if err != nil {
return err
}
// Resolve PLS/M3U wrapper URLs to actual stream URLs so the
// player receives a direct audio stream instead of a playlist file.
tracks, expanded := resolveWrapperURLs(tracks)
return tracksLoadedMsg{tracks: tracks, playlistID: playlistID, providerName: prov.Name(), playlistExact: !expanded}
}
}
// resolveWrapperURLs expands any PLS/M3U track paths into the actual stream
// URLs they contain. Non-wrapper tracks are passed through unchanged.
func resolveWrapperURLs(tracks []playlist.Track) ([]playlist.Track, bool) {
var out []playlist.Track
expanded := false
for _, t := range tracks {
if playlist.IsURL(t.Path) && (playlist.IsPLS(t.Path) || playlist.IsM3U(t.Path)) {
resolved, err := resolve.Remote([]string{t.Path})
if err == nil && len(resolved) > 0 {
expanded = true
// Preserve the original title/artist on resolved tracks.
for i := range resolved {
if resolved[i].Title == "" || resolved[i].Title == resolved[i].Path {
resolved[i].Title = t.Title
}
if resolved[i].Artist == "" {
resolved[i].Artist = t.Artist
}
if t.Realtime {
resolved[i].Realtime = true
}
}
out = append(out, resolved...)
continue
}
}
out = append(out, t)
}
return out, expanded
}
const navAlbumPageSize = 100
func fetchNavArtistsCmd(b provider.ArtistBrowser) tea.Cmd {
return func() tea.Msg {
artists, err := b.Artists()
if err != nil {
return err
}
return navArtistsLoadedMsg(artists)
}
}
func fetchNavArtistAlbumsCmd(b provider.ArtistBrowser, artistID string) tea.Cmd {
return func() tea.Msg {
albums, err := b.ArtistAlbums(artistID)
if err != nil {
return err
}
// Artist album lists are complete in one call — treat as last page.
return navAlbumsLoadedMsg{albums: albums, offset: 0, isLast: true}
}
}
func fetchNavAlbumListCmd(b provider.AlbumBrowser, sortType string, offset int) tea.Cmd {
return func() tea.Msg {
albums, err := b.AlbumList(sortType, offset, navAlbumPageSize)
if err != nil {
return err
}
return navAlbumsLoadedMsg{
albums: albums,
offset: offset,
isLast: len(albums) < navAlbumPageSize,
}
}
}
func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string) tea.Cmd {
return func() tea.Msg {
tracks, err := l.AlbumTracks(albumID)
if err != nil {
return err
}
return navTracksLoadedMsg(tracks)
}
}
// catalogSearchMsg carries the result of a provider.CatalogSearcher.SearchCatalog call.
type catalogSearchMsg struct {
count int
err error
}
func fetchCatalogSearchCmd(s provider.CatalogSearcher, query string) tea.Cmd {
return func() tea.Msg {
count, err := s.SearchCatalog(query)
return catalogSearchMsg{count: count, err: err}
}
}
// — Catalog batch loading for providers with lazy catalogs —
// catalogBatchSize is the number of catalog entries to fetch per page.
const catalogBatchSize = 100
// catalogBatchMsg carries the result of a provider.CatalogLoader.LoadCatalogPage call.
type catalogBatchMsg struct {
added int
err error
}
func fetchCatalogBatchCmd(loader provider.CatalogLoader, offset, limit int) tea.Cmd {
return func() tea.Msg {
added, err := loader.LoadCatalogPage(offset, limit)
return catalogBatchMsg{added: added, err: err}
}
}
// — Spotify search + add-to-playlist messages —
type spotSearchResultsMsg struct {
tracks []playlist.Track
err error
}
type spotPlaylistsMsg struct {
playlists []playlist.PlaylistInfo
err error
}
type spotAddedMsg struct {
name string
err error
}
type spotCreatedMsg struct {
name string
err error
}
func fetchSpotSearchCmd(s provider.Searcher, query string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tracks, err := s.SearchTracks(ctx, query, 20)
return spotSearchResultsMsg{tracks: tracks, err: err}
}
}
func fetchSpotPlaylistsCmd(prov playlist.Provider) tea.Cmd {
return func() tea.Msg {
playlists, err := prov.Playlists()
if err == nil && prov.Name() == "Local" {
filtered := playlists[:0]
for _, pl := range playlists {
if pl.Name != history.PlaylistName {
filtered = append(filtered, pl)
}
}
playlists = filtered
}
return spotPlaylistsMsg{playlists: playlists, err: err}
}
}
func addToSpotPlaylistCmd(w provider.PlaylistWriter, playlistID string, track playlist.Track, name string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := w.AddTrackToPlaylist(ctx, playlistID, track)
return spotAddedMsg{name: name, err: err}
}
}
func createSpotPlaylistCmd(c provider.PlaylistCreator, w provider.PlaylistWriter, name string, track playlist.Track) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
id, err := c.CreatePlaylist(ctx, name)
if err != nil {
return spotCreatedMsg{name: name, err: err}
}
err = w.AddTrackToPlaylist(ctx, id, track)
return spotCreatedMsg{name: name, err: err}
}
}
+123
View File
@@ -0,0 +1,123 @@
package model
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
type commandsTestProvider struct {
name string
lists []playlist.PlaylistInfo
}
func (p commandsTestProvider) Name() string { return p.name }
func (p commandsTestProvider) Playlists() ([]playlist.PlaylistInfo, error) {
return append([]playlist.PlaylistInfo(nil), p.lists...), nil
}
func (p commandsTestProvider) Tracks(string) ([]playlist.Track, error) { return nil, nil }
type playlistManagerTestProvider struct {
commandsTestProvider
saveName string
saved []playlist.Track
}
func (p *playlistManagerTestProvider) SavePlaylist(name string, tracks []playlist.Track) error {
p.saveName = name
p.saved = append([]playlist.Track(nil), tracks...)
return nil
}
func TestFetchSpotPlaylistsFiltersHistoryOnlyForLocal(t *testing.T) {
lists := []playlist.PlaylistInfo{
{ID: "recent", Name: history.PlaylistName},
{ID: "mix", Name: "Mix"},
}
msg := fetchSpotPlaylistsCmd(commandsTestProvider{name: "Spotify", lists: lists})().(spotPlaylistsMsg)
if len(msg.playlists) != 2 {
t.Fatalf("Spotify playlists = %d, want 2", len(msg.playlists))
}
msg = fetchSpotPlaylistsCmd(commandsTestProvider{name: "Local", lists: lists})().(spotPlaylistsMsg)
if len(msg.playlists) != 1 || msg.playlists[0].Name != "Mix" {
t.Fatalf("Local playlists = %+v, want only Mix", msg.playlists)
}
}
func TestTracksLoadedMsgMarksOnlyExactLocalPlaylist(t *testing.T) {
player := &playbackFakeEngine{}
m := Model{
player: player,
playlist: playlist.New(),
localProvider: commandsTestProvider{name: "Local"},
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
updated, _ := m.Update(tracksLoadedMsg{
tracks: []playlist.Track{{Path: "/a.mp3", Title: "A"}},
playlistID: "mix",
providerName: "Local",
playlistExact: true,
})
m = updated.(Model)
if m.loadedPlaylist != "mix" {
t.Fatalf("loadedPlaylist = %q, want mix", m.loadedPlaylist)
}
updated, _ = m.Update(tracksLoadedMsg{
tracks: []playlist.Track{{Path: "https://example.com/stream", Title: "Stream", Stream: true}},
playlistID: "mix",
providerName: "Local",
playlistExact: false,
})
m = updated.(Model)
if m.loadedPlaylist != "" {
t.Fatalf("loadedPlaylist = %q, want empty after expanded playlist load", m.loadedPlaylist)
}
}
func TestPlaylistManagerTrackSortUsesLowercaseKey(t *testing.T) {
player := &playbackFakeEngine{}
local := &playlistManagerTestProvider{commandsTestProvider: commandsTestProvider{name: "Local"}}
m := Model{
player: player,
playlist: playlist.New(),
localProvider: local,
provider: local,
providers: []ProviderEntry{
{Key: "spotify", Name: "Spotify", Provider: commandsTestProvider{name: "Spotify"}},
},
vis: ui.NewVisualizer(float64(player.SampleRate())),
plManager: plManagerState{
visible: true,
screen: plMgrScreenTracks,
selPlaylist: "mix",
tracks: []playlist.Track{
{Path: "/b.mp3", Title: "B"},
{Path: "/a.mp3", Title: "A"},
},
},
}
cmd := m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "s"})
if cmd != nil {
t.Fatal("s returned command; want playlist sort only")
}
if !m.plManager.visible {
t.Fatal("playlist manager was closed; want it to stay open")
}
if local.saveName != "mix" {
t.Fatalf("SavePlaylist name = %q, want mix", local.saveName)
}
if len(local.saved) != 2 || local.saved[0].Title != "A" || local.saved[1].Title != "B" {
t.Fatalf("saved tracks = %+v, want sorted by title", local.saved)
}
}
+42
View File
@@ -0,0 +1,42 @@
package model
import "strings"
const eqBandCount = 10
// EQPreset is a named 10-band EQ curve.
type EQPreset struct {
Name string
Bands [eqBandCount]float64
}
// eqPresets is the ordered list of built-in EQ presets.
// Bands: 70Hz, 180Hz, 320Hz, 600Hz, 1kHz, 3kHz, 6kHz, 12kHz, 14kHz, 16kHz
var eqPresets = []EQPreset{
{"Flat", [eqBandCount]float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
{"Rock", [eqBandCount]float64{5, 4, 2, -1, -2, 2, 4, 5, 5, 5}},
{"Pop", [eqBandCount]float64{-1, 2, 4, 5, 4, 1, -1, -1, 1, 2}},
{"Jazz", [eqBandCount]float64{3, 4, 2, 1, -1, -1, 1, 2, 3, 4}},
{"Classical", [eqBandCount]float64{3, 2, 1, 0, -1, -1, 0, 2, 3, 4}},
{"Bass Boost", [eqBandCount]float64{8, 6, 4, 2, 0, 0, 0, 0, 0, 0}},
{"Treble Boost", [eqBandCount]float64{0, 0, 0, 0, 0, 1, 3, 5, 6, 7}},
{"Vocal", [eqBandCount]float64{-2, -1, 1, 4, 5, 4, 2, 0, -1, -2}},
{"Electronic", [eqBandCount]float64{6, 4, 1, -1, -2, 1, 3, 4, 5, 6}},
{"Acoustic", [eqBandCount]float64{3, 3, 2, 0, 1, 2, 3, 3, 2, 1}},
{"Hip-Hop", [eqBandCount]float64{7, 5, 3, 1, -1, -1, 1, 3, 3, 3}},
{"R&B", [eqBandCount]float64{4, 6, 3, 1, -1, 1, 2, 2, 1, 0}},
{"Loudness", [eqBandCount]float64{6, 4, 1, 0, -2, -1, 1, 4, 5, 5}},
{"Late Night", [eqBandCount]float64{5, 3, 1, 0, -2, -1, 0, 2, 3, 3}},
{"Podcast", [eqBandCount]float64{-3, -1, 2, 4, 4, 3, 1, -1, -2, -3}},
{"Small Speakers", [eqBandCount]float64{7, 5, 4, 2, 1, 0, -1, 0, 1, 2}},
}
// EQPresetByName looks up a built-in preset by case-insensitive name.
func EQPresetByName(name string) (EQPreset, bool) {
for _, p := range eqPresets {
if strings.EqualFold(p.Name, name) {
return p, true
}
}
return EQPreset{}, false
}
+514
View File
@@ -0,0 +1,514 @@
package model
import (
"os"
"path/filepath"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/internal/fuzzy"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/resolve"
)
// fbEntry is a single item in the file browser listing.
type fbEntry struct {
name string
path string
isDir bool
isAudio bool
isParent bool
}
// fbTracksResolvedMsg carries tracks resolved from file browser selections.
type fbTracksResolvedMsg struct {
tracks []playlist.Track
replace bool
toPlaylist bool
targetPlaylist string
}
func (m *Model) fbCount() int {
if m.fileBrowser.searching || m.fileBrowser.search != "" {
return len(m.fileBrowser.filtered)
}
return len(m.fileBrowser.entries)
}
func (m *Model) fbEntry(idx int) fbEntry {
if m.fileBrowser.searching || m.fileBrowser.search != "" {
return m.fileBrowser.entries[m.fileBrowser.filtered[idx]]
}
return m.fileBrowser.entries[idx]
}
func (m Model) fbHelpLine() string {
if m.fileBrowser.searching {
return helpKey("Enter", "Confirm ") + helpKey("Esc", "Cancel ") + helpKey("Type", "Filter")
}
help := helpKey("←↓↑→", "Navigate ") + helpKey("Enter", "Open ") + helpKey("/", "Filter ") +
helpKey("Spc", "Select ") + helpKey("a", "All ") +
helpKey("←", "Back ") + helpKey("~.", "Home/Cwd ")
if os.PathSeparator == '\\' {
help += helpKey("AltCZ", "Drive ")
}
if len(m.fileBrowser.selected) > 0 {
if m.fileBrowser.targetPlaylist == "" {
help += helpKey("w", "Playlist ") + helpKey("R", "Replace ")
} else {
help += helpKey("Enter", "Add to "+m.fileBrowser.targetPlaylist+" ")
}
}
help += helpKey("Esc", "Close")
return help
}
// fbVisible returns the file-browser list height. The browser renders inline in
// the playlist region, so it shares the playlist's row budget.
func (m *Model) fbVisible() int {
return m.effectivePlaylistVisible()
}
// fbMaybeAdjustScroll keeps the cursor visible in the current file-browser window.
func (m *Model) fbMaybeAdjustScroll(visible int) {
clampScroll(&m.fileBrowser.cursor, &m.fileBrowser.scroll, m.fbCount(), visible)
}
// openFileBrowser initialises and shows the file browser overlay.
func (m *Model) openFileBrowser() {
if m.fileBrowser.dir == "" {
dir := os.ExpandEnv(m.initialDir)
if strings.HasPrefix(dir, "~") {
if home, _ := os.UserHomeDir(); home != "" {
dir = filepath.Join(home, dir[1:])
}
}
if dir != "" {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
m.fileBrowser.dir = dir
}
}
if m.fileBrowser.dir == "" {
m.fileBrowser.dir, _ = os.UserHomeDir()
if m.fileBrowser.dir == "" {
m.fileBrowser.dir = "/"
}
}
}
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
m.fileBrowser.selected = make(map[string]bool)
m.fileBrowser.err = ""
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
m.fileBrowser.targetPlaylist = ""
m.loadFBDir()
m.fileBrowser.visible = true
}
func (m *Model) openFileBrowserForPlaylist(name string) {
m.openFileBrowser()
m.fileBrowser.targetPlaylist = name
}
// loadFBDir reads the current directory and populates fbEntries.
func (m *Model) loadFBDir() {
m.fileBrowser.err = ""
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
clear(m.fileBrowser.selected)
// Reuse internal memory buffer of m.fileBrowser.entries.
m.fileBrowser.entries = m.fileBrowser.entries[:0]
if cap(m.fileBrowser.entries) > 512 {
// Previous directory list was too large, do not retain memory, re-allocate buffer.
m.fileBrowser.entries = nil
}
m.fileBrowser.entries = append(m.fileBrowser.entries, fbEntry{
name: "..",
path: filepath.Dir(m.fileBrowser.dir),
isDir: true,
isParent: true,
})
// Get entries sorted by name, dirs and files mixed
entries, err := os.ReadDir(m.fileBrowser.dir)
if err != nil {
m.fileBrowser.err = err.Error()
return
}
// Add directories to m.fileBrowser.entries (reuse internal memory),
// add files to files, then append all files to m.fileBrowser.entries, skip dotfiles.
var files []fbEntry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
// Detect directories and directory-like entries.
dirType := "" // Name suffix for directories and some non-regular file types.
if e.IsDir() {
dirType = "/"
} else if !e.Type().IsRegular() {
if e.Type()&os.ModeSymlink != 0 && !player.SupportedExts[strings.ToLower(filepath.Ext(name))] {
// Treat symlink as a directory unless it points to media file.
// os.DirEntry has no option to test the type of object symlink points to.
dirType = "@"
} else if os.PathSeparator == '\\' && e.Type()&os.ModeIrregular != 0 {
// Try to support directory junctions on Windows (mklink /J).
// Go do not support such files, it treats them as os.ModeIrregular (?---------).
dirType = "?"
}
}
// Add entry to m.fileBrowser.entries or to files slice
if dirType != "" {
full := name + dirType
m.fileBrowser.entries = append(m.fileBrowser.entries, fbEntry{
name: full,
path: filepath.Join(m.fileBrowser.dir, name),
isDir: true,
})
} else {
if files == nil {
files = make([]fbEntry, 0, 16) // Avoid reallocations
}
files = append(files, fbEntry{
name: name,
path: filepath.Join(m.fileBrowser.dir, name),
isAudio: player.SupportedExts[strings.ToLower(filepath.Ext(name))],
})
}
}
m.fileBrowser.entries = append(m.fileBrowser.entries, files...)
}
// fbUpdateFilter rebuilds the filtered view from the current search query.
// With no query, entries keep their natural directory order; otherwise matches
// are ranked by fuzzy relevance (best match first). The parent ("..") entry is
// never shown while filtering.
func (m *Model) fbUpdateFilter() {
m.fileBrowser.filtered = nil
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
if m.fileBrowser.search == "" {
for i, e := range m.fileBrowser.entries {
if !e.isParent {
m.fileBrowser.filtered = append(m.fileBrowser.filtered, i)
}
}
return
}
type match struct{ idx, score int }
matches := make([]match, 0, len(m.fileBrowser.entries))
for i, e := range m.fileBrowser.entries {
if e.isParent {
continue
}
if score, ok := fuzzy.Match(m.fileBrowser.search, e.name); ok {
matches = append(matches, match{i, score})
}
}
sort.SliceStable(matches, func(a, b int) bool {
return matches[a].score > matches[b].score
})
for _, mt := range matches {
m.fileBrowser.filtered = append(m.fileBrowser.filtered, mt.idx)
}
}
func (m *Model) handleFileBrowserSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.fileBrowser.visible = false
return m.quit()
case "esc":
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
return nil
case "enter":
m.fileBrowser.searching = false
if m.fileBrowser.search == "" {
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
}
return nil
case "down":
m.fileBrowser.searching = false
if m.fbCount() > 0 {
m.fileBrowser.cursor = 0
m.fbMaybeAdjustScroll(m.fbVisible())
}
return nil
case "backspace":
if m.fileBrowser.search != "" {
m.fileBrowser.search = removeLastRune(m.fileBrowser.search)
m.fbUpdateFilter()
} else {
m.fileBrowser.searching = false
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
}
return nil
case "space":
m.fileBrowser.search += " "
m.fbUpdateFilter()
return nil
}
if len(msg.Text) > 0 {
m.fileBrowser.search += msg.Text
m.fbUpdateFilter()
}
return nil
}
// handleFileBrowserKey processes key presses while the file browser is open.
func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
if m.fileBrowser.searching {
return m.handleFileBrowserSearchKey(msg)
}
var cd string
switch msg.String() {
case "ctrl+c":
m.fileBrowser.visible = false
return m.quit()
case "esc", "o", "q":
m.fileBrowser.visible = false
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
case "ctrl+x":
m.toggleExpandedView()
m.fbMaybeAdjustScroll(m.fbVisible())
case "/":
m.fileBrowser.savedCursor = m.fileBrowser.cursor
m.fileBrowser.savedScroll = m.fileBrowser.scroll
m.fileBrowser.searching = true
m.fileBrowser.search = ""
m.fbUpdateFilter()
return nil
case "up", "k":
if m.fileBrowser.search != "" && m.fileBrowser.cursor == 0 {
m.fileBrowser.searching = true
return nil
}
count := m.fbCount()
if m.fileBrowser.cursor > 0 {
m.fileBrowser.cursor--
} else if count > 0 {
m.fileBrowser.cursor = count - 1
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "down", "j":
count := m.fbCount()
if m.fileBrowser.cursor < count-1 {
m.fileBrowser.cursor++
} else if count > 0 {
m.fileBrowser.cursor = 0
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "pgup", "ctrl+u":
if m.fileBrowser.cursor > 0 {
visible := m.fbVisible()
m.fileBrowser.cursor -= min(m.fileBrowser.cursor, visible)
m.fbMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
count := m.fbCount()
if m.fileBrowser.cursor < count-1 {
visible := m.fbVisible()
m.fileBrowser.cursor = min(count-1, m.fileBrowser.cursor+visible)
m.fbMaybeAdjustScroll(visible)
}
case "enter", "right", "l":
if len(m.fileBrowser.selected) > 0 {
return m.fbConfirm(false)
}
if m.fileBrowser.cursor < m.fbCount() {
e := m.fbEntry(m.fileBrowser.cursor)
if e.isDir {
cd = m.fileBrowser.dir
m.fileBrowser.dir = e.path
m.loadFBDir()
if e.name == ".." {
// cd .. and reveal previous directory name in list
for i := range m.fileBrowser.entries {
if m.fileBrowser.entries[i].path == cd {
m.fileBrowser.cursor = i
break
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
}
} else if e.isAudio {
m.fileBrowser.selected[e.path] = true
return m.fbConfirm(false)
}
}
case "backspace", "left", "h":
cd = m.fileBrowser.dir
m.fileBrowser.dir = filepath.Dir(m.fileBrowser.dir)
m.loadFBDir()
// Reveal previous directory name in list
for i := range m.fileBrowser.entries {
if m.fileBrowser.entries[i].path == cd {
m.fileBrowser.cursor = i
break
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "~":
if cd, _ = os.UserHomeDir(); cd != "" && m.fileBrowser.dir != cd {
m.fileBrowser.dir = cd
m.loadFBDir()
}
case ".":
if cd, _ = os.Getwd(); cd != "" && m.fileBrowser.dir != cd {
m.fileBrowser.dir = cd
m.loadFBDir()
}
case "space":
count := m.fbCount()
if m.fileBrowser.cursor < count {
e := m.fbEntry(m.fileBrowser.cursor)
if !e.isParent && (e.isAudio || e.isDir) {
if m.fileBrowser.selected[e.path] {
delete(m.fileBrowser.selected, e.path)
} else {
m.fileBrowser.selected[e.path] = true
}
}
if m.fileBrowser.cursor < count-1 {
m.fileBrowser.cursor++
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "a":
// Toggle select all audio files in current view.
var selectAll bool
count := m.fbCount()
for i := 0; i < count; i++ {
e := m.fbEntry(i)
// If we found at least one unselected file then all files should be selected:
// set selectAll flag and skip checking selection of remaining files.
if e.isAudio && (selectAll || !m.fileBrowser.selected[e.path]) {
selectAll, m.fileBrowser.selected[e.path] = true, true
}
}
if !selectAll {
// All files in current view selected: clear selection for them.
for i := 0; i < count; i++ {
e := m.fbEntry(i)
if e.isAudio {
delete(m.fileBrowser.selected, e.path)
}
}
}
case "home", "g":
m.fileBrowser.cursor = 0
m.fbMaybeAdjustScroll(m.fbVisible())
case "end", "G":
count := m.fbCount()
if count > 0 {
m.fileBrowser.cursor = count - 1
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "R":
if len(m.fileBrowser.selected) > 0 && m.fileBrowser.targetPlaylist == "" {
return m.fbConfirm(true)
}
case "w":
if len(m.fileBrowser.selected) > 0 && m.fileBrowser.targetPlaylist == "" {
return m.fbConfirmToPlaylist()
}
if m.fileBrowser.cursor < m.fbCount() && m.fileBrowser.targetPlaylist == "" {
e := m.fbEntry(m.fileBrowser.cursor)
if e.isAudio || e.isDir {
m.fileBrowser.selected[e.path] = true
return m.fbConfirmToPlaylist()
}
}
}
// Change drive letter on Windows by pressing alt+[c..z]
if os.PathSeparator == '\\' {
if cd = msg.String(); len(cd) == 5 && strings.HasPrefix(cd, "alt+") && 'c' <= cd[4] && cd[4] <= 'z' {
cd = strings.ToUpper(cd[4:]) + ":\\"
m.fileBrowser.dir = cd
m.loadFBDir()
}
}
return nil
}
// fbConfirm collects selected paths, closes the overlay, and returns an async
// command that resolves the paths into tracks. Paths are emitted in the
// directory listing's natural (alphabetical) order so albums play in track
// order rather than the random map iteration order.
func (m *Model) fbConfirm(replace bool) tea.Cmd {
paths := make([]string, 0, len(m.fileBrowser.selected))
for _, e := range m.fileBrowser.entries {
if m.fileBrowser.selected[e.path] {
paths = append(paths, e.path)
}
}
m.fileBrowser.visible = false
target := m.fileBrowser.targetPlaylist
return func() tea.Msg {
r, err := resolve.Args(paths)
if err != nil {
return err
}
return fbTracksResolvedMsg{tracks: r.Tracks, replace: replace, targetPlaylist: target}
}
}
func (m *Model) fbConfirmToPlaylist() tea.Cmd {
paths := make([]string, 0, len(m.fileBrowser.selected))
for _, e := range m.fileBrowser.entries {
if m.fileBrowser.selected[e.path] {
paths = append(paths, e.path)
}
}
m.fileBrowser.visible = false
return func() tea.Msg {
r, err := resolve.Args(paths)
if err != nil {
return err
}
return fbTracksResolvedMsg{tracks: r.Tracks, toPlaylist: true}
}
}
+204
View File
@@ -0,0 +1,204 @@
package model
import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/luaplugin"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// applyThemeAll updates colors, spectrum styles, and model-specific styles.
func applyThemeAll(t theme.Theme) {
ui.ApplyThemeColors(t)
rebuildModelStyles()
}
// New creates a Model wired to the given player and playlist.
// providers is the ordered list of available providers (Radio, Navidrome, Spotify, Jellyfin, etc.).
// defaultProvider is the config key of the provider to select initially.
// localProv is an optional direct reference to the local provider for write ops.
func New(p player.Engine, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv playlist.Provider, themes []theme.Theme, luaMgr *luaplugin.Manager, cs ConfigSaver) Model {
m := Model{
player: p,
playlist: pl,
configSaver: cs,
vis: ui.NewVisualizer(float64(p.SampleRate())),
seekStepLarge: 30 * time.Second,
plVisible: 5,
eqPresetIdx: -1, // custom until a preset is selected
themes: themes,
themeIdx: -1, // Default (ANSI)
localProvider: localProv,
providers: providers,
navBrowser: navBrowserState{},
luaMgr: luaMgr,
historyStore: history.New(),
showAlbumHeaders: false,
}
if luaMgr != nil {
m.pluginEmit = &pluginEmitState{}
}
m.termTitle = initialTerminalTitleState()
// Select the default provider pill.
for i, pe := range providers {
if pe.Key == defaultProvider {
m.provPillIdx = i
m.provider = pe.Provider
break
}
}
// Fallback: select first available provider.
if m.provider == nil && len(providers) > 0 {
m.provPillIdx = 0
m.provider = providers[0].Provider
}
return m
}
// SetVisVolumeLinked controls whether the visualizer scales samples by the
// current volume before FFT analysis, making bar height follow volume.
func (m *Model) SetVisVolumeLinked(linked bool) {
m.visVolumeLinked = linked
}
// findProviderWith returns the first registered provider that satisfies the
// given capability check. This is used for cross-provider shortcuts like "N"
// (browse) and "F" (search) which should work regardless of the active provider.
func (m *Model) findProviderWith(check func(playlist.Provider) bool) playlist.Provider {
// Prefer the active provider if it matches.
if check(m.provider) {
return m.provider
}
for _, pe := range m.providers {
if pe.Provider != nil && check(pe.Provider) {
return pe.Provider
}
}
return nil
}
// SetAutoPlay makes the player start playback immediately on Init.
func (m *Model) SetAutoPlay(v bool) { m.autoPlay = v }
// SetLowPower lowers UI cadences without affecting normal mode.
func (m *Model) SetLowPower(v bool) { m.lowPower = v }
// SetCompact enables compact mode which caps the frame width at 80 columns.
func (m *Model) SetCompact(v bool) {
m.compact = v
m.refreshChrome()
}
// SetInitialDirectory sets the initial directory for the file browser.
func (m *Model) SetInitialDirectory(dir string) { m.initialDir = dir }
// SetSeekStepLarge configures the Shift+Left/Right seek jump amount.
func (m *Model) SetSeekStepLarge(d time.Duration) {
switch {
case d <= 0:
m.seekStepLarge = 30 * time.Second
case d <= 5*time.Second:
m.seekStepLarge = 6 * time.Second
default:
m.seekStepLarge = d
}
}
// SetTheme finds a theme by name and applies it. Returns true if found.
func (m *Model) SetTheme(name string) bool {
if name == "" || strings.EqualFold(name, "default") {
m.themeIdx = -1
applyThemeAll(theme.Default())
return true
}
for i, t := range m.themes {
if strings.EqualFold(t.Name, name) {
m.themeIdx = i
applyThemeAll(t)
return true
}
}
return false
}
// SetVisualizer sets the visualizer mode by name (case-insensitive).
// Returns true if a valid mode name was recognized. Does not modify state
// if the name is not found, matching the SetTheme guard pattern.
func (m *Model) SetVisualizer(name string) bool {
mode, ok := ui.StringToVisModeExact(name)
if !ok {
return false
}
m.vis.Mode = mode
m.vis.RequestRefresh()
m.refreshChrome()
// Skip the terminal-title intro animation when the visualizer is disabled
// (e.g. low-power mode); the user opted out of visual flair, so the 3-second
// TickFast intro burn (~20 FPS UI rendering) is just wasted CPU.
if mode == ui.VisNone {
m.termTitle.introActive = false
}
return true
}
// VisualizerName returns the current visualizer mode's display name.
func (m *Model) VisualizerName() string {
return m.vis.ModeName()
}
// RegisterLuaVisualizers adds Lua visualizer plugins to the visualizer cycle.
func (m *Model) RegisterLuaVisualizers(names []string, renderer ui.LuaVisRenderer) {
m.vis.RegisterLuaVisualizers(names, renderer)
}
// SetResume registers a path+position to seek to when that track first plays.
func (m *Model) SetResume(path string, secs int) {
m.resume.path = path
m.resume.secs = secs
}
// ResumePlaylist loads a playlist into the model for session resume.
func (m *Model) ResumePlaylist(name string, tracks []playlist.Track) {
m.playlist.Replace(tracks)
m.setHeaderStateFromTracks(tracks)
m.loadedPlaylist = name
}
// ResumeState returns the track path, playback position, and playlist name captured at exit.
// Called after prog.Run() returns (player already closed).
func (m Model) ResumeState() (path string, secs int, playlist string) {
return m.exitResume.path, m.exitResume.secs, m.exitResume.playlist
}
// ThemeName returns the current theme name.
func (m Model) ThemeName() string {
if m.themeIdx < 0 || m.themeIdx >= len(m.themes) {
return theme.DefaultName
}
return m.themes[m.themeIdx].Name
}
// Init starts the tick timer and requests the terminal size.
func (m Model) Init() tea.Cmd {
if m.luaMgr != nil {
m.luaMgr.Emit(luaplugin.EventAppStart, nil)
}
cmds := []tea.Cmd{tickCmd(), func() tea.Msg { return tea.RequestWindowSize() }}
if m.provider != nil {
cmds = append(cmds, fetchPlaylistsCmd(m.provider))
}
if len(m.pendingURLs) > 0 {
cmds = append(cmds, resolveRemoteCmd(m.pendingURLs, m.autoPlay))
}
if m.autoPlay && m.playlist.Len() > 0 {
cmds = append(cmds, func() tea.Msg { return autoPlayMsg{} })
}
return tea.Batch(cmds...)
}
+506
View File
@@ -0,0 +1,506 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/bjarneo/cliamp/lyrics"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// Inline overlays render in the playlist region while the now-playing,
// visualizer, and controls chrome stays live above them. Each overlay supplies
// three pieces, all the same vertical size as the normal playlist chrome so
// opening an overlay never shifts the layout height:
//
// - a header line (via overlayHeaderLine, used by renderPlaylistHeader)
// - a body (via overlayBody, fills effectivePlaylistVisible rows)
// - a help line (via overlayHelpLine, used by renderHelp)
//
// The switches below are ordered to match activeScreen so the header, body, and
// help always describe the same overlay.
// — shared header/body helpers —
// sepHeader renders a labeled separator. The label is embedded before the "─"
// fill, so separatorLine truncates it to the panel width: it never wraps.
func sepHeader(label string) string {
return dimStyle.Render(labeledSeparator("", label))
}
// sepHeaderN appends an "n/total" position counter to a separator label.
func sepHeaderN(label string, pos, total int) string {
if total <= 0 {
return sepHeader(label)
}
return sepHeader(fmt.Sprintf("%s %d/%d", label, pos, total))
}
// promptHeader renders an editable "label: value_" input as the header line,
// truncated to the panel width.
func promptHeader(label, value string) string {
return playlistSelectedStyle.Render(truncate(" "+label+": "+value+"_", ui.PanelWidth))
}
// filterPromptHeader renders a `/` filter input as the header line.
func filterPromptHeader(query string) string {
return playlistSelectedStyle.Render(truncate(" / "+query+"_", ui.PanelWidth))
}
// filterCountHeader renders a `/` filter prompt with a trailing match count,
// kept to one panel-wide row by clipping the query to leave room for the count.
func filterCountHeader(query, count string) string {
maxPrompt := max(1, ui.PanelWidth-len(count)-2)
return playlistSelectedStyle.Render(truncate(" / "+query+"_", maxPrompt)) + dimStyle.Render(" "+count)
}
// windowList renders items[scroll:] into at most budget rows, applying the
// cursor highlight via cursorLine.
func windowList(items []string, cursor, scroll, budget int) string {
if budget <= 0 {
return ""
}
lines := make([]string, 0, budget)
for i := scroll; i < len(items) && len(lines) < budget; i++ {
lines = append(lines, cursorLine(items[i], i == cursor))
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
// bodyLines fits pre-built lines into the budget (truncate + pad to budget).
func bodyLines(lines []string, budget int) string {
if budget <= 0 {
return ""
}
return strings.Join(fitLines(lines, budget), "\n")
}
// bodyMessage renders a single dim message line into the budget.
func bodyMessage(msg string, budget int) string {
return bodyLines([]string{dimStyle.Render(" " + msg)}, budget)
}
// renderTrackRowsBody renders a track list with album-header separators into
// the playlist-region budget, highlighting the row at cursor. Shared by the
// nav browser and playlist manager unfiltered track views.
func (m Model) renderTrackRowsBody(tracks []playlist.Track, cursor, scroll, budget int) string {
lines := make([]string, 0, budget)
for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
if len(lines) >= budget {
break
}
if row.Index < 0 {
lines = append(lines, m.albumSeparator(row.Album, row.Year))
continue
}
i, t := row.Index, row.Track
label := formatTrackRow(i+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs)
lines = append(lines, cursorLine(label, i == cursor))
}
return bodyLines(lines, budget)
}
// — dispatch —
// overlayView bundles the three render pieces of an inline overlay: the header
// line (shown where the playlist header is), the help line, and the body that
// fills the playlist region. The pieces are method expressions (func(*Model)),
// not bound method values, so building an overlayView does not copy the Model
// onto the heap on the render hot path.
type overlayView struct {
header func(*Model) string
help func(*Model) string
body func(*Model) string
}
// activeOverlay returns the render pieces for the active inline overlay, or
// ok=false when no overlay is open (the normal playlist is shown). Describing
// each overlay once here keeps its header, help, and body in sync, and the
// cases are ordered to match activeScreen. renderPlaylistHeader, renderHelp,
// and renderMainBody each call this and invoke the piece they need with &m.
func (m Model) activeOverlay() (overlayView, bool) {
switch {
case m.keymap.visible:
return overlayView{(*Model).keymapHeaderLine, (*Model).keymapHelpLine, (*Model).renderKeymapList}, true
case m.themePicker.visible:
return overlayView{
func(m *Model) string { return sepHeaderN("Themes", m.themePicker.cursor+1, m.themeCount()) },
(*Model).themePickerHelpLine, (*Model).renderThemeBody}, true
case m.visPicker.visible:
return overlayView{
func(m *Model) string { return sepHeaderN("Visualizers", m.visPicker.cursor+1, len(m.visPicker.modes)) },
(*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true
case m.devicePicker.visible:
return overlayView{(*Model).deviceHeaderLine, (*Model).devicePickerHelpLine, (*Model).renderDeviceBody}, true
case m.plPicker.visible:
return overlayView{(*Model).plPickerHeaderLine, (*Model).plPickerHelpLine, (*Model).renderPlaylistPickerBody}, true
case m.fileBrowser.visible:
return overlayView{(*Model).fbHeaderLine, (*Model).fbHelpLine, (*Model).renderFileBrowserBody}, true
case m.navBrowser.visible:
return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true
case m.plManager.visible:
return overlayView{(*Model).plMgrHeaderLine, (*Model).plMgrHelpLine, (*Model).renderPlMgrBody}, true
case m.spotSearch.visible:
return overlayView{(*Model).spotSearchHeaderLine, (*Model).spotSearchHelpLine, (*Model).renderSpotSearchBody}, true
case m.queue.visible:
return overlayView{
func(m *Model) string { return sepHeaderN("Queue", m.queue.cursor+1, m.playlist.QueueLen()) },
(*Model).queueHelpLine, (*Model).renderQueueBody}, true
case m.showInfo:
return overlayView{
func(*Model) string { return sepHeader("Track Info") },
func(*Model) string { return helpKey("Esc", "Close") },
(*Model).renderInfoBody}, true
case m.search.active:
return overlayView{(*Model).searchHeaderLine, (*Model).searchHelpLine, (*Model).renderSearchList}, true
case m.netSearch.active:
return overlayView{(*Model).netSearchHeaderLine, (*Model).netSearchHelpLine, (*Model).renderNetSearchBody}, true
case m.urlInputting:
return overlayView{
func(m *Model) string { return promptHeader("Load URL", m.urlInput) },
func(*Model) string { return helpKey("Enter", "Load ") + helpKey("Esc", "Cancel") },
(*Model).renderURLBody}, true
case m.lyrics.visible:
return overlayView{
func(*Model) string { return sepHeader("Lyrics") },
(*Model).lyricsHelpLine, (*Model).renderLyricsBody}, true
case m.jumping:
return overlayView{
func(*Model) string { return sepHeader("Jump to Time") },
func(*Model) string { return helpKey("Enter", "Jump ") + helpKey("Esc", "Cancel") },
(*Model).renderJumpBody}, true
}
return overlayView{}, false
}
// renderMainBody returns the active overlay's body, or the playlist when no
// overlay is open.
func (m Model) renderMainBody() string {
if ov, ok := m.activeOverlay(); ok {
return ov.body(&m)
}
return m.renderPlaylist()
}
// — search —
func (m Model) searchHeaderLine() string {
return filterCountHeader(m.search.query, m.formatListMatchCount(len(m.search.results), m.playlist.Len()))
}
// — theme picker —
func (m Model) themeCount() int { return len(m.themes) + 1 }
func (m Model) renderThemeBody() string {
budget := m.effectivePlaylistVisible()
items := make([]string, 0, m.themeCount())
items = append(items, theme.DefaultName)
for _, t := range m.themes {
items = append(items, t.Name)
}
return windowList(items, m.themePicker.cursor, m.themePicker.scroll, budget)
}
// — device picker —
func (m Model) deviceHeaderLine() string {
if m.devicePicker.loading {
return sepHeader("Audio Devices")
}
return sepHeaderN("Audio Devices", m.devicePicker.cursor+1, len(m.devicePicker.devices))
}
func (m Model) renderDeviceBody() string {
budget := m.effectivePlaylistVisible()
if m.devicePicker.loading {
return bodyLines([]string{loadingLine("Loading devices…")}, budget)
}
if len(m.devicePicker.devices) == 0 {
return bodyMessage("No audio output devices found.", budget)
}
items := make([]string, len(m.devicePicker.devices))
for i, d := range m.devicePicker.devices {
label := d.Description
if label == "" {
label = d.Name
}
if d.Active {
label += " " + activeToggle.Render("●")
}
items[i] = label
}
return windowList(items, m.devicePicker.cursor, m.devicePicker.scroll, budget)
}
// — queue —
func (m Model) renderQueueBody() string {
budget := m.effectivePlaylistVisible()
tracks := m.playlist.QueueTracks()
if len(tracks) == 0 {
return bodyMessage("(empty)", budget)
}
items := make([]string, len(tracks))
for i, t := range tracks {
items[i] = fmt.Sprintf("%d. %s", i+1, truncate(t.DisplayName(), ui.PanelWidth-8))
}
return windowList(items, m.queue.cursor, m.queue.scroll, budget)
}
// — track info —
func (m Model) renderInfoBody() string {
budget := m.effectivePlaylistVisible()
track, _ := m.currentPlaybackTrack()
var lines []string
field := func(label, value string) {
if value != "" {
lines = append(lines, dimStyle.Render(" "+label+": ")+trackStyle.Render(value))
}
}
field("Title", track.Title)
field("Artist", track.Artist)
field("Album", track.Album)
field("Genre", track.Genre)
if track.Year != 0 {
field("Year", fmt.Sprintf("%d", track.Year))
}
if track.TrackNumber != 0 {
field("Track", fmt.Sprintf("%d", track.TrackNumber))
}
field("Path", track.Path)
if len(lines) == 0 {
lines = append(lines, dimStyle.Render(" No track metadata available."))
}
return bodyLines(lines, budget)
}
// — URL input —
func (m Model) renderURLBody() string {
budget := m.effectivePlaylistVisible()
return bodyMessage("Paste a stream, track, or playlist URL above.", budget)
}
// — jump to time —
func (m Model) renderJumpBody() string {
budget := m.effectivePlaylistVisible()
pos := m.player.Position()
dur := m.player.Duration()
inputLine := dimStyle.Faint(true).Render(" " + formatJumpPlaceholder(dur))
if m.jumpInput != "" {
inputLine = playlistSelectedStyle.Render(" " + m.jumpInput + "_")
}
return bodyLines([]string{
dimStyle.Render(fmt.Sprintf(" %s / %s", formatJumpClock(pos), formatJumpClock(dur))),
"",
inputLine,
}, budget)
}
// — lyrics —
func (m Model) lyricsHelpLine() string {
if m.lyricsSyncable() && m.lyricsHaveTimestamps() {
return helpKey("Esc", "Close")
}
return helpKey("↓↑", "Scroll ") + helpKey("Esc", "Close")
}
func (m Model) renderLyricsBody() string {
visible := m.effectivePlaylistVisible()
if visible <= 0 {
return ""
}
var lines []string
switch {
case m.lyrics.loading:
lines = append(lines, dimStyle.Render(" Searching for lyrics..."))
case m.lyrics.err != nil:
if errors.Is(m.lyrics.err, lyrics.ErrNotFound) {
lines = append(lines, dimStyle.Render(" No lyrics found for this track."))
} else {
lines = append(lines, helpStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error()))
}
case len(m.lyrics.lines) == 0:
artist, title := m.lyricsArtistTitle()
if artist == "" && title == "" {
lines = append(lines, dimStyle.Render(" No artist/title metadata available."))
if track, idx := m.currentPlaybackTrack(); idx >= 0 && track.Stream {
lines = append(lines, dimStyle.Render(" Waiting for stream metadata..."))
}
} else {
lines = append(lines, dimStyle.Render(" No lyrics loaded. Press y to retry."))
}
case m.lyricsSyncable() && m.lyricsHaveTimestamps():
pos := m.player.Position()
activeIdx := -1
for i, line := range m.lyrics.lines {
if line.Start <= pos {
activeIdx = i
} else {
break
}
}
half := visible / 2
startIdx := max(activeIdx-half, 0)
endIdx := startIdx + visible
if endIdx > len(m.lyrics.lines) {
endIdx = len(m.lyrics.lines)
startIdx = max(endIdx-visible, 0)
}
for i := startIdx; i < endIdx; i++ {
text := m.lyrics.lines[i].Text
if text == "" {
text = "♪"
}
if i == activeIdx {
lines = append(lines, playlistSelectedStyle.Render(" "+text))
} else {
lines = append(lines, dimStyle.Render(" "+text))
}
}
default:
endIdx := min(m.lyrics.scroll+visible, len(m.lyrics.lines))
for i := m.lyrics.scroll; i < endIdx; i++ {
text := m.lyrics.lines[i].Text
if text == "" {
text = "♪"
}
lines = append(lines, dimStyle.Render(" "+text))
}
}
return bodyLines(lines, visible)
}
// — online (net) search —
func (m Model) netSearchSource() string {
if m.netSearch.soundcloud {
return "SoundCloud"
}
return "YouTube"
}
func (m Model) netSearchHeaderLine() string {
if m.netSearch.screen == netSearchResults {
return sepHeaderN("Online Results", m.netSearch.cursor+1, len(m.netSearch.results))
}
return promptHeader(m.netSearchSource()+" search", m.netSearch.query)
}
func (m Model) netSearchHelpLine() string {
if m.netSearch.screen == netSearchResults {
return m.netSearchResultsHelpLine()
}
return helpKey("Enter", "Search ") + helpKey("Esc", "Cancel")
}
func (m Model) renderNetSearchBody() string {
budget := m.effectivePlaylistVisible()
if m.netSearch.screen == netSearchInput {
var lines []string
if m.netSearch.loading {
lines = append(lines, dimStyle.Render(" Searching "+m.netSearchSource()+"..."))
} else {
lines = append(lines, dimStyle.Render(" Type a query and press Enter to search "+m.netSearchSource()+"."))
}
if m.netSearch.err != "" {
lines = append(lines, "", helpStyle.Render(" "+m.netSearch.err))
}
return bodyLines(lines, budget)
}
if len(m.netSearch.results) == 0 {
return bodyMessage("No results", budget)
}
items := make([]string, len(m.netSearch.results))
for i, t := range m.netSearch.results {
items[i] = truncate(t.DisplayName(), ui.PanelWidth-8)
}
return windowList(items, m.netSearch.cursor, m.netSearch.scroll, budget)
}
// — provider (Spotify) search —
func (m Model) spotSearchHeaderLine() string {
switch m.spotSearch.screen {
case spotSearchResults:
return sepHeaderN("Results", m.spotSearch.cursor+1, len(m.spotSearch.results))
case spotSearchPlaylist:
return sepHeaderN("Add to Playlist", m.spotSearch.cursor+1, len(m.spotSearch.playlists)+1)
case spotSearchNewName:
return promptHeader("New Playlist", m.spotSearch.newName)
default:
return promptHeader("Search", m.spotSearch.query)
}
}
func (m Model) spotSearchHelpLine() string {
switch m.spotSearch.screen {
case spotSearchResults:
return m.spotSearchResultsHelpLine()
case spotSearchPlaylist:
return m.spotSearchPlaylistHelpLine()
case spotSearchNewName:
return helpKey("Enter", "Create & add ") + helpKey("Esc", "Cancel")
default:
return helpKey("Enter", "Search ") + helpKey("Esc", "Cancel")
}
}
func (m Model) renderSpotSearchBody() string {
budget := m.effectivePlaylistVisible()
var body string
switch m.spotSearch.screen {
case spotSearchResults:
if len(m.spotSearch.results) == 0 {
body = bodyMessage("No results", budget)
} else {
items := make([]string, len(m.spotSearch.results))
for i, t := range m.spotSearch.results {
items[i] = truncate(fmt.Sprintf("%s - %s", t.Artist, t.Title), ui.PanelWidth-8)
}
body = windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, budget)
}
case spotSearchPlaylist:
if m.spotSearch.loading {
body = bodyLines([]string{loadingLine("Loading playlists…")}, budget)
break
}
track := m.spotSearch.selTrack
head := dimStyle.Render(" " + truncate(fmt.Sprintf("%s - %s", track.Artist, track.Title), ui.PanelWidth-2))
count := len(m.spotSearch.playlists) + 1
items := make([]string, count)
for i := range count {
if i < len(m.spotSearch.playlists) {
items[i] = m.spotSearch.playlists[i].Name
} else {
items[i] = "+ New Playlist..."
}
}
list := windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, max(0, budget-1))
body = strings.Join([]string{head, list}, "\n")
case spotSearchNewName:
body = bodyMessage("Enter a name for the new playlist above.", budget)
default:
var lines []string
if m.spotSearch.loading {
lines = append(lines, dimStyle.Render(" Searching..."))
} else {
lines = append(lines, dimStyle.Render(" Type a query and press Enter to search."))
}
body = bodyLines(lines, budget)
}
if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist {
return strings.Join([]string{body, helpStyle.Render(" " + m.spotSearch.err)}, "\n")
}
return body
}
+229
View File
@@ -0,0 +1,229 @@
package model
import (
"fmt"
"strings"
"github.com/bjarneo/cliamp/ui"
)
// — provider browser (nav) —
type navViewKind int
const (
navViewMenu navViewKind = iota
navViewArtists
navViewAlbums
navViewTracks
)
// navView collapses the nav browser's mode + screen into the list actually
// shown, so the header, help, and body all agree.
func (m Model) navView() navViewKind {
switch m.navBrowser.mode {
case navBrowseModeByAlbum:
if m.navBrowser.screen == navBrowseScreenTracks {
return navViewTracks
}
return navViewAlbums
case navBrowseModeByArtist:
if m.navBrowser.screen == navBrowseScreenTracks {
return navViewTracks
}
return navViewArtists
case navBrowseModeByArtistAlbum:
switch m.navBrowser.screen {
case navBrowseScreenAlbums:
return navViewAlbums
case navBrowseScreenTracks:
return navViewTracks
default:
return navViewArtists
}
default:
return navViewMenu
}
}
func (m Model) navTrackBreadcrumb() string {
switch m.navBrowser.mode {
case navBrowseModeByArtist:
return "Artist: " + m.navBrowser.selArtist.Name
case navBrowseModeByAlbum:
return "Album: " + m.navBrowser.selAlbum.Name
case navBrowseModeByArtistAlbum:
return m.navBrowser.selArtist.Name + " / " + m.navBrowser.selAlbum.Name
}
return "Tracks"
}
func (m Model) navHeaderLine() string {
if m.navBrowser.searching {
return filterPromptHeader(m.navBrowser.search)
}
switch m.navView() {
case navViewArtists:
return sepHeaderN("Artists", m.navBrowser.cursor+1, len(m.navBrowser.artists))
case navViewAlbums:
label := "Albums"
if m.navBrowser.mode == navBrowseModeByArtistAlbum {
label = "Albums: " + m.navBrowser.selArtist.Name
} else if s := m.navSortLabel(m.navBrowser.sortType); s != "" {
label += " Sort: " + s
}
return sepHeaderN(label, m.navBrowser.cursor+1, len(m.navBrowser.albums))
case navViewTracks:
return sepHeaderN(m.navTrackBreadcrumb(), m.navBrowser.cursor+1, len(m.navBrowser.tracks))
default:
name := "Browse"
if m.navBrowser.prov != nil {
name = m.navBrowser.prov.Name()
}
return sepHeader(name)
}
}
func (m Model) navHelpLine() string {
if m.navBrowser.searching {
return helpKey("Enter", "Confirm ") + helpKey("Esc", "Cancel ") + helpKey("Type", "Filter")
}
switch m.navView() {
case navViewArtists:
return helpKey("←↓↑→", "Navigate ") + helpKey("Enter", "Open ") + helpKey("/", "Search")
case navViewAlbums:
h := helpKey("←↓↑→", "Navigate ") + helpKey("Enter", "Open ")
if m.navBrowser.mode == navBrowseModeByAlbum {
h += helpKey("s", "Sort ")
}
return h + helpKey("/", "Search")
case navViewTracks:
return helpKey("←↓↑→", "Navigate ") + helpKey("Enter", "Play from here ") +
helpKey("q", "Queue ") + helpKey("R", "Replace ") + helpKey("a", "Append ") + helpKey("/", "Search")
default:
return helpKey("↓↑", "Scroll ") + helpKey("Enter", "Select ") + helpKey("Esc", "Close")
}
}
func (m Model) renderNavBody() string {
budget := m.effectivePlaylistVisible()
switch m.navView() {
case navViewArtists:
if m.navBrowser.loading && len(m.navBrowser.artists) == 0 {
return bodyLines([]string{loadingLine("Loading artists…")}, budget)
}
if len(m.navBrowser.artists) == 0 {
return bodyMessage("No artists found.", budget)
}
items := m.navScrollItems(len(m.navBrowser.artists), func(i int) string {
a := m.navBrowser.artists[i]
return truncate(fmt.Sprintf("%s (%d albums)", a.Name, a.AlbumCount), ui.PanelWidth-6)
})
return strings.Join(items, "\n")
case navViewAlbums:
if m.navBrowser.loading && len(m.navBrowser.albums) == 0 {
return bodyLines([]string{loadingLine("Loading albums…")}, budget)
}
if len(m.navBrowser.albums) == 0 {
return bodyMessage("No albums found.", budget)
}
items := m.navScrollItems(len(m.navBrowser.albums), func(i int) string {
a := m.navBrowser.albums[i]
if a.Year > 0 {
return truncate(fmt.Sprintf("%s — %s (%d)", a.Name, a.Artist, a.Year), ui.PanelWidth-6)
}
return truncate(fmt.Sprintf("%s — %s", a.Name, a.Artist), ui.PanelWidth-6)
})
return strings.Join(items, "\n")
case navViewTracks:
return m.renderNavTrackBody(budget)
default:
items := []string{"By Album", "By Artist", "By Artist / Album"}
return windowList(items, m.navBrowser.cursor, 0, budget)
}
}
func (m Model) renderNavTrackBody(budget int) string {
if m.navBrowser.loading && len(m.navBrowser.tracks) == 0 {
return bodyLines([]string{loadingLine("Loading tracks…")}, budget)
}
if len(m.navBrowser.tracks) == 0 {
return bodyMessage("No tracks found.", budget)
}
if len(m.navBrowser.searchIdx) > 0 || m.navBrowser.search != "" {
items := m.navScrollItems(len(m.navBrowser.tracks), func(i int) string {
t := m.navBrowser.tracks[i]
return formatTrackRow(i+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs)
})
return strings.Join(items, "\n")
}
return m.renderTrackRowsBody(m.navBrowser.tracks, m.navBrowser.cursor, m.navBrowser.scroll, budget)
}
// — file browser —
func (m Model) fbHeaderLine() string {
if m.fileBrowser.searching {
return filterPromptHeader(m.fileBrowser.search)
}
label := "Files: " + m.fileBrowser.dir
if n := len(m.fileBrowser.selected); n > 0 {
label += fmt.Sprintf(" [%d selected]", n)
}
return sepHeader(label)
}
func (m Model) renderFileBrowserBody() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
var lines []string
if m.fileBrowser.err != "" {
lines = append(lines, errorStyle.Render(" "+m.fileBrowser.err))
}
count := m.fbCount()
if count == 0 {
if m.fileBrowser.search != "" {
lines = append(lines, dimStyle.Render(" No matches"))
} else {
lines = append(lines, dimStyle.Render(" (empty)"))
}
return bodyLines(lines, budget)
}
scroll := max(m.fileBrowser.scroll, 0)
if scroll > count-1 {
scroll = max(0, count-1)
}
for i := scroll; i < count && len(lines) < budget; i++ {
e := m.fbEntry(i)
check := " "
if m.fileBrowser.selected[e.path] {
check = "✓ "
}
suffix := ""
if e.isAudio {
suffix = " ♫"
}
label := truncate(check+e.name+suffix, max(1, ui.PanelWidth-2))
switch {
case m.fileBrowser.searching:
lines = append(lines, dimStyle.Render(" "+label))
case i == m.fileBrowser.cursor:
lines = append(lines, playlistSelectedStyle.Render("> "+label))
case e.isDir:
lines = append(lines, trackStyle.Render(" "+label))
case e.isAudio:
lines = append(lines, playlistItemStyle.Render(" "+label))
default:
lines = append(lines, dimStyle.Render(" "+label))
}
}
return bodyLines(lines, budget)
}
+152
View File
@@ -0,0 +1,152 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
)
func parseJumpTarget(raw string) (time.Duration, error) {
s := strings.TrimSpace(raw)
if s == "" {
return 0, fmt.Errorf("use ss, mm:ss, or hh:mm:ss format")
}
parts := strings.Split(s, ":")
switch len(parts) {
case 1:
secs, err := parseTotalSeconds(parts[0])
if err != nil {
return 0, err
}
return time.Duration(secs) * time.Second, nil
case 2:
minPart := normalizeClockField(parts[0])
secPart := normalizeClockField(parts[1])
mins, err := parseTotalMinutes(minPart)
if err != nil {
return 0, err
}
secs, err := parseClockSeconds(secPart)
if err != nil {
return 0, err
}
return time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second, nil
case 3:
hourPart := normalizeClockField(parts[0])
minPart := normalizeClockField(parts[1])
secPart := normalizeClockField(parts[2])
hours, err := parseTotalHours(hourPart)
if err != nil {
return 0, err
}
mins, err := parseClockMinutes(minPart)
if err != nil {
return 0, err
}
secs, err := parseClockSeconds(secPart)
if err != nil {
return 0, err
}
return time.Duration(hours)*time.Hour +
time.Duration(mins)*time.Minute +
time.Duration(secs)*time.Second, nil
default:
return 0, fmt.Errorf("use ss, mm:ss, or hh:mm:ss format")
}
}
func normalizeClockField(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "0"
}
return s
}
func parseTotalSeconds(s string) (int, error) {
secs, err := parseNonNegativeInt(strings.TrimSpace(s), "seconds")
if err != nil {
return 0, err
}
return secs, nil
}
func parseTotalMinutes(s string) (int, error) {
mins, err := parseNonNegativeInt(s, "minutes")
if err != nil {
return 0, err
}
return mins, nil
}
func parseTotalHours(s string) (int, error) {
hours, err := parseNonNegativeInt(s, "hours")
if err != nil {
return 0, err
}
return hours, nil
}
func parseClockMinutes(s string) (int, error) {
if len(s) > 2 {
return 0, fmt.Errorf("minutes must be 0-59")
}
mins, err := parseNonNegativeInt(s, "minutes")
if err != nil {
return 0, err
}
if mins > 59 {
return 0, fmt.Errorf("minutes must be 0-59")
}
return mins, nil
}
func parseClockSeconds(s string) (int, error) {
if len(s) > 2 {
return 0, fmt.Errorf("seconds must be 0-59")
}
secs, err := parseNonNegativeInt(s, "seconds")
if err != nil {
return 0, err
}
if secs > 59 {
return 0, fmt.Errorf("seconds must be 0-59")
}
return secs, nil
}
func parseNonNegativeInt(s, label string) (int, error) {
if s == "" {
return 0, fmt.Errorf("%s must be a number", label)
}
for _, r := range s {
if r < '0' || r > '9' {
return 0, fmt.Errorf("%s must be a number", label)
}
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%s must be a number", label)
}
return v, nil
}
func formatJumpClock(d time.Duration) string {
if d < 0 {
d = 0
}
total := int(d.Seconds())
mm := total / 60
ss := total % 60
return fmt.Sprintf("%02d:%02d", mm, ss)
}
func formatJumpPlaceholder(d time.Duration) string {
return "00:00"
}
+98
View File
@@ -0,0 +1,98 @@
package model
import (
"testing"
"time"
)
func TestParseJumpTarget(t *testing.T) {
tests := []struct {
name string
in string
want time.Duration
wantErr bool
}{
// Expected success cases.
{name: "seconds only", in: "10", want: 10 * time.Second},
{name: "missing minutes accepted", in: ":49", want: 49 * time.Second},
{name: "minutes seconds", in: "58:05", want: 58*time.Minute + 5*time.Second},
{name: "minutes one-digit seconds", in: "58:6", want: 58*time.Minute + 6*time.Second},
{name: "minutes trailing colon", in: "58:", want: 58 * time.Minute},
{name: "spaces trimmed", in: " 12:3 ", want: 12*time.Minute + 3*time.Second},
{name: "hours minutes seconds", in: "1:02:03", want: time.Hour + 2*time.Minute + 3*time.Second},
{name: "hours one-digit parts", in: "2:3:4", want: 2*time.Hour + 3*time.Minute + 4*time.Second},
{name: "hours missing minutes accepted", in: "1::03", want: time.Hour + 3*time.Second},
{name: "hours trailing colon accepted", in: "1:02:", want: time.Hour + 2*time.Minute},
// Expected failure cases.
{name: "empty", in: "", wantErr: true},
{name: "not number", in: "abc", wantErr: true},
{name: "bad minutes", in: "x:05", wantErr: true},
{name: "bad seconds", in: "10:x", wantErr: true},
{name: "hours bad minutes", in: "1:60:00", wantErr: true},
{name: "hours bad seconds", in: "1:00:60", wantErr: true},
{name: "hours non-numeric", in: "x:02:03", wantErr: true},
{name: "hours minutes too many digits", in: "1:123:03", wantErr: true},
{name: "seconds too large", in: "10:60", wantErr: true},
{name: "high minute high second", in: "99:99", wantErr: true},
{name: "too many colons", in: "1:2:3:4", wantErr: true},
{name: "too many second digits", in: "10:123", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseJumpTarget(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %v want %v", got, tt.want)
}
})
}
}
func TestFormatJumpClock(t *testing.T) {
tests := []struct {
in time.Duration
want string
}{
{in: 0, want: "00:00"},
{in: 10 * time.Second, want: "00:10"},
{in: 58*time.Minute + 5*time.Second, want: "58:05"},
{in: 1 * time.Hour, want: "60:00"},
{in: 75*time.Minute + 48*time.Second, want: "75:48"},
}
for _, tt := range tests {
if got := formatJumpClock(tt.in); got != tt.want {
t.Fatalf("formatJumpClock(%v) = %q want %q", tt.in, got, tt.want)
}
}
}
func TestFormatJumpPlaceholder(t *testing.T) {
tests := []struct {
in time.Duration
want string
}{
{in: -1 * time.Second, want: "00:00"},
{in: 0, want: "00:00"},
{in: 59 * time.Second, want: "00:00"},
{in: 1 * time.Minute, want: "00:00"},
{in: 59*time.Minute + 59*time.Second, want: "00:00"},
{in: 1 * time.Hour, want: "00:00"},
}
for _, tt := range tests {
if got := formatJumpPlaceholder(tt.in); got != tt.want {
t.Fatalf("formatJumpPlaceholder(%v) = %q want %q", tt.in, got, tt.want)
}
}
}
+411
View File
@@ -0,0 +1,411 @@
package model
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
)
// keymapEntry is a row in the Ctrl+K overlay. Rows with `divider = true` are
// unselectable section headers (e.g. "— plugins —").
type keymapEntry struct {
key, action string
divider bool
}
// keymapEntries is the full list of keybindings shown in the keymap overlay.
var keymapEntries = []keymapEntry{
{key: "Space", action: "Play / Pause"},
{key: "s", action: "Stop"},
{key: "> .", action: "Next track"},
{key: "< ,", action: "Previous track"},
{key: "← →", action: "Seek ±5s"},
{key: "Shift+← →", action: "Seek ±large step"},
{key: "Nj", action: "Seek to N×10% of track (e.g. 7j = 70%)"},
{key: "+ -", action: "Volume up/down"},
{key: "] [", action: "Speed up/down (±0.25x)"},
{key: "z", action: "Toggle shuffle"},
{key: "r", action: "Cycle repeat"},
{key: "m", action: "Toggle mono"},
{key: "e", action: "Cycle EQ preset"},
{key: "t", action: "Choose theme"},
{key: "v", action: "Cycle visualizer"},
{key: "Ctrl+V", action: "Choose visualizer"},
{key: "V", action: "Full-screen visualizer"},
{key: "↑ ↓", action: "Playlist scroll / EQ adjust (wraps around)"},
{key: "PgUp PgDn / Ctrl+U D", action: "Scroll playlist/browser by page"},
{key: "Home End / g G", action: "Go to top/end of playlist/browser"},
{key: "Shift+↑ ↓", action: "Move track up/down"},
{key: "h l", action: "EQ cursor left/right"},
{key: "Enter", action: "Play selected track"},
{key: "a", action: "Toggle queue (play next)"},
{key: "A", action: "Queue manager"},
{key: "x", action: "Remove selected track from playlist"},
{key: "w", action: "Write selected track/selection to playlist"},
{key: "o", action: "Open file browser"},
{key: "N", action: "Navidrome browser"},
{key: "L", action: "Browse local playlists"},
{key: "R", action: "Open radio provider"},
{key: "S", action: "Open Spotify provider"},
{key: "P", action: "Open Plex provider"},
{key: "Y", action: "Open YouTube provider"},
{key: "C", action: "Open SoundCloud provider"},
{key: "M", action: "Open NetEase provider"},
{key: "J", action: "Open Jellyfin provider"},
{key: "E", action: "Open Emby provider"},
{key: "Q", action: "Open Qobuz provider"},
{key: "Ctrl+J", action: "Jump to time"},
{key: "p", action: "Playlist manager"},
{key: "Ctrl+H", action: "Toggle album headers"},
{key: "i", action: "Track info / metadata"},
{key: "Ctrl+S", action: "Save/download track to ~/Music"},
{key: "Ctrl+X", action: "Expand/collapse view"},
{key: "/", action: "Filter/search list"},
{key: "f", action: "Toggle bookmark ★ (or favorite station in radio)"},
{key: "Ctrl+F", action: "Search (active provider or YouTube)"},
{key: "u", action: "Load URL (stream/playlist)"},
{key: "d", action: "Audio device picker"},
{key: "y", action: "Show lyrics"},
{key: "Tab", action: "Toggle focus"},
{key: "Esc", action: "Back to provider"},
{key: "? Ctrl+K", action: "This keymap"},
{key: "q", action: "Quit"},
}
// coreReservedKeys is the set of keys owned by cliamp's global UI handler.
// Plugins are refused registration for any key in this set. Kept as a plain
// slice so it's obvious at a glance which strings belong here; every entry
// is in Bubbletea's `msg.String()` form (lowercase, ctrl+ prefix, etc.).
//
// This must be kept in sync with handleKey() in keys.go. If you add or remove
// a case there, update this list — the TestReservedKeys test in keymap_test.go
// guards against drift.
var coreReservedKeys = []string{
// Global quit / escape.
"q", "ctrl+c", "esc", "backspace", "b",
// Playback.
"space", "s", ">", ".", "<", ",",
"left", "right", "shift+left", "shift+right",
"+", "=", "-", "]", "[",
"f",
// Percentage seek primes on digits 0-9 and consumes the following `j`.
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "j",
// Navigation and focus.
"up", "k", "down", "ctrl+n", "ctrl+p",
"shift+up", "shift+down",
"pgup", "pgdown", "ctrl+u", "ctrl+d",
"g", "G", "home", "end",
"enter", "tab", "h", "l",
// Features.
"r", "z", "m", "e", "a", "A", "ctrl+h",
"ctrl+s", "S", "/", "ctrl+f", "w",
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
"N", "L", "R", "P", "Y", "C", "M", "Q",
"v", "V", "ctrl+v", "ctrl+x", "x", "d", "ctrl+k", "?",
"ctrl+r",
}
// ReservedKeys returns a fresh copy of the core-reserved key set. Handed to
// the Lua plugin manager at startup so it can reject conflicting plugin binds.
func ReservedKeys() map[string]bool {
out := make(map[string]bool, len(coreReservedKeys))
for _, k := range coreReservedKeys {
out[k] = true
}
return out
}
// buildKeymapEntries returns the core keybindings plus any plugin-registered
// binds that supplied a description. Plugins appear under a divider row.
// Only called when the overlay is opened; the result is cached on keymap.entries
// so navigation (which calls keymapCount many times per frame) is allocation-free.
func (m Model) buildKeymapEntries() []keymapEntry {
out := make([]keymapEntry, 0, len(keymapEntries)+4)
out = append(out, keymapEntries...)
if m.luaMgr == nil {
return out
}
binds := m.luaMgr.KeyBindings()
if len(binds) == 0 {
return out
}
out = append(out, keymapEntry{action: "— plugins —", divider: true})
for _, b := range binds {
label := b.Description
if b.Plugin != "" {
label += " (" + b.Plugin + ")"
}
out = append(out, keymapEntry{key: b.Key, action: label})
}
return out
}
func (m *Model) keymapCount() int {
if m.keymap.searching || m.keymap.search != "" {
return len(m.keymap.filtered)
}
return len(m.keymap.entries)
}
func (m *Model) keymapHelpLine() string {
if m.keymap.searching {
return helpKey("Enter", "Confirm ") + helpKey("Esc", "Cancel ") + helpKey("Type", "Filter")
}
return helpKey("↓↑", "Scroll ") + helpKey("PgUp/Dn", "Page ") +
helpKey("Home/End", "Jump ") + helpKey("/", "Filter ") + helpKey("Esc", "Close")
}
// keymapHeaderLine renders the keymap's single-line header for the playlist
// region: the filter prompt while searching/filtered, otherwise a labeled
// separator with the match count.
func (m Model) keymapHeaderLine() string {
if m.keymap.searching || m.keymap.search != "" {
return filterCountHeader(m.keymap.search, fmt.Sprintf("%d/%d", m.keymapCount(), len(m.keymap.entries)))
}
return sepHeaderN("Keymap", m.keymap.cursor+1, len(m.keymap.entries))
}
func (m *Model) keymapVisible() int {
return m.effectivePlaylistVisible()
}
// keymapMaybeAdjustScroll keeps the cursor visible in the current keymap window.
func (m *Model) keymapMaybeAdjustScroll(visible int) {
clampScroll(&m.keymap.cursor, &m.keymap.scroll, m.keymapCount(), visible)
}
// openKeymap resets the keymap state and shows it. Snapshots plugin bindings
// once so the render/navigation code doesn't re-query the plugin manager.
func (m *Model) openKeymap() {
m.keymap.searching = false
m.keymap.search = ""
m.keymap.filtered = nil
m.keymap.cursor = 0
m.keymap.scroll = 0
m.keymap.entries = m.buildKeymapEntries()
m.keymap.visible = true
// The keymap now renders in the playlist region; recompute chrome so its
// header/help are reflected in the visible-row budget, then fit the cursor.
m.refreshChrome()
m.applyHeightMode()
m.keymapMaybeAdjustScroll(m.keymapVisible())
}
// closeKeymap hides the keymap, clears its filter state, and restores playlist
// sizing after the inline header and help line are dismissed.
func (m *Model) closeKeymap() {
m.keymap.visible = false
m.keymap.searching = false
m.keymap.search = ""
m.keymap.filtered = nil
m.refreshChrome()
m.applyHeightMode()
m.adjustScroll()
}
func (m *Model) handleKeymapSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.keymap.visible = false
return m.quit()
case "esc":
m.keymap.searching = false
m.keymap.search = ""
m.keymap.filtered = nil
m.keymap.cursor = m.keymap.savedCursor
m.keymap.scroll = m.keymap.savedScroll
return nil
case "enter":
m.keymap.searching = false
if m.keymap.search == "" {
m.keymap.cursor = m.keymap.savedCursor
m.keymap.scroll = m.keymap.savedScroll
}
return nil
case "down":
m.keymap.searching = false
if m.keymapCount() > 0 {
m.keymap.cursor = 0
m.keymapMaybeAdjustScroll(m.keymapVisible())
}
return nil
case "backspace":
if m.keymap.search != "" {
m.keymap.search = removeLastRune(m.keymap.search)
m.updateKeymapFilter()
} else {
m.keymap.searching = false
m.keymap.cursor = m.keymap.savedCursor
m.keymap.scroll = m.keymap.savedScroll
}
return nil
case "space":
m.keymap.search += " "
m.updateKeymapFilter()
return nil
}
if len(msg.Text) > 0 {
m.keymap.search += msg.Text
m.updateKeymapFilter()
}
return nil
}
// handleKeymapKey processes key presses while the keymap overlay is open.
func (m *Model) handleKeymapKey(msg tea.KeyPressMsg) tea.Cmd {
if m.keymap.searching {
return m.handleKeymapSearchKey(msg)
}
switch msg.String() {
case "ctrl+c":
m.keymap.visible = false
return m.quit()
case "esc", "ctrl+k", "?", "q":
m.closeKeymap()
case "/":
m.keymap.savedCursor = m.keymap.cursor
m.keymap.savedScroll = m.keymap.scroll
m.keymap.searching = true
m.keymap.search = ""
m.updateKeymapFilter()
return nil
case "up", "k":
if m.keymap.search != "" && m.keymap.cursor == 0 {
m.keymap.searching = true
return nil
}
count := m.keymapCount()
if m.keymap.cursor > 0 {
m.keymap.cursor--
} else if count > 0 {
m.keymap.cursor = count - 1
}
m.keymapMaybeAdjustScroll(m.keymapVisible())
case "down", "j":
count := m.keymapCount()
if m.keymap.cursor < count-1 {
m.keymap.cursor++
} else if count > 0 {
m.keymap.cursor = 0
}
m.keymapMaybeAdjustScroll(m.keymapVisible())
case "ctrl+x":
m.toggleExpandedView()
m.keymapMaybeAdjustScroll(m.keymapVisible())
case "pgup", "ctrl+u":
if m.keymap.cursor > 0 {
visible := m.keymapVisible()
m.keymap.cursor -= min(m.keymap.cursor, visible)
m.keymapMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
count := m.keymapCount()
if m.keymap.cursor < count-1 {
visible := m.keymapVisible()
m.keymap.cursor = min(count-1, m.keymap.cursor+visible)
m.keymapMaybeAdjustScroll(visible)
}
case "home", "g":
m.keymap.cursor = 0
m.keymapMaybeAdjustScroll(m.keymapVisible())
case "end", "G":
count := m.keymapCount()
if count > 0 {
m.keymap.cursor = count - 1
}
m.keymapMaybeAdjustScroll(m.keymapVisible())
case "backspace", "h":
if m.keymap.search != "" {
m.keymap.search = ""
m.updateKeymapFilter()
} else {
m.closeKeymap()
}
case "enter", "l":
m.closeKeymap()
}
return nil
}
// updateKeymapFilter rebuilds the filtered indices and clamps the cursor.
func (m *Model) updateKeymapFilter() {
m.keymap.filtered = nil
m.keymap.cursor = 0
m.keymap.scroll = 0
if m.keymap.search == "" {
return
}
query := strings.ToLower(m.keymap.search)
for i, e := range m.keymap.entries {
if e.divider {
continue
}
if strings.Contains(strings.ToLower(e.key), query) ||
strings.Contains(strings.ToLower(e.action), query) {
m.keymap.filtered = append(m.keymap.filtered, i)
}
}
}
// renderKeymapList renders the keymap entries for the playlist region while the
// keymap is open. The header and help line are supplied by the main layout
// (renderPlaylistHeader / renderHelp), mirroring renderVisPickerList.
func (m Model) renderKeymapList() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
entries := m.keymap.entries
var visible []keymapEntry
if m.keymap.search != "" {
for _, i := range m.keymap.filtered {
visible = append(visible, entries[i])
}
} else {
visible = entries
}
if len(visible) == 0 {
msg := "(empty)"
if m.keymap.search != "" {
msg = "No matches"
}
return strings.Join(fitLines([]string{dimStyle.Render(" " + msg)}, budget), "\n")
}
lines := make([]string, 0, budget)
for i := m.keymap.scroll; i < len(visible) && len(lines) < budget; i++ {
entry := visible[i]
if entry.divider {
lines = append(lines, dimStyle.Render(" "+entry.action))
continue
}
line := fmt.Sprintf("%-10s %s", entry.key, entry.action)
if m.keymap.searching {
lines = append(lines, dimStyle.Render(" "+line))
} else {
lines = append(lines, cursorLine(line, i == m.keymap.cursor))
}
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
+57
View File
@@ -0,0 +1,57 @@
package model
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// TestReservedKeysCoversHandleKey is a drift guard: every `case "..."` clause in
// the main handleKey switch (keys.go) must be represented in coreReservedKeys.
// If this fails after editing keys.go, add the missing key to coreReservedKeys
// in keymap.go. The test deliberately skips case strings containing "+" with
// keys like "shift+up" which *are* included — it scans all quoted tokens.
func TestReservedKeysCoversHandleKey(t *testing.T) {
path := filepath.Join("keys.go")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Find every `case "X", "Y", ...:` clause in keys.go. We intentionally
// over-collect (subhandler switches too) and then filter to the main
// handler's section between "func (m *Model) handleKey" and its close.
src := string(data)
// The main dispatch switch is anchored by its comment header; overlays
// and subhandlers have their own switches with different anchors.
start := strings.Index(src, "// Vim-style count prefix")
if start < 0 {
t.Fatal("could not locate main dispatch anchor in keys.go")
}
// Bound at the next top-level function declaration to avoid scanning
// into helper functions below handleKey.
body := src[start:]
if end := strings.Index(body, "\nfunc "); end > 0 {
body = body[:end]
}
caseRe := regexp.MustCompile(`case ("[^"]+"(?:, "[^"]+")*):`)
tokenRe := regexp.MustCompile(`"([^"]+)"`)
reserved := ReservedKeys()
var missing []string
for _, m := range caseRe.FindAllStringSubmatch(body, -1) {
for _, tok := range tokenRe.FindAllStringSubmatch(m[1], -1) {
key := tok[1]
if !reserved[key] {
missing = append(missing, key)
}
}
}
if len(missing) > 0 {
t.Fatalf("handleKey has case clauses not covered by coreReservedKeys: %v\nAdd these to keymap.go so plugin binds can't shadow them.", missing)
}
}
+2488
View File
File diff suppressed because it is too large Load Diff
+528
View File
@@ -0,0 +1,528 @@
package model
import (
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// handleNavBrowserKey processes key presses while the provider browser is open.
func (m *Model) handleNavBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
if m.navBrowser.prov == nil {
m.navBrowser.visible = false
return nil
}
key := msg.String()
if !m.navBrowser.searching && key == "ctrl+f" {
m.openProviderSearchWith(m.navBrowser.prov)
return nil
}
// Shift+letter quick-switch to another provider — only when not typing
// into the filter, so users can still type capital letters in queries.
if !m.navBrowser.searching {
if cmd := m.quickSwitchProvider(key); cmd != nil {
return cmd
}
}
// Search bar: active on any list/track screen (not the mode menu).
if m.navBrowser.mode != navBrowseModeMenu {
if m.navBrowser.searching {
return m.handleNavSearchKey(msg)
}
if key == "ctrl+x" {
m.toggleExpandedView()
m.navMaybeAdjustScroll()
return nil
}
if key == "/" {
// Toggle: if already filtered, clear; otherwise open.
if m.navBrowser.search != "" {
m.navClearSearch()
} else {
m.navBrowser.searching = true
}
return nil
}
}
switch m.navBrowser.mode {
case navBrowseModeMenu:
return m.handleNavMenuKey(msg)
case navBrowseModeByAlbum:
return m.handleNavByAlbumKey(msg)
case navBrowseModeByArtist:
return m.handleNavByArtistKey(msg)
case navBrowseModeByArtistAlbum:
return m.handleNavByArtistAlbumKey(msg)
}
return nil
}
func (m *Model) handleNavMenuKey(msg tea.KeyPressMsg) tea.Cmd {
const menuItems = 3
switch msg.String() {
case "ctrl+x":
m.toggleExpandedView()
return nil
case "ctrl+c":
m.navBrowser.visible = false
return m.quit()
case "up", "k":
if m.navBrowser.cursor > 0 {
m.navBrowser.cursor--
} else {
m.navBrowser.cursor = menuItems - 1
}
case "down", "j":
if m.navBrowser.cursor < menuItems-1 {
m.navBrowser.cursor++
} else {
m.navBrowser.cursor = 0
}
case "enter", "l", "right":
switch m.navBrowser.cursor {
case 0: // By Album
ab, ok := m.navBrowser.prov.(provider.AlbumBrowser)
if !ok {
return nil
}
m.navBrowser.mode = navBrowseModeByAlbum
m.navBrowser.screen = navBrowseScreenList
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.albums = nil
m.navBrowser.albumLoading = true
m.navBrowser.albumDone = false
m.navBrowser.loading = false
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0)
case 1: // By Artist
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
if !ok {
return nil
}
m.navBrowser.mode = navBrowseModeByArtist
m.navBrowser.screen = navBrowseScreenList
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.artists = nil
m.navBrowser.loading = true
return fetchNavArtistsCmd(ab)
case 2: // By Artist / Album
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
if !ok {
return nil
}
m.navBrowser.mode = navBrowseModeByArtistAlbum
m.navBrowser.screen = navBrowseScreenList
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.artists = nil
m.navBrowser.loading = true
return fetchNavArtistsCmd(ab)
}
case "esc", "N", "backspace", "b":
m.navBrowser.visible = false
}
return nil
}
func (m *Model) handleNavByAlbumKey(msg tea.KeyPressMsg) tea.Cmd {
switch m.navBrowser.screen {
case navBrowseScreenList:
return m.handleNavAlbumListKey(msg, false)
case navBrowseScreenTracks:
return m.handleNavTrackListKey(msg)
}
return nil
}
func (m *Model) handleNavByArtistKey(msg tea.KeyPressMsg) tea.Cmd {
switch m.navBrowser.screen {
case navBrowseScreenList:
return m.handleNavArtistListKey(msg)
case navBrowseScreenTracks:
return m.handleNavTrackListKey(msg)
}
return nil
}
func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyPressMsg) tea.Cmd {
switch m.navBrowser.screen {
case navBrowseScreenList:
return m.handleNavArtistListKey(msg)
case navBrowseScreenAlbums:
return m.handleNavAlbumListKey(msg, true)
case navBrowseScreenTracks:
return m.handleNavTrackListKey(msg)
}
return nil
}
// handleNavArtistListKey handles the artist list screen.
func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd {
// Determine effective list length (filtered or full).
listLen := len(m.navBrowser.artists)
if len(m.navBrowser.searchIdx) > 0 {
listLen = len(m.navBrowser.searchIdx)
}
switch msg.String() {
case "ctrl+c":
m.navBrowser.visible = false
return m.quit()
case "up", "k":
if m.navBrowser.cursor > 0 {
m.navBrowser.cursor--
} else if listLen > 0 {
m.navBrowser.cursor = listLen - 1
}
m.navMaybeAdjustScroll()
case "down", "j":
if m.navBrowser.cursor < listLen-1 {
m.navBrowser.cursor++
} else if listLen > 0 {
m.navBrowser.cursor = 0
}
m.navMaybeAdjustScroll()
case "enter", "l", "right":
if m.navBrowser.loading || len(m.navBrowser.artists) == 0 {
return nil
}
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
if !ok {
return nil
}
// Resolve raw index (filtered or direct).
rawIdx := m.navBrowser.cursor
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
}
artist := m.navBrowser.artists[rawIdx]
m.navBrowser.selArtist = artist
m.navBrowser.loading = true
if m.navBrowser.mode == navBrowseModeByArtistAlbum {
// Drill into album list for this artist.
m.navBrowser.albums = nil
m.navBrowser.albumLoading = false
m.navBrowser.screen = navBrowseScreenAlbums
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navClearSearch()
return fetchNavArtistAlbumsCmd(ab, artist.ID)
}
m.navClearSearch()
return m.fetchNavArtistAllTracksCmd(ab, artist.ID)
case "esc", "h", "left", "backspace":
// Back to menu.
m.navClearSearch()
m.navBrowser.mode = navBrowseModeMenu
m.navBrowser.screen = navBrowseScreenList
}
return nil
}
// handleNavAlbumListKey handles the album list screen.
// artistAlbums=true means this is the artist's album sub-screen (ArtistAlbum mode), not the global list.
func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) tea.Cmd {
// Determine effective list length (filtered or full).
listLen := len(m.navBrowser.albums)
if len(m.navBrowser.searchIdx) > 0 {
listLen = len(m.navBrowser.searchIdx)
}
switch msg.String() {
case "ctrl+c":
m.navBrowser.visible = false
return m.quit()
case "up", "k":
if m.navBrowser.cursor > 0 {
m.navBrowser.cursor--
} else if listLen > 0 {
m.navBrowser.cursor = listLen - 1
}
m.navMaybeAdjustScroll()
case "down", "j":
if m.navBrowser.cursor < listLen-1 {
m.navBrowser.cursor++
m.navMaybeAdjustScroll()
// Lazy-load next page: only trigger on the raw (unfiltered) list.
if !artistAlbums && len(m.navBrowser.searchIdx) == 0 && !m.navBrowser.albumLoading && !m.navBrowser.albumDone && m.navBrowser.cursor >= len(m.navBrowser.albums)-10 {
if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok {
m.navBrowser.albumLoading = true
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, len(m.navBrowser.albums))
}
}
} else if listLen > 0 {
m.navBrowser.cursor = 0
m.navMaybeAdjustScroll()
}
case "enter", "l", "right":
if (m.navBrowser.loading && !artistAlbums) || len(m.navBrowser.albums) == 0 {
return nil
}
// Resolve raw index (filtered or direct).
rawIdx := m.navBrowser.cursor
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
}
album := m.navBrowser.albums[rawIdx]
m.navBrowser.selAlbum = album
m.navBrowser.loading = true
m.navClearSearch()
if l, ok := m.navBrowser.prov.(provider.AlbumTrackLoader); ok {
return fetchNavAlbumTracksCmd(l, album.ID)
}
return nil
case "s":
if artistAlbums {
return nil // Sort only applies to global album list.
}
ab, ok := m.navBrowser.prov.(provider.AlbumBrowser)
if !ok {
return nil
}
m.navBrowser.sortType = navNextSort(m.navBrowser.sortType, ab.AlbumSortTypes())
m.navBrowser.albums = nil
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.albumLoading = true
m.navBrowser.albumDone = false
m.navClearSearch()
if saver, ok := m.navBrowser.prov.(provider.AlbumSortSaver); ok {
if err := saver.SaveAlbumSort(m.navBrowser.sortType); err != nil {
m.status.Showf(statusTTLDefault, "Sort save failed: %s", err)
}
}
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0)
case "esc", "h", "left", "backspace":
m.navClearSearch()
if artistAlbums {
// Back to artist list.
m.navBrowser.screen = navBrowseScreenList
} else {
// Back to menu.
m.navBrowser.mode = navBrowseModeMenu
m.navBrowser.screen = navBrowseScreenList
}
}
return nil
}
// handleNavTrackListKey handles the final track-list screen (used by all modes).
func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd {
// Determine effective list length (filtered or full).
listLen := len(m.navBrowser.tracks)
if len(m.navBrowser.searchIdx) > 0 {
listLen = len(m.navBrowser.searchIdx)
}
switch msg.String() {
case "ctrl+h":
m.toggleAlbumHeadersManual()
m.navMaybeAdjustScroll()
return nil
case "ctrl+c":
m.navBrowser.visible = false
return m.quit()
case "up", "k":
if m.navBrowser.cursor > 0 {
m.navBrowser.cursor--
} else if listLen > 0 {
m.navBrowser.cursor = listLen - 1
}
m.navMaybeAdjustScroll()
case "down", "j":
if m.navBrowser.cursor < listLen-1 {
m.navBrowser.cursor++
} else if listLen > 0 {
m.navBrowser.cursor = 0
}
m.navMaybeAdjustScroll()
case "enter":
// Play the highlighted track immediately, then enqueue everything from
// that position to the end of the list (capped at 500 total tracks).
if len(m.navBrowser.tracks) == 0 {
return nil
}
rawIdx := m.navBrowser.cursor
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
}
if rawIdx < len(m.navBrowser.tracks) {
const maxAdd = 500
m.player.Stop()
m.player.ClearPreload()
var toAdd []playlist.Track
if len(m.navBrowser.searchIdx) > 0 {
for j := m.navBrowser.cursor; j < len(m.navBrowser.searchIdx) && len(toAdd) < maxAdd; j++ {
toAdd = append(toAdd, m.navBrowser.tracks[m.navBrowser.searchIdx[j]])
}
} else {
for i := rawIdx; i < len(m.navBrowser.tracks) && len(toAdd) < maxAdd; i++ {
toAdd = append(toAdd, m.navBrowser.tracks[i])
}
}
m.playlist.Add(toAdd...)
m.loadedPlaylist = ""
m.addToHeaderState(toAdd)
newIdx := m.playlist.Len() - len(toAdd)
m.playlist.SetIndex(newIdx)
m.plCursor = newIdx
m.adjustScroll()
if len(toAdd) > 1 {
m.status.Showf(statusTTLMedium, "Playing: %s (+%d queued)", toAdd[0].DisplayName(), len(toAdd)-1)
} else {
m.status.Showf(statusTTLMedium, "Playing: %s", toAdd[0].DisplayName())
}
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
case "R":
// Replace playlist with all displayed tracks and close browser.
tracks := m.navBrowser.tracks
if len(m.navBrowser.searchIdx) > 0 {
filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx))
for _, i := range m.navBrowser.searchIdx {
filtered = append(filtered, m.navBrowser.tracks[i])
}
tracks = filtered
}
if len(tracks) > 0 {
m.player.Stop()
m.player.ClearPreload()
m.resetYTDLBatch()
m.playlist.Replace(tracks)
m.loadedPlaylist = ""
m.setHeaderStateFromTracks(tracks)
m.plCursor = 0
m.plScroll = 0
m.playlist.SetIndex(0)
m.focus = focusPlaylist
m.navBrowser.visible = false
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
case "a":
// Append all displayed tracks to the playlist (keep current playback).
tracks := m.navBrowser.tracks
if len(m.navBrowser.searchIdx) > 0 {
filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx))
for _, i := range m.navBrowser.searchIdx {
filtered = append(filtered, m.navBrowser.tracks[i])
}
tracks = filtered
}
if len(tracks) > 0 {
wasEmpty := m.playlist.Len() == 0
m.playlist.Add(tracks...)
m.loadedPlaylist = ""
m.addToHeaderState(tracks)
m.status.Showf(statusTTLMedium, "Added %d tracks", len(tracks))
if wasEmpty || !m.player.IsPlaying() {
m.playlist.SetIndex(0)
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
}
case "q":
// Add the highlighted track and queue it to play next.
if len(m.navBrowser.tracks) == 0 {
return nil
}
rawIdx := m.navBrowser.cursor
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
}
if rawIdx < len(m.navBrowser.tracks) {
t := m.navBrowser.tracks[rawIdx]
m.playlist.Add(t)
m.loadedPlaylist = ""
m.addToHeaderState([]playlist.Track{t})
newIdx := m.playlist.Len() - 1
m.playlist.Queue(newIdx)
m.status.Showf(statusTTLMedium, "Queued: %s", t.DisplayName())
if !m.player.IsPlaying() {
cmd := m.nextTrack()
m.notifyPlayback()
return cmd
}
}
case "esc", "h", "left", "backspace":
// Navigate back one level depending on the mode and how we got here.
m.navClearSearch()
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
switch m.navBrowser.mode {
case navBrowseModeByAlbum:
m.navBrowser.screen = navBrowseScreenList
case navBrowseModeByArtist:
m.navBrowser.screen = navBrowseScreenList
case navBrowseModeByArtistAlbum:
m.navBrowser.screen = navBrowseScreenAlbums
}
}
return nil
}
// handleNavSearchKey handles key input while the nav search bar is open.
func (m *Model) handleNavSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.navBrowser.searching = false
return nil
case tea.KeyEnter:
m.navBrowser.searching = false
return nil
case tea.KeyBackspace, tea.KeyDelete:
if m.navBrowser.search != "" {
m.navBrowser.search = removeLastRune(m.navBrowser.search)
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navUpdateSearch()
}
return nil
}
if len(msg.Text) > 0 {
m.navBrowser.search += msg.Text
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navUpdateSearch()
}
return nil
}
// navNextSort returns the next sort option, wrapping around the list.
func navNextSort(s string, types []provider.SortType) string {
for i, t := range types {
if t.ID == s {
return types[(i+1)%len(types)].ID
}
}
if len(types) > 0 {
return types[0].ID
}
return s
}
// navMaybeAdjustScroll keeps navCursor visible within the rendered list window.
func (m *Model) navMaybeAdjustScroll() {
visible := m.navVisible()
if m.navBrowser.cursor < m.navBrowser.scroll {
m.navBrowser.scroll = m.navBrowser.cursor
}
if m.navBrowser.cursor >= m.navBrowser.scroll+visible {
m.navBrowser.scroll = m.navBrowser.cursor - visible + 1
}
}
+66
View File
@@ -0,0 +1,66 @@
package model
import (
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/provider"
)
// maybeLoadCatalogBatch triggers a catalog batch fetch when the cursor is near the
// bottom of the provider list and more entries are available.
func (m *Model) maybeLoadCatalogBatch() tea.Cmd {
loader, ok := m.provider.(provider.CatalogLoader)
if !ok {
return nil
}
if m.catalogBatch.loading || m.catalogBatch.done {
return nil
}
if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() {
return nil
}
if m.provCursor >= len(m.providerLists)-10 {
m.catalogBatch.loading = true
return fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize)
}
return nil
}
// toggleProviderFavorite toggles favorite status for the current entry in the
// provider list (only works for providers implementing FavoriteToggler + SectionedList).
func (m *Model) toggleProviderFavorite() tea.Cmd {
ft, ok := m.provider.(provider.FavoriteToggler)
if !ok || len(m.providerLists) == 0 {
return nil
}
id := m.providerLists[m.provCursor].ID
if sl, ok := m.provider.(provider.SectionedList); ok {
if !sl.IsFavoritableID(id) {
return nil
}
}
added, name, err := ft.ToggleFavorite(id)
if err != nil {
return nil
}
if added {
m.status.Showf(statusTTLMedium, "Favorited: %s", name)
} else {
m.status.Showf(statusTTLMedium, "Removed: %s", name)
}
prevID := id
if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists
for i, p := range m.providerLists {
if p.ID == prevID {
m.provCursor = i
return nil
}
}
if m.provCursor >= len(m.providerLists) {
m.provCursor = max(0, len(m.providerLists)-1)
}
}
return nil
}
+228
View File
@@ -0,0 +1,228 @@
package model
import (
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/provider"
)
// handleSpotSearchKey dispatches key presses to the active provider search screen.
func (m *Model) handleSpotSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.closeSpotSearch()
return m.quit()
}
switch m.spotSearch.screen {
case spotSearchInput:
return m.handleSpotSearchInputKey(msg)
case spotSearchResults:
return m.handleSpotSearchResultsKey(msg)
case spotSearchPlaylist:
return m.handleSpotSearchPlaylistKey(msg)
case spotSearchNewName:
return m.handleSpotSearchNewNameKey(msg)
}
return nil
}
// handleSpotSearchInputKey handles text input for the search query.
func (m *Model) handleSpotSearchInputKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.closeSpotSearch()
case tea.KeyEnter:
if m.spotSearch.query != "" && !m.spotSearch.loading {
s, ok := m.spotSearch.prov.(provider.Searcher)
if !ok {
return nil
}
m.spotSearch.loading = true
m.spotSearch.err = ""
return fetchSpotSearchCmd(s, m.spotSearch.query)
}
case tea.KeyBackspace:
if m.spotSearch.query != "" {
m.spotSearch.query = removeLastRune(m.spotSearch.query)
}
case tea.KeySpace:
m.spotSearch.query += " "
default:
if len(msg.Text) > 0 {
m.spotSearch.query += msg.Text
}
}
return nil
}
func (m *Model) spotSearchResultsMaybeAdjustScroll(visible int) {
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, len(m.spotSearch.results), visible)
}
// handleSpotSearchResultsKey handles navigation through search results.
func (m *Model) handleSpotSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.spotSearch.results)
switch msg.String() {
case "ctrl+x":
m.toggleExpandedView()
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
case "up", "k", "ctrl+p":
if m.spotSearch.cursor > 0 {
m.spotSearch.cursor--
} else if count > 0 {
m.spotSearch.cursor = count - 1
}
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
case "down", "j", "ctrl+n":
if m.spotSearch.cursor < count-1 {
m.spotSearch.cursor++
} else if count > 0 {
m.spotSearch.cursor = 0
}
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
case "enter":
if count > 0 && !m.spotSearch.loading {
track := m.spotSearch.results[m.spotSearch.cursor]
m.closeSpotSearch()
return m.playTrackImmediate(track)
}
case "a":
if count > 0 && !m.spotSearch.loading {
track := m.spotSearch.results[m.spotSearch.cursor]
m.closeSpotSearch()
return m.appendTrack(track)
}
case "q":
if count > 0 && !m.spotSearch.loading {
track := m.spotSearch.results[m.spotSearch.cursor]
m.closeSpotSearch()
return m.queueTrackNext(track)
}
case "p":
if count > 0 && !m.spotSearch.loading {
m.spotSearch.selTrack = m.spotSearch.results[m.spotSearch.cursor]
m.spotSearch.loading = true
m.spotSearch.err = ""
return fetchSpotPlaylistsCmd(m.spotSearch.prov)
}
case "esc", "backspace":
m.spotSearch.screen = spotSearchInput
m.spotSearch.err = ""
case "ctrl+u":
step := m.spotSearchResultsVisible()
if step < 1 {
step = 1
}
if m.spotSearch.cursor >= step {
m.spotSearch.cursor -= step
} else {
m.spotSearch.cursor = 0
}
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
case "ctrl+d":
step := m.spotSearchResultsVisible()
if step < 1 {
step = 1
}
m.spotSearch.cursor += step
if m.spotSearch.cursor >= count {
m.spotSearch.cursor = max(0, count-1)
}
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
}
return nil
}
func (m *Model) spotSearchPlaylistMaybeAdjustScroll(visible int) {
count := len(m.spotSearch.playlists) + 1
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, count, visible)
}
// handleSpotSearchPlaylistKey handles picking a playlist to add to.
func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.spotSearch.playlists) + 1 // +1 for "+ New Playlist..."
switch msg.String() {
case "ctrl+x":
m.toggleExpandedView()
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
case "up", "k":
if m.spotSearch.cursor > 0 {
m.spotSearch.cursor--
} else if count > 0 {
m.spotSearch.cursor = count - 1
}
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
case "down", "j":
if m.spotSearch.cursor < count-1 {
m.spotSearch.cursor++
} else if count > 0 {
m.spotSearch.cursor = 0
}
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
case "enter":
if m.spotSearch.loading {
return nil
}
w, ok := m.spotSearch.prov.(provider.PlaylistWriter)
if !ok {
return nil
}
if m.spotSearch.cursor < len(m.spotSearch.playlists) {
// Add to existing playlist.
pl := m.spotSearch.playlists[m.spotSearch.cursor]
// Skip "Your Music" — uses a different endpoint.
if pl.ID == "YOUR MUSIC" {
return nil
}
m.spotSearch.loading = true
m.spotSearch.err = ""
return addToSpotPlaylistCmd(w, pl.ID, m.spotSearch.selTrack, pl.Name)
}
// "+ New Playlist..." selected.
m.spotSearch.screen = spotSearchNewName
m.spotSearch.newName = ""
m.spotSearch.cursor = 0
m.spotSearch.scroll = 0
case "esc", "backspace":
m.spotSearch.screen = spotSearchResults
m.spotSearch.cursor = 0
m.spotSearch.scroll = 0
m.spotSearch.err = ""
}
return nil
}
// handleSpotSearchNewNameKey handles text input for new playlist name.
func (m *Model) handleSpotSearchNewNameKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.spotSearch.screen = spotSearchPlaylist
m.spotSearch.cursor = len(m.spotSearch.playlists)
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
case tea.KeyEnter:
if m.spotSearch.newName != "" && !m.spotSearch.loading {
c, cOk := m.spotSearch.prov.(provider.PlaylistCreator)
w, wOk := m.spotSearch.prov.(provider.PlaylistWriter)
if !cOk || !wOk {
return nil
}
m.spotSearch.loading = true
m.spotSearch.err = ""
return createSpotPlaylistCmd(c, w, m.spotSearch.newName, m.spotSearch.selTrack)
}
case tea.KeyBackspace:
if m.spotSearch.newName != "" {
m.spotSearch.newName = removeLastRune(m.spotSearch.newName)
}
case tea.KeySpace:
m.spotSearch.newName += " "
default:
if len(msg.Text) > 0 {
m.spotSearch.newName += msg.Text
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package model
import (
"strings"
"github.com/bjarneo/cliamp/playlist"
)
// lyricsArtistTitle resolves the best artist and title for a lyrics lookup.
// For streams with ICY metadata ("Artist - Song"), it parses the stream title.
// For regular tracks, it uses the track's metadata fields.
func (m *Model) lyricsArtistTitle() (artist, title string) {
track, idx := m.currentPlaybackTrack()
if idx < 0 {
return "", ""
}
// For streams, prefer the live ICY stream title which updates per-song.
if m.streamTitle != "" && track.Stream {
if a, t, ok := strings.Cut(m.streamTitle, " - "); ok {
return strings.TrimSpace(a), strings.TrimSpace(t)
}
}
return track.Artist, track.Title
}
func lyricsLookupKey(track playlist.Track, artist, title string) string {
if artist != "" && title != "" {
return artist + "\n" + title
}
if track.EmbeddedLyrics == "" {
return ""
}
if track.Path != "" {
return "embedded\n" + track.Path
}
if track.Title != "" {
return "embedded\n" + track.Title
}
return "embedded"
}
// lyricsSyncable reports whether synced lyrics can track the current playback
// position. This is true for local files and Navidrome streams (which have
// accurate position tracking), but false for live radio (ICY — position is
// from stream start, not song start) and yt-dlp pipe streams (position is 0).
func (m *Model) lyricsSyncable() bool {
track, idx := m.currentPlaybackTrack()
if idx < 0 {
return false
}
// YouTube/yt-dlp pipe streams report position 0.
if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) {
return false
}
// ICY radio streams: position counts from stream connect, not song start.
// Provider streams with metadata (e.g. Navidrome) track position correctly.
if track.Stream && len(track.ProviderMeta) == 0 {
return false
}
return true
}
// lyricsHaveTimestamps reports whether the loaded lyrics have meaningful
// timestamps (i.e., not all lines at 0).
func (m *Model) lyricsHaveTimestamps() bool {
for _, l := range m.lyrics.lines {
if l.Start > 0 {
return true
}
}
return false
}
+366
View File
@@ -0,0 +1,366 @@
// Package ui implements the Bubbletea TUI for the CLIAMP terminal music player.
package model
import (
"time"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/luaplugin"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// ConfigSaver persists individual config key-value pairs.
// Satisfied by config.SaveFunc (the default) or a test stub.
type ConfigSaver interface {
Save(key, value string) error
}
type focusArea int
const (
focusPlaylist focusArea = iota
focusEQ
focusSpeed
focusProvPill
focusSearch
focusProvider
focusNetSearch
)
func (f focusArea) label() string {
switch f {
case focusPlaylist:
return "Playlist"
case focusEQ:
return "Equalizer"
case focusSpeed:
return "Speed"
case focusProvPill:
return "Source"
case focusProvider:
return "Provider"
case focusSearch:
return "Search"
case focusNetSearch:
return "Online Search"
default:
return ""
}
}
type topLevelScreen int
const (
screenMain topLevelScreen = iota
screenKeymap
screenThemePicker
screenVisPicker
screenDevicePicker
screenPlaylistPicker
screenFileBrowser
screenNavBrowser
screenPlaylistManager
screenSpotSearch
screenQueue
screenInfo
screenSearch
screenNetSearch
screenURLInput
screenLyrics
screenJump
screenFullVisualizer
)
func (s topLevelScreen) hidesVisualizer() bool {
// Every overlay now renders inline in the playlist region while the
// now-playing, visualizer, and controls chrome stays visible above it, so
// the visualizer is never fully hidden.
return false
}
// maxPlVisible caps the playlist at a readable height even on tall terminals.
// maxPlExpandVisible is the higher cap used when the user expands with 'x'.
const (
maxPlVisible = 12
maxPlExpandVisible = 24
)
type plMgrScreenType int
const (
plMgrScreenList plMgrScreenType = iota
plMgrScreenTracks
plMgrScreenNewName
plMgrScreenRename
)
// navBrowseModeType identifies which Navidrome browse mode is active.
type navBrowseModeType int
const (
navBrowseModeMenu navBrowseModeType = iota // top-level mode selector
navBrowseModeByAlbum // paginated album list → track list
navBrowseModeByArtist // artist list → track list (album-separated)
navBrowseModeByArtistAlbum // artist list → album list → track list
)
// navBrowseScreenType identifies which screen within the active browse mode is shown.
type navBrowseScreenType int
const (
navBrowseScreenList navBrowseScreenType = iota // first-level list (artists or albums)
navBrowseScreenAlbums // artist's albums (ArtistAlbum mode only)
navBrowseScreenTracks // final song list in any mode
)
// ProviderEntry pairs a display name with a key and provider implementation.
type ProviderEntry struct {
Key string // config key: "radio", "navidrome", "spotify"
Name string // display name: "Radio", "Navidrome", "Spotify"
Provider playlist.Provider // nil if not configured
}
// statusTTL* constants define how long a status message is shown.
const (
statusTTLShort statusTTL = statusTTL(2 * time.Second) // brief confirmations
statusTTLDefault statusTTL = statusTTL(3 * time.Second) // standard status messages
statusTTLMedium statusTTL = statusTTL(4 * time.Second) // messages needing extra visibility
statusTTLBatch statusTTL = statusTTL(4500 * time.Millisecond) // batch operation feedback
statusTTLLong statusTTL = statusTTL(6 * time.Second) // loading indicators
)
// Model is the Bubbletea model for the CLIAMP TUI.
type Model struct {
// Core playback
player player.Engine
playlist *playlist.Playlist
configSaver ConfigSaver
vis *ui.Visualizer
seekStepLarge time.Duration
pausedAt time.Time
// Primed Nj seek: digit sets pct, next `j` completes.
pendingSeekActive bool
pendingSeekPct int
// UI navigation
focus focusArea
prevFocus focusArea // focus to restore on cancel (search, net search)
eqCursor int // selected EQ band (0-9)
plCursor int // selected playlist item
plScroll int // scroll offset for playlist view
plVisible int // desired max visible playlist lines
titleOff int // scroll offset for long track titles
titleLastScroll time.Time // last time the title scrolled
err error
quitting bool
width int
height int
// Provider state
provider playlist.Provider
localProvider playlist.Provider // local playlist provider for file-based playlist management (always available)
providerLists []playlist.PlaylistInfo
provCursor int
provScroll int
provLoading bool
provSignIn bool // true when provider needs interactive sign-in
provAuthURL string // OAuth URL to display while interactive auth is in flight
providers []ProviderEntry // all available providers
provPillIdx int // selected pill index
eqPresetIdx int // -1 = custom, 0+ = index into eqPresets
eqCustomLabel string // non-empty = plugin-defined preset label (shown instead of "Custom")
// Overlay / feature state (see state.go for struct definitions)
search searchState
netSearch netSearchState
provSearch provSearchState
seek seekState
themePicker themePickerState
visPicker visPickerState
lyrics lyricsState
keymap keymapOverlay
queue queueOverlay
plManager plManagerState
plPicker playlistPickerState
spotSearch spotSearchState
fileBrowser fileBrowserState
navBrowser navBrowserState
catalogBatch catalogBatchState
ytdlBatch ytdlBatchState
reconnect reconnectState
save saveState
status statusMsg
logLines []logLine
network networkStats
speedSaveAfter time.Duration
termTitle terminalTitleState
// Jump to time mode
jumping bool
jumpInput string
// URL input mode (load playlist/stream URL at runtime)
urlInputting bool
urlInput string
// Async feed/M3U URL resolution
pendingURLs []string
feedLoading bool
visVolumeLinked bool // when true, samples are scaled by volume gain before FFT
// Async stream buffering (true while HTTP connect is in progress)
buffering bool
bufferingAt time.Time // when buffering started, for elapsed display
// resume holds the path and position to seek to when the matching track
// starts playing. Cleared after the seek is performed.
resume struct {
path string
secs int
}
loadedPlaylist string // name of the currently loaded local playlist (for resume)
// activeProviderPlaylistID is the ID of the most recently loaded playlist
// from a non-local provider (Spotify, Navidrome, …). Used to highlight that
// row in the provider browser. Empty when no provider playlist is active.
activeProviderPlaylistID string
// exitResume holds the playback state captured just before player.Close()
// so ResumeState() can read it after the player is shut down.
exitResume struct {
path string
secs int
playlist string
}
// preloading is true while a preloadStreamCmd goroutine is in-flight.
preloading bool
// Live stream title from ICY metadata (e.g., "Artist - Song")
streamTitle string
// playingTrack is the track currently owned by the audio engine. It can differ
// from playlist.Current() after browsing loads a new provider playlist while
// the old track keeps playing.
playingTrack playlist.Track
playingTrackActive bool
playbackDetached bool
notifier playback.Notifier
// Lua plugin manager (nil if no plugins loaded)
luaMgr *luaplugin.Manager
// pluginEmit tracks last-emitted player/queue state so Update can fire
// delta events to plugins from one place. Held behind a pointer so the
// snapshot survives Update's value-receiver copy.
pluginEmit *pluginEmitState
// History recorder (nil if config dir unavailable; safe to call when nil)
historyStore *history.Store
// initialDir is the starting path for the file browser ('o' key).
initialDir string
// Theme state: -1 = Default (ANSI), 0+ = index into themes
themes []theme.Theme
themeIdx int
// Track info overlay (metadata details)
showInfo bool
showAlbumHeaders bool
headerManual bool
// Running counters for the cohesion heuristic so Add can update header
// visibility in O(k) instead of walking the whole playlist on each call.
headerLastAlbum string
headerSegments int
headerTracks int
// Audio device picker overlay
devicePicker devicePickerState
// Full-screen visualizer mode (Shift+V)
fullVis bool
autoPlay bool // start playing immediately on launch
lowPower bool // lower UI/render cadences in low-power mode
compact bool // compact mode: cap frame width at 80 columns
heightExpanded bool // tracks whether manual 'x' expansion is active
// Cached per-tick to avoid repeated speaker.Lock() calls in View().
cachedPos time.Duration
cachedDur time.Duration
lastTickAt time.Time // wall time of previous tickMsg; used for tick delta
// Cached height of the fixed chrome (title, track info, time, seek bar,
// controls, provider pill, playlist header, help, bottom status, no
// transient footer). Reused to avoid rendering all chrome sections twice
// per View() call. The measurement in effectivePlaylistVisible() uses
// this cache instead of a full render pass.
chromeHeight int
chromeOK bool
}
func (m Model) activeScreen() topLevelScreen {
switch {
case m.keymap.visible:
return screenKeymap
case m.themePicker.visible:
return screenThemePicker
case m.visPicker.visible:
return screenVisPicker
case m.devicePicker.visible:
return screenDevicePicker
case m.plPicker.visible:
return screenPlaylistPicker
case m.fileBrowser.visible:
return screenFileBrowser
case m.navBrowser.visible:
return screenNavBrowser
case m.plManager.visible:
return screenPlaylistManager
case m.spotSearch.visible:
return screenSpotSearch
case m.queue.visible:
return screenQueue
case m.showInfo:
return screenInfo
case m.search.active:
return screenSearch
case m.netSearch.active:
return screenNetSearch
case m.urlInputting:
return screenURLInput
case m.lyrics.visible:
return screenLyrics
case m.jumping:
return screenJump
case m.fullVis:
return screenFullVisualizer
default:
return screenMain
}
}
// isOverlayActive reports whether an overlay suppresses the live main view.
// Overlays now render inline over the live view (see hidesVisualizer), so this
// is always false; it is kept as the single seam the tick loop gates on.
func (m Model) isOverlayActive() bool {
return false
}
func (m Model) isPlaying() bool {
return m.player != nil && m.player.IsPlaying()
}
func (m Model) isPaused() bool {
return m.player != nil && m.player.IsPaused()
}
+192
View File
@@ -0,0 +1,192 @@
package model
import (
"strings"
"time"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/luaplugin"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// notifyAll sends the current playback state to both OS media controls and Lua plugins.
func (m *Model) notifyAll() {
m.notifyPlayback()
m.notifyPlugins()
}
func (m *Model) attachNotifier(notifier playback.Notifier) {
m.notifier = notifier
m.notifyAll()
}
// notifyPlugins emits a playback state event to Lua plugins.
func (m *Model) notifyPlugins() {
if m.luaMgr == nil || !m.luaMgr.HasHooks() {
return
}
track, _ := m.currentPlaybackTrack()
artist, title := m.resolveTrackDisplay(track)
status := "stopped"
if m.player.IsPlaying() {
if m.player.IsPaused() {
status = "paused"
} else {
status = "playing"
}
}
data := trackToMap(track)
data["status"] = status
data["title"] = title
data["artist"] = artist
data["position"] = m.player.Position().Seconds()
m.luaMgr.Emit(luaplugin.EventPlaybackState, data)
}
// resolveTrackDisplay returns the display artist and title, applying ICY
// stream title override for radio streams.
func (m *Model) resolveTrackDisplay(track playlist.Track) (artist, title string) {
artist, title = track.Artist, track.Title
if m.streamTitle != "" && track.Stream {
if a, t, ok := strings.Cut(m.streamTitle, " - "); ok {
artist, title = a, t
} else {
title = m.streamTitle
}
}
return
}
// trackToMap builds a metadata map from a track for Lua plugin events.
func trackToMap(track playlist.Track) map[string]any {
return map[string]any{
"title": track.Title,
"artist": track.Artist,
"album": track.Album,
"genre": track.Genre,
"year": track.Year,
"path": track.Path,
"duration": track.DurationSecs,
"stream": track.Stream,
}
}
func (m *Model) notifyPlayback() {
if m.notifier == nil {
return
}
status := playback.StatusStopped
if m.player.IsPlaying() {
if m.player.IsPaused() {
status = playback.StatusPaused
} else {
status = playback.StatusPlaying
}
}
track, _ := m.currentPlaybackTrack()
artist, title := m.resolveTrackDisplay(track)
m.notifier.Update(playback.State{
Status: status,
Track: playback.Track{
Title: title,
Artist: artist,
Album: track.Album,
Genre: track.Genre,
TrackNumber: track.TrackNumber,
URL: track.Path,
ArtURL: track.AlbumArtURL,
Duration: m.player.Duration(),
},
VolumeDB: m.player.Volume(),
Position: m.player.Position(),
Seekable: m.player.Seekable(),
})
}
// nowPlaying fires a now-playing notification for the given track if configured.
func (m *Model) nowPlaying(track playlist.Track) {
if m.luaMgr != nil && m.luaMgr.HasHooks() {
m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track))
}
reporter := m.findPlaybackReporter(track)
if reporter == nil {
return
}
canSeek := m.player.Seekable()
go reporter.ReportNowPlaying(track, m.player.Position(), canSeek)
}
// maybeScrobble fires a playback-complete report for the given track if all
// conditions are met:
// - a provider claims the track via provider metadata
// - the track reached at least 50% of its known duration
//
// The call is dispatched in a goroutine so it never blocks the UI. The same
// 50% threshold gates a local history entry so skipped tracks never land in
// "Recently Played".
func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Duration) {
dur := duration
if dur <= 0 {
dur = time.Duration(track.DurationSecs) * time.Second
}
pastThreshold := dur > 0 && elapsed >= dur/2
// Emit scrobble event to Lua plugins for all tracks (not just Navidrome).
if m.luaMgr != nil && m.luaMgr.HasHooks() && pastThreshold {
data := trackToMap(track)
data["played_secs"] = elapsed.Seconds()
m.luaMgr.Emit(luaplugin.EventTrackScrobble, data)
}
// Record into local history regardless of provider. Live streams without
// duration are filtered by pastThreshold. The write is synchronous so
// successive scrobbles preserve their ordering on disk; the file is small
// (~30 KB at the 200-entry cap) so the latency is sub-millisecond.
if pastThreshold && m.historyStore != nil {
_ = m.historyStore.Record(track, time.Now())
}
reporter := m.findPlaybackReporter(track)
if reporter == nil {
return
}
if duration <= 0 {
// Unknown duration: use DurationSecs metadata as fallback.
duration = time.Duration(track.DurationSecs) * time.Second
}
if duration <= 0 {
return // still unknown — skip
}
if elapsed < duration/2 {
return // less than 50% played
}
canSeek := m.player.Seekable()
go reporter.ReportScrobble(track, elapsed, duration, canSeek)
}
// findPlaybackReporter returns the first registered provider that can report
// playback for the given track.
func (m *Model) findPlaybackReporter(track playlist.Track) provider.PlaybackReporter {
match := func(p playlist.Provider) provider.PlaybackReporter {
reporter, ok := p.(provider.PlaybackReporter)
if !ok || !reporter.CanReportPlayback(track) {
return nil
}
return reporter
}
if reporter := match(m.provider); reporter != nil {
return reporter
}
for _, pe := range m.providers {
if pe.Provider == nil {
continue
}
if reporter := match(pe.Provider); reporter != nil {
return reporter
}
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package model
import (
"testing"
"time"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/playlist"
)
type fakeNotifier struct {
updates []playback.State
seeked []time.Duration
}
func (f *fakeNotifier) Update(state playback.State) {
f.updates = append(f.updates, state)
}
func (f *fakeNotifier) Seeked(position time.Duration) {
f.seeked = append(f.seeked, position)
}
func TestAttachNotifierPublishesCurrentPlaybackState(t *testing.T) {
pl := playlist.New()
pl.Add(playlist.Track{
Title: "Song",
Artist: "Artist",
Album: "Album",
Path: "/tmp/song.mp3",
})
notifier := &fakeNotifier{}
m := Model{
player: &fakeEngine{},
playlist: pl,
}
next, _ := m.Update(AttachNotifier(notifier))
nextModel := next.(Model)
if nextModel.notifier != notifier {
t.Fatal("notifier was not attached to model")
}
if len(notifier.updates) != 1 {
t.Fatalf("notifier update count = %d, want 1", len(notifier.updates))
}
want := playback.State{
Status: playback.StatusPlaying,
Track: playback.Track{
Title: "Song",
Artist: "Artist",
Album: "Album",
URL: "/tmp/song.mp3",
Duration: time.Hour,
},
}
if got := notifier.updates[0]; got != want {
t.Fatalf("notifier update = %#v, want %#v", got, want)
}
}
+98
View File
@@ -0,0 +1,98 @@
package model
import (
"fmt"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
// newInlineOverlayModel builds a Model with a real player/playlist/visualizer
// for exercising the inline overlay render path.
func newInlineOverlayModel(t *testing.T, w, h int) Model {
t.Helper()
sharedPlayer.Stop()
pl := playlist.New()
for i := range 8 {
pl.Add(playlist.Track{
Path: fmt.Sprintf("/tmp/track-%d.mp3", i),
Title: fmt.Sprintf("Track %d", i+1),
})
}
m := Model{
player: sharedPlayer,
playlist: pl,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: w,
height: h,
focus: focusPlaylist,
plVisible: 6,
}
m.vis.Mode = ui.VisNone
m.refreshChrome()
m.applyHeightMode()
return m
}
// TestInlineOverlaysFitTerminal opens each overlay inline and asserts the framed
// view never overflows the terminal height or width. Overlays render in the
// playlist region beneath the live now-playing/visualizer/controls chrome, so
// the total frame must still fit.
func TestInlineOverlaysFitTerminal(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
sizes := []struct{ w, h int }{
{80, 24},
{80, 20},
{100, 30},
}
overlays := []struct {
name string
set func(m *Model)
}{
{"themePicker", func(m *Model) { m.themePicker.visible = true }},
{"devicePicker", func(m *Model) { m.devicePicker.visible = true }},
{"queue", func(m *Model) { m.queue.visible = true }},
{"info", func(m *Model) { m.showInfo = true }},
{"search", func(m *Model) { m.search.active = true }},
{"keymap", func(m *Model) { m.keymap.visible = true; m.keymap.entries = m.buildKeymapEntries() }},
{"netSearch", func(m *Model) { m.netSearch.active = true }},
{"urlInput", func(m *Model) { m.urlInputting = true }},
{"lyrics", func(m *Model) { m.lyrics.visible = true }},
{"jump", func(m *Model) { m.jumping = true }},
{"spotSearch", func(m *Model) { m.spotSearch.visible = true }},
{"navBrowser", func(m *Model) { m.navBrowser.visible = true; m.navBrowser.mode = navBrowseModeMenu }},
{"playlistManager", func(m *Model) { m.plManager.visible = true; m.plManager.screen = plMgrScreenList }},
{"fileBrowser", func(m *Model) { m.fileBrowser.visible = true }},
{"visPicker", func(m *Model) { m.openVisPicker() }},
}
for _, sz := range sizes {
for _, ov := range overlays {
t.Run(fmt.Sprintf("%s_%dx%d", ov.name, sz.w, sz.h), func(t *testing.T) {
withFrameWidth(t, sz.w)
m := newInlineOverlayModel(t, sz.w, sz.h)
ov.set(&m)
out := m.View().Content
if got := lipgloss.Height(out); got > sz.h {
t.Fatalf("%s view height = %d, want <= %d", ov.name, got, sz.h)
}
for _, line := range strings.Split(out, "\n") {
if got := lipgloss.Width(line); got > sz.w {
t.Fatalf("%s view line width = %d, want <= %d: %q", ov.name, got, sz.w, line)
}
}
})
}
}
}
+411
View File
@@ -0,0 +1,411 @@
package model
import (
"fmt"
"strings"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// openThemePicker re-loads themes from disk (picking up new user files)
// and opens the theme selector overlay.
func (m *Model) openThemePicker() {
m.themes = theme.LoadAll()
m.themePicker.visible = true
m.themePicker.savedIdx = m.themeIdx
// Position cursor on the currently active theme.
// Picker list: 0 = Default, 1..N = themes[0..N-1]
m.themePicker.cursor = m.themeIdx + 1
m.themePicker.scroll = 0
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
}
// themePickerApply applies the theme under the cursor for live preview.
func (m *Model) themePickerApply() {
if m.themePicker.cursor == 0 {
m.themeIdx = -1
applyThemeAll(theme.Default())
} else {
m.themeIdx = m.themePicker.cursor - 1
applyThemeAll(m.themes[m.themeIdx])
}
}
// themePickerSelect confirms the current selection and closes the picker.
func (m *Model) themePickerSelect() {
m.themePickerApply()
m.themePicker.visible = false
}
// themePickerCancel restores the theme from before the picker was opened.
func (m *Model) themePickerCancel() {
m.themeIdx = m.themePicker.savedIdx
if m.themeIdx < 0 {
applyThemeAll(theme.Default())
} else {
applyThemeAll(m.themes[m.themeIdx])
}
m.themePicker.visible = false
}
func (m *Model) themePickerHelpLine() string {
return helpKey("↓↑", "Scroll ") + helpKey("Enter", "Select ") + helpKey("Esc", "Close")
}
func (m *Model) themePickerVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) themePickerMaybeAdjustScroll(visible int) {
clampScroll(&m.themePicker.cursor, &m.themePicker.scroll, len(m.themes)+1, visible)
}
// openVisPicker opens the visualizer picker, which renders the mode list in the
// playlist region while keeping the visualizer live above it for preview. The
// cursor starts on the currently active mode.
func (m *Model) openVisPicker() {
m.visPicker.visible = true
m.visPicker.savedMode = int(m.vis.Mode)
m.visPicker.cursor = int(m.vis.Mode)
m.visPicker.scroll = 0
// Capture the mode list once; it is stable while the picker is open (Lua
// visualizers are registered at startup), so callers avoid re-allocating it.
m.visPicker.modes = m.vis.AllModeNames()
// Recompute chrome/height for the picker layout (its header + help differ
// from the playlist), then fit the cursor into the visible window.
m.refreshChrome()
m.applyHeightMode()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
}
// visPickerApply switches to the visualizer mode under the cursor. Run on every
// cursor move so the live preview updates as the user scrolls. Only recompute
// the layout when crossing the VisNone boundary, since that is the sole mode
// change that adds/removes the spectrum block (all other modes share a height).
func (m *Model) visPickerApply() {
wasNone := m.vis.Mode == ui.VisNone
m.vis.SetMode(ui.VisMode(m.visPicker.cursor))
if wasNone != (m.vis.Mode == ui.VisNone) {
m.refreshChrome()
m.applyHeightMode()
}
}
// visPickerClose restores playlist sizing after the picker layout is dismissed.
func (m *Model) visPickerClose() {
m.visPicker.visible = false
m.visPicker.modes = nil
m.refreshChrome()
m.applyHeightMode()
m.adjustScroll()
}
// visPickerSelect confirms the current selection, persists it, and closes.
func (m *Model) visPickerSelect() {
m.visPickerApply()
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
m.visPickerClose()
}
// visPickerCancel restores the mode from before the picker was opened.
func (m *Model) visPickerCancel() {
m.vis.SetMode(ui.VisMode(m.visPicker.savedMode))
m.visPickerClose()
}
func (m *Model) visPickerHelpLine() string {
return helpKey("↓↑", "Preview ") + helpKey("Enter", "Select ") + helpKey("Esc", "Cancel ") + helpKey("Ctrl+K", "Keys")
}
func (m *Model) visPickerVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) visPickerMaybeAdjustScroll(visible int) {
clampScroll(&m.visPicker.cursor, &m.visPicker.scroll, len(m.visPicker.modes), visible)
}
func (m *Model) devicePickerHelpLine() string {
return helpKey("↓↑", "Scroll ") + helpKey("Enter", "Select ") + helpKey("Esc", "Cancel")
}
func (m *Model) devicePickerVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) queueHelpLine() string {
return helpKey("↓↑", "Scroll ") +
helpKey("Shift+↓↑", "Reorder ") +
helpKey("d", "Remove ") +
helpKey("c", "Clear ") +
helpKey("Esc", "Close")
}
func (m *Model) queueVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) searchHelpLine() string {
return helpKey("↓↑", "Scroll ") +
helpKey("Enter", "Play ") +
helpKey("Tab", "Queue ") +
helpKey("Esc", "Close")
}
func (m *Model) searchVisible() int {
return m.effectivePlaylistVisible()
}
// closeSearchLayout restores playlist sizing after the inline search header and
// help line are dismissed, then refits the playlist cursor into view.
func (m *Model) closeSearchLayout() {
m.refreshChrome()
m.applyHeightMode()
m.adjustScroll()
}
func (m *Model) netSearchResultsHelpLine() string {
return helpKey("↓↑", "Scroll ") +
helpKey("Enter", "Play ") +
helpKey("a", "Append ") +
helpKey("q", "Queue next ") +
helpKey("Esc", "Back")
}
func (m *Model) netSearchResultsVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) spotSearchResultsHelpLine() string {
return helpKey("↓↑", "Scroll ") +
helpKey("Enter", "Play ") +
helpKey("a", "Append ") +
helpKey("q", "Queue next ") +
helpKey("p", "Add to playlist ") +
helpKey("Esc", "Back")
}
func (m *Model) spotSearchResultsVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) spotSearchPlaylistHelpLine() string {
return helpKey("↓↑", "Scroll ") + helpKey("Enter", "Select ") + helpKey("Esc", "Close")
}
func (m *Model) spotSearchPlaylistVisible() int {
return m.effectivePlaylistVisible()
}
// navVisible returns the nav-browser list height. The nav browser renders
// inline in the playlist region, so it shares the playlist's row budget.
func (m *Model) navVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) plMgrListHelpLine() string {
addLabel := "Add (nothing playing)"
if track, idx := m.currentPlaybackTrack(); idx >= 0 && track.Path != "" {
addLabel = "Add: " + truncate(track.DisplayName(), 32)
}
return helpKey("↓↑→", "Navigate ") +
helpKey("Enter", "Open ") +
helpKey("a", addLabel+" ") +
helpKey("w", "Save queue ") +
helpKey("r", "Rename ") +
helpKey("d", "Delete ") +
helpKey("u", "Undo ") +
helpKey("/", "Filter ") +
helpKey("Esc", "Close")
}
func (m *Model) plMgrListVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) plMgrListMaybeAdjustScroll(visible int) {
clampScroll(&m.plManager.cursor, &m.plManager.scroll, m.plMgrListViewCount(), visible)
}
func (m *Model) plMgrTracksHelpLine() string {
return helpKey("←↓↑", "Navigate ") +
helpKey("Enter", "Play this ") +
helpKey("p", "Play all ") +
helpKey("Spc/a", "Mark ") +
helpKey("[]", "Move ") +
helpKey("s", "Sort ") +
helpKey("o", "Add files ") +
helpKey("w", "Write ") +
helpKey("d", "Remove ") +
helpKey("u", "Undo ") +
helpKey("/", "Filter ") +
helpKey("Esc", "Back")
}
func (m *Model) plMgrTracksVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) plMgrTracksMaybeAdjustScroll(visible int) {
if m.plManager.filter != "" || !m.showAlbumHeaders {
clampScroll(&m.plManager.cursor, &m.plManager.scroll, m.plMgrTracksViewCount(), visible)
return
}
tracks := m.plManager.tracks
if len(tracks) == 0 {
return
}
if m.plManager.cursor < m.plManager.scroll {
m.plManager.scroll = m.plManager.cursor
}
for m.plManager.scroll < m.plManager.cursor && m.albumSeparatorRows(tracks, m.plManager.scroll, m.plManager.cursor, true) > visible {
m.plManager.scroll++
}
}
// openPlaylistManager loads playlist metadata and opens the manager overlay.
func (m *Model) openPlaylistManager() {
m.plMgrResetFilter()
m.plMgrRefreshList()
m.plManager.screen = plMgrScreenList
m.plManager.cursor = 0
m.plManager.scroll = 0
m.plManager.confirmDel = false
m.plManager.renameOldName = ""
m.plManager.renameName = ""
m.plManager.visible = true
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
}
// plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1.
func (m *Model) plMgrEnterTrackList(name string) {
tracks, err := m.localProvider.Tracks(name)
if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err)
return
}
m.plManager.selPlaylist = name
m.plManager.tracks = tracks
m.plManager.marked = make(map[int]bool)
m.plManager.sortMode = 0
m.setHeaderStateFromTracks(tracks)
m.plManager.screen = plMgrScreenTracks
m.plManager.cursor = 0
m.plManager.scroll = 0
m.plManager.confirmDel = false
m.plMgrResetFilter()
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
// plMgrResetFilter clears any active `/` filter on the playlist manager.
func (m *Model) plMgrResetFilter() {
m.plManager.filtering = false
m.plManager.filter = ""
m.plManager.filtered = nil
m.plManager.cursor = 0
m.plManager.scroll = 0
m.plManager.savedCursor = 0
m.plManager.savedScroll = 0
}
// plMgrRecomputeFilter rebuilds the filter index for the active screen.
func (m *Model) plMgrRecomputeFilter() {
m.plManager.filtered = m.plManager.filtered[:0]
if m.plManager.filter == "" {
m.plManager.filtered = nil
return
}
q := strings.ToLower(m.plManager.filter)
switch m.plManager.screen {
case plMgrScreenList:
for i, p := range m.plManager.playlists {
if strings.Contains(strings.ToLower(p.Name), q) {
m.plManager.filtered = append(m.plManager.filtered, i)
}
}
case plMgrScreenTracks:
for i, t := range m.plManager.tracks {
hay := strings.ToLower(t.DisplayName() + " " + t.Album + " " + t.Artist)
if strings.Contains(hay, q) {
m.plManager.filtered = append(m.plManager.filtered, i)
}
}
}
if m.plManager.cursor < 0 {
m.plManager.cursor = 0
}
m.plManager.scroll = 0
if m.plManager.screen == plMgrScreenList {
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
} else if m.plManager.screen == plMgrScreenTracks {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
}
// plMgrRealIndex maps a view-index to the real index in the underlying slice
// (playlists on the list screen, tracks on the track screen). Returns -1 if
// out of range or pointing at the "+ New Playlist" pseudo-entry on the list
// screen. unfilteredLen is the length of the unfiltered slice.
func (m Model) plMgrRealIndex(view, unfilteredLen int) int {
if m.plManager.filter == "" {
if view < 0 || view >= unfilteredLen {
return -1
}
return view
}
if view < 0 || view >= len(m.plManager.filtered) {
return -1
}
return m.plManager.filtered[view]
}
func (m Model) plMgrPlaylistRealIndex(view int) int {
return m.plMgrRealIndex(view, len(m.plManager.playlists))
}
func (m Model) plMgrTrackRealIndex(view int) int {
return m.plMgrRealIndex(view, len(m.plManager.tracks))
}
// plMgrRefreshList reloads playlist names and counts from disk and clamps the cursor.
func (m *Model) plMgrRefreshList() {
if m.localProvider == nil {
return
}
playlists, err := m.localProvider.Playlists()
if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err)
}
m.plManager.playlists = playlists
if m.plManager.filter != "" {
m.plMgrRecomputeFilter()
}
total := m.plMgrListViewCount()
if m.plManager.cursor >= total {
m.plManager.cursor = total - 1
}
if m.plManager.cursor < 0 {
m.plManager.cursor = 0
}
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
}
// plMgrListViewCount returns the visible row count on the list screen
// (filtered playlists + "+ New Playlist..." entry).
func (m Model) plMgrListViewCount() int {
if m.plManager.filter != "" {
return len(m.plManager.filtered) + 1
}
return len(m.plManager.playlists) + 1
}
// plMgrTracksViewCount returns the visible row count on the tracks screen.
func (m Model) plMgrTracksViewCount() int {
if m.plManager.filter != "" {
return len(m.plManager.filtered)
}
return len(m.plManager.tracks)
}
+197
View File
@@ -0,0 +1,197 @@
package model
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
)
func TestHandlePasteRoutesToActiveInput(t *testing.T) {
tests := []struct {
name string
model Model
content string
check func(t *testing.T, m *Model)
}{
{
name: "keymap search",
model: Model{keymap: keymapOverlay{visible: true}},
content: "ctrl",
check: func(t *testing.T, m *Model) {
if m.keymap.search != "ctrl" {
t.Fatalf("keymap.search = %q, want %q", m.keymap.search, "ctrl")
}
},
},
{
name: "net search",
model: Model{netSearch: netSearchState{active: true, query: "hello "}},
content: "world",
check: func(t *testing.T, m *Model) {
if m.netSearch.query != "hello world" {
t.Fatalf("netSearch.query = %q, want %q", m.netSearch.query, "hello world")
}
},
},
{
name: "search appends and filters",
model: Model{search: searchState{active: true, query: "ja"}, playlist: playlist.New()},
content: "zz",
check: func(t *testing.T, m *Model) {
if m.search.query != "jazz" {
t.Fatalf("search.query = %q, want %q", m.search.query, "jazz")
}
},
},
{
name: "jump input",
model: Model{jumping: true, jumpInput: "1:"},
content: "30",
check: func(t *testing.T, m *Model) {
if m.jumpInput != "1:30" {
t.Fatalf("jumpInput = %q, want %q", m.jumpInput, "1:30")
}
},
},
{
name: "url input",
model: Model{urlInputting: true},
content: "https://example.com/song.mp3",
check: func(t *testing.T, m *Model) {
if m.urlInput != "https://example.com/song.mp3" {
t.Fatalf("urlInput = %q, want %q", m.urlInput, "https://example.com/song.mp3")
}
},
},
{
name: "playlist manager new name",
model: Model{plManager: plManagerState{
visible: true,
screen: plMgrScreenNewName,
}},
content: "My Playlist",
check: func(t *testing.T, m *Model) {
if m.plManager.newName != "My Playlist" {
t.Fatalf("plManager.newName = %q, want %q", m.plManager.newName, "My Playlist")
}
},
},
{
name: "spotify search input",
model: Model{spotSearch: spotSearchState{
visible: true,
screen: spotSearchInput,
}},
content: "arctic monkeys",
check: func(t *testing.T, m *Model) {
if m.spotSearch.query != "arctic monkeys" {
t.Fatalf("spotSearch.query = %q, want %q", m.spotSearch.query, "arctic monkeys")
}
},
},
{
name: "spotify new name",
model: Model{spotSearch: spotSearchState{
visible: true,
screen: spotSearchNewName,
}},
content: "New Playlist",
check: func(t *testing.T, m *Model) {
if m.spotSearch.newName != "New Playlist" {
t.Fatalf("spotSearch.newName = %q, want %q", m.spotSearch.newName, "New Playlist")
}
},
},
{
name: "provider search (non-catalog)",
model: Model{provSearch: provSearchState{active: true, query: "rock"}},
content: " ballads",
check: func(t *testing.T, m *Model) {
if m.provSearch.query != "rock ballads" {
t.Fatalf("provSearch.query = %q, want %q", m.provSearch.query, "rock ballads")
}
},
},
{
name: "nav browser search",
model: Model{navBrowser: navBrowserState{
visible: true,
mode: navBrowseModeByAlbum,
searching: true,
}},
content: "album",
check: func(t *testing.T, m *Model) {
if m.navBrowser.search != "album" {
t.Fatalf("navBrowser.search = %q, want %q", m.navBrowser.search, "album")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := tt.model
if cmd := m.handlePaste(tt.content); cmd != nil {
t.Fatalf("handlePaste returned non-nil cmd")
}
tt.check(t, &m)
})
}
}
func TestHandlePasteEmptyContentIsNoop(t *testing.T) {
m := Model{netSearch: netSearchState{active: true, query: "before"}}
if cmd := m.handlePaste(""); cmd != nil {
t.Fatalf("handlePaste(\"\") returned non-nil cmd")
}
if m.netSearch.query != "before" {
t.Fatalf("query changed on empty paste: got %q", m.netSearch.query)
}
}
func TestHandlePasteNoInputActiveIsNoop(t *testing.T) {
m := Model{focus: focusPlaylist}
if cmd := m.handlePaste("ignored text"); cmd != nil {
t.Fatalf("handlePaste returned non-nil cmd when no input active")
}
}
func TestHandlePastePriorityOrder(t *testing.T) {
// When multiple input states are active, the highest-priority one wins.
// Nav browser search has higher priority than net search.
m := Model{
navBrowser: navBrowserState{
visible: true,
mode: navBrowseModeByAlbum,
searching: true,
},
netSearch: netSearchState{active: true},
}
m.handlePaste("test")
if m.navBrowser.search != "test" {
t.Fatalf("navBrowser.search = %q, want %q", m.navBrowser.search, "test")
}
if m.netSearch.query != "" {
t.Fatalf("netSearch.query = %q, want empty (lower priority)", m.netSearch.query)
}
}
func TestUpdateRoutesPasteMsg(t *testing.T) {
m := Model{netSearch: netSearchState{active: true}}
next, cmd := m.Update(tea.PasteMsg{Content: "pasted"})
got := next.(Model)
if cmd != nil {
t.Fatalf("Update(PasteMsg) cmd = %v, want nil", cmd)
}
if got.netSearch.query != "pasted" {
t.Fatalf("netSearch.query = %q, want %q", got.netSearch.query, "pasted")
}
}
+78
View File
@@ -0,0 +1,78 @@
package model
import (
"testing"
"time"
"github.com/bjarneo/cliamp/playlist"
)
func TestShouldReconnectOnUnpause(t *testing.T) {
tests := []struct {
name string
track playlist.Track
idx int
pause time.Duration
want bool
}{
{
name: "live http stream reconnects",
track: playlist.Track{
Path: "https://radio.example.com/stream",
Stream: true,
Realtime: true,
},
idx: 0,
want: true,
},
{
name: "short-paused yt-dlp stream does not reconnect",
track: playlist.Track{
Path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
Stream: true,
},
idx: 0,
pause: ytdlReconnectPauseThreshold - time.Second,
want: false,
},
{
name: "long-paused yt-dlp stream reconnects",
track: playlist.Track{
Path: "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
Stream: true,
},
idx: 0,
pause: ytdlReconnectPauseThreshold,
want: true,
},
{
name: "invalid current index does not reconnect",
track: playlist.Track{
Path: "https://radio.example.com/stream",
Stream: true,
Realtime: true,
},
idx: -1,
want: false,
},
{
name: "known duration live stream still reconnects",
track: playlist.Track{
Path: "https://radio.example.com/show.mp3",
Stream: true,
Realtime: true,
DurationSecs: 120,
},
idx: 0,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldReconnectOnUnpause(tt.track, tt.idx, tt.pause); got != tt.want {
t.Fatalf("shouldReconnectOnUnpause(...) = %v, want %v", got, tt.want)
}
})
}
}
+271
View File
@@ -0,0 +1,271 @@
package model
import (
"context"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/ui"
)
func (m *Model) openPlaylistPicker(tracks []playlist.Track, title string) {
if m.localProvider == nil {
m.status.Show("Local playlists are unavailable", statusTTLDefault)
return
}
lists, err := m.localProvider.Playlists()
if err != nil {
m.status.Showf(statusTTLDefault, "Playlist list failed: %s", err)
return
}
playlists := make([]playlist.PlaylistInfo, 0, len(lists))
for _, pl := range lists {
if pl.Name != history.PlaylistName {
playlists = append(playlists, pl)
}
}
m.plPicker = playlistPickerState{
visible: true,
screen: plPickerChoose,
playlists: playlists,
tracks: append([]playlist.Track(nil), tracks...),
title: title,
}
m.refreshChrome()
m.applyHeightMode()
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
}
func (m *Model) closePlaylistPicker() {
m.plPicker = playlistPickerState{}
m.refreshChrome()
m.applyHeightMode()
}
func (m *Model) plPickerCount() int {
return len(m.plPicker.playlists) + 1
}
func (m *Model) plPickerVisible() int {
return m.effectivePlaylistVisible()
}
func (m *Model) plPickerMaybeAdjustScroll(visible int) {
clampScroll(&m.plPicker.cursor, &m.plPicker.scroll, m.plPickerCount(), visible)
}
func (m Model) plPickerHeaderLine() string {
if m.plPicker.screen == plPickerNewName {
return promptHeader("New Playlist", m.plPicker.newName)
}
return sepHeaderN("Write to Playlist", m.plPicker.cursor+1, m.plPickerCount())
}
func (m Model) plPickerHelpLine() string {
if m.plPicker.screen == plPickerNewName {
if len(m.plPicker.tracks) == 0 {
return helpKey("Enter", "Create ") + helpKey("Esc", "Cancel")
}
return helpKey("Enter", "Create & add ") + helpKey("Esc", "Cancel")
}
return helpKey("↓↑", "Select ") + helpKey("Enter", "Write ") + helpKey("Esc", "Cancel")
}
func (m Model) renderPlaylistPickerBody() string {
budget := m.effectivePlaylistVisible()
if m.plPicker.screen == plPickerNewName {
msg := "Create an empty playlist."
if n := len(m.plPicker.tracks); n == 1 {
msg = "Create and add: " + truncate(m.plPicker.tracks[0].DisplayName(), max(1, ui.PanelWidth-18))
} else if n > 1 {
msg = fmt.Sprintf("Create and add %d tracks.", n)
}
return bodyMessage(msg, budget)
}
items := make([]string, len(m.plPicker.playlists)+1)
for i, pl := range m.plPicker.playlists {
items[i] = playlistLabel("", pl)
}
items[len(items)-1] = "+ New Playlist..."
var head string
switch n := len(m.plPicker.tracks); {
case m.plPicker.title != "":
head = m.plPicker.title
case n == 0:
head = "No tracks selected. Choose + New Playlist to create an empty one."
case n == 1:
head = "Track: " + m.plPicker.tracks[0].DisplayName()
default:
head = fmt.Sprintf("%d tracks selected", n)
}
head = dimStyle.Render(" " + truncate(head, max(1, ui.PanelWidth-2)))
list := windowList(items, m.plPicker.cursor, m.plPicker.scroll, max(0, budget-1))
return strings.Join([]string{head, list}, "\n")
}
func (m *Model) handlePlaylistPickerKey(msg tea.KeyPressMsg) tea.Cmd {
if m.plPicker.screen == plPickerNewName {
return m.handlePlaylistPickerNewNameKey(msg)
}
count := m.plPickerCount()
switch msg.String() {
case "ctrl+c":
m.closePlaylistPicker()
return m.quit()
case "esc", "backspace", "q":
m.closePlaylistPicker()
case "ctrl+x":
m.toggleExpandedView()
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case "up", "k":
if m.plPicker.cursor > 0 {
m.plPicker.cursor--
} else if count > 0 {
m.plPicker.cursor = count - 1
}
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case "down", "j":
if m.plPicker.cursor < count-1 {
m.plPicker.cursor++
} else if count > 0 {
m.plPicker.cursor = 0
}
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case "pgup", "ctrl+u":
if m.plPicker.cursor > 0 {
visible := m.plPickerVisible()
m.plPicker.cursor -= min(m.plPicker.cursor, visible)
m.plPickerMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
if m.plPicker.cursor < count-1 {
visible := m.plPickerVisible()
m.plPicker.cursor = min(count-1, m.plPicker.cursor+visible)
m.plPickerMaybeAdjustScroll(visible)
}
case "home", "g":
m.plPicker.cursor = 0
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case "end", "G":
if count > 0 {
m.plPicker.cursor = count - 1
}
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case "enter":
if m.plPicker.cursor < len(m.plPicker.playlists) {
m.writePickerTracks(m.plPicker.playlists[m.plPicker.cursor].Name)
m.closePlaylistPicker()
return nil
}
m.plPicker.screen = plPickerNewName
m.plPicker.newName = ""
m.plPicker.cursor = 0
m.plPicker.scroll = 0
}
return nil
}
func (m *Model) handlePlaylistPickerNewNameKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.plPicker.screen = plPickerChoose
m.plPicker.cursor = len(m.plPicker.playlists)
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
case tea.KeyEnter:
name := strings.TrimSpace(m.plPicker.newName)
if name == "" {
return nil
}
m.createPickerPlaylist(name)
m.closePlaylistPicker()
case tea.KeyBackspace:
m.plPicker.newName = removeLastRune(m.plPicker.newName)
case tea.KeySpace:
m.plPicker.newName += " "
default:
if len(msg.Text) > 0 {
m.plPicker.newName += msg.Text
}
}
return nil
}
func (m *Model) createPickerPlaylist(name string) {
c, ok := m.localProvider.(provider.PlaylistCreator)
if !ok {
m.status.Show("Playlist creation is not supported", statusTTLDefault)
return
}
id, err := c.CreatePlaylist(context.Background(), name)
if err != nil {
m.status.Showf(statusTTLDefault, "Create failed: %s", err)
return
}
if len(m.plPicker.tracks) == 0 {
m.status.Showf(statusTTLDefault, "Created %q", name)
m.refreshPlaylistManagerAfterWrite(id)
return
}
m.writePickerTracks(id)
}
func (m *Model) writePickerTracks(name string) {
added, skipped, err := m.writeTracksToPlaylist(name, m.plPicker.tracks)
if err != nil {
m.status.Showf(statusTTLDefault, "Write failed: %s", err)
return
}
switch {
case added > 0 && skipped > 0:
m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, name, skipped)
case added > 0:
m.status.Showf(statusTTLDefault, "Added %d to %q", added, name)
case skipped > 0:
m.status.Showf(statusTTLDefault, "Skipped %d duplicates in %q", skipped, name)
default:
m.status.Showf(statusTTLDefault, "Nothing added to %q", name)
}
m.refreshPlaylistManagerAfterWrite(name)
}
func (m *Model) writeTracksToPlaylist(name string, tracks []playlist.Track) (added, skipped int, err error) {
if len(tracks) == 0 {
return 0, 0, nil
}
if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok {
return bw.AddTracksToPlaylist(context.Background(), name, tracks)
}
w, ok := m.localProvider.(provider.PlaylistWriter)
if !ok {
return 0, 0, fmt.Errorf("playlist writes are not supported")
}
for _, track := range tracks {
if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil {
return added, skipped, err
}
added++
}
return added, skipped, nil
}
func (m *Model) refreshPlaylistManagerAfterWrite(name string) {
if !m.plManager.visible {
return
}
m.plMgrRefreshList()
if m.plManager.screen == plMgrScreenTracks && m.plManager.selPlaylist == name {
if tracks, err := m.localProvider.Tracks(name); err == nil {
m.plManager.tracks = tracks
m.plMgrRecomputeFilter()
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
}
}
+436
View File
@@ -0,0 +1,436 @@
package model
import (
"errors"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
const ytdlReconnectPauseThreshold = 45 * time.Second
// nextTrack advances to the next playlist track and starts playing it.
// Unplayable tracks are skipped automatically.
func (m *Model) nextTrack() tea.Cmd {
if m.playbackDetached {
m.playbackDetached = false
if m.playlist.Len() == 0 {
m.player.Stop()
m.clearPlaybackTrack()
return nil
}
return m.playCurrentTrack()
}
track, ok := m.playlist.Next()
if !ok {
m.player.Stop()
m.clearPlaybackTrack()
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
return m.playTrack(track)
}
// prevTrack goes to the previous track, or restarts if >3s into the current one.
// Unplayable tracks are skipped automatically.
func (m *Model) prevTrack() tea.Cmd {
if m.player.Position() > 3*time.Second {
if m.player.Seekable() {
// Seekable media rewinds in place; non-seekable streams must be restarted.
m.player.Seek(-m.player.Position())
return nil
}
track, idx := m.currentPlaybackTrack()
if idx >= 0 {
return m.playTrack(track)
}
return nil
}
track, ok := m.playlist.Prev()
if !ok {
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
return m.playTrack(track)
}
// playCurrentLogicalTrack starts playback from the playlist's active logical
// track, preserving queued playback state.
func (m *Model) playCurrentLogicalTrack() tea.Cmd {
track, idx := m.playlist.Current()
if idx < 0 {
return nil
}
m.titleOff = 0
m.plCursor = idx
m.adjustScroll()
return m.playTrack(track)
}
// playCurrentTrack starts playing the selected track, skipping forward in
// playlist order if the selection is unplayable.
func (m *Model) playCurrentTrack() tea.Cmd {
m.titleOff = 0
if m.playlist.Len() == 0 {
return nil
}
activation, ok := m.playlist.ActivateSelected()
if !ok {
m.player.Stop()
m.clearPlaybackTrack()
m.status.Show("No available tracks", statusTTLDefault)
return nil
}
if activation.Skipped {
m.status.Show("Track unavailable, skipping...", statusTTLDefault)
}
m.plCursor = activation.Index
m.adjustScroll()
return m.playTrack(activation.Track)
}
// playTrackImmediate appends a track to the playlist and starts playing it now,
// stopping any current playback. Used by search-result "Play now" actions.
func (m *Model) playTrackImmediate(track playlist.Track) tea.Cmd {
m.player.Stop()
m.player.ClearPreload()
m.playlist.Add(track)
m.loadedPlaylist = ""
m.addToHeaderState([]playlist.Track{track})
idx := m.playlist.Len() - 1
m.playlist.SetIndex(idx)
m.plCursor = idx
m.adjustScroll()
m.status.Showf(statusTTLMedium, "Playing: %s", track.DisplayName())
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
// appendTrack appends a track to the playlist; auto-plays if nothing is playing.
func (m *Model) appendTrack(track playlist.Track) tea.Cmd {
wasEmpty := m.playlist.Len() == 0
m.playlist.Add(track)
m.loadedPlaylist = ""
m.addToHeaderState([]playlist.Track{track})
idx := m.playlist.Len() - 1
m.status.Showf(statusTTLMedium, "Added: %s", track.DisplayName())
if wasEmpty || !m.player.IsPlaying() {
m.playlist.SetIndex(idx)
m.plCursor = idx
m.adjustScroll()
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
return nil
}
// closeNetSearch fully resets the net search overlay and restores focus,
// dropping any cached results so they don't linger between sessions.
func (m *Model) closeNetSearch() {
m.netSearch = netSearchState{}
m.focus = m.prevFocus
}
// closeSpotSearch fully resets the Spotify search overlay, dropping cached
// results, playlists, and the selected track.
func (m *Model) closeSpotSearch() {
m.spotSearch = spotSearchState{}
}
// queueTrackNext adds a track to the playlist and queues it to play next.
func (m *Model) queueTrackNext(track playlist.Track) tea.Cmd {
m.playlist.Add(track)
m.loadedPlaylist = ""
m.addToHeaderState([]playlist.Track{track})
idx := m.playlist.Len() - 1
m.playlist.Queue(idx)
m.status.Showf(statusTTLMedium, "Queued: %s", track.DisplayName())
if !m.player.IsPlaying() {
cmd := m.nextTrack()
m.notifyPlayback()
return cmd
}
return nil
}
// removeSelectedFromPlaylist removes the track at the current playlist cursor.
// If the active track is removed, playback is stopped; the cursor is clamped
// to the new playlist length.
func (m *Model) removeSelectedFromPlaylist() {
idx := m.plCursor
if idx < 0 || idx >= m.playlist.Len() {
return
}
track := m.playlist.Tracks()[idx]
loaded := m.loadedPlaylist
if loaded != "" {
if saver, ok := m.localProvider.(provider.PlaylistSaver); ok {
saved, err := m.localProvider.Tracks(loaded)
if err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
return
}
removed := false
for i := range saved {
if saved[i].Path == track.Path {
saved = append(saved[:i], saved[i+1:]...)
removed = true
break
}
}
if !removed {
m.status.Showf(statusTTLDefault, "Remove failed: %s is not in %q", track.DisplayName(), loaded)
return
}
if err := saver.SavePlaylist(loaded, saved); err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
return
}
}
}
wasActive := idx == m.playlist.Index()
if !m.playlist.Remove(idx) {
return
}
if wasActive {
m.player.Stop()
m.player.ClearPreload()
m.clearPlaybackTrack()
}
if newLen := m.playlist.Len(); newLen == 0 {
m.plCursor = 0
} else if m.plCursor >= newLen {
m.plCursor = newLen - 1
}
m.adjustScroll()
if loaded != "" {
m.status.Showf(statusTTLDefault, "Removed from %q: %s", loaded, track.DisplayName())
} else {
m.status.Showf(statusTTLDefault, "Removed from queue: %s", track.DisplayName())
}
m.notifyPlayback()
}
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
// yt-dlp URLs are streamed via a piped yt-dlp | ffmpeg chain for instant playback.
func (m *Model) playTrack(track playlist.Track) tea.Cmd {
m.pausedAt = time.Time{}
if track.Feed || playlist.IsFeed(track.Path) {
m.feedLoading = true
m.status.Show("Loading feed...", statusTTLLong)
return resolveFeedTrackCmd(track.Path)
}
track, fetchCmd := m.beginPlaybackTrack(track)
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
if playlist.IsYTDL(track.Path) {
m.buffering = true
m.bufferingAt = time.Now()
m.err = nil
dur := time.Duration(track.DurationSecs) * time.Second
if fetchCmd != nil {
return tea.Batch(playYTDLStreamCmd(m.player, track.Path, dur), fetchCmd)
}
return playYTDLStreamCmd(m.player, track.Path, dur)
}
// Fire now-playing notification for Navidrome tracks.
m.nowPlaying(track)
dur := time.Duration(track.DurationSecs) * time.Second
if track.Stream {
m.buffering = true
m.bufferingAt = time.Now()
m.err = nil
return tea.Batch(playStreamCmd(m.player, track.Path, dur), fetchCmd)
}
if err := m.player.Play(track.Path, dur); err != nil {
// Provider session went stale (e.g. Spotify auth expired and
// silent reconnect failed). Surface the standard sign-in
// overlay rather than the raw stream error.
if errors.Is(err, playlist.ErrNeedsAuth) {
m.provSignIn = true
m.err = nil
} else {
m.err = err
}
} else {
m.err = nil
m.applyResume()
m.backfillLoadedPlaylistDuration(track)
}
if fetchCmd != nil {
return tea.Batch(m.preloadNext(), fetchCmd)
}
return m.preloadNext()
}
func (m *Model) backfillLoadedPlaylistDuration(track playlist.Track) {
if m.loadedPlaylist == "" || track.DurationSecs > 0 || track.Stream || playlist.IsURL(track.Path) || strings.HasPrefix(track.Path, "ssh://") {
return
}
dur := int(m.player.Duration().Seconds())
if dur <= 0 {
return
}
saver, ok := m.localProvider.(provider.PlaylistSaver)
if !ok {
return
}
tracks, err := m.localProvider.Tracks(m.loadedPlaylist)
if err != nil {
return
}
changed := false
for i := range tracks {
if tracks[i].Path == track.Path && tracks[i].DurationSecs == 0 {
tracks[i].DurationSecs = dur
changed = true
break
}
}
if !changed {
return
}
if err := saver.SavePlaylist(m.loadedPlaylist, tracks); err == nil {
if idx := m.playlist.Index(); idx >= 0 {
track.DurationSecs = dur
m.playlist.SetTrack(idx, track)
}
}
}
// beginPlaybackTrack centralizes metadata refresh and model state reset for a
// new active track. It is used both by explicit playback and by gapless
// transitions, which advance audio without calling playTrack.
func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cmd) {
track = playlist.RefreshEmbeddedMetadata(track)
m.setPlaybackTrack(track)
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
if m.lyrics.visible {
q := lyricsLookupKey(track, track.Artist, track.Title)
if q == "" {
m.lyrics.loading = false
return track, nil
}
m.lyrics.loading = true
m.lyrics.query = q
return track, fetchTrackLyricsCmd(track, track.Artist, track.Title)
}
m.lyrics.loading = false
return track, nil
}
// togglePlayPause starts playback if stopped, or toggles pause if playing.
// For live streams and long-paused yt-dlp streams, unpausing reconnects instead
// of playing stale data sitting in OS/decoder buffers from before the pause.
func (m *Model) togglePlayPause() tea.Cmd {
if m.buffering {
return nil
}
if !m.player.IsPlaying() {
if m.playlist.CurrentIsQueued() {
return m.playCurrentLogicalTrack()
}
return m.playCurrentTrack()
}
if m.player.IsPaused() {
track, idx := m.currentPlaybackTrack()
pausedFor := time.Duration(0)
if !m.pausedAt.IsZero() {
pausedFor = time.Since(m.pausedAt)
}
if shouldReconnectOnUnpause(track, idx, pausedFor) {
if playlist.IsYTDL(track.Path) && m.player.IsYTDLSeek() {
return m.reconnectYTDLOnUnpause()
}
m.pausedAt = time.Time{}
m.player.Stop()
return m.playTrack(track)
}
}
m.togglePlayerPause()
return nil
}
func (m *Model) togglePlayerPause() {
m.player.TogglePause()
if m.player.IsPaused() {
m.pausedAt = time.Now()
return
}
m.pausedAt = time.Time{}
}
func (m *Model) reconnectYTDLOnUnpause() tea.Cmd {
m.seek.active = true
m.seek.targetPos = m.player.Position()
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
m.player.CancelSeekYTDL()
m.status.Show("Reconnecting stream...", statusTTLMedium)
p := m.player
return func() tea.Msg {
err := p.SeekYTDL(0)
if err == nil {
p.TogglePause()
}
return ytdlUnpauseReconnectMsg{err: err}
}
}
// shouldReconnectOnUnpause reports whether unpausing should reconnect and
// restart instead of resuming buffered audio.
func shouldReconnectOnUnpause(track playlist.Track, idx int, pausedFor time.Duration) bool {
if idx < 0 {
return false
}
if track.IsLive() {
return true
}
return pausedFor >= ytdlReconnectPauseThreshold && playlist.IsYTDL(track.Path)
}
// applyResume seeks to the saved resume position if the current track matches.
// It clears the resume state after a successful seek so it only fires once.
func (m *Model) applyResume() {
// secs == 0 is indistinguishable from "never played"; skip resume.
if m.resume.path == "" || m.resume.secs <= 0 {
return
}
track, _ := m.currentPlaybackTrack()
if track.Path != m.resume.path {
return
}
// Only seek if the player reports the stream is seekable; otherwise the
// seek is a no-op that returns nil, which we must not mistake for success.
if !m.player.Seekable() {
return
}
target := time.Duration(m.resume.secs) * time.Second
if err := m.player.Seek(target - m.player.Position()); err == nil {
m.resume.path = ""
m.resume.secs = 0
}
}
+35
View File
@@ -0,0 +1,35 @@
package model
import "github.com/bjarneo/cliamp/playlist"
func (m Model) currentPlaybackTrack() (playlist.Track, int) {
if m.playingTrackActive && (m.buffering || (m.player != nil && m.player.IsPlaying())) {
return m.playingTrack, 0
}
if m.playlist == nil {
return playlist.Track{}, -1
}
return m.playlist.Current()
}
func (m *Model) setPlaybackTrack(track playlist.Track) {
m.playingTrack = track
m.playingTrackActive = true
m.playbackDetached = false
}
func (m *Model) detachPlaybackTrack() {
track, idx := m.currentPlaybackTrack()
if idx < 0 {
return
}
m.playingTrack = track
m.playingTrackActive = true
m.playbackDetached = true
}
func (m *Model) clearPlaybackTrack() {
m.playingTrack = playlist.Track{}
m.playingTrackActive = false
m.playbackDetached = false
}
+407
View File
@@ -0,0 +1,407 @@
package model
import (
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
type playbackFakeEngine struct {
playing bool
gaplessAdvanced bool
paused bool
ytdlSeek bool
position time.Duration
playCalls []string
seekYTDLCalls []time.Duration
preloadCalls []string
clearPreloadCalls int
}
func (f *playbackFakeEngine) Play(path string, _ time.Duration) error {
f.playing = true
f.paused = false
f.playCalls = append(f.playCalls, path)
return nil
}
func (f *playbackFakeEngine) PlayYTDL(string, time.Duration) error { return nil }
func (f *playbackFakeEngine) Preload(path string, _ time.Duration) error {
f.preloadCalls = append(f.preloadCalls, path)
return nil
}
func (f *playbackFakeEngine) PreloadYTDL(string, time.Duration) error { return nil }
func (f *playbackFakeEngine) ClearPreload() { f.clearPreloadCalls++ }
func (f *playbackFakeEngine) Stop() { f.playing, f.paused = false, false }
func (f *playbackFakeEngine) Close() {}
func (f *playbackFakeEngine) TogglePause() { f.paused = !f.paused }
func (f *playbackFakeEngine) Seek(time.Duration) error { return nil }
func (f *playbackFakeEngine) SeekYTDL(d time.Duration) error {
f.seekYTDLCalls = append(f.seekYTDLCalls, d)
return nil
}
func (f *playbackFakeEngine) CancelSeekYTDL() {}
func (f *playbackFakeEngine) IsPlaying() bool { return f.playing }
func (f *playbackFakeEngine) IsPaused() bool { return f.paused }
func (f *playbackFakeEngine) Drained() bool { return false }
func (f *playbackFakeEngine) HasPreload() bool { return false }
func (f *playbackFakeEngine) Seekable() bool { return false }
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
func (f *playbackFakeEngine) IsYTDLSeek() bool { return f.ytdlSeek }
func (f *playbackFakeEngine) GaplessAdvanced() bool {
if !f.gaplessAdvanced {
return false
}
f.gaplessAdvanced = false
return true
}
func (f *playbackFakeEngine) Position() time.Duration { return f.position }
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) {
return 0, 0
}
func (f *playbackFakeEngine) SetVolumeMin(float64) {}
func (f *playbackFakeEngine) VolumeMin() float64 { return -50 }
func (f *playbackFakeEngine) SetVolume(float64) {}
func (f *playbackFakeEngine) Volume() float64 { return 0 }
func (f *playbackFakeEngine) SetSpeed(float64) {}
func (f *playbackFakeEngine) Speed() float64 { return 1 }
func (f *playbackFakeEngine) ToggleMono() {}
func (f *playbackFakeEngine) Mono() bool { return false }
func (f *playbackFakeEngine) SetEQBand(int, float64) {}
func (f *playbackFakeEngine) EQBands() [10]float64 { return [10]float64{} }
func (f *playbackFakeEngine) StreamErr() error { return nil }
func (f *playbackFakeEngine) StreamTitle() string { return "" }
func (f *playbackFakeEngine) StreamBytes() (downloaded, total int64) { return 0, 0 }
func (f *playbackFakeEngine) SamplesInto([]float64) int { return 0 }
func (f *playbackFakeEngine) SampleRate() int { return 44100 }
func TestNavTrackListQueueStartsQueuedTrackWhenStopped(t *testing.T) {
player := &playbackFakeEngine{}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Existing", Path: "https://example.com/existing", Stream: true},
{Title: "Other", Path: "https://example.com/other", Stream: true},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
navBrowser: navBrowserState{
tracks: []playlist.Track{
{Title: "Queued", Path: "https://example.com/queued", Stream: true},
},
},
}
cmd := m.handleNavTrackListKey(tea.KeyPressMsg{Text: "q"})
if cmd == nil {
t.Fatal("handleNavTrackListKey(q) = nil, want command")
}
if current, idx := m.playlist.Current(); current.Title != "Queued" || idx != 2 {
t.Fatalf("current = (%q,%d), want (\"Queued\",2)", current.Title, idx)
}
if m.plCursor != 2 {
t.Fatalf("plCursor = %d, want 2", m.plCursor)
}
if p.QueueLen() != 0 {
t.Fatalf("QueueLen() = %d, want 0 after starting queued track", p.QueueLen())
}
}
func TestTogglePlayPauseRestartsQueuedCurrentTrack(t *testing.T) {
player := &playbackFakeEngine{}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Base", Path: "base.mp3", DurationSecs: 180},
{Title: "Queued", Path: "queued.mp3", DurationSecs: 180},
})
p.SetIndex(0)
p.Queue(1)
if track, ok := p.Next(); !ok || track.Title != "Queued" {
t.Fatalf("Next() = (%q,%t), want (\"Queued\",true)", track.Title, ok)
}
if !p.CurrentIsQueued() {
t.Fatal("CurrentIsQueued() = false, want true")
}
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
if cmd := m.togglePlayPause(); cmd != nil {
_ = cmd()
}
if len(player.playCalls) != 1 || player.playCalls[0] != "queued.mp3" {
t.Fatalf("playCalls = %v, want [queued.mp3]", player.playCalls)
}
if current, idx := m.playlist.Current(); current.Title != "Queued" || idx != 1 {
t.Fatalf("current = (%q,%d), want (\"Queued\",1)", current.Title, idx)
}
}
func TestTogglePlayPauseReconnectsLongPausedYTDLAtCurrentPosition(t *testing.T) {
player := &playbackFakeEngine{
playing: true,
paused: true,
ytdlSeek: true,
position: 90 * time.Second,
}
p := playlist.New()
p.Replace([]playlist.Track{{
Title: "YouTube Music",
Path: "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
Stream: true,
DurationSecs: 180,
}})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
pausedAt: time.Now().Add(-ytdlReconnectPauseThreshold),
}
cmd := m.togglePlayPause()
if cmd == nil {
t.Fatal("togglePlayPause() = nil, want reconnect command")
}
msg := cmd()
if reconnect, ok := msg.(ytdlUnpauseReconnectMsg); !ok || reconnect.err != nil {
t.Fatalf("cmd() = %#v, want successful ytdlUnpauseReconnectMsg", msg)
}
if len(player.seekYTDLCalls) != 1 || player.seekYTDLCalls[0] != 0 {
t.Fatalf("seekYTDLCalls = %v, want [0]", player.seekYTDLCalls)
}
if player.paused {
t.Fatal("player stayed paused after reconnect command")
}
if !m.seek.active || m.seek.targetPos != 90*time.Second {
t.Fatalf("seek state = active:%v target:%s, want active target 1m30s", m.seek.active, m.seek.targetPos)
}
}
func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) {
player := &playbackFakeEngine{}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Queued", Path: "https://example.com/queued", Stream: true},
{Title: "Missing", Unplayable: true},
{Title: "Replacement", Path: "https://example.com/replacement", Stream: true},
})
p.SetIndex(1)
p.Queue(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
cmd := m.playCurrentTrack()
if cmd == nil {
t.Fatal("playCurrentTrack() = nil, want command")
}
if idx := m.playlist.Index(); idx != 2 {
t.Fatalf("playlist.Index() = %d, want 2", idx)
}
if m.plCursor != 2 {
t.Fatalf("plCursor = %d, want 2", m.plCursor)
}
if m.status.text != "Track unavailable, skipping..." {
t.Fatalf("status.text = %q, want %q", m.status.text, "Track unavailable, skipping...")
}
if p.QueueLen() != 1 {
t.Fatalf("QueueLen() = %d, want 1", p.QueueLen())
}
}
func TestPlayCurrentTrackUnplayableStopsWhenNoReplacementExists(t *testing.T) {
player := &playbackFakeEngine{playing: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Playing", Path: "playing.mp3", DurationSecs: 2},
{Title: "Missing", Unplayable: true},
})
p.SetIndex(1)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
if cmd := m.playCurrentTrack(); cmd != nil {
t.Fatalf("playCurrentTrack() = %v, want nil", cmd)
}
if len(player.playCalls) != 0 {
t.Fatalf("playCalls = %v, want none", player.playCalls)
}
if player.IsPlaying() {
t.Fatal("player.IsPlaying() = true, want false")
}
if _, idx := m.playlist.Current(); idx != 1 {
t.Fatalf("current index = %d, want 1", idx)
}
if m.status.text != "No available tracks" {
t.Fatalf("status.text = %q, want %q", m.status.text, "No available tracks")
}
}
func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackFakeEngine) {
t.Helper()
player := &playbackFakeEngine{playing: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Old", Path: "old.mp3", DurationSecs: 180},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
updated, _ := m.Update(tracksLoadedMsg{
tracks: []playlist.Track{
{Title: "New 1", Path: "new1.mp3", DurationSecs: 180},
{Title: "New 2", Path: "new2.mp3", DurationSecs: 180},
},
})
m = updated.(Model)
return m, player
}
func TestProviderPlaylistLoadWhilePlayingKeepsNowPlayingTrack(t *testing.T) {
m, player := modelAfterProviderPlaylistLoadWhilePlaying(t)
track, idx := m.currentPlaybackTrack()
if idx < 0 || track.Title != "Old" {
t.Fatalf("currentPlaybackTrack() = (%q,%d), want old playing track", track.Title, idx)
}
if !m.playbackDetached {
t.Fatal("playbackDetached = false, want true")
}
if player.clearPreloadCalls != 1 {
t.Fatalf("ClearPreload calls = %d, want 1", player.clearPreloadCalls)
}
tracks := m.playlist.Tracks()
if len(tracks) != 2 || tracks[0].Title != "New 1" || tracks[1].Title != "New 2" {
t.Fatalf("playlist tracks = %#v, want new provider playlist only", tracks)
}
if info := m.renderTrackInfo(); !strings.Contains(info, "Old") {
t.Fatalf("renderTrackInfo() = %q, want old playing track", info)
}
}
func TestNextAfterProviderPlaylistLoadStartsFirstNewTrack(t *testing.T) {
m, player := modelAfterProviderPlaylistLoadWhilePlaying(t)
cmd := m.nextTrack()
if cmd != nil {
_ = cmd()
}
if len(player.playCalls) == 0 || player.playCalls[0] != "new1.mp3" {
t.Fatalf("playCalls = %v, want first new track", player.playCalls)
}
if m.playbackDetached {
t.Fatal("playbackDetached = true, want false")
}
track, _ := m.currentPlaybackTrack()
if track.Title != "New 1" {
t.Fatalf("currentPlaybackTrack() = %q, want New 1", track.Title)
}
}
func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
m, player := modelAfterProviderPlaylistLoadWhilePlaying(t)
cmd := m.preloadNext()
if cmd == nil {
t.Fatal("preloadNext() = nil, want preload command")
}
_ = cmd()
if len(player.preloadCalls) != 1 || player.preloadCalls[0] != "new1.mp3" {
t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls)
}
}
func TestBeginPlaybackTrackFetchesEmbeddedLyricsWithoutNetworkMetadata(t *testing.T) {
m := Model{lyrics: lyricsState{visible: true}}
track := playlist.Track{Title: "Local", EmbeddedLyrics: "Line one\nLine two"}
_, cmd := m.beginPlaybackTrack(track)
if cmd == nil {
t.Fatal("beginPlaybackTrack() command = nil, want embedded lyrics command")
}
if !m.lyrics.loading {
t.Fatal("lyrics.loading = false, want true")
}
msg, ok := cmd().(lyricsLoadedMsg)
if !ok {
t.Fatalf("lyrics command returned %T, want lyricsLoadedMsg", msg)
}
if msg.err != nil {
t.Fatalf("lyrics command error = %v", msg.err)
}
if len(msg.lines) != 2 || msg.lines[0].Text != "Line one" || msg.lines[1].Text != "Line two" {
t.Fatalf("lyrics lines = %+v, want embedded plain text", msg.lines)
}
}
func TestGaplessAdvanceRefreshesLyricsAndArtwork(t *testing.T) {
player := &playbackFakeEngine{playing: true, gaplessAdvanced: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Old", Artist: "Artist", Path: "old.mp3", DurationSecs: 180, EmbeddedLyrics: "old lyric", AlbumArtURL: "file:///old.jpg"},
{Title: "New", Artist: "Artist", Path: "new.mp3", DurationSecs: 180, EmbeddedLyrics: "[00:01.00]new lyric", AlbumArtURL: "file:///new.jpg"},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
lyrics: lyricsState{
visible: true,
query: "Artist\nOld",
},
}
m.setPlaybackTrack(p.Tracks()[0])
m.lyrics.lines = nil
next, cmd := m.Update(tickMsg(time.Now()))
m2 := next.(Model)
if cmd == nil {
t.Fatal("Update() command = nil, want lyric/preload/tick batch")
}
track, _ := m2.currentPlaybackTrack()
if track.Title != "New" {
t.Fatalf("current track = %q, want New", track.Title)
}
if track.AlbumArtURL != "file:///new.jpg" {
t.Fatalf("AlbumArtURL = %q, want new artwork", track.AlbumArtURL)
}
if m2.lyrics.query != "Artist\nNew" {
t.Fatalf("lyrics.query = %q, want new track query", m2.lyrics.query)
}
if !m2.lyrics.loading {
t.Fatal("lyrics.loading = false, want true for new track fetch")
}
}
+97
View File
@@ -0,0 +1,97 @@
package model
import (
"github.com/bjarneo/cliamp/luaplugin"
)
// pluginEmitState is the last player/queue state emitted to plugins. It lets
// emitPluginEvents diff against the previous Update and fire an event only when
// a value actually changed, regardless of which code path mutated it.
type pluginEmitState struct {
ready bool
shuffle bool
repeat string
volume float64
eqBands [10]float64
plCount int
plIndex int
queueLen int
}
// emitPlugin sends a single event to plugins, guarding the nil/has-hooks case
// so callers don't repeat it. Safe to call when no plugins are loaded.
func (m *Model) emitPlugin(event string, data map[string]any) {
if m.luaMgr == nil || !m.luaMgr.HasHook(event) {
return
}
m.luaMgr.Emit(event, data)
}
// emitPluginEvents diffs current player/queue state against the last emission
// and fires the corresponding delta events. Called once per Update (after the
// message is handled) so every mutation path is covered from a single site.
//
// Each branch is gated on HasHook so the live-state reads (some of which take
// the speaker lock, e.g. Volume/EQBands) only happen when a plugin is actually
// listening for that event.
func (m *Model) emitPluginEvents() {
if m.luaMgr == nil || m.pluginEmit == nil {
return
}
pe := m.pluginEmit
// First call: snapshot current state without emitting so a freshly started
// app doesn't fire spurious "changed" events.
if !pe.ready {
pe.shuffle = m.playlist.Shuffled()
pe.repeat = m.playlist.Repeat().String()
pe.volume = m.player.Volume()
pe.eqBands = m.player.EQBands()
pe.plCount = m.playlist.Len()
pe.plIndex = m.playlist.Index()
pe.queueLen = m.playlist.QueueLen()
pe.ready = true
return
}
if m.luaMgr.HasHook(luaplugin.EventPlayerMode) {
shuffle := m.playlist.Shuffled()
repeat := m.playlist.Repeat().String()
if shuffle != pe.shuffle || repeat != pe.repeat {
pe.shuffle, pe.repeat = shuffle, repeat
m.luaMgr.Emit(luaplugin.EventPlayerMode, map[string]any{
"shuffle": shuffle,
"repeat": repeat,
})
}
}
if m.luaMgr.HasHook(luaplugin.EventPlayerVolume) {
if v := m.player.Volume(); v != pe.volume {
pe.volume = v
m.luaMgr.Emit(luaplugin.EventPlayerVolume, map[string]any{"db": v})
}
}
if m.luaMgr.HasHook(luaplugin.EventPlayerEQ) {
if bands := m.player.EQBands(); bands != pe.eqBands {
pe.eqBands = bands
m.luaMgr.Emit(luaplugin.EventPlayerEQ, map[string]any{
"bands": bands[:],
"preset": m.EQPresetName(),
})
}
}
if m.luaMgr.HasHook(luaplugin.EventQueueChange) {
count, index, qlen := m.playlist.Len(), m.playlist.Index(), m.playlist.QueueLen()
if count != pe.plCount || index != pe.plIndex || qlen != pe.queueLen {
pe.plCount, pe.plIndex, pe.queueLen = count, index, qlen
m.luaMgr.Emit(luaplugin.EventQueueChange, map[string]any{
"count": count,
"index": index,
"queued": qlen,
})
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package model
import (
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/resolve"
)
// PluginQueueMsg is sent by Lua plugins (cliamp.queue.*) to mutate the queue.
// Mutations are routed through the Update loop rather than applied directly
// from the plugin goroutine so the model's derived state (cursor, current
// index, playback) stays consistent. Indices are 0-based.
type PluginQueueMsg struct {
Op string // "add" | "jump" | "remove" | "move"
Path string // add
Index int // jump, remove, move (from)
To int // move (to)
}
// pluginQueueAddedMsg carries tracks resolved for a cliamp.queue.add() call
// back to the Update loop, which appends them.
type pluginQueueAddedMsg struct{ tracks []playlist.Track }
// handlePluginQueue applies a queue mutation requested by a plugin and returns
// any follow-up command (resolve for add, playback for jump).
func (m *Model) handlePluginQueue(msg PluginQueueMsg) tea.Cmd {
switch msg.Op {
case "add":
return resolvePluginAddCmd(msg.Path)
case "jump":
if msg.Index < 0 || msg.Index >= m.playlist.Len() {
return nil
}
m.scrobbleCurrent()
m.playlist.SetIndex(msg.Index)
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
case "remove":
m.removeIndex(msg.Index)
return nil
case "move":
if m.playlist.Move(msg.Index, msg.To) {
m.adjustScroll()
}
return nil
}
return nil
}
// removeIndex removes the track at idx, mirroring the side effects of the
// interactive delete: stop playback if the active track was removed and clamp
// the playlist cursor.
func (m *Model) removeIndex(idx int) {
if idx < 0 || idx >= m.playlist.Len() {
return
}
wasActive := idx == m.playlist.Index()
if !m.playlist.Remove(idx) {
return
}
if wasActive {
m.player.Stop()
m.player.ClearPreload()
m.clearPlaybackTrack()
}
if newLen := m.playlist.Len(); newLen == 0 {
m.plCursor = 0
} else if m.plCursor >= newLen {
m.plCursor = newLen - 1
}
m.adjustScroll()
m.notifyPlayback()
}
// resolvePluginAddCmd resolves a plugin-supplied path/URL off the UI thread,
// reusing the same pipeline as CLI positional arguments, and hands the tracks
// back via pluginQueueAddedMsg.
func resolvePluginAddCmd(path string) tea.Cmd {
return func() tea.Msg {
res, err := resolve.Args([]string{path})
if err != nil {
return pluginQueueAddedMsg{}
}
tracks := res.Tracks
if len(res.Pending) > 0 {
if remote, rerr := resolve.Remote(res.Pending); rerr == nil {
tracks = append(tracks, remote...)
}
}
return pluginQueueAddedMsg{tracks: tracks}
}
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
)
// streamPreloadLeadTime is how far before the end of a stream we arm the
// gapless next pipeline. Opening the preload HTTP connection too early can
// cause the server to close the current stream (e.g., per-user concurrent
// stream limits on Navidrome), which makes the mp3 decoder error out and
// triggers a premature gapless transition. 3 seconds is short enough that
// most servers won't enforce a concurrency limit for such a brief overlap,
// and any resulting early skip is imperceptible (≤3 s from the true end).
const streamPreloadLeadTime = 3 * time.Second
// ytdlPreloadLeadTime is the lead time used for yt-dlp (YouTube/SoundCloud)
// URLs. These need longer because spinning up the yt-dlp | ffmpeg pipe chain
// takes 3-10 seconds, so we start preloading much earlier.
const ytdlPreloadLeadTime = 15 * time.Second
// preloadNext looks ahead in the playlist and preloads the next track for
// gapless transition. Errors are silently ignored — playback falls back to
// non-gapless if preloading fails.
//
// For HTTP streams with a known duration, preloading is deferred until the
// current track is within streamPreloadLeadTime of its end. This prevents the
// gapless streamer from having a live HTTP connection armed too early, which
// would cause the player to skip to the next track if the decoder signals EOF
// prematurely (e.g. a mis-estimated Content-Length from a transcoding server).
// When position has not yet reached the threshold, this function returns nil
// and the tick loop will retry on the next pass.
func (m *Model) preloadNext() tea.Cmd {
var next playlist.Track
var ok bool
if m.playbackDetached {
var idx int
next, idx = m.playlist.Current()
ok = idx >= 0
} else {
next, ok = m.playlist.PeekNext()
}
if !ok {
return nil
}
// Preload yt-dlp tracks with the same lead-time deferral as HTTP streams.
if playlist.IsYTDL(next.Path) {
dur := m.player.Duration()
if dur > 0 {
remaining := dur - m.player.Position()
if remaining > ytdlPreloadLeadTime {
return nil
}
}
nextDur := time.Duration(next.DurationSecs) * time.Second
m.preloading = true
return preloadYTDLStreamCmd(m.player, next.Path, nextDur)
}
if next.Stream {
// For streams, only arm gapless if we're within the lead-time window.
// If we don't know the duration yet (0), preload immediately as before
// so that streams without duration metadata still get gapless behaviour.
dur := m.player.Duration()
if dur > 0 {
pos := m.player.Position()
remaining := dur - pos
if remaining > streamPreloadLeadTime {
// Too early — caller should retry from the tick loop.
return nil
}
}
nextDur := time.Duration(next.DurationSecs) * time.Second
// Mark in-flight so the tick loop doesn't dispatch a second concurrent
// preload before this goroutine has finished arming gapless.SetNext.
m.preloading = true
return preloadStreamCmd(m.player, next.Path, nextDur)
}
nextDur := time.Duration(next.DurationSecs) * time.Second
m.preloading = true
return preloadLocalCmd(m.player, next.Path, nextDur)
}
+219
View File
@@ -0,0 +1,219 @@
package model
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// resetProviderNav resets provider navigation and search state to the top.
func (m *Model) resetProviderNav() {
m.provCursor = 0
m.provScroll = 0
m.provLoading = true
m.provSearch.active = false
m.provSearch.query = ""
m.provSearch.results = nil
m.provSearch.cursor = 0
m.provSearch.scroll = 0
}
// StartInProvider configures the model to begin in the provider browse view.
// Call this from main when no CLI tracks or pending URLs were given.
func (m *Model) StartInProvider() {
if m.provider != nil {
m.focus = focusProvider
m.resetProviderNav()
}
}
// switchProvider sets the active provider by pill index and fetches its playlists.
func (m *Model) switchProvider(idx int) tea.Cmd {
if idx < 0 || idx >= len(m.providers) {
return nil
}
m.provPillIdx = idx
m.provider = m.providers[idx].Provider
m.providerLists = nil
m.provSignIn = false
m.catalogBatch = catalogBatchState{}
m.activeProviderPlaylistID = ""
m.resetProviderNav()
m.focus = focusProvider
return fetchPlaylistsCmd(m.provider)
}
// quickSwitchProvider closes any browser overlays and jumps to the provider
// matched by key. Use the same Shift+letter shortcuts that switch providers
// from the main pane (S, N, P, J, E, Y, M, R, L). Returns nil when the key doesn't
// match a known provider.
func (m *Model) quickSwitchProvider(key string) tea.Cmd {
provKey := providerKeyForShortcut(key)
if provKey == "" {
return nil
}
// Close any open overlays so the user lands on the provider pane.
m.navBrowser.visible = false
m.plManager.visible = false
m.fileBrowser.visible = false
return m.switchToProvider(provKey)
}
// providerKeyForShortcut maps the Shift+letter provider shortcuts to the
// config key used by switchToProvider, or "" when the key is unrelated.
func providerKeyForShortcut(key string) string {
switch key {
case "S":
return "spotify"
case "N":
return "navidrome"
case "P":
return "plex"
case "J":
return "jellyfin"
case "E":
return "emby"
case "Y":
return "yt"
case "M":
return "netease"
case "L":
return "local"
case "R":
return "radio"
}
return ""
}
// switchToProvider finds a provider by config key and switches to it.
// Returns nil if the provider is not configured.
func (m *Model) switchToProvider(key string) tea.Cmd {
for i, pe := range m.providers {
if pe.Key == key {
return m.switchProvider(i)
}
}
return nil
}
// SetPendingURLs stores remote URLs (feeds, M3U) for async resolution after Init.
func (m *Model) SetPendingURLs(urls []string) {
m.pendingURLs = urls
m.feedLoading = len(urls) > 0
}
// SetLoadedPlaylist records that the live queue exactly mirrors a local saved
// playlist, allowing path-based write-backs such as bookmarks and removals.
func (m *Model) SetLoadedPlaylist(name string) {
m.loadedPlaylist = name
}
// findBrowseProvider returns the first provider that supports browsing
// (ArtistBrowser or AlbumBrowser), preferring the active provider.
func (m *Model) findBrowseProvider() playlist.Provider {
return m.findProviderWith(func(p playlist.Provider) bool {
if _, ok := p.(provider.ArtistBrowser); ok {
return true
}
_, ok := p.(provider.AlbumBrowser)
return ok
})
}
func (m *Model) openNavBrowserWith(prov playlist.Provider) {
m.navBrowser.prov = prov
m.navBrowser.visible = true
m.navBrowser.mode = navBrowseModeMenu
m.navBrowser.screen = navBrowseScreenList
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.artists = nil
m.navBrowser.albums = nil
m.navBrowser.tracks = nil
m.navBrowser.loading = false
m.navBrowser.albumLoading = false
m.navBrowser.albumDone = false
m.navBrowser.searching = false
m.navBrowser.search = ""
m.navBrowser.searchIdx = nil
m.navBrowser.selArtist = provider.ArtistInfo{}
m.navBrowser.selAlbum = provider.AlbumInfo{}
if ab, ok := prov.(provider.AlbumBrowser); ok {
m.navBrowser.sortType = ab.DefaultAlbumSort()
} else {
m.navBrowser.sortType = ""
}
}
// navUpdateSearch rebuilds navSearchIdx from the current navSearch query
// against whichever list is active on the current nav screen.
func (m *Model) navUpdateSearch() {
q := strings.ToLower(m.navBrowser.search)
if q == "" {
m.navBrowser.searchIdx = nil
return
}
m.navBrowser.searchIdx = nil
switch {
case m.navBrowser.mode == navBrowseModeByArtist && m.navBrowser.screen == navBrowseScreenList,
m.navBrowser.mode == navBrowseModeByArtistAlbum && m.navBrowser.screen == navBrowseScreenList:
for i, a := range m.navBrowser.artists {
if strings.Contains(strings.ToLower(a.Name), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
case m.navBrowser.mode == navBrowseModeByAlbum && m.navBrowser.screen == navBrowseScreenList,
m.navBrowser.mode == navBrowseModeByArtistAlbum && m.navBrowser.screen == navBrowseScreenAlbums:
for i, a := range m.navBrowser.albums {
if strings.Contains(strings.ToLower(a.Name), q) ||
strings.Contains(strings.ToLower(a.Artist), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
case m.navBrowser.screen == navBrowseScreenTracks:
for i, t := range m.navBrowser.tracks {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Artist), q) ||
strings.Contains(strings.ToLower(t.Album), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
}
}
// navClearSearch resets the nav search state.
func (m *Model) navClearSearch() {
m.navBrowser.searching = false
m.navBrowser.search = ""
m.navBrowser.searchIdx = nil
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
}
// fetchNavArtistAllTracksCmd first fetches the artist's album list, then fetches
// all tracks across every album. This is used by the "By Artist" browse mode.
// The provider must implement both ArtistBrowser and AlbumTrackLoader.
func (m *Model) fetchNavArtistAllTracksCmd(ab provider.ArtistBrowser, artistID string) tea.Cmd {
loader, _ := m.navBrowser.prov.(provider.AlbumTrackLoader)
return func() tea.Msg {
albums, err := ab.ArtistAlbums(artistID)
if err != nil {
return err
}
if loader == nil {
return navTracksLoadedMsg(nil)
}
var all []playlist.Track
for _, album := range albums {
tracks, err := loader.AlbumTracks(album.ID)
if err != nil {
return err
}
all = append(all, tracks...)
}
return navTracksLoadedMsg(all)
}
}
+58
View File
@@ -0,0 +1,58 @@
package model
import "testing"
func TestSaveStateActivityTextTracksPendingDownloads(t *testing.T) {
var save saveState
if got := save.activityText(); got != "" {
t.Fatalf("activityText() = %q, want empty", got)
}
save.startDownload()
if got := save.activityText(); got != "Downloading..." {
t.Fatalf("activityText() after first start = %q, want %q", got, "Downloading...")
}
save.startDownload()
if got := save.activityText(); got != "Downloading... (2)" {
t.Fatalf("activityText() after second start = %q, want %q", got, "Downloading... (2)")
}
save.finishDownload()
if got := save.activityText(); got != "Downloading..." {
t.Fatalf("activityText() after first finish = %q, want %q", got, "Downloading...")
}
save.finishDownload()
if got := save.activityText(); got != "" {
t.Fatalf("activityText() after second finish = %q, want empty", got)
}
}
func TestYTDLSavedMsgKeepsSaveActivityWhileDownloadsRemain(t *testing.T) {
m := Model{
save: saveState{
pendingDownloads: 2,
},
}
nextModel, cmd := m.Update(ytdlSavedMsg{path: "/tmp/song.mp3"})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want ui.Model", nextModel)
}
if next.save.pendingDownloads != 1 {
t.Fatalf("pendingDownloads after ytdlSavedMsg = %d, want 1", next.save.pendingDownloads)
}
if got := next.save.activityText(); got != "Downloading..." {
t.Fatalf("activityText() after ytdlSavedMsg = %q, want %q", got, "Downloading...")
}
if got := next.status.text; got != "Saved to /tmp/song.mp3" {
t.Fatalf("status.text after ytdlSavedMsg = %q, want %q", got, "Saved to /tmp/song.mp3")
}
}
+150
View File
@@ -0,0 +1,150 @@
package model
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/ui"
)
// clampScroll keeps cursor inside [0, count) and adjusts scroll so that
// the cursor sits within the visible window of `visible` rows.
func clampScroll(cursor, scroll *int, count, visible int) {
if visible <= 0 {
return
}
if *cursor < 0 {
*cursor = 0
}
if *cursor >= count && count > 0 {
*cursor = count - 1
}
if *cursor < *scroll {
*scroll = *cursor
} else if *cursor >= *scroll+visible {
*scroll = *cursor - visible + 1
}
if *scroll+visible > count && count > 0 {
*scroll = max(0, count-visible)
}
if *scroll < 0 {
*scroll = 0
}
}
// measurePlVisible calculates playlist lines available for a given upper limit.
func (m *Model) measurePlVisible(limit int) int {
saved := m.plVisible
m.plVisible = 3 // temporary minimal value for measurement
defer func() { m.plVisible = saved }()
// Use mainSections to get all fixed chrome plus any active transient messages.
probe := strings.Join(m.mainSections("x", true), "\n")
fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1
return max(3, min(limit, m.height-fixedLines))
}
// collapsedPlVisible returns the natural (non-expanded) playlist height.
func (m *Model) collapsedPlVisible() int {
return m.measurePlVisible(maxPlVisible)
}
// expandedPlVisible returns the expanded playlist height with no cap.
func (m *Model) expandedPlVisible() int {
return m.measurePlVisible(m.height)
}
// applyHeightMode sets plVisible based on the current heightExpanded state.
func (m *Model) applyHeightMode() {
if m.playlist == nil {
return
}
if m.heightExpanded {
m.plVisible = m.expandedPlVisible()
} else {
m.plVisible = m.collapsedPlVisible()
}
}
// adjustScroll ensures plCursor is visible in the playlist view.
// It accounts for album separator lines that reduce the number of
// tracks that fit in the visible window.
func (m *Model) adjustScroll() {
if m.playlist == nil {
return
}
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
return
}
visible := m.effectivePlaylistVisible()
if visible <= 0 {
return
}
m.plScroll = m.playlistScroll(visible)
}
func (m Model) playlistScroll(visible int) int {
tracks := m.playlist.Tracks()
scroll := max(0, m.plScroll)
if scroll >= len(tracks) {
scroll = max(0, len(tracks)-1)
}
if m.plCursor < scroll {
return m.plCursor
}
for scroll < m.plCursor && m.albumSeparatorRows(tracks, scroll, m.plCursor, m.showAlbumHeaders) > visible {
scroll++
}
return scroll
}
func (m Model) mainFrameFixedLines(includeTransient bool) int {
if m.chromeOK {
if !includeTransient {
return m.chromeHeight
}
transientLines := len(m.footerMessages())
if m.err != nil {
transientLines++
}
return m.chromeHeight + transientLines
}
// Fallback: render and measure (only needed until first WindowSizeMsg)
content := strings.Join(m.mainSections("", includeTransient), "\n")
return lipgloss.Height(ui.FrameStyle.Render(content))
}
func (m Model) effectivePlaylistVisible() int {
available := m.height - m.mainFrameFixedLines(true)
if available <= 0 {
return 0
}
if m.plVisible <= 0 {
return 0
}
return min(m.plVisible, available)
}
// recomputeChrome renders the fixed chrome (without playlist or transients)
// and caches its height. Called when terminal width or compact mode changes.
func (m *Model) recomputeChrome() {
content := strings.Join(m.mainSections("", false), "\n")
m.chromeHeight = lipgloss.Height(ui.FrameStyle.Render(content))
m.chromeOK = true
}
// invalidateChrome marks the chrome height dirty. Until the next recompute,
// mainFrameFixedLines falls back to direct measurement.
func (m *Model) invalidateChrome() {
m.chromeOK = false
}
func (m *Model) refreshChrome() {
if m.width > 0 {
m.recomputeChrome()
return
}
m.invalidateChrome()
}
+31
View File
@@ -0,0 +1,31 @@
package model
import (
"sort"
"github.com/bjarneo/cliamp/internal/fuzzy"
)
// updateSearch filters the playlist by the current search query, ranking
// matches by fuzzy relevance (best match first).
func (m *Model) updateSearch() {
m.search.results = nil
m.search.cursor = 0
m.search.scroll = 0
if m.search.query == "" {
return
}
type match struct{ idx, score int }
matches := make([]match, 0, m.playlist.Len())
for i, t := range m.playlist.Tracks() {
if score, ok := fuzzy.Match(m.search.query, t.DisplayName()); ok {
matches = append(matches, match{i, score})
}
}
sort.SliceStable(matches, func(a, b int) bool {
return matches[a].score > matches[b].score
})
for _, mt := range matches {
m.search.results = append(m.search.results, mt.idx)
}
}
+138
View File
@@ -0,0 +1,138 @@
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()
}
+35
View File
@@ -0,0 +1,35 @@
package model
import (
"testing"
"time"
)
func TestSetSeekStepLarge(t *testing.T) {
t.Run("sets positive value", func(t *testing.T) {
m := Model{}
m.SetSeekStepLarge(45 * time.Second)
if got, want := m.seekStepLarge, 45*time.Second; got != want {
t.Fatalf("seekStepLarge = %v, want %v", got, want)
}
})
t.Run("resets non-positive to default", func(t *testing.T) {
tests := []time.Duration{0, -5 * time.Second}
for _, in := range tests {
m := Model{}
m.SetSeekStepLarge(in)
if got, want := m.seekStepLarge, 30*time.Second; got != want {
t.Fatalf("SetSeekStepLarge(%v): seekStepLarge = %v, want %v", in, got, want)
}
}
})
t.Run("clamps too-small positive value", func(t *testing.T) {
m := Model{}
m.SetSeekStepLarge(5 * time.Second)
if got, want := m.seekStepLarge, 6*time.Second; got != want {
t.Fatalf("seekStepLarge = %v, want %v", got, want)
}
})
}
+125
View File
@@ -0,0 +1,125 @@
package model
import (
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/ui"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
func TestHandleSpeedKeyUsesArrowKeysWhenSpeedFocused(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
sharedPlayer.Stop()
origSpeed := sharedPlayer.Speed()
sharedPlayer.SetSpeed(1.0)
t.Cleanup(func() {
sharedPlayer.SetSpeed(origSpeed)
})
m := Model{
player: sharedPlayer,
configSaver: config.SaveFunc{},
focus: focusSpeed,
}
if cmd := m.handleKey(tea.KeyPressMsg{Code: tea.KeyRight}); cmd != nil {
t.Fatalf("handleKey(right) cmd = %v, want nil", cmd)
}
if got := sharedPlayer.Speed(); got != 1.25 {
t.Fatalf("speed after right = %.2f, want 1.25", got)
}
if got := m.speedSaveAfter; got != speedSaveDebounce {
t.Fatalf("speedSaveAfter after right = %v, want %v", got, speedSaveDebounce)
}
if cmd := m.handleKey(tea.KeyPressMsg{Code: tea.KeyLeft}); cmd != nil {
t.Fatalf("handleKey(left) cmd = %v, want nil", cmd)
}
if got := sharedPlayer.Speed(); got != 1.0 {
t.Fatalf("speed after left = %.2f, want 1.00", got)
}
if got := m.speedSaveAfter; got != speedSaveDebounce {
t.Fatalf("speedSaveAfter after left = %v, want %v", got, speedSaveDebounce)
}
}
func TestTickPendingSpeedSaveUsesElapsedTime(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", home)
sharedPlayer.Stop()
origSpeed := sharedPlayer.Speed()
sharedPlayer.SetSpeed(1.0)
t.Cleanup(func() {
sharedPlayer.SetSpeed(origSpeed)
})
m := Model{player: sharedPlayer, configSaver: config.SaveFunc{}}
m.changeSpeed(0.5)
configPath := filepath.Join(home, ".config", "cliamp", "config.toml")
for i := range 4 {
m.tickPendingSpeedSave(ui.TickSlow)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("config created after %d slow ticks, want no save before %v", i+1, speedSaveDebounce)
}
}
m.tickPendingSpeedSave(ui.TickSlow)
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", configPath, err)
}
if got := string(data); !strings.Contains(got, "speed = 1.50") {
t.Fatalf("config contents = %q, want speed = 1.50", got)
}
if got := m.speedSaveAfter; got != 0 {
t.Fatalf("speedSaveAfter after save = %v, want 0", got)
}
}
func TestFlushPendingSpeedSavePersistsImmediately(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("HOME", home)
sharedPlayer.Stop()
origSpeed := sharedPlayer.Speed()
sharedPlayer.SetSpeed(1.0)
t.Cleanup(func() {
sharedPlayer.SetSpeed(origSpeed)
})
m := Model{player: sharedPlayer, configSaver: config.SaveFunc{}}
m.changeSpeed(0.25)
m.flushPendingSpeedSave()
configPath := filepath.Join(home, ".config", "cliamp", "config.toml")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", configPath, err)
}
if got := string(data); !strings.Contains(got, "speed = 1.25") {
t.Fatalf("config contents = %q, want speed = 1.25", got)
}
if got := m.speedSaveAfter; got != 0 {
t.Fatalf("speedSaveAfter after flush = %v, want 0", got)
}
}
+367
View File
@@ -0,0 +1,367 @@
// state.go defines sub-structs that group related fields in the Model,
// making the overall model scannable and maintainable.
package model
import (
"fmt"
"strings"
"time"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/lyrics"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// searchState holds state for the playlist search overlay.
type searchState struct {
active bool
query string
results []int // indices into playlist tracks
cursor int
scroll int
}
// netSearchScreenType identifies which screen of the net search overlay is active.
type netSearchScreenType int
const (
netSearchInput netSearchScreenType = iota // typing search query
netSearchResults // browsing search results
)
// netSearchState holds state for the internet search overlay.
type netSearchState struct {
active bool
screen netSearchScreenType
query string
soundcloud bool // true = SoundCloud (scsearch), false = YouTube (ytsearch)
loading bool
results []playlist.Track
cursor int
scroll int
err string
}
// provSearchState holds state for filtering the provider playlist list.
type provSearchState struct {
active bool
query string
results []int // indices into providerLists
cursor int
scroll int
}
// seekState holds debounce state for yt-dlp seek-by-restart.
type seekState struct {
active bool // true from first keypress until seek completes
targetPos time.Duration // absolute target position
timer int // tick countdown for debounce (0 = idle)
grace int // ticks to suppress reconnect after seek completes
timerFor time.Duration
graceFor time.Duration
}
// themePickerState holds state for the theme picker overlay.
type themePickerState struct {
visible bool
cursor int
scroll int
savedIdx int // themeIdx before opening picker, for cancel/restore
}
// visPickerState holds state for the visualizer picker overlay.
type visPickerState struct {
visible bool
cursor int
scroll int
savedMode int // vis.Mode before opening, for cancel/restore
modes []string // mode names captured at open (stable while open)
}
// lyricsState holds state for the lyrics display overlay.
type lyricsState struct {
visible bool
lines []lyrics.Line
loading bool
err error
query string // "artist\ntitle" of the last fetch
scroll int
}
// keymapOverlay holds state for the keybindings overlay.
type keymapOverlay struct {
visible bool
cursor int
scroll int
savedCursor int
savedScroll int
searching bool
search string
filtered []int // indices into entries
entries []keymapEntry // core keys + plugin keys, rebuilt on openKeymap
}
// queueOverlay holds state for the queue manager overlay.
type queueOverlay struct {
visible bool
cursor int
scroll int
}
// plManagerState holds state for the playlist manager overlay.
type plManagerState struct {
visible bool
screen plMgrScreenType
cursor int // view-index: offset into filtered when filter != "", else direct index
scroll int
playlists []playlist.PlaylistInfo
selPlaylist string // playlist name open in screen 1
tracks []playlist.Track // tracks in the selected playlist
newName string
confirmDel bool
renameOldName string
renameName string
marked map[int]bool // real track indices marked on the tracks screen
sortMode int
undo plManagerUndo
// Filter (`/`) state. Reset on screen change. `filtered` indexes into
// `playlists` (list screen) or `tracks` (tracks screen).
filtering bool
filter string
filtered []int
savedCursor int // cursor before `/` was pressed, restored on Esc
savedScroll int
}
type plManagerUndoKind int
const (
plUndoNone plManagerUndoKind = iota
plUndoTracks
plUndoPlaylist
)
type plManagerUndo struct {
kind plManagerUndoKind
name string
tracks []playlist.Track
}
type playlistPickerScreen int
const (
plPickerChoose playlistPickerScreen = iota
plPickerNewName
)
// playlistPickerState holds the reusable local "write to playlist" picker.
type playlistPickerState struct {
visible bool
screen playlistPickerScreen
cursor int
scroll int
playlists []playlist.PlaylistInfo
tracks []playlist.Track
title string
newName string
}
// fileBrowserState holds state for the file browser overlay.
type fileBrowserState struct {
visible bool
dir string
entries []fbEntry
cursor int
scroll int
savedCursor int
savedScroll int
selected map[string]bool
err string
searching bool
search string
filtered []int // indices into entries
targetPlaylist string
}
// navBrowserState holds state for the provider browser overlay.
type navBrowserState struct {
prov playlist.Provider
visible bool
mode navBrowseModeType
screen navBrowseScreenType
cursor int
scroll int
artists []provider.ArtistInfo
albums []provider.AlbumInfo
tracks []playlist.Track
selArtist provider.ArtistInfo
selAlbum provider.AlbumInfo
sortType string
albumLoading bool
albumDone bool
loading bool
searching bool
search string
searchIdx []int
}
// spotSearchScreenType identifies which screen of the Spotify search overlay is active.
type spotSearchScreenType int
const (
spotSearchInput spotSearchScreenType = iota // typing search query
spotSearchResults // browsing search results
spotSearchPlaylist // picking a playlist to add to
spotSearchNewName // typing new playlist name
)
// spotSearchState holds state for the provider search + add-to-playlist overlay.
type spotSearchState struct {
prov playlist.Provider // the provider being searched (may differ from active provider)
visible bool
screen spotSearchScreenType
query string
results []playlist.Track
cursor int
scroll int
loading bool
playlists []playlist.PlaylistInfo // user's Spotify playlists for picker
selTrack playlist.Track // track selected to add
newName string // new playlist name input
err string
}
// catalogBatchState holds state for lazy-loading catalog entries from a provider.CatalogLoader.
type catalogBatchState struct {
offset int // next offset to fetch
loading bool // true while a fetch is in flight
done bool // true when all stations have been loaded
}
// ytdlBatchState holds state for incremental yt-dlp playlist loading.
type ytdlBatchState struct {
url string
gen uint64
offset int
done bool
loading bool
}
// reconnectState holds state for stream auto-reconnect with exponential backoff.
type reconnectState struct {
attempts int
at time.Time
}
// devicePickerState holds state for the audio device picker overlay.
type devicePickerState struct {
visible bool
devices []player.AudioDevice
cursor int
scroll int
loading bool
}
type saveState struct {
pendingDownloads int
}
func (s saveState) activityText() string {
switch s.pendingDownloads {
case 0:
return ""
case 1:
return "Downloading..."
default:
return fmt.Sprintf("Downloading... (%d)", s.pendingDownloads)
}
}
func (s *saveState) startDownload() {
s.pendingDownloads++
}
func (s *saveState) finishDownload() {
if s.pendingDownloads > 0 {
s.pendingDownloads--
}
}
// statusTTL is how long a status line stays visible.
type statusTTL time.Duration
func (t statusTTL) expiresAt(now time.Time) time.Time {
return now.Add(time.Duration(t))
}
// statusMsg holds a temporary status message shown at the bottom of the UI.
type statusMsg struct {
text string
expiresAt time.Time // zero = no active message
}
func (s statusMsg) Expired(now time.Time) bool {
return !s.expiresAt.IsZero() && !now.Before(s.expiresAt)
}
func (s *statusMsg) Show(text string, ttl statusTTL) {
s.ShowAt(time.Now(), text, ttl)
}
func (s *statusMsg) Showf(ttl statusTTL, format string, args ...any) {
s.Show(fmt.Sprintf(format, args...), ttl)
}
func (s *statusMsg) ShowAt(now time.Time, text string, ttl statusTTL) {
s.text = text
s.expiresAt = ttl.expiresAt(now)
}
func (s *statusMsg) Clear() {
*s = statusMsg{}
}
// logLine is a timestamped log message shown in the footer.
type logLine struct {
text string
expiresAt time.Time
}
const logLineTTL = 6 * time.Second
// tickLogLines drains the applog buffer and expires old entries.
func (m *Model) tickLogLines(now time.Time) {
for _, e := range applog.Drain() {
text := strings.TrimRight(e.Text, "\n")
m.logLines = append(m.logLines, logLine{
text: text,
expiresAt: e.At.Add(logLineTTL),
})
}
// Expire old entries.
n := 0
for _, l := range m.logLines {
if now.Before(l.expiresAt) {
m.logLines[n] = l
n++
}
}
m.logLines = m.logLines[:n]
}
// networkStats tracks network throughput for the stream status bar.
type networkStats struct {
speed float64 // bytes per second (smoothed)
lastBytes int64
sampleFor time.Duration
}
type terminalTitleState struct {
introActive bool
introOffset int
introTick int
}
+50
View File
@@ -0,0 +1,50 @@
package model
import (
"testing"
"time"
)
func TestStatusShowAtSetsTextAndDeadline(t *testing.T) {
var status statusMsg
now := time.Date(2026, time.March, 28, 12, 0, 0, 0, time.UTC)
status.ShowAt(now, "Saved", statusTTLMedium)
if status.text != "Saved" {
t.Fatalf("text = %q, want %q", status.text, "Saved")
}
want := now.Add(time.Duration(statusTTLMedium))
if !status.expiresAt.Equal(want) {
t.Fatalf("expiresAt = %v, want %v", status.expiresAt, want)
}
}
func TestStatusExpiresAtDeadline(t *testing.T) {
var status statusMsg
now := time.Date(2026, time.March, 28, 12, 0, 0, 0, time.UTC)
status.ShowAt(now, "Saved", statusTTLMedium)
if status.Expired(now.Add(time.Duration(statusTTLMedium) - time.Nanosecond)) {
t.Fatal("Expired() = true before deadline, want false")
}
if !status.Expired(now.Add(time.Duration(statusTTLMedium))) {
t.Fatal("Expired() = false at deadline, want true")
}
}
func TestStatusClearResetsMessage(t *testing.T) {
var status statusMsg
now := time.Date(2026, time.March, 28, 12, 0, 0, 0, time.UTC)
status.ShowAt(now, "Saved", statusTTLMedium)
status.Clear()
if status.text != "" {
t.Fatalf("text after Clear() = %q, want empty", status.text)
}
if !status.expiresAt.IsZero() {
t.Fatalf("expiresAt after Clear() = %v, want zero", status.expiresAt)
}
}
+188
View File
@@ -0,0 +1,188 @@
package model
import (
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/ipc"
)
type fakeEngine struct {
streamSeek bool
seekCalls []time.Duration
position time.Duration
}
func (f *fakeEngine) Play(string, time.Duration) error { return nil }
func (f *fakeEngine) PlayYTDL(string, time.Duration) error { return nil }
func (f *fakeEngine) Preload(string, time.Duration) error { return nil }
func (f *fakeEngine) PreloadYTDL(string, time.Duration) error { return nil }
func (f *fakeEngine) ClearPreload() {}
func (f *fakeEngine) Stop() {}
func (f *fakeEngine) Close() {}
func (f *fakeEngine) TogglePause() {}
func (f *fakeEngine) Seek(d time.Duration) error { f.seekCalls = append(f.seekCalls, d); return nil }
func (f *fakeEngine) SeekYTDL(time.Duration) error { return nil }
func (f *fakeEngine) CancelSeekYTDL() {}
func (f *fakeEngine) IsPlaying() bool { return true }
func (f *fakeEngine) IsPaused() bool { return false }
func (f *fakeEngine) Drained() bool { return false }
func (f *fakeEngine) HasPreload() bool { return false }
func (f *fakeEngine) Seekable() bool { return f.streamSeek }
func (f *fakeEngine) IsStreamSeek() bool { return f.streamSeek }
func (f *fakeEngine) IsYTDLSeek() bool { return false }
func (f *fakeEngine) GaplessAdvanced() bool { return false }
func (f *fakeEngine) Position() time.Duration { return f.position }
func (f *fakeEngine) Duration() time.Duration { return time.Hour }
func (f *fakeEngine) PositionAndDuration() (time.Duration, time.Duration) { return 0, time.Hour }
func (f *fakeEngine) SetVolumeMin(float64) {}
func (f *fakeEngine) VolumeMin() float64 { return -50 }
func (f *fakeEngine) SetVolume(float64) {}
func (f *fakeEngine) Volume() float64 { return 0 }
func (f *fakeEngine) SetSpeed(float64) {}
func (f *fakeEngine) Speed() float64 { return 1 }
func (f *fakeEngine) ToggleMono() {}
func (f *fakeEngine) Mono() bool { return false }
func (f *fakeEngine) SetEQBand(int, float64) {}
func (f *fakeEngine) EQBands() [10]float64 { return [10]float64{} }
func (f *fakeEngine) StreamErr() error { return nil }
func (f *fakeEngine) StreamTitle() string { return "" }
func (f *fakeEngine) StreamBytes() (downloaded, total int64) { return 0, 0 }
func (f *fakeEngine) SamplesInto([]float64) int { return 0 }
func (f *fakeEngine) SampleRate() int { return 44100 }
func assertStreamSeekCmd(t *testing.T, eng *fakeEngine, cmd tea.Cmd, want time.Duration) {
t.Helper()
if cmd == nil {
t.Fatal("cmd = nil, want seek cmd for HTTP stream")
}
msg := cmd()
if _, ok := msg.(seekTickMsg); !ok {
t.Fatalf("cmd() msg = %T, want seekTickMsg", msg)
}
if len(eng.seekCalls) != 1 {
t.Fatalf("Seek call count after cmd() = %d, want 1", len(eng.seekCalls))
}
if got := eng.seekCalls[0]; got != want {
t.Fatalf("Seek arg = %v, want %v", got, want)
}
}
func assertDeferredStreamSeek(t *testing.T, eng *fakeEngine, cmd tea.Cmd, position, want time.Duration) {
t.Helper()
if len(eng.seekCalls) != 0 {
t.Fatalf("Seek call count before cmd() = %d, want 0", len(eng.seekCalls))
}
eng.position = position
assertStreamSeekCmd(t, eng, cmd, want)
}
func assertImmediateStreamSeek(t *testing.T, eng *fakeEngine, cmd tea.Cmd, want time.Duration) {
t.Helper()
if cmd != nil {
t.Fatalf("cmd = %v, want nil for synchronous seek", cmd)
}
if len(eng.seekCalls) != 1 {
t.Fatalf("Seek call count = %d, want 1", len(eng.seekCalls))
}
if got := eng.seekCalls[0]; got != want {
t.Fatalf("Seek arg = %v, want %v", got, want)
}
}
func TestDeferredHTTPStreamSeek(t *testing.T) {
cases := []struct {
name string
initialPos time.Duration
settlePos time.Duration
want time.Duration
invoke func(*Model) tea.Cmd
}{
{
name: "right key",
settlePos: 8 * time.Second,
want: 5 * time.Second,
invoke: func(m *Model) tea.Cmd {
return m.handleKey(tea.KeyPressMsg{Code: tea.KeyRight})
},
},
{
name: "set position update",
initialPos: 3 * time.Second,
settlePos: 5 * time.Second,
want: 5 * time.Second,
invoke: func(m *Model) tea.Cmd {
_, cmd := m.Update(playback.SetPositionMsg{Position: 10 * time.Second})
return cmd
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
eng := &fakeEngine{streamSeek: true, position: tt.initialPos}
m := Model{player: eng}
cmd := tt.invoke(&m)
assertDeferredStreamSeek(t, eng, cmd, tt.settlePos, tt.want)
})
}
}
func TestImmediateHTTPStreamSeek(t *testing.T) {
cases := []struct {
name string
want time.Duration
invoke func(*Model) tea.Cmd
check func(*testing.T, *Model)
}{
{
name: "jump enter",
want: 7 * time.Second,
invoke: func(m *Model) tea.Cmd {
m.jumping = true
m.jumpInput = "10"
return m.handleJumpKey(tea.KeyPressMsg{Code: tea.KeyEnter})
},
check: func(t *testing.T, m *Model) {
t.Helper()
if m.jumping {
t.Fatal("jump mode remained active after enter")
}
if m.jumpInput != "" {
t.Fatalf("jump input = %q, want empty", m.jumpInput)
}
},
},
{
name: "ipc seek",
want: 4 * time.Second,
invoke: func(m *Model) tea.Cmd {
_, cmd := m.Update(ipc.SeekMsg{Offset: 4 * time.Second})
return cmd
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
eng := &fakeEngine{streamSeek: true, position: 3 * time.Second}
m := Model{player: eng}
cmd := tt.invoke(&m)
if tt.check != nil {
tt.check(t, &m)
}
assertImmediateStreamSeek(t, eng, cmd, tt.want)
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package model
import (
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/ui"
)
// Model-specific lipgloss styles, rebuilt when the theme changes.
var (
titleStyle = lipgloss.NewStyle().
Foreground(ui.ColorTitle).
Bold(true)
trackStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent)
timeStyle = lipgloss.NewStyle().
Foreground(ui.ColorText)
statusStyle = lipgloss.NewStyle().
Foreground(ui.ColorPlaying).
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
labelStyle = lipgloss.NewStyle().
Foreground(ui.ColorText).
Bold(true)
eqActiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
eqInactiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
playlistActiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorPlaying).
Bold(true)
playlistItemStyle = lipgloss.NewStyle().
Foreground(ui.ColorText)
playlistSelectedStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
playlistUnavailableStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim).
Faint(true)
helpStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
helpKeyStyle = lipgloss.NewStyle().
Foreground(ui.ColorKeyFG).
Background(ui.ColorKeyBG).
Bold(true)
errorStyle = lipgloss.NewStyle().
Foreground(ui.ColorError)
)
// rebuildModelStyles reconstructs all model-specific lipgloss styles from current color variables.
func rebuildModelStyles() {
titleStyle = lipgloss.NewStyle().Foreground(ui.ColorTitle).Bold(true)
trackStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent)
timeStyle = lipgloss.NewStyle().Foreground(ui.ColorText)
statusStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
dimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
labelStyle = lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true)
eqActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
eqInactiveStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
playlistActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
playlistItemStyle = lipgloss.NewStyle().Foreground(ui.ColorText)
playlistSelectedStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
playlistUnavailableStyle = lipgloss.NewStyle().Foreground(ui.ColorDim).Faint(true)
helpStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
helpKeyStyle = lipgloss.NewStyle().Foreground(ui.ColorKeyFG).Background(ui.ColorKeyBG).Bold(true)
errorStyle = lipgloss.NewStyle().Foreground(ui.ColorError)
seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume)
activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
}
+12
View File
@@ -0,0 +1,12 @@
package model
import "unicode/utf8"
// removeLastRune trims the final UTF-8 rune from s, used by all text input handlers.
func removeLastRune(s string) string {
if len(s) > 0 {
_, size := utf8.DecodeLastRuneInString(s)
return s[:len(s)-size]
}
return s
}
+198
View File
@@ -0,0 +1,198 @@
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()
}
}
+252
View File
@@ -0,0 +1,252 @@
package model
import (
"os"
"testing"
"time"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
tea "charm.land/bubbletea/v2"
)
var sharedPlayer player.Engine
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
sr := player.DeviceSampleRate()
if sr <= 0 {
sr = 44100
}
p, err := player.New(player.Quality{SampleRate: sr, BufferMs: 100, ResampleQuality: 1})
if err == nil {
sharedPlayer = p
defer p.Close()
}
os.Exit(m.Run())
}
// TestTickIntervalStoppedUsesIdle verifies that a fully-idle model (player
// stopped, no overlay, no pending status / reconnect) ticks at ui.TickIdle
// rather than ui.TickSlow / ui.TickFast. This is what lets the CPU sit in a
// low P-state between user actions (issue #92 and follow-ups).
func TestTickIntervalStoppedUsesIdle(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
m := Model{
player: sharedPlayer,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
playlist: playlist.New(),
termTitle: terminalTitleState{},
}
if sharedPlayer.IsPlaying() {
t.Fatal("expected player to be stopped")
}
if !m.isFullyIdle() {
t.Fatal("isFullyIdle() = false on a fresh stopped model, want true")
}
if got := m.tickInterval(); got != ui.TickIdle {
t.Errorf("tickInterval() = %v, want %v (ui.TickIdle)", got, ui.TickIdle)
}
}
// TestTickIntervalPendingStatusUsesSlow verifies that a pending status
// message keeps us off the idle cadence — otherwise the message would linger
// up to TickIdle past its expiry.
func TestTickIntervalPendingStatusUsesSlow(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
m := Model{
player: sharedPlayer,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
playlist: playlist.New(),
}
m.status.Show("hello", statusTTL(2*time.Second))
if m.isFullyIdle() {
t.Fatal("isFullyIdle() = true with pending status message, want false")
}
if got := m.tickInterval(); got == ui.TickIdle {
t.Errorf("tickInterval() = %v with pending status, want a faster cadence", got)
}
}
func TestTickIntervalPlayingUsesFastCadence(t *testing.T) {
p := &playbackFakeEngine{playing: true}
m := Model{
player: p,
vis: ui.NewVisualizer(float64(p.SampleRate())),
playlist: playlist.New(),
}
m.SetVisualizer("none")
if got := m.tickInterval(); got != ui.TickFast {
t.Fatalf("tickInterval() = %v, want %v", got, ui.TickFast)
}
}
func TestTickIntervalLowPowerPlayingUsesLowPowerCadence(t *testing.T) {
p := &playbackFakeEngine{playing: true}
m := Model{
player: p,
vis: ui.NewVisualizer(float64(p.SampleRate())),
playlist: playlist.New(),
}
m.SetVisualizer("none")
m.SetLowPower(true)
if got := m.tickInterval(); got != ui.TickLowPowerPlaying {
t.Fatalf("tickInterval() = %v, want %v", got, ui.TickLowPowerPlaying)
}
}
func TestInitialTickUsesFastCadence(t *testing.T) {
prev := teaTick
t.Cleanup(func() {
teaTick = prev
})
called := false
teaTick = func(d time.Duration, fn func(time.Time) tea.Msg) tea.Cmd {
called = true
if d != ui.TickFast {
t.Fatalf("tick duration = %v, want %v", d, ui.TickFast)
}
return func() tea.Msg {
return fn(time.Unix(0, 0))
}
}
msg := tickCmd()()
if _, ok := msg.(tickMsg); !ok {
t.Fatalf("tickCmd() message = %T, want tickMsg", msg)
}
if !called {
t.Fatal("tickCmd() did not schedule teaTick")
}
}
func TestRefreshVisualizerIfPendingConsumesOneShotRequest(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
}
m.vis.RequestRefresh()
m.refreshVisualizerIfPending()
if m.vis.RefreshPending() {
t.Fatal("refreshPending = true after refreshVisualizerIfPending(), want false")
}
if m.vis.Frame() != 1 {
t.Fatalf("frame after refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
}
m.refreshVisualizerIfPending()
if m.vis.Frame() != 1 {
t.Fatalf("frame after second refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
}
}
func TestLyricsScreenKeepsVisualizerLive(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
if got := m.activeScreen(); got != screenLyrics {
t.Fatalf("activeScreen() = %v, want %v", got, screenLyrics)
}
// Overlays now render inline over the live main view, so the visualizer is
// never treated as hidden.
if m.isOverlayActive() {
t.Fatal("isOverlayActive() = true, want false: overlays render inline")
}
if m.visualizerTickContext(time.Now()).OverlayActive {
t.Fatal("visualizerTickContext(...).OverlayActive = true, want false for inline lyrics")
}
m.vis.RequestRefresh()
m.refreshVisualizerIfPending()
if m.vis.RefreshPending() {
t.Fatal("refreshPending = true after refresh, want false: visualizer stays live under lyrics")
}
if m.vis.Frame() != 1 {
t.Fatalf("frame after refresh = %d, want 1 while lyrics open", m.vis.Frame())
}
}
func TestUpdateRequestsVisualizerRefreshWhenOverlayCloses(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
keymap: keymapOverlay{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want Model", nextModel)
}
if next.keymap.visible {
t.Fatal("keymap overlay remained visible after escape")
}
if !next.vis.RefreshPending() {
t.Fatal("refreshPending = false after overlay close, want true")
}
}
func TestUpdateRequestsVisualizerRefreshWhenLyricsClose(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want Model", nextModel)
}
if next.lyrics.visible {
t.Fatal("lyrics overlay remained visible after escape")
}
if !next.vis.RefreshPending() {
t.Fatal("refreshPending = false after lyrics close, want true")
}
}
func TestAdvanceTickUnitsClearsElapsedWhenCounterCompletes(t *testing.T) {
ttl := 1
elapsed := time.Duration(0)
if got := advanceTickUnits(&ttl, &elapsed, 3*time.Second, ui.TickFast); got != 1 {
t.Fatalf("advanceTickUnits() steps = %d, want 1", got)
}
if ttl != 0 {
t.Fatalf("ttl after completion = %d, want 0", ttl)
}
if elapsed != 0 {
t.Fatalf("elapsed after completion = %v, want 0", elapsed)
}
}
+203
View File
@@ -0,0 +1,203 @@
package model
import (
"strings"
"unicode"
"github.com/bjarneo/cliamp/playlist"
)
const (
baseTerminalTitle = "cliamp"
defaultTerminalTitleIntro = "It really whips the terminal's ass."
titleIntroViewportMin = 18
titleIntroViewportDefault = 24
titleIntroStep = 2
titleIntroTickDivisor = 2
)
type terminalTitleValues struct {
stateIcon string
metadata string
title string
artist string
path string
streamTitle string
}
var defaultTerminalTitleIntroRunes = []rune(defaultTerminalTitleIntro)
func initialTerminalTitleState() terminalTitleState {
if len(defaultTerminalTitleIntroRunes) == 0 {
return terminalTitleState{}
}
return terminalTitleState{
introActive: true,
introOffset: titleIntroInitialOffset(titleIntroViewportDefault),
}
}
func titleIntroViewportForWidth(width int, introLen int) int {
if width <= 0 {
return titleIntroViewportDefault
}
return max(titleIntroViewportMin, min(introLen, width/3))
}
func titleIntroInitialOffset(viewport int) int {
return min(4, viewport)
}
func titleIntroMaxOffset(viewport, introLen int) int {
return introLen + viewport
}
func titleIntroFrame(offset, viewport int, introRunes []rune) string {
maxOffset := titleIntroMaxOffset(viewport, len(introRunes))
switch {
case offset < 0:
offset = 0
case offset > maxOffset:
offset = maxOffset
}
padded := make([]rune, 0, viewport+len(introRunes)+viewport)
padded = append(padded, []rune(strings.Repeat(" ", viewport))...)
padded = append(padded, introRunes...)
padded = append(padded, []rune(strings.Repeat(" ", viewport))...)
return string(padded[offset : offset+viewport])
}
func renderTerminalTitle(values terminalTitleValues) string {
switch {
case values.stateIcon != "" && values.metadata != "":
return values.stateIcon + " " + values.metadata + " | " + baseTerminalTitle
case values.stateIcon != "":
return values.stateIcon + " " + baseTerminalTitle
case values.metadata != "":
return values.metadata + " | " + baseTerminalTitle
default:
return baseTerminalTitle
}
}
func currentTerminalTitle(state terminalTitleState, width int, values terminalTitleValues) string {
if state.introActive && len(defaultTerminalTitleIntroRunes) > 0 {
return sanitizeTerminalTitle(titleIntroFrame(state.introOffset, titleIntroViewportForWidth(width, len(defaultTerminalTitleIntroRunes)), defaultTerminalTitleIntroRunes))
}
return sanitizeTerminalTitle(renderTerminalTitle(values))
}
func advanceTerminalTitleState(state *terminalTitleState, width int) {
if !state.introActive || len(defaultTerminalTitleIntroRunes) == 0 {
return
}
state.introTick++
if state.introTick < titleIntroTickDivisor {
return
}
state.introTick = 0
maxOffset := titleIntroMaxOffset(titleIntroViewportForWidth(width, len(defaultTerminalTitleIntroRunes)), len(defaultTerminalTitleIntroRunes))
if state.introOffset >= maxOffset {
state.introActive = false
state.introOffset = maxOffset
state.introTick = 0
return
}
state.introOffset += titleIntroStep
if state.introOffset > maxOffset {
state.introOffset = maxOffset
}
}
func sanitizeTerminalTitle(title string) string {
var b strings.Builder
b.Grow(len(title))
lastWasSpace := false
for _, r := range title {
switch {
case r == '\n' || r == '\r' || r == '\t':
if !lastWasSpace {
b.WriteByte(' ')
lastWasSpace = true
}
case unicode.IsControl(r) || (r >= 0x80 && r <= 0x9f):
default:
b.WriteRune(r)
lastWasSpace = r == ' '
}
}
return b.String()
}
func (m *Model) advanceTerminalTitle() {
advanceTerminalTitleState(&m.termTitle, m.width)
}
func (m Model) terminalTitleValues() terminalTitleValues {
if m.playlist == nil {
return terminalTitleStateValues(m.isPlaying(), m.isPaused())
}
track, idx := m.currentPlaybackTrack()
if idx < 0 {
return terminalTitleStateValues(m.isPlaying(), m.isPaused())
}
return terminalTitleValuesForTrack(track, m.streamTitle, m.isPlaying(), m.isPaused())
}
func terminalTitleStateValues(playing, paused bool) terminalTitleValues {
values := terminalTitleValues{}
switch {
case playing && !paused:
values.stateIcon = "▶"
case paused:
values.stateIcon = "⏸"
}
return values
}
func terminalTitleMetadata(title, artist, path string) string {
switch {
case title != "" && artist != "":
return title + " - " + artist
case title != "":
return title
case artist != "":
return artist
case path != "":
return path
default:
return ""
}
}
func terminalTitleValuesForTrack(track playlist.Track, streamTitle string, playing, paused bool) terminalTitleValues {
values := terminalTitleStateValues(playing, paused)
if !playing && !paused {
return values
}
values.path = track.Path
switch {
case track.Stream && streamTitle != "":
values.streamTitle = streamTitle
if artist, title, ok := strings.Cut(streamTitle, " - "); ok && artist != "" && title != "" {
values.artist = artist
values.title = title
} else {
values.title = streamTitle
}
default:
values.title = track.Title
values.artist = track.Artist
}
values.metadata = terminalTitleMetadata(values.title, values.artist, values.path)
return values
}
+168
View File
@@ -0,0 +1,168 @@
package model
import (
"strings"
"testing"
"github.com/bjarneo/cliamp/playlist"
)
func TestTerminalTitleValuesForTrack(t *testing.T) {
t.Run("stream title is parsed into logical song fields", func(t *testing.T) {
values := terminalTitleValuesForTrack(
playlist.Track{Stream: true, Path: "https://radio.example.test/stream"},
"Artist - Song",
true,
false,
)
if values.stateIcon != "▶" {
t.Fatalf("stateIcon = %q, want ▶", values.stateIcon)
}
if values.title != "Song" || values.artist != "Artist" {
t.Fatalf("parsed values = title %q artist %q", values.title, values.artist)
}
if values.metadata != "Song - Artist" {
t.Fatalf("metadata = %q, want %q", values.metadata, "Song - Artist")
}
if values.streamTitle != "Artist - Song" {
t.Fatalf("streamTitle = %q, want raw value", values.streamTitle)
}
})
t.Run("non parsable stream title falls back to raw title", func(t *testing.T) {
values := terminalTitleValuesForTrack(
playlist.Track{Stream: true},
"NTS Live",
true,
false,
)
if values.title != "NTS Live" || values.artist != "" {
t.Fatalf("values = title %q artist %q", values.title, values.artist)
}
if values.metadata != "NTS Live" {
t.Fatalf("metadata = %q, want %q", values.metadata, "NTS Live")
}
})
t.Run("stopped clears track metadata", func(t *testing.T) {
values := terminalTitleValuesForTrack(
playlist.Track{Title: "Angel", Artist: "Massive Attack", Path: "/music/angel.flac"},
"",
false,
false,
)
if values.stateIcon != "" {
t.Fatalf("stateIcon = %q, want empty for stopped", values.stateIcon)
}
if values.metadata != "" || values.title != "" || values.artist != "" || values.path != "" {
t.Fatalf("stopped values should be empty, got %+v", values)
}
})
}
func TestRenderTerminalTitle(t *testing.T) {
track := playlist.Track{Title: "Angel", Artist: "Massive Attack"}
t.Run("playing", func(t *testing.T) {
got := renderTerminalTitle(terminalTitleValuesForTrack(track, "", true, false))
want := "▶ Angel - Massive Attack | cliamp"
if got != want {
t.Fatalf("render(playing) = %q, want %q", got, want)
}
})
t.Run("paused", func(t *testing.T) {
got := renderTerminalTitle(terminalTitleValuesForTrack(track, "", true, true))
want := "⏸ Angel - Massive Attack | cliamp"
if got != want {
t.Fatalf("render(paused) = %q, want %q", got, want)
}
})
t.Run("stopped", func(t *testing.T) {
got := renderTerminalTitle(terminalTitleValuesForTrack(track, "", false, false))
if got != baseTerminalTitle {
t.Fatalf("render(stopped) = %q, want %q", got, baseTerminalTitle)
}
})
}
func TestTerminalTitleIntroSequence(t *testing.T) {
state := initialTerminalTitleState()
frames := []string{currentTerminalTitle(state, 0, terminalTitleStateValues(false, false))}
for state.introActive {
advanceTerminalTitleState(&state, 0)
title := currentTerminalTitle(state, 0, terminalTitleStateValues(false, false))
if title != frames[len(frames)-1] {
frames = append(frames, title)
}
}
if got, want := frames[0], strings.Repeat(" ", titleIntroViewportDefault-4)+"It r"; got != want {
t.Fatalf("first intro frame = %q, want %q", got, want)
}
if got, wantSuffix := frames[1], "It rea"; !strings.HasSuffix(got, wantSuffix) {
t.Fatalf("second intro frame = %q, want suffix %q", got, wantSuffix)
}
if got, want := frames[len(frames)-2], strings.Repeat(" ", titleIntroViewportDefault); got != want {
t.Fatalf("last intro frame = %q, want %q", got, want)
}
if got := frames[len(frames)-1]; got != baseTerminalTitle {
t.Fatalf("post-intro title = %q, want %q", got, baseTerminalTitle)
}
}
func TestCurrentTerminalTitleSanitizesRenderedTitle(t *testing.T) {
tests := []struct {
name string
values terminalTitleValues
want string
}{
{
name: "drops control bytes",
values: terminalTitleValues{
stateIcon: "▶",
metadata: "Song\a\x1b[31m - Artist\r\nName",
},
want: "▶ Song[31m - Artist Name | cliamp",
},
{
name: "collapses control whitespace",
values: terminalTitleValues{
metadata: "Song\r\n\tArtist",
},
want: "Song Artist | cliamp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := currentTerminalTitle(terminalTitleState{}, 0, tt.values); got != tt.want {
t.Fatalf("currentTerminalTitle() = %q, want %q", got, tt.want)
}
})
}
}
func TestTitleIntroViewportForWidth(t *testing.T) {
tests := []struct {
width int
introLen int
want int
}{
{width: 0, introLen: len(defaultTerminalTitleIntroRunes), want: titleIntroViewportDefault},
{width: 40, introLen: len(defaultTerminalTitleIntroRunes), want: titleIntroViewportMin},
{width: 80, introLen: len(defaultTerminalTitleIntroRunes), want: 26},
{width: 160, introLen: len(defaultTerminalTitleIntroRunes), want: len(defaultTerminalTitleIntroRunes)},
}
for _, tt := range tests {
if got := titleIntroViewportForWidth(tt.width, tt.introLen); got != tt.want {
t.Fatalf("titleIntroViewportForWidth(%d, %d) = %d, want %d", tt.width, tt.introLen, got, tt.want)
}
}
}
+1130
View File
File diff suppressed because it is too large Load Diff
+925
View File
@@ -0,0 +1,925 @@
package model
import (
"fmt"
"math"
"slices"
"strings"
"time"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// titleScrollSep is the separator runes for cyclic title scrolling,
// pre-allocated to avoid per-frame conversion.
var titleScrollSep = []rune(" ♫ ")
// Pre-built styles for elements created per-render to avoid repeated allocation.
var (
seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume)
activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
)
// providerEmptyStateHint, keyed by lowercase provider Name(), returns the
// remediation hint shown under the generic "No playlists in X" message.
var providerEmptyStateHint = map[string]string{
"local playlists": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"local": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
"navidrome": "Verify [navidrome] url/username/password in config.toml.",
"jellyfin": "Verify [jellyfin] url and token in config.toml.",
"emby": "Verify [emby] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token or library filter in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"soundcloud": "Set [soundcloud] user in config.toml to browse a profile.",
"netease cloud music": "Run `cliamp setup` and configure NetEase browser cookies.",
}
// renderProviderEmptyState explains why the playlists pane is empty for the
// current provider and offers a remediation hint. Always pads to budget so the
// pane height stays stable.
func (m Model) renderProviderEmptyState(budget int) string {
name := "this provider"
if m.provider != nil {
name = m.provider.Name()
}
lines := []string{
dimStyle.Render(fmt.Sprintf(" No playlists in %s.", name)),
"",
}
if _, searchable := m.provider.(provider.Searcher); searchable {
lines = append(lines,
dimStyle.Render(" Press ")+helpKeyStyle.Render(" Ctrl+F ")+dimStyle.Render(" to search."))
}
if m.provider != nil {
if hint, ok := providerEmptyStateHint[strings.ToLower(m.provider.Name())]; ok {
lines = append(lines, dimStyle.Render(" "+hint))
}
}
return strings.Join(fitLines(lines, budget), "\n")
}
// providerRowStyle picks the prefix and style for a provider-list row.
// Cursor takes precedence; "currently loaded" gets the active-track style and
// the ▶ prefix so users can see at a glance which playlist is in the queue.
func (m Model) providerRowStyle(p playlist.PlaylistInfo, isCursor bool) (string, lipgloss.Style) {
if isCursor {
return "> ", playlistSelectedStyle
}
if m.isProviderRowActive(p) {
return "▶ ", playlistActiveStyle
}
return " ", playlistItemStyle
}
// isProviderRowActive reports whether the given playlist is the one whose
// tracks are currently loaded into the player.
func (m Model) isProviderRowActive(p playlist.PlaylistInfo) bool {
if m.activeProviderPlaylistID != "" && m.activeProviderPlaylistID == p.ID {
return true
}
if m.loadedPlaylist != "" && m.loadedPlaylist == p.Name {
return true
}
return false
}
// playlistLabel formats a playlist entry, omitting fields the provider didn't
// supply. Track count and total duration are appended when available.
func playlistLabel(prefix string, p playlist.PlaylistInfo) string {
out := prefix + p.Name
parts := make([]string, 0, 2)
if p.TrackCount > 0 {
parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount))
}
if d := formatPlaylistDuration(p.DurationSecs); d != "" {
parts = append(parts, d)
}
if len(parts) > 0 {
out += " · " + strings.Join(parts, " · ")
}
return out
}
// View renders the full TUI frame.
func (m Model) View() tea.View {
if m.quitting {
return tea.NewView("")
}
screen := m.activeScreen()
if !screen.hidesVisualizer() {
m.refreshVisualizerIfPending()
}
var content string
switch screen {
case screenFullVisualizer:
content = m.renderFullVisualizer()
default:
// Every overlay renders inline in the playlist region (renderMainBody),
// with its header/help supplied by renderPlaylistHeader / renderHelp, so
// the now-playing + visualizer chrome stays live above and the layout
// height never shifts when an overlay opens.
content = strings.Join(m.mainSections(m.renderMainBody(), true), "\n")
}
// Every screen now renders within the main frame, so frame and center
// uniformly.
rendered := m.centerFrame(ui.FrameStyle.Render(content))
view := tea.NewView(rendered)
view.AltScreen = true
view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues())
return view
}
func trimTrailingEmpty(sections []string) []string {
for len(sections) > 0 && sections[len(sections)-1] == "" {
sections = sections[:len(sections)-1]
}
return sections
}
func (m Model) mainSections(playlist string, includeTransient bool) []string {
sections := []string{
// Now playing
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
// ui.Visualizer
m.renderSpectrum(),
m.renderSeekBar(),
"",
// Controls
m.renderControls(),
m.renderProviderPill(),
"",
// Playlist
m.renderPlaylistHeader(),
}
if playlist != "" {
sections = append(sections, playlist)
}
sections = append(sections,
"",
// Help
m.renderHelp(),
m.renderBottomStatus(),
)
if includeTransient {
if m.err != nil {
sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)))
}
sections = append(sections, m.footerMessages()...)
}
return trimTrailingEmpty(sections)
}
func (m Model) footerMessages() []string {
var lines []string
if text := m.save.activityText(); text != "" {
lines = append(lines, statusStyle.Render(text))
}
if m.status.text != "" {
lines = append(lines, statusStyle.Render(m.status.text))
}
for _, l := range m.logLines {
lines = append(lines, dimStyle.Render(l.text))
}
return lines
}
// centerFrame centers a pre-rendered frame in the terminal using plain string
// padding instead of allocating a new lipgloss.Style every render.
func (m Model) centerFrame(frame string) string {
frameW := lipgloss.Width(frame)
frameH := lipgloss.Height(frame)
padLeft := max(0, (m.width-frameW)/2)
padTop := max(0, (m.height-frameH)/2)
if padLeft == 0 {
return strings.Repeat("\n", padTop) + frame
}
// Indent every line by padLeft spaces.
prefix := strings.Repeat(" ", padLeft)
lines := strings.Split(frame, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Repeat("\n", padTop) + strings.Join(lines, "\n")
}
func (m Model) renderTitle() string {
title := titleStyle.Render("C L I A M P")
label := m.focus.label()
if label == "" {
return title
}
indicator := dimStyle.Render("[" + label + "]")
gap := max(ui.PanelWidth-lipgloss.Width(title)-lipgloss.Width(indicator), 1)
return title + strings.Repeat(" ", gap) + indicator
}
func (m Model) renderTrackInfo() string {
track, _ := m.currentPlaybackTrack()
name := track.DisplayName()
if name == "" {
name = "No track loaded"
}
// Show live ICY stream title instead of static track name for radio streams.
if m.streamTitle != "" && track.Stream {
name = m.streamTitle
}
// Append album to the title line to save vertical space.
// The album is truncated (never scrolled) so artist/song stays readable.
album := track.Album
if m.streamTitle != "" && track.Stream {
album = ""
}
maxW := ui.PanelWidth - 4
if maxW < 1 {
return trackStyle.Render("♫ " + name)
}
nameRunes := []rune(name)
if album != "" {
sep := " · "
sepLen := len([]rune(sep))
remaining := maxW - len(nameRunes) - sepLen
if remaining >= 4 {
name += sep + truncate(album, remaining)
}
// remaining < 4: drop album, name alone fits or scrolls below.
}
runes := []rune(name)
if len(runes) <= maxW {
return trackStyle.Render("♫ " + name)
}
// Cyclic scrolling for long titles (only artist/song, album already handled)
padded := append(runes, titleScrollSep...)
total := len(padded)
off := m.titleOff % total
display := make([]rune, maxW)
for i := range maxW {
display[i] = padded[(off+i)%total]
}
return trackStyle.Render("♫ " + string(display))
}
func (m Model) renderTimeStatus() string {
// Use per-tick cached values to avoid repeated speaker.Lock() calls.
pos := m.cachedPos
dur := m.cachedDur
posMin := int(pos.Minutes())
posSec := int(pos.Seconds()) % 60
durMin := int(dur.Minutes())
durSec := int(dur.Seconds()) % 60
timeStr := fmt.Sprintf("%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec)
track, _ := m.currentPlaybackTrack()
var status string
switch {
case m.seek.active:
status = statusStyle.Render("⟳ Seeking...")
case m.buffering:
if elapsed := int(time.Since(m.bufferingAt).Seconds()); elapsed > 0 {
status = statusStyle.Render(fmt.Sprintf("◌ Buffering... (%ds)", elapsed))
} else {
status = statusStyle.Render("◌ Buffering...")
}
case m.player.IsPlaying() && m.player.IsPaused():
status = statusStyle.Render("⏸ Paused")
case m.player.IsPlaying() && track.Stream:
status = statusStyle.Render("● Streaming")
case m.player.IsPlaying():
status = statusStyle.Render("▶ Playing")
default:
status = dimStyle.Render("■ Stopped")
}
left := timeStyle.Render(timeStr)
gap := max(ui.PanelWidth-lipgloss.Width(left)-lipgloss.Width(status), 1)
return left + strings.Repeat(" ", gap) + status
}
func (m Model) renderSpectrum() string {
if m.vis.Mode == ui.VisNone {
return ""
}
return m.vis.Render()
}
// renderFullVisualizer renders a full-screen view showing only the visualizer
// with minimal track info and a seek bar.
func (m Model) renderFullVisualizer() string {
sections := []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
m.renderSpectrum(),
m.renderSeekBar(),
"",
helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol"),
}
return strings.Join(sections, "\n")
}
func (m Model) renderSeekBar() string {
if ui.PanelWidth <= 0 {
return ""
}
// During buffering, show a dim bar — avoids speaker.Lock() contention.
if m.buffering {
return seekDimStyle.Render(strings.Repeat("━", ui.PanelWidth))
}
// Show a static streaming bar for non-seekable streams with no known duration.
if !m.player.Seekable() && m.player.IsPlaying() && m.cachedDur == 0 {
label := " STREAMING "
pad := ui.PanelWidth - lipgloss.Width(label)
if pad < 0 {
return seekFillStyle.Render(label[:ui.PanelWidth])
}
left := pad / 2
right := pad - left
return seekFillStyle.Render(strings.Repeat("━", left) + label + strings.Repeat("━", right))
}
pos := m.cachedPos
dur := m.cachedDur
var progress float64
if dur > 0 {
progress = float64(pos) / float64(dur)
}
progress = max(0, min(1, progress))
// Half-cell resolution: each cell is two sub-units, so a 4-minute track on
// an 80-wide bar advances the tip every ~1.5s instead of ~3s. The
// transition cell uses ╸ (heavy left-half) as a half-step stub.
w := ui.PanelWidth
subPos := int(progress * 2 * float64(w))
fullCells := subPos / 2
hasHalf := subPos%2 == 1 && fullCells < w
var b strings.Builder
b.WriteString(seekFillStyle.Render(strings.Repeat("━", fullCells)))
if hasHalf {
b.WriteString(seekFillStyle.Render("╸"))
b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells-1)))
} else {
b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells)))
}
return b.String()
}
func (m Model) renderControls() string {
// ── EQ [Preset] (left) ····· VOL bar dB [Mono] (right) ──
bands := m.player.EQBands()
presetName := m.EQPresetName()
eqParts := make([]string, 10)
eqLabels := [10]string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"}
for i, label := range eqLabels {
style := eqInactiveStyle
if bands[i] != 0 {
label = fmt.Sprintf("%+.0f", bands[i])
}
if m.focus == focusEQ && i == m.eqCursor {
style = eqActiveStyle
}
eqParts[i] = style.Render(label)
}
eqLabel := labelStyle.Render("EQ ")
if m.focus == focusEQ {
eqLabel = activeToggle.Render("EQ ▸ ")
}
left := eqLabel + dimStyle.Render("[") + activeToggle.Render(presetName) + dimStyle.Render("] ") + strings.Join(eqParts, " ")
vol := m.player.Volume()
volMin := m.player.VolumeMin()
frac := max(0, min(1, (vol-volMin)/(6-volMin)))
dbStr := fmt.Sprintf(" %+.0fdB", vol)
monoStr := ""
if m.player.Mono() {
monoStr = " " + activeToggle.Render("[M]")
}
leftW := lipgloss.Width(left)
volLabel := labelStyle.Render("VOL ")
volSuffix := dimStyle.Render(dbStr) + monoStr
volLabelW := lipgloss.Width(volLabel)
volSuffixW := lipgloss.Width(volSuffix)
barW := max(6, (ui.PanelWidth-leftW-2-volLabelW-volSuffixW)*3/4)
filled := int(frac * float64(barW))
bar := volBarStyle.Render(strings.Repeat("█", filled)) +
dimStyle.Render(strings.Repeat("░", barW-filled))
right := volLabel + bar + volSuffix
rightW := lipgloss.Width(right)
gap := max(1, ui.PanelWidth-leftW-rightW)
return left + strings.Repeat(" ", gap) + right
}
func (m Model) renderProviderPill() string {
if len(m.providers) <= 1 {
return ""
}
var pills []string
for i, pe := range m.providers {
name := pe.Name
if m.focus == focusProvPill && i == m.provPillIdx {
pills = append(pills, activeToggle.Render("["+name+"]"))
} else if i == m.provPillIdx {
pills = append(pills, dimStyle.Render("[")+trackStyle.Render(name)+dimStyle.Render("]"))
} else {
pills = append(pills, dimStyle.Render("["+name+"]"))
}
}
srcLabel := labelStyle.Render("SRC ")
if m.focus == focusProvPill {
srcLabel = activeToggle.Render("SRC ▸ ")
}
return srcLabel + strings.Join(pills, " ")
}
func (m Model) renderPlaylistHeader() string {
if ov, ok := m.activeOverlay(); ok {
return ov.header(&m)
}
if m.focus == focusProvider {
return dimStyle.Render(labeledSeparator("", fmt.Sprintf("%s Playlists", m.provider.Name())))
}
var shuffle string
if m.playlist.Shuffled() {
shuffle = activeToggle.Render("[Shuffle]")
} else {
shuffle = dimStyle.Render("[") + trackStyle.Render("Shuffle") + dimStyle.Render("]")
}
repeatVal := m.playlist.Repeat().String()
if m.playlist.Repeat() != 0 {
repeatStr := fmt.Sprintf("[Repeat: %s]", repeatVal)
repeatStr = activeToggle.Render(repeatStr)
shuffle += " " + repeatStr
} else {
repeatStr := dimStyle.Render("[") + trackStyle.Render("Repeat") + dimStyle.Render(": ") + dimStyle.Render(repeatVal) + dimStyle.Render("]")
shuffle += " " + repeatStr
}
var queueStr string
if qLen := m.playlist.QueueLen(); qLen > 0 {
queueStr = " " + activeToggle.Render(fmt.Sprintf("[Queue: %d]", qLen))
}
var bookmarkStr string
if bookmarkCount := m.playlist.BookmarkCount(); bookmarkCount > 0 {
bookmarkStr = " " + activeToggle.Render(fmt.Sprintf("[★ %d]", bookmarkCount))
}
var themeStr string
if name := m.ThemeName(); name != theme.DefaultName {
themeStr = " " + activeToggle.Render("[Theme: "+name+"]")
}
var posStr string
if total := m.playlist.Len(); total > 0 {
posStr = " " + dimStyle.Render(fmt.Sprintf("[%d/%d]", m.playlist.Index()+1, total))
}
headerStyle := dimStyle
headerLabel := "── Playlist ── "
if m.focus == focusPlaylist {
headerStyle = activeToggle
headerLabel = "▸─ Playlist ── "
}
return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + posStr + themeStr + " " + dimStyle.Render("──")
}
func (m Model) renderProviderList() string {
visibleBudget := m.effectivePlaylistVisible()
if visibleBudget <= 0 {
return ""
}
if m.provSignIn {
return dimStyle.Render(fmt.Sprintf(" Sign in to %s. Press Enter to continue.", m.provider.Name()))
}
if m.provLoading {
lines := []string{loadingLine(fmt.Sprintf("Loading %s…", m.provider.Name()))}
if m.provAuthURL != "" {
lines = append(lines,
"",
dimStyle.Render(" If your browser didn't open, visit this URL to sign in:"),
" "+m.provAuthURL,
)
}
for len(lines) < visibleBudget {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
if len(m.providerLists) == 0 {
return m.renderProviderEmptyState(visibleBudget)
}
sl, isRadio := m.provider.(provider.SectionedList)
var lines []string
if m.provSearch.active {
lines = append(lines, playlistSelectedStyle.Render(" / "+m.provSearch.query+"_"))
if isRadio {
if m.provSearch.query == "" {
lines = append(lines, dimStyle.Render(" Type a station name, Enter to search…"))
} else {
lines = append(lines, dimStyle.Render(" Press Enter to search"))
}
} else {
if m.provSearch.query == "" {
lines = append(lines, dimStyle.Render(" Type to filter…"))
} else if len(m.provSearch.results) == 0 {
lines = append(lines, dimStyle.Render(" No matches"))
} else {
visible := max(0, min(visibleBudget-1, len(m.provSearch.results)))
scroll := m.provSearch.scroll
for j := scroll; j < scroll+visible && j < len(m.provSearch.results); j++ {
idx := m.provSearch.results[j]
p := m.providerLists[idx]
prefix, style := m.providerRowStyle(p, j == m.provSearch.cursor)
lines = append(lines, style.Render(playlistLabel(prefix, p)))
}
lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d playlists", len(m.provSearch.results), len(m.providerLists))))
}
}
} else {
scroll := max(0, m.provScroll)
if scroll >= len(m.providerLists) {
scroll = max(0, len(m.providerLists)-1)
}
if m.provCursor < scroll {
scroll = m.provCursor
}
hasSections := !isRadio && slices.ContainsFunc(m.providerLists, func(p playlist.PlaylistInfo) bool {
return p.Section != ""
})
if isRadio {
for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget {
scroll++
}
} else if m.provCursor >= scroll+visibleBudget {
scroll = m.provCursor - visibleBudget + 1
}
prevPrefix := ""
if isRadio && scroll > 0 {
prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID)
}
prevSection := ""
if hasSections && scroll > 0 {
prevSection = m.providerLists[scroll-1].Section
}
for j := scroll; j < len(m.providerLists) && len(lines) < visibleBudget; j++ {
p := m.providerLists[j]
if isRadio {
pfx := sl.IDPrefix(p.ID)
if pfx != prevPrefix {
var header string
switch pfx {
case "f":
header = labeledSeparator(" ", "Favorites")
case "c":
header = labeledSeparator(" ", "Catalog")
case "s":
header = labeledSeparator(" ", "Search Results")
}
if header != "" && len(lines) < visibleBudget {
lines = append(lines, dimStyle.Render(header))
}
prevPrefix = pfx
}
} else if hasSections && p.Section != prevSection {
header := labeledSeparator(" ", p.Section)
if len(lines) < visibleBudget {
lines = append(lines, dimStyle.Render(header))
}
prevSection = p.Section
}
if len(lines) >= visibleBudget {
break
}
prefix, style := m.providerRowStyle(p, j == m.provCursor)
lines = append(lines, style.Render(playlistLabel(prefix, p)))
}
}
// Loading indicator for catalog batch (never displace selected row if full).
if isRadio && m.catalogBatch.loading && len(lines) < visibleBudget {
lines = append(lines, loadingLine("Loading more stations…"))
}
return strings.Join(fitLines(lines, visibleBudget), "\n")
}
func (m Model) renderPlaylist() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
if m.focus == focusProvider {
return m.renderProviderList()
}
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
var lines []string
if m.feedLoading {
lines = append(lines, loadingLine("Loading feed…"))
} else {
lines = append(lines, dimStyle.Render(" No tracks loaded"))
}
return strings.Join(fitLines(lines, budget), "\n")
}
currentIdx := m.playlist.Index()
scroll := m.playlistScroll(budget)
lines := make([]string, 0, budget)
numWidth := len(fmt.Sprintf("%d", len(tracks)))
for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
if row.Index < 0 {
if len(lines)+1 >= budget {
break
}
lines = append(lines, m.albumSeparator(row.Album, row.Year))
continue
}
if len(lines) >= budget {
break
}
i, t := row.Index, row.Track
prefix := " "
style := playlistItemStyle
if i == currentIdx && m.player.IsPlaying() {
prefix = "▶ "
style = playlistActiveStyle
} else if strings.HasPrefix(t.Path, "ssh://") {
prefix = "↗ "
}
if m.focus == focusPlaylist && i == m.plCursor {
style = playlistSelectedStyle
}
if t.Unplayable {
if m.focus == focusPlaylist && i == m.plCursor {
style = dimStyle
} else {
style = playlistUnavailableStyle
}
}
name := t.DisplayName()
isBookmark := t.Bookmark
bookmarkBudget := 0
if isBookmark {
bookmarkBudget = 2 // "★ "
}
queueSuffix := ""
if qp := m.playlist.QueuePosition(i); qp > 0 {
queueSuffix = fmt.Sprintf(" [Q%d]", qp)
}
queueLen := utf8.RuneCountInString(queueSuffix)
linePrefixWidth := utf8.RuneCountInString(prefix) + numWidth + 2 // 2 for ". "
// Truncate the track name only against queue/bookmark overhead, never album.
name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen-bookmarkBudget)
// Truncate the album to fit whatever space remains after the track name.
albumSuffix := ""
nameLen := utf8.RuneCountInString(name)
if t.Unplayable {
remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen
if remaining >= 4 {
albumSuffix = truncate(" (unavailable)", remaining)
}
} else if album := t.Album; album != "" && !m.showAlbumHeaders {
remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen - 3 // 3 = " · "
if remaining >= 4 {
albumSuffix = " · " + truncate(album, remaining)
}
}
numStr := fmt.Sprintf("%s%*d. ", prefix, numWidth, i+1)
line := style.Render(numStr)
if isBookmark {
line += activeToggle.Render("★ ")
}
line += style.Render(name)
if albumSuffix != "" {
line += dimStyle.Render(albumSuffix)
}
if queueSuffix != "" {
line += activeToggle.Render(queueSuffix)
}
lines = append(lines, line)
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
func (m Model) renderHelp() string {
if ov, ok := m.activeOverlay(); ok {
return fitHelpLine(ov.help(&m))
}
if m.focus == focusProvider {
help := helpKey("↓↑", "Scroll ") + helpKey("Enter", "Load ") + helpKey("/", "Search ")
if _, ok := m.provider.(provider.FavoriteToggler); ok {
help += helpKey("f", "Fav ")
}
return help + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
}
if m.focus == focusProvPill {
return helpKey("←→", "Select ") + helpKey("Enter", "Open ") + helpKey("Esc", "Back ") + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
}
// Show only the 4-5 most relevant keys per mode; Ctrl+K always anchored for full list.
var hints []helpHint
if m.focus == focusSpeed {
hints = append(hints,
helpHint{helpKey("←→", "Speed "), 100},
helpHint{helpKey("[]", "Speed "), 90},
helpHint{helpKey("Spc", "▶❚❚ "), 80},
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
} else if m.focus == focusEQ {
hints = append(hints,
helpHint{helpKey("←→", "Band "), 100},
helpHint{helpKey("↓↑", "Gain "), 100},
helpHint{helpKey("e", "Preset "), 90},
helpHint{helpKey("Spc", "▶❚❚ "), 80},
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
} else {
// focusPlaylist (default)
hints = append(hints,
helpHint{helpKey("↓↑", "Scroll "), 100},
helpHint{helpKey("Enter", "Play "), 100},
helpHint{helpKey("Spc", "▶❚❚ "), 90},
)
track, _ := m.currentPlaybackTrack()
if !track.Stream || m.player.Seekable() {
hints = append(hints, helpHint{helpKey("←→", "Seek "), 80})
}
if m.loadedPlaylist != "" {
hints = append(hints, helpHint{helpKey("f", "Bookmark "), 75})
}
hints = append(hints,
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
}
return fitHints(hints, ui.PanelWidth)
}
// helpHint is a rendered help key with an associated display priority.
type helpHint struct {
text string
priority int
}
// fitHints drops lowest-priority hints until they fit within maxWidth.
// Widths are pre-computed once to avoid repeated lipgloss.Width calls.
func fitHints(hints []helpHint, maxWidth int) string {
active := make([]bool, len(hints))
widths := make([]int, len(hints))
var total int
for i, h := range hints {
active[i] = true
widths[i] = lipgloss.Width(h.text)
total += widths[i]
}
for total > maxWidth {
// Find lowest-priority active hint and drop it.
minPri := math.MaxInt
minIdx := -1
for i, h := range hints {
if active[i] && h.priority < minPri {
minPri = h.priority
minIdx = i
}
}
if minIdx < 0 {
break
}
active[minIdx] = false
total -= widths[minIdx]
}
var sb strings.Builder
for i, h := range hints {
if active[i] {
sb.WriteString(h.text)
}
}
return sb.String()
}
// renderBottomStatus renders the bottom status line: speed (left) and
// network stats (right) on the same row.
func (m Model) renderBottomStatus() string {
// Left: speed indicator.
speed := m.player.Speed()
if speed == 0 {
speed = 1.0
}
speedVal := fmt.Sprintf("%.2gx", speed)
var left string
speedLabel := labelStyle.Render("SPD ")
if m.focus == focusSpeed {
speedLabel = activeToggle.Render("SPD ▸ ")
left = speedLabel + activeToggle.Render("["+speedVal+"]")
} else if speed != 1.0 {
left = speedLabel + activeToggle.Render("["+speedVal+"]")
} else {
left = speedLabel + dimStyle.Render("[") + trackStyle.Render(speedVal) + dimStyle.Render("]")
}
// Right: network stream stats (empty for local files).
var right string
downloaded, total := m.player.StreamBytes()
if downloaded > 0 || total > 0 {
mb := float64(downloaded) / (1024 * 1024)
if total > 0 {
totalMB := float64(total) / (1024 * 1024)
pct := float64(downloaded) / float64(total) * 100
right = fmt.Sprintf("↓ %.1f / %.1f MB (%.0f%%)", mb, totalMB, pct)
} else {
right = fmt.Sprintf("↓ %.1f MB", mb)
}
if m.network.speed > 0 {
kbs := m.network.speed / 1024
if kbs >= 1024 {
right += fmt.Sprintf(" %.1f MB/s", kbs/1024)
} else {
right += fmt.Sprintf(" %.0f KB/s", kbs)
}
}
right = dimStyle.Render(right)
}
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
gap := max(1, ui.PanelWidth-leftW-rightW)
if right == "" {
return left
}
return left + strings.Repeat(" ", gap) + right
}
+350
View File
@@ -0,0 +1,350 @@
package model
import (
"fmt"
"iter"
"strings"
"time"
"unicode/utf8"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
// formatListMatchCount returns a human-readable "matches of total" summary
// for filtered lists.
func (m Model) formatListMatchCount(matches, total int) string {
if matches < 0 {
matches = 0
}
if total < 0 {
total = 0
}
return fmt.Sprintf("%d matches of %d total", matches, total)
}
// formatTrackTime formats a duration in seconds as M:SS or H:MM:SS for tracks.
// Returns "" when secs is non-positive so callers can skip rendering entirely.
func formatTrackTime(secs int) string {
if secs <= 0 {
return ""
}
h := secs / 3600
m := (secs % 3600) / 60
s := secs % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
// formatPlaylistDuration formats a total runtime for a playlist as "1h 23m"
// or "12m" or "45s". Returns "" when secs is non-positive.
func formatPlaylistDuration(secs int) string {
if secs <= 0 {
return ""
}
h := secs / 3600
m := (secs % 3600) / 60
if h > 0 {
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh %dm", h, m)
}
if m > 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%ds", secs)
}
// formatTrackRow renders a track list row of the form
//
// "01. Title · Album 3:42"
//
// with the duration right-aligned at ui.PanelWidth - 4 (to leave space for
// the cursor prefix the caller adds). The title column is truncated as
// needed; the duration is hidden when secs is 0.
func formatTrackRow(num int, name string, secs int) string {
const prefixOverhead = 4 // leaves room for " " / "> " caller prefix
dur := formatTrackTime(secs)
numStr := fmt.Sprintf("%d. ", num)
numLen := utf8.RuneCountInString(numStr)
durLen := utf8.RuneCountInString(dur)
titleBudget := ui.PanelWidth - prefixOverhead - numLen
if dur != "" {
titleBudget -= durLen + 1 // +1 for spacing gap
}
if titleBudget < 4 {
titleBudget = 4
}
title := truncate(name, titleBudget)
if dur == "" {
return numStr + title
}
pad := ui.PanelWidth - prefixOverhead - durLen - numLen - utf8.RuneCountInString(title)
if pad < 1 {
pad = 1
}
return numStr + title + strings.Repeat(" ", pad) + dur
}
// truncate shortens s to maxW runes, appending "…" if truncated.
// Uses RuneCountInString first to avoid rune slice allocation in the common
// case where the string is already short enough.
func truncate(s string, maxW int) string {
if maxW <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxW {
return s
}
if maxW == 1 {
return "…"
}
r := []rune(s)
return string(r[:maxW-1]) + "…"
}
// cursorLine renders a list item with "> " prefix when active, " " otherwise.
func cursorLine(label string, active bool) string {
if active {
return playlistSelectedStyle.Render("> " + label)
}
return dimStyle.Render(" " + label)
}
// spinnerFrames is the braille-dot animation used to indicate loading. The
// view re-renders on the model tick so the spinner advances on its own.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// spinnerFrame returns the current animation frame, time-driven so the caller
// doesn't need to track an animation index.
func spinnerFrame() string {
idx := (time.Now().UnixMilli() / 100) % int64(len(spinnerFrames))
return spinnerFrames[idx]
}
// loadingLine renders a single styled "<spinner> <label>" line for use as a
// loading indicator inside a list pane.
func loadingLine(label string) string {
return activeToggle.Render(" "+spinnerFrame()) + dimStyle.Render(" "+label)
}
// padLines appends empty strings so that rendered items fill maxVisible rows.
func padLines(lines []string, maxVisible, rendered int) []string {
for range maxVisible - rendered {
lines = append(lines, "")
}
return lines
}
// fitLines truncates lines to budget then pads with empty strings to exactly budget rows.
func fitLines(lines []string, budget int) []string {
if len(lines) > budget {
lines = lines[:budget]
}
return padLines(lines, budget, len(lines))
}
// helpKey renders a key as a pill (background-highlighted) followed by a dim label.
func helpKey(key, label string) string {
return helpKeyStyle.Render(" "+key+" ") + helpStyle.Render(" "+label)
}
// fitHelpLine keeps a help line to a single panel-wide row. Overlay help lines
// are fixed strings that can exceed the panel width and wrap to two rows, which
// would shift the layout height; this clips them (ANSI-aware) to one row.
func fitHelpLine(s string) string {
if ui.PanelWidth <= 0 || lipgloss.Width(s) <= ui.PanelWidth {
return s
}
return ansi.Truncate(s, ui.PanelWidth, "")
}
// toggleAlbumHeadersManual flips header visibility and pins the choice so
// later Adds don't re-run the cohesion heuristic over the user.
func (m *Model) toggleAlbumHeadersManual() {
m.showAlbumHeaders = !m.showAlbumHeaders
m.headerManual = true
}
// minTracksPerAlbum is the threshold at which a list is considered cohesive
// enough to default to showing album headers; below this average tracks/album,
// the list looks like a fragmented mixtape and headers add noise.
const minTracksPerAlbum = 3.0
// setHeaderStateFromTracks resets the running counters and re-runs the
// cohesion heuristic. A fresh load also clears any manual override.
func (m *Model) setHeaderStateFromTracks(tracks []playlist.Track) {
m.headerManual = false
m.headerLastAlbum = ""
m.headerSegments = 0
m.headerTracks = 0
m.addToHeaderState(tracks)
}
// addToHeaderState advances the cohesion counters by the newly added tracks
// (O(k)) and refreshes header visibility, unless the user has pinned it.
func (m *Model) addToHeaderState(tracks []playlist.Track) {
for _, t := range tracks {
if m.headerTracks == 0 || t.Album != m.headerLastAlbum {
m.headerSegments++
}
m.headerLastAlbum = t.Album
m.headerTracks++
}
if m.headerManual {
return
}
if m.headerSegments == 0 {
m.showAlbumHeaders = false
return
}
m.showAlbumHeaders = float64(m.headerTracks)/float64(m.headerSegments) >= minTracksPerAlbum
}
// trackAlbumSuffix returns the " · Album" suffix shown after track names when
// album headers are hidden. Empty when headers are on or the track has no album.
func trackAlbumSuffix(t playlist.Track, showHeaders bool) string {
if showHeaders || t.Album == "" {
return ""
}
return " · " + t.Album
}
// playlistRow represents a single line in a track list, which can be either
// an album separator header or an actual track.
type playlistRow struct {
Index int // index into the original track list; -1 for headers
Track playlist.Track // only populated if Index >= 0
Album string // only populated for headers (Index == -1)
Year int // only populated for headers (Index == -1)
}
// playlistRows returns an iterator over tracks and their injected album headers,
// starting from the given scroll position. It accounts for "sticky" headers
// (showing the header for an album even if we scrolled into the middle of it).
func (m Model) playlistRows(tracks []playlist.Track, scroll int, showHeaders bool) iter.Seq[playlistRow] {
return func(yield func(playlistRow) bool) {
if len(tracks) == 0 || scroll < 0 || scroll >= len(tracks) {
return
}
prevAlbum := ""
if scroll > 0 {
prevAlbum = tracks[scroll-1].Album
}
for i := scroll; i < len(tracks); i++ {
t := tracks[i]
if showHeaders {
// Sticky header when the viewport opens mid-album.
if i == scroll && t.Album != "" && t.Album == prevAlbum {
if !yield(playlistRow{Index: -1, Album: t.Album, Year: t.Year}) {
return
}
}
// Suppress a blank closing separator at the very top of the view.
if t.Album != prevAlbum && (t.Album != "" || i > scroll) {
if !yield(playlistRow{Index: -1, Album: t.Album, Year: t.Year}) {
return
}
}
}
if !yield(playlistRow{Index: i, Track: t}) {
return
}
prevAlbum = t.Album
}
}
}
// albumSeparatorRows counts rendered rows between scroll and cursor (inclusive)
// in a playlist view that emits an album-separator row whenever the album
// changes. Streaming tracks are treated as not contributing a separator,
// matching the renderer.
func (m Model) albumSeparatorRows(tracks []playlist.Track, scroll, cursor int, showHeaders bool) int {
if len(tracks) == 0 || scroll < 0 || cursor < scroll || cursor >= len(tracks) {
return 0
}
if !showHeaders {
return cursor - scroll + 1
}
rows := 0
for row := range m.playlistRows(tracks, scroll, showHeaders) {
rows++
if row.Index == cursor {
break
}
}
return rows
}
// separatorLine pads or truncates an unstyled separator to exactly fill the playlist pane width.
func separatorLine(line string) string {
if ui.PanelWidth <= 0 {
return ""
}
if w := lipgloss.Width(line); w < ui.PanelWidth {
return line + strings.Repeat("─", ui.PanelWidth-w)
}
if lipgloss.Width(line) > ui.PanelWidth {
return ansi.Truncate(line, ui.PanelWidth, "")
}
return line
}
// labeledSeparator builds a labeled separator line.
func labeledSeparator(indent, label string) string {
return separatorLine(indent + "── " + label + " ")
}
// albumSeparator builds an album separator line.
func (m Model) albumSeparator(album string, year int) string {
if album == "" {
return dimStyle.Render(strings.Repeat("─", ui.PanelWidth))
}
label := album
if year != 0 {
label += fmt.Sprintf(" (%d)", year)
}
return dimStyle.Render(labeledSeparator("", label))
}
// navScrollItems renders a filtered or unfiltered scrolled list for nav browsers.
func (m Model) navScrollItems(total int, labelFn func(int) string) []string {
maxVisible := m.navVisible()
useFilter := len(m.navBrowser.searchIdx) > 0 || m.navBrowser.search != ""
scroll := m.navBrowser.scroll
var lines []string
rendered := 0
if useFilter {
for j := scroll; j < len(m.navBrowser.searchIdx) && rendered < maxVisible; j++ {
label := labelFn(m.navBrowser.searchIdx[j])
lines = append(lines, cursorLine(label, j == m.navBrowser.cursor))
rendered++
}
} else {
for i := scroll; i < total && rendered < maxVisible; i++ {
label := labelFn(i)
lines = append(lines, cursorLine(label, i == m.navBrowser.cursor))
rendered++
}
}
return padLines(lines, maxVisible, rendered)
}
+244
View File
@@ -0,0 +1,244 @@
package model
import (
"strings"
"testing"
"github.com/bjarneo/cliamp/playlist"
)
func TestFormatTrackTime(t *testing.T) {
tests := []struct {
secs int
want string
}{
{0, ""},
{-5, ""},
{1, "0:01"},
{59, "0:59"},
{60, "1:00"},
{222, "3:42"},
{3599, "59:59"},
{3600, "1:00:00"},
{3661, "1:01:01"},
{36000, "10:00:00"},
}
for _, tt := range tests {
if got := formatTrackTime(tt.secs); got != tt.want {
t.Errorf("formatTrackTime(%d) = %q, want %q", tt.secs, got, tt.want)
}
}
}
func TestFormatPlaylistDuration(t *testing.T) {
tests := []struct {
secs int
want string
}{
{0, ""},
{-1, ""},
{45, "45s"},
{59, "59s"},
{60, "1m"},
{600, "10m"},
{3540, "59m"},
{3600, "1h"},
{3660, "1h 1m"},
{7200, "2h"},
{7320, "2h 2m"},
}
for _, tt := range tests {
if got := formatPlaylistDuration(tt.secs); got != tt.want {
t.Errorf("formatPlaylistDuration(%d) = %q, want %q", tt.secs, got, tt.want)
}
}
}
func TestPlaylistLabel(t *testing.T) {
tests := []struct {
name string
prefix string
info playlist.PlaylistInfo
want string
}{
{
"name only when both unknown",
" ",
playlist.PlaylistInfo{Name: "Mix"},
" Mix",
},
{
"track count only",
"> ",
playlist.PlaylistInfo{Name: "Mix", TrackCount: 12},
"> Mix · 12 tracks",
},
{
"duration only",
" ",
playlist.PlaylistInfo{Name: "Mix", DurationSecs: 3660},
" Mix · 1h 1m",
},
{
"both",
" ",
playlist.PlaylistInfo{Name: "Mix", TrackCount: 12, DurationSecs: 2700},
" Mix · 12 tracks · 45m",
},
}
for _, tt := range tests {
got := playlistLabel(tt.prefix, tt.info)
if got != tt.want {
t.Errorf("%s: playlistLabel = %q, want %q", tt.name, got, tt.want)
}
}
}
func TestFormatTrackRow(t *testing.T) {
// No duration: returns just "N. title".
row := formatTrackRow(3, "Song", 0)
if row != "3. Song" {
t.Errorf("no-duration row = %q, want %q", row, "3. Song")
}
// With duration: ends with the time string.
row = formatTrackRow(3, "Song", 222)
if !strings.HasSuffix(row, "3:42") {
t.Errorf("with-duration row %q does not end with %q", row, "3:42")
}
if !strings.HasPrefix(row, "3. Song") {
t.Errorf("with-duration row %q does not start with %q", row, "3. Song")
}
}
func TestHeaderStateIncremental(t *testing.T) {
mk := func(album string) playlist.Track { return playlist.Track{Album: album} }
tests := []struct {
name string
batches [][]playlist.Track
wantHeaders bool
wantTracks int
wantSegs int
}{
{
name: "empty",
batches: nil,
wantHeaders: false,
},
{
name: "single track is below cohesion threshold",
batches: [][]playlist.Track{
{mk("Aja")},
},
wantHeaders: false,
wantTracks: 1,
wantSegs: 1,
},
{
name: "full album in one shot is cohesive",
batches: [][]playlist.Track{
{mk("Aja"), mk("Aja"), mk("Aja"), mk("Aja")},
},
wantHeaders: true,
wantTracks: 4,
wantSegs: 1,
},
{
name: "full album split across batches stays cohesive",
batches: [][]playlist.Track{
{mk("Aja"), mk("Aja")},
{mk("Aja"), mk("Aja")},
},
wantHeaders: true,
wantTracks: 4,
wantSegs: 1,
},
{
name: "mixtape across batches is not cohesive",
batches: [][]playlist.Track{
{mk("A"), mk("B")},
{mk("C"), mk("D")},
},
wantHeaders: false,
wantTracks: 4,
wantSegs: 4,
},
{
name: "two albums of 3 tracks each meet threshold",
batches: [][]playlist.Track{
{mk("X"), mk("X"), mk("X")},
{mk("Y"), mk("Y"), mk("Y")},
},
wantHeaders: true,
wantTracks: 6,
wantSegs: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Model{}
m.setHeaderStateFromTracks(nil) // reset counters
for _, batch := range tt.batches {
m.addToHeaderState(batch)
}
if m.showAlbumHeaders != tt.wantHeaders {
t.Errorf("showAlbumHeaders = %v, want %v", m.showAlbumHeaders, tt.wantHeaders)
}
if m.headerTracks != tt.wantTracks {
t.Errorf("headerTracks = %d, want %d", m.headerTracks, tt.wantTracks)
}
if m.headerSegments != tt.wantSegs {
t.Errorf("headerSegments = %d, want %d", m.headerSegments, tt.wantSegs)
}
})
}
}
func TestHeaderStateManualOverride(t *testing.T) {
mk := func(album string) playlist.Track { return playlist.Track{Album: album} }
m := &Model{}
// Start with a cohesive album so the heuristic would prefer headers.
m.setHeaderStateFromTracks([]playlist.Track{mk("A"), mk("A"), mk("A"), mk("A")})
if !m.showAlbumHeaders {
t.Fatalf("baseline cohesive album should default to showing headers")
}
// User manually toggles off.
m.toggleAlbumHeadersManual()
if m.showAlbumHeaders {
t.Fatalf("after manual toggle showAlbumHeaders should be false")
}
// Adding more cohesive tracks must NOT flip back on.
m.addToHeaderState([]playlist.Track{mk("A"), mk("A"), mk("A")})
if m.showAlbumHeaders {
t.Fatalf("manual override should suppress heuristic after Add")
}
// A fresh load via setHeaderStateFromTracks clears the manual flag.
m.setHeaderStateFromTracks([]playlist.Track{mk("B"), mk("B"), mk("B"), mk("B")})
if !m.showAlbumHeaders {
t.Fatalf("setHeaderStateFromTracks should clear manual flag and re-run heuristic")
}
}
func TestProviderKeyForShortcut(t *testing.T) {
tests := map[string]string{
"S": "spotify",
"N": "navidrome",
"P": "plex",
"J": "jellyfin",
"Y": "yt",
"L": "local",
"R": "radio",
"x": "",
"": "",
}
for in, want := range tests {
if got := providerKeyForShortcut(in); got != want {
t.Errorf("providerKeyForShortcut(%q) = %q, want %q", in, got, want)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package model
import "github.com/bjarneo/cliamp/provider"
// navSortLabel resolves a sort-type ID to its human label for the active
// provider's album browser, falling back to the raw ID.
func (m Model) navSortLabel(sortID string) string {
if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok {
for _, st := range ab.AlbumSortTypes() {
if st.ID == sortID {
return st.Label
}
}
}
return sortID
}
+301
View File
@@ -0,0 +1,301 @@
package model
import (
"fmt"
"os"
"strings"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
// renderVisPickerList renders the visualizer mode list for the playlist region
// while the picker is open. The header and help line are supplied by the main
// layout (renderPlaylistHeader / renderHelp).
func (m Model) renderVisPickerList() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
items := m.visPicker.modes
scroll := m.visPicker.scroll
lines := make([]string, 0, budget)
for i := scroll; i < len(items) && len(lines) < budget; i++ {
lines = append(lines, cursorLine(items[i], i == m.visPicker.cursor))
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
// — playlist manager (inline) —
func (m Model) plMgrHeaderLine() string {
if m.plManager.filtering {
return filterPromptHeader(m.plManager.filter)
}
switch m.plManager.screen {
case plMgrScreenTracks:
label := "Playlist: " + m.plManager.selPlaylist
if m.plManager.sortMode > 0 {
mode := plMgrSortModes[(m.plManager.sortMode-1)%len(plMgrSortModes)]
label += " · sort: " + mode
}
return sepHeaderN(label, m.plManager.cursor+1, len(m.plManager.tracks))
case plMgrScreenNewName:
return promptHeader("New Playlist", m.plManager.newName)
case plMgrScreenRename:
return promptHeader("Rename "+m.plManager.renameOldName, m.plManager.renameName)
default:
total := len(m.plManager.playlists)
if m.plManager.filter != "" {
total = len(m.plManager.filtered)
}
return sepHeaderN("Playlists", m.plManager.cursor+1, total)
}
}
func (m Model) plMgrHelpLine() string {
switch m.plManager.screen {
case plMgrScreenTracks:
return m.plMgrTracksHelpLine()
case plMgrScreenNewName:
return helpKey("Enter", "Create & add ") + helpKey("Esc", "Cancel")
case plMgrScreenRename:
return helpKey("Enter", "Rename ") + helpKey("Esc", "Cancel")
default:
return m.plMgrListHelpLine()
}
}
func (m Model) renderPlMgrBody() string {
switch m.plManager.screen {
case plMgrScreenTracks:
return m.renderPlMgrTracksBody()
case plMgrScreenNewName, plMgrScreenRename:
return m.renderPlMgrFormBody()
default:
return m.renderPlMgrListBody()
}
}
func (m Model) renderPlMgrFormBody() string {
budget := m.effectivePlaylistVisible()
if m.plManager.screen == plMgrScreenRename {
return bodyMessage("Enter a new name for the playlist above.", budget)
}
label := "Create the playlist (nothing playing to add)."
if track, idx := m.currentPlaybackTrack(); idx >= 0 && track.Path != "" {
label = "Create & add: " + truncate(track.DisplayName(), max(1, ui.PanelWidth-16))
}
return bodyMessage(label, budget)
}
func (m Model) renderPlMgrListBody() string {
budget := m.effectivePlaylistVisible()
visibleN := len(m.plManager.playlists)
if m.plManager.filter != "" {
visibleN = len(m.plManager.filtered)
}
// Empty state: no playlists at all.
if len(m.plManager.playlists) == 0 {
return bodyLines([]string{
dimStyle.Render(" No playlists yet."),
dimStyle.Render(" Press Enter on \"+ New Playlist…\" below,"),
dimStyle.Render(" or `a` to save the now-playing track."),
"",
playlistSelectedStyle.Render("> + New Playlist..."),
}, budget)
}
// Filtered with no matches: still allow "+ New Playlist..."
if m.plManager.filter != "" && visibleN == 0 {
newLabel := "+ New Playlist \"" + m.plManager.filter + "\"..."
return bodyLines([]string{
dimStyle.Render(fmt.Sprintf(" No playlists match %q", m.plManager.filter)),
cursorLine(newLabel, m.plManager.cursor == 0),
}, budget)
}
type plRow struct {
label string
realIdx int // -1 for "New" or spacer
viewIdx int // logical index for cursor comparison
spacer bool
}
var rows []plRow
foundUser := false
for i := 0; i < visibleN; i++ {
idx := m.plMgrPlaylistRealIndex(i)
p := m.plManager.playlists[idx]
if p.Name != history.PlaylistName {
if !foundUser && i > 0 {
rows = append(rows, plRow{spacer: true, viewIdx: -1})
}
foundUser = true
}
rows = append(rows, plRow{
label: playlistLabel("", p),
realIdx: idx,
viewIdx: i,
})
}
if visibleN > 0 {
rows = append(rows, plRow{spacer: true, viewIdx: -1})
}
newLabel := "+ New Playlist..."
if m.plManager.filter != "" {
newLabel = "+ New Playlist \"" + m.plManager.filter + "\"..."
}
rows = append(rows, plRow{label: newLabel, realIdx: -1, viewIdx: visibleN})
// Map the logical scroll position to our row index.
startIndex := 0
for i, r := range rows {
if r.viewIdx == m.plManager.scroll {
startIndex = i
break
}
}
lines := make([]string, 0, budget)
for i := startIndex; i < len(rows) && len(lines) < budget; i++ {
r := rows[i]
if r.spacer {
lines = append(lines, "")
continue
}
if r.viewIdx == m.plManager.cursor {
if m.plManager.confirmDel && r.realIdx >= 0 {
lines = append(lines, playlistSelectedStyle.Render("> Delete \""+m.plManager.playlists[r.realIdx].Name+"\"? [y/n]"))
} else {
lines = append(lines, playlistSelectedStyle.Render("> "+r.label))
}
} else {
lines = append(lines, dimStyle.Render(" "+r.label))
}
}
return bodyLines(lines, budget)
}
func (m Model) renderPlMgrTracksBody() string {
budget := m.effectivePlaylistVisible()
if len(m.plManager.tracks) == 0 {
return bodyLines([]string{
dimStyle.Render(" This playlist is empty."),
dimStyle.Render(" Press `a` to add the now-playing track."),
}, budget)
}
visibleN := len(m.plManager.tracks)
if m.plManager.filter != "" {
visibleN = len(m.plManager.filtered)
if visibleN == 0 {
return bodyMessage(fmt.Sprintf("No tracks match %q", m.plManager.filter), budget)
}
}
scroll := m.plManager.scroll
if m.plManager.filter != "" {
lines := make([]string, 0, budget)
for i := scroll; i < visibleN && len(lines) < budget; i++ {
realIdx := m.plMgrTrackRealIndex(i)
label := m.plMgrTrackLabel(realIdx)
lines = append(lines, cursorLine(label, i == m.plManager.cursor))
}
return bodyLines(lines, budget)
}
lines := make([]string, 0, budget)
for row := range m.playlistRows(m.plManager.tracks, scroll, m.showAlbumHeaders) {
if len(lines) >= budget {
break
}
if row.Index < 0 {
lines = append(lines, m.albumSeparator(row.Album, row.Year))
continue
}
lines = append(lines, cursorLine(m.plMgrTrackLabel(row.Index), row.Index == m.plManager.cursor))
}
return bodyLines(lines, budget)
}
func (m Model) plMgrTrackLabel(realIdx int) string {
t := m.plManager.tracks[realIdx]
mark := " "
if m.plManager.marked[realIdx] {
mark = "* "
}
missing := ""
if missingLocalTrack(t) {
missing = "! "
}
return mark + missing + formatTrackRow(realIdx+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs)
}
func missingLocalTrack(t playlist.Track) bool {
if t.Path == "" || t.Stream || playlist.IsURL(t.Path) || strings.HasPrefix(t.Path, "ssh://") {
return false
}
_, err := os.Stat(t.Path)
return os.IsNotExist(err)
}
// renderSearchList renders the playlist-search results for the playlist region
// while search is active. The query prompt and help line are supplied by the
// main layout (renderPlaylistHeader / renderHelp), mirroring renderVisPickerList.
func (m Model) renderSearchList() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
if len(m.search.results) == 0 {
msg := "Type to search…"
if m.search.query != "" {
msg = "No matches"
}
return strings.Join(fitLines([]string{dimStyle.Render(" " + msg)}, budget), "\n")
}
tracks := m.playlist.Tracks()
currentIdx := m.playlist.Index()
isPlaying := m.player.IsPlaying()
scroll := m.search.scroll
lines := make([]string, 0, budget)
for j := scroll; j < len(m.search.results) && len(lines) < budget; j++ {
i := m.search.results[j]
prefix := " "
style := dimStyle
if i == currentIdx && isPlaying {
prefix = "▶ "
style = playlistActiveStyle
}
name := tracks[i].DisplayName()
queueSuffix := ""
if qp := m.playlist.QueuePosition(i); qp > 0 {
queueSuffix = fmt.Sprintf(" [Q%d]", qp)
}
name = truncate(name, ui.PanelWidth-8-len([]rune(queueSuffix)))
line := fmt.Sprintf("%s%d. %s", prefix, i+1, name)
item := style.Render(line)
if queueSuffix != "" {
item += activeToggle.Render(queueSuffix)
}
// cursorLine adds the "> "/" " prefix and selected styling.
lines = append(lines, cursorLine(item, j == m.search.cursor))
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
+266
View File
@@ -0,0 +1,266 @@
package model
import (
"fmt"
"regexp"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/ui"
)
func withFrameWidth(t *testing.T, width int) {
t.Helper()
prevFrameStyle := ui.FrameStyle
prevPanelWidth := ui.PanelWidth
ui.FrameStyle = ui.FrameStyle.Width(width)
ui.PanelWidth = max(0, width-2*ui.PaddingH)
t.Cleanup(func() {
ui.FrameStyle = prevFrameStyle
ui.PanelWidth = prevPanelWidth
})
}
func TestMainViewShrinksPlaylistForFooterMessages(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
pl := playlist.New()
for i := range 12 {
pl.Add(playlist.Track{
Path: fmt.Sprintf("/tmp/track-%d.mp3", i),
Title: fmt.Sprintf("Track %d", i+1),
})
}
m := Model{
player: sharedPlayer,
playlist: pl,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
plVisible: 3,
}
m.vis.Mode = ui.VisNone
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 1
if got := m.effectivePlaylistVisible(); got != 1 {
t.Fatalf("effectivePlaylistVisible() = %d, want 1 with one row left after footer lines", got)
}
if got := lipgloss.Height(m.View().Content); got > m.height {
t.Fatalf("View() height = %d, want <= %d after footer lines shrink playlist", got, m.height)
}
}
func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
sharedPlayer.Stop()
pl := playlist.New()
for i := range 12 {
pl.Add(playlist.Track{
Path: fmt.Sprintf("/tmp/track-%d.mp3", i),
Title: fmt.Sprintf("Track %d", i+1),
})
}
m := Model{
player: sharedPlayer,
playlist: pl,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
focus: focusPlaylist,
plVisible: 3,
plScroll: 7,
plCursor: 9,
}
m.vis.Mode = ui.VisNone
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 2
if got := m.effectivePlaylistVisible(); got != 2 {
t.Fatalf("effectivePlaylistVisible() = %d, want 2 with footer-shrunk playlist", got)
}
out := m.renderPlaylist()
if !strings.Contains(out, "Track 10") {
t.Fatalf("renderPlaylist() = %q, want selected row to remain visible", out)
}
}
func TestViewConsumesInitialVisualizerRefresh(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
m := Model{
player: sharedPlayer,
playlist: playlist.New(),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
height: 24,
}
if !m.vis.RefreshPending() {
t.Fatal("refreshPending = false on new visualizer, want initial refresh request")
}
_ = m.View()
if m.vis.RefreshPending() {
t.Fatal("refreshPending = true after first View(), want refresh consumed")
}
if m.vis.Frame() != 1 {
t.Fatalf("visualizer frame after first View() = %d, want 1", m.vis.Frame())
}
}
func TestOverlayViewIncludesFooterMessages(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
sharedPlayer.Stop()
// Footer/transient messages are now rendered by the inline overlay layout
// (mainSectionsOverlay) rather than by each overlay renderer.
m := Model{
player: sharedPlayer,
playlist: playlist.New(),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
height: 24,
plVisible: 5,
}
m.vis.Mode = ui.VisNone
m.refreshChrome()
m.applyHeightMode()
m.save.startDownload()
out := m.View().Content
if !strings.Contains(out, "Downloading...") {
t.Fatalf("overlay view missing download footer: %q", out)
}
}
func TestKeymapRendersInline(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
sharedPlayer.Stop()
m := Model{
player: sharedPlayer,
playlist: playlist.New(),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
height: 24,
plVisible: 5,
keymap: keymapOverlay{
visible: true,
entries: []keymapEntry{{key: "Space", action: "Play/Pause"}},
},
}
m.vis.Mode = ui.VisNone
out := m.View().Content
if got := lipgloss.Height(out); got > m.height {
t.Fatalf("View() height = %d, want <= %d for inline keymap", got, m.height)
}
// The keymap renders in the playlist region, so its entries appear inline
// rather than as a standalone centered overlay.
if !strings.Contains(out, "Play/Pause") {
t.Fatalf("View() missing inline keymap entry: %q", out)
}
}
func TestFullVisualizerViewFitsTerminalWidth(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
sharedPlayer.Stop()
m := Model{
player: sharedPlayer,
playlist: playlist.New(),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
height: 24,
fullVis: true,
}
m.vis.Mode = ui.VisNone
if got := lipgloss.Width(m.View().Content); got > m.width {
t.Fatalf("View() width = %d, want <= %d in full visualizer mode", got, m.width)
}
}
var stripAnsiRegExp = regexp.MustCompile(`\x1b\[[0-9;]*[mK]`)
func stripAnsi(str string) string {
return stripAnsiRegExp.ReplaceAllString(str, "")
}
func TestRenderPlaylistAddsPaddingToTrackNumber(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
withFrameWidth(t, 80)
sharedPlayer.Stop()
pl := playlist.New()
for i := range 120 {
pl.Add(playlist.Track{
Path: fmt.Sprintf("/tmp/track-%d.mp3", i),
Title: fmt.Sprintf("Track %d", i+1),
})
}
m := Model{
player: sharedPlayer,
playlist: pl,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
plVisible: 120,
}
m.vis.Mode = ui.VisNone
m.height = m.mainFrameFixedLines(false) + 120
out := m.renderPlaylist()
lines := strings.Split(out, "\n")
if len(lines) < 120 {
t.Fatalf("renderPlaylist() returned %d lines, want 120", len(lines))
}
line9 := stripAnsi(lines[8])
line99 := stripAnsi(lines[98])
line119 := stripAnsi(lines[118])
ninthLineTrackIndex := strings.Index(line9, "Track")
ninetyNinthLineTrackIndex := strings.Index(line99, "Track")
oneHundredNineteenthLineTrackIndex := strings.Index(line119, "Track")
if ninthLineTrackIndex != ninetyNinthLineTrackIndex || ninthLineTrackIndex != oneHundredNineteenthLineTrackIndex {
t.Errorf(`Track name alignment is off for 3-digit numbers.
Line 9: %q (index %d)
Line 99: %q (index %d)
Line 119: %q (index %d)`, line9, ninthLineTrackIndex, line99, ninetyNinthLineTrackIndex, line119, oneHundredNineteenthLineTrackIndex)
}
}
+45
View File
@@ -0,0 +1,45 @@
package model
import (
"net/url"
"strings"
"github.com/bjarneo/cliamp/resolve"
tea "charm.land/bubbletea/v2"
)
const ytdlBatchSize = 100 // items per background batch
// resetYTDLBatch invalidates any in-progress batch loading session.
// Incrementing the generation ensures that stale in-flight responses
// are discarded by the handler, even if the same URL is reloaded.
func (m *Model) resetYTDLBatch() {
m.ytdlBatch.gen++
m.ytdlBatch.url = ""
m.ytdlBatch.offset = 0
m.ytdlBatch.done = false
m.ytdlBatch.loading = false
}
// initYTDLBatch detects a YouTube Radio URL among the given source URLs and
// kicks off incremental batch loading. The offset is derived from the known
// initial fetch size (resolve.YTDLRadioInitialItems) so it stays correct
// regardless of how many tracks other URLs contributed to the same load.
func (m *Model) initYTDLBatch(urls []string) tea.Cmd {
for _, u := range urls {
parsed, err := url.Parse(u)
if err != nil {
continue
}
if strings.HasPrefix(parsed.Query().Get("list"), "RD") {
m.ytdlBatch.gen++
m.ytdlBatch.url = u
m.ytdlBatch.offset = resolve.YTDLRadioInitialItems
m.ytdlBatch.done = false
m.ytdlBatch.loading = true
return fetchYTDLBatchCmd(m.ytdlBatch.gen, u, resolve.YTDLRadioInitialItems, ytdlBatchSize)
}
}
return nil
}