chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user