diff --git a/README.md b/README.md index 8aee0fd..38da791 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A terminal-based music player for [SomaFM](https://somafm.com/) radio stations b - Stations sorted by listener count - Favorites management - Persistent configuration (volume, favorites, last station) +- User-selectable preferred stream format (MP3 or AAC) - Customizable color themes - Automatic retry on stream failure @@ -71,6 +72,7 @@ somafm --help # Show help and config file path | `←` `→` or `+` `-` | Volume up / down | | `Scroll` | Volume (on volume bar) | | `m` | Mute / Unmute | +| `s` | Toggle preferred stream format | | `f` | Toggle favorite | | `?` | Show help | | `a` | About | @@ -84,6 +86,7 @@ Configuration is saved automatically to `~/.config/somafm/config.yml`. volume: 70 # Volume level (0-100) last_station: groovesalad # Last played station ID autostart: false # Auto-play last station on launch (true/false) +preferred_format: mp3 # Preferred stream format: mp3 or aac favorites: # List of favorite station IDs - groovesalad - dronezone @@ -94,7 +97,7 @@ theme: # Color customization highlight: "#ff9d65" ``` -Settings are saved when you adjust volume, select a station, or toggle favorites. +Settings are saved when you adjust volume, select a station, toggle favorites, or switch the preferred stream format. ### Theme Options diff --git a/go.mod b/go.mod index b828e00..96406d8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/glebovdev/somafm-cli -go 1.25.0 +go 1.25.6 require ( github.com/gdamore/tcell/v2 v2.13.8 @@ -8,6 +8,7 @@ require ( github.com/gopxl/beep/v2 v2.1.1 github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.34.0 + github.com/skrashevich/go-aac v0.1.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 950a74d..95f006a 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/skrashevich/go-aac v0.1.0 h1:7oHNj1ADmgfjAHvi3wAIFbmbCpQBrcjZEVTLlRtAS1A= +github.com/skrashevich/go-aac v0.1.0/go.mod h1:Mj7r//4LDL4FC0ezORj+MnmQ+nDEkJhTOy2aMC8dzww= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/config/config.go b/internal/config/config.go index d07495d..238f494 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "github.com/gdamore/tcell/v2" @@ -27,6 +28,9 @@ const ( DefaultVolume = 70 MinVolume = 0 MaxVolume = 100 + + AudioFormatMP3 = "mp3" + AudioFormatAAC = "aac" ) // ClampVolume ensures volume is within the valid range [0, 100]. @@ -40,6 +44,15 @@ func ClampVolume(volume int) int { return volume } +func NormalizePreferredFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case AudioFormatAAC: + return AudioFormatAAC + default: + return AudioFormatMP3 + } +} + // AppVersion can be overridden at build time using ldflags: // go build -ldflags "-X github.com/glebovdev/somafm-cli/internal/config.AppVersion=1.0.0" var AppVersion = "dev" @@ -61,11 +74,12 @@ type Theme struct { } type Config struct { - Volume int `yaml:"volume"` - LastStation string `yaml:"last_station"` - Autostart bool `yaml:"autostart"` - Favorites []string `yaml:"favorites"` - Theme Theme `yaml:"theme"` + Volume int `yaml:"volume"` + LastStation string `yaml:"last_station"` + Autostart bool `yaml:"autostart"` + PreferredFormat string `yaml:"preferred_format"` + Favorites []string `yaml:"favorites"` + Theme Theme `yaml:"theme"` saveMu sync.Mutex `yaml:"-"` } @@ -101,6 +115,7 @@ func Load() (*Config, error) { } cfg.Volume = ClampVolume(cfg.Volume) + cfg.PreferredFormat = NormalizePreferredFormat(cfg.PreferredFormat) return cfg, nil } @@ -156,10 +171,11 @@ func (c *Config) Save() error { func DefaultConfig() *Config { return &Config{ - Volume: DefaultVolume, - LastStation: "", - Autostart: false, - Favorites: []string{}, + Volume: DefaultVolume, + LastStation: "", + Autostart: false, + PreferredFormat: AudioFormatMP3, + Favorites: []string{}, Theme: Theme{ Background: "#1a1b25", Foreground: "#a3aacb", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4c6a774..03efe73 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,6 +20,10 @@ func TestDefaultConfig(t *testing.T) { if cfg.Autostart != false { t.Errorf("DefaultConfig().Autostart = %v, want false", cfg.Autostart) } + + if cfg.PreferredFormat != AudioFormatMP3 { + t.Errorf("DefaultConfig().PreferredFormat = %q, want %q", cfg.PreferredFormat, AudioFormatMP3) + } } func TestConfigSaveAndLoad(t *testing.T) { @@ -27,8 +31,9 @@ func TestConfigSaveAndLoad(t *testing.T) { t.Setenv("HOME", tmpDir) testCfg := &Config{ - Volume: 85, - LastStation: "groovesalad", + Volume: 85, + LastStation: "groovesalad", + PreferredFormat: AudioFormatAAC, } err := testCfg.Save() @@ -53,6 +58,10 @@ func TestConfigSaveAndLoad(t *testing.T) { if loadedCfg.LastStation != testCfg.LastStation { t.Errorf("Load().LastStation = %q, want %q", loadedCfg.LastStation, testCfg.LastStation) } + + if loadedCfg.PreferredFormat != testCfg.PreferredFormat { + t.Errorf("Load().PreferredFormat = %q, want %q", loadedCfg.PreferredFormat, testCfg.PreferredFormat) + } } func TestLoadNonExistentConfig(t *testing.T) { @@ -93,8 +102,9 @@ func TestVolumeValidation(t *testing.T) { t.Setenv("HOME", tmpDir) testCfg := &Config{ - Volume: tt.inputVolume, - LastStation: "groovesalad", + Volume: tt.inputVolume, + LastStation: "groovesalad", + PreferredFormat: AudioFormatAAC, } err := testCfg.Save() @@ -145,8 +155,9 @@ func TestThemePersistence(t *testing.T) { t.Setenv("HOME", tmpDir) testCfg := &Config{ - Volume: 70, - LastStation: "groovesalad", + Volume: 70, + LastStation: "groovesalad", + PreferredFormat: AudioFormatAAC, Theme: Theme{ Background: "black", Foreground: "yellow", @@ -177,6 +188,9 @@ func TestThemePersistence(t *testing.T) { if loadedCfg.Theme.Highlight != "red" { t.Errorf("Theme.Highlight = %q, want %q", loadedCfg.Theme.Highlight, "red") } + if loadedCfg.PreferredFormat != AudioFormatAAC { + t.Errorf("PreferredFormat = %q, want %q", loadedCfg.PreferredFormat, AudioFormatAAC) + } } func TestIsFavorite(t *testing.T) { diff --git a/internal/player/player.go b/internal/player/player.go index bdf4074..211e7a4 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -20,6 +20,9 @@ import ( "github.com/gopxl/beep/v2/mp3" "github.com/gopxl/beep/v2/speaker" "github.com/rs/zerolog/log" + aacadts "github.com/skrashevich/go-aac/pkg/adts" + aacdecoder "github.com/skrashevich/go-aac/pkg/decoder" + aactables "github.com/skrashevich/go-aac/pkg/tables" ) const ( @@ -117,16 +120,17 @@ func (cr *contextReader) Read(p []byte) (n int, err error) { // Player manages audio streaming and playback for SomaFM radio stations. type Player struct { - format beep.Format - volume *effects.Volume - ctrl *beep.Ctrl - mu sync.Mutex - cancelFunc context.CancelFunc - isPaused bool - isPlaying bool - speakerInit bool - volumePercent int - httpClient *http.Client + format beep.Format + volume *effects.Volume + ctrl *beep.Ctrl + mu sync.Mutex + cancelFunc context.CancelFunc + isPaused bool + isPlaying bool + speakerInit bool + volumePercent int + httpClient *http.Client + preferredFormat string sampleCh chan [2]float64 wg sync.WaitGroup @@ -184,20 +188,31 @@ func NewPlayer() *Player { NumChannels: 2, Precision: 2, }, - speakerInit: false, - isPaused: false, - isPlaying: false, - volumePercent: -1, - httpClient: httpClient, - currentTrack: "", + speakerInit: false, + isPaused: false, + isPlaying: false, + volumePercent: -1, + httpClient: httpClient, + preferredFormat: config.AudioFormatMP3, + currentTrack: "", } } +func (p *Player) SetPreferredFormat(format string) { + p.mu.Lock() + defer p.mu.Unlock() + p.preferredFormat = config.NormalizePreferredFormat(format) +} + +func (p *Player) GetPreferredFormat() string { + return p.preferredFormat +} + func (p *Player) initSpeaker(sampleRate beep.SampleRate) error { p.mu.Lock() defer p.mu.Unlock() - if !p.speakerInit || sampleRate != p.format.SampleRate { + if !p.speakerInit { err := speaker.Init(sampleRate, sampleRate.N(SpeakerBufferSize)) if err != nil { return fmt.Errorf("failed to initialize speaker: %w", err) @@ -495,7 +510,7 @@ func (p *Player) Play(s *station.Station) error { } func (p *Player) playWithRetry(s *station.Station, maxRetries int) error { - playlistURLs := s.GetAllPlaylistURLs() + playlistURLs := s.GetPlaylistURLs(p.GetPreferredFormat()) if len(playlistURLs) == 0 { p.setState(StateError) p.setLastError("No playlists available") @@ -515,14 +530,11 @@ func (p *Player) playWithRetry(s *station.Station, maxRetries int) error { } var reconnectStreamURLs []string - var reconnectStreamInfo StreamInfo playlists: for playlistIdx, playlistURL := range playlistURLs { log.Debug().Msgf("Trying playlist %d/%d: %s", playlistIdx+1, len(playlistURLs), playlistURL) - streamInfo := parseStreamInfoFromURL(playlistURL) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) streamURLs, err := p.fetchAndParsePLS(ctx, playlistURL) cancel() @@ -556,13 +568,14 @@ playlists: p.cancelFunc = cancel p.mu.Unlock() - p.setStreamInfo(streamInfo) - err := p.playStreamURL(ctx, s, streamURL) if err == nil { return nil } + log.Debug().Err(err).Msgf("Playback attempt failed for stream %d/%d (attempt %d/%d)", + urlIdx+1, len(streamURLs), attempt, maxRetries) + if errors.Is(err, context.Canceled) { return context.Canceled } @@ -571,7 +584,6 @@ playlists: if p.GetState() == StatePlaying { log.Info().Msg("Stream was playing, entering reconnect mode") reconnectStreamURLs = streamURLs - reconnectStreamInfo = streamInfo break playlists } @@ -588,7 +600,7 @@ playlists: var finalErr error if len(reconnectStreamURLs) > 0 { - err := p.reconnectWithRotation(s, reconnectStreamURLs, reconnectStreamInfo, maxRetries) + err := p.reconnectWithRotation(s, reconnectStreamURLs, maxRetries) if err == nil { return nil } @@ -606,7 +618,7 @@ playlists: } // If a stream recovers then drops again, the retry counter resets. -func (p *Player) reconnectWithRotation(s *station.Station, streamURLs []string, streamInfo StreamInfo, maxRetries int) error { +func (p *Player) reconnectWithRotation(s *station.Station, streamURLs []string, maxRetries int) error { var lastErr error for retryCount := 1; retryCount <= maxRetries; retryCount++ { @@ -626,8 +638,6 @@ func (p *Player) reconnectWithRotation(s *station.Station, streamURLs []string, p.cancelFunc = cancel p.mu.Unlock() - p.setStreamInfo(streamInfo) - err := p.playStreamURL(ctx, s, streamURL) if err == nil { return nil @@ -672,6 +682,7 @@ func isNonRetryableError(err error) bool { func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamURL string) error { speaker.Clear() + streamInfo := parseStreamInfoFromURL(streamURL) log.Debug().Msgf("Connecting to stream: %s", streamURL) @@ -695,6 +706,8 @@ func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamUR return &httpStatusError{StatusCode: resp.StatusCode, Status: resp.Status} } + p.setStreamInfo(streamInfo) + var icyMetaint int if val := resp.Header.Get("icy-metaint"); val != "" { _, _ = fmt.Sscanf(val, "%d", &icyMetaint) @@ -710,6 +723,8 @@ func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamUR p.streamErr = make(chan error, 1) p.pausedAt = time.Time{} p.totalPausedMs = 0 + p.currentStation = s + p.streamAlive = true p.mu.Unlock() timeoutBody := &contextReader{ @@ -721,38 +736,63 @@ func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamUR p.wg.Add(1) go p.readNetworkStream(ctx, resp.Body, timeoutBody, pipeWriter, icyMetaint) - log.Debug().Msg("Decoding MP3 stream...") - streamer, format, err := mp3.Decode(pipeReader) - if err != nil { - pipeReader.Close() - pipeWriter.Close() - resp.Body.Close() - return fmt.Errorf("failed to decode MP3 stream: %w", err) - } + if strings.EqualFold(streamInfo.Format, "AAC") { + log.Debug().Msg("Decoding AAC stream...") + p.wg.Add(1) + go p.decodeAACStream(ctx, pipeReader) + } else { + log.Debug().Msg("Decoding MP3 stream...") + streamer, format, err := mp3.Decode(pipeReader) + if err != nil { + pipeReader.Close() + pipeWriter.Close() + resp.Body.Close() + return fmt.Errorf("failed to decode MP3 stream: %w", err) + } - log.Debug().Msgf("Initializing audio output (sample rate: %d Hz)...", format.SampleRate) - if err := p.initSpeaker(format.SampleRate); err != nil { - streamer.Close() - pipeReader.Close() - pipeWriter.Close() - resp.Body.Close() - return fmt.Errorf("failed to initialize audio output: %w", err) + if err := p.configurePlaybackOutput(format.SampleRate); err != nil { + streamer.Close() + pipeReader.Close() + pipeWriter.Close() + resp.Body.Close() + return fmt.Errorf("failed to initialize audio output: %w", err) + } + + p.wg.Add(1) + go p.decodeAndBuffer(ctx, streamer, pipeReader) + + log.Debug().Msgf("Now playing: %s", s.Title) } - p.mu.Lock() - p.format = format - p.mu.Unlock() + stopPlayback := func() { + p.closeStreamDone() + p.wg.Wait() + speaker.Clear() + p.mu.Lock() + p.isPlaying = false + p.isPaused = false + p.mu.Unlock() + } - p.streamAliveMu.Lock() - p.streamAlive = true - p.streamAliveMu.Unlock() + select { + case <-ctx.Done(): + stopPlayback() + return ctx.Err() + case err := <-p.streamErr: + stopPlayback() + return fmt.Errorf("stream error: %w", err) + case <-p.streamDone: + stopPlayback() + return fmt.Errorf("stream ended unexpectedly") + } +} - p.mu.Lock() - p.currentStation = s - p.mu.Unlock() +func (p *Player) configurePlaybackOutput(sourceSampleRate beep.SampleRate) error { + if err := p.initSpeaker(DefaultSampleRate); err != nil { + return err + } - p.wg.Add(1) - go p.decodeAndBuffer(ctx, streamer, pipeReader) + targetSampleRate := DefaultSampleRate p.mu.Lock() volumePercent := p.volumePercent @@ -760,25 +800,32 @@ func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamUR volumePercent = config.DefaultVolume } volumeLevel := percentToExponent(float64(volumePercent)) - - fadeInSamples := int(format.SampleRate.N(fadeInDuration)) + fadeInSamples := int(targetSampleRate.N(fadeInDuration)) bufferedStreamer := &bufferedStreamerWrapper{ player: p, fadeInRemaining: fadeInSamples, fadeInTotal: fadeInSamples, } + playbackStreamer := beep.Streamer(bufferedStreamer) + if sourceSampleRate != targetSampleRate { + playbackStreamer = beep.Resample(3, sourceSampleRate, targetSampleRate, playbackStreamer) + } p.volume = &effects.Volume{ - Streamer: bufferedStreamer, + Streamer: playbackStreamer, Base: 2, Volume: volumeLevel, Silent: volumePercent == 0, } - p.ctrl = &beep.Ctrl{ Streamer: p.volume, Paused: false, } + p.format = beep.Format{ + SampleRate: targetSampleRate, + NumChannels: 2, + Precision: 2, + } p.isPlaying = true p.isPaused = false p.mu.Unlock() @@ -789,33 +836,184 @@ func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamUR p.startSession() p.stateMu.Lock() - p.streamInfo.SampleRate = int(format.SampleRate) + p.streamInfo.SampleRate = int(sourceSampleRate) p.stateMu.Unlock() p.setLastError("") - log.Debug().Msgf("Now playing: %s", s.Title) + return nil +} - stopPlayback := func() { +func (p *Player) decodeAACStream(ctx context.Context, pipeReader *io.PipeReader) { + defer func() { + pipeReader.Close() + close(p.sampleCh) + p.wg.Done() + + p.streamAliveMu.Lock() + p.streamAlive = false + p.streamAliveMu.Unlock() + + log.Debug().Msg("AAC decoder stopped") + + if ctx.Err() == nil { + p.closeStreamDone() + } + }() + + reportError := func(err error) { p.closeStreamDone() - p.wg.Wait() - speaker.Clear() - p.mu.Lock() - p.isPlaying = false - p.isPaused = false - p.mu.Unlock() + select { + case p.streamErr <- err: + default: + } } - select { - case <-ctx.Done(): - stopPlayback() - return ctx.Err() - case err := <-p.streamErr: - stopPlayback() - return fmt.Errorf("stream error: %w", err) - case <-p.streamDone: - stopPlayback() - return fmt.Errorf("stream ended unexpectedly") + reader := bufio.NewReader(pipeReader) + dec := aacdecoder.New() + + firstFrame, header, err := readAACFrame(reader) + if err != nil { + if ctx.Err() == nil { + log.Error().Err(err).Msg("Failed to read AAC frame") + reportError(fmt.Errorf("aac frame read error: %w", err)) + } + return + } + + sampleRate := 0 + if header.SamplingIndex >= 0 && header.SamplingIndex < len(aactables.SampleRates) { + sampleRate = int(aactables.SampleRates[header.SamplingIndex]) + } + if sampleRate <= 0 { + reportError(fmt.Errorf("invalid AAC sample rate index %d", header.SamplingIndex)) + return + } + + log.Debug().Int("aac_profile", header.Profile). + Int("aac_sample_index", header.SamplingIndex). + Int("aac_channel_config", header.ChannelConfig). + Int("aac_frame_length", header.FrameLength). + Bool("aac_crc_present", !header.ProtectionAbsent). + Msg("Parsed first AAC ADTS frame") + + decodedSamples, err := dec.DecodeFrame(firstFrame) + if err != nil { + reportError(fmt.Errorf("failed to decode AAC frame: %w", err)) + return + } + + if err := p.configurePlaybackOutput(beep.SampleRate(sampleRate)); err != nil { + reportError(fmt.Errorf("failed to initialize audio output: %w", err)) + return + } + + if err := p.enqueueAACSamples(decodedSamples, header); err != nil { + reportError(err) + return + } + + log.Debug().Msgf("Now playing AAC stream") + + for { + select { + case <-ctx.Done(): + log.Debug().Msg("AAC decoder exiting: context canceled") + return + case <-p.streamDone: + log.Debug().Msg("AAC decoder exiting: stream done") + return + default: + } + + frame, frameHeader, err := readAACFrame(reader) + if err != nil { + if ctx.Err() != nil || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + log.Debug().Err(err).Msg("AAC decoder exiting") + return + } + log.Error().Err(err).Msg("Error reading AAC frame") + reportError(fmt.Errorf("aac frame read error: %w", err)) + return + } + + samples, err := dec.DecodeFrame(frame) + if err != nil { + log.Error().Err(err).Msg("Error decoding AAC frame") + reportError(fmt.Errorf("aac decode error: %w", err)) + return + } + + if err := p.enqueueAACSamples(samples, frameHeader); err != nil { + reportError(err) + return + } + } +} + +func readAACFrame(reader *bufio.Reader) ([]byte, aacadts.Header, error) { + headerBuf := make([]byte, 7) + if _, err := io.ReadFull(reader, headerBuf); err != nil { + return nil, aacadts.Header{}, err + } + + protectionAbsent := headerBuf[1]&0x01 != 0 + if !protectionAbsent { + crc := make([]byte, 2) + if _, err := io.ReadFull(reader, crc); err != nil { + return nil, aacadts.Header{}, err + } + headerBuf = append(headerBuf, crc...) + } + + header, err := aacadts.ReadHeaderFromBytes(headerBuf) + if err != nil { + return nil, aacadts.Header{}, err + } + if header.FrameLength < len(headerBuf) { + return nil, aacadts.Header{}, fmt.Errorf("invalid AAC frame length %d", header.FrameLength) + } + + frame := make([]byte, header.FrameLength) + copy(frame, headerBuf) + if len(frame) > len(headerBuf) { + if _, err := io.ReadFull(reader, frame[len(headerBuf):]); err != nil { + return nil, aacadts.Header{}, err + } + } + + return frame, header, nil +} + +func (p *Player) enqueueAACSamples(samples []float32, header aacadts.Header) error { + channels := header.ChannelConfig + if channels <= 0 { + channels = 2 + } + if channels > 2 { + channels = 2 + } + + if len(samples)%channels != 0 { + return fmt.Errorf("invalid AAC sample count %d for %d channels", len(samples), channels) } + + frameCount := len(samples) / channels + for i := 0; i < frameCount; i++ { + base := i * channels + left := float64(samples[base]) + right := left + if channels > 1 { + right = float64(samples[base+1]) + } + + select { + case <-p.streamDone: + return nil + case p.sampleCh <- [2]float64{left, right}: + } + } + + return nil } func (p *Player) readNetworkStream(ctx context.Context, respBody io.ReadCloser, bodyReader io.Reader, pipeWriter *io.PipeWriter, icyMetaint int) { diff --git a/internal/player/player_test.go b/internal/player/player_test.go index cb47af5..004a104 100644 --- a/internal/player/player_test.go +++ b/internal/player/player_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/glebovdev/somafm-cli/internal/config" ) func TestPercentToExponent(t *testing.T) { @@ -80,6 +82,24 @@ func TestNewPlayer(t *testing.T) { if p.isPaused { t.Error("New player should not be paused") } + + if p.GetPreferredFormat() != config.AudioFormatMP3 { + t.Errorf("New player preferred format = %q, want %q", p.GetPreferredFormat(), config.AudioFormatMP3) + } +} + +func TestPlayerPreferredFormat(t *testing.T) { + p := NewPlayer() + + p.SetPreferredFormat("aac") + if got := p.GetPreferredFormat(); got != config.AudioFormatAAC { + t.Errorf("GetPreferredFormat() = %q, want %q", got, config.AudioFormatAAC) + } + + p.SetPreferredFormat("something-else") + if got := p.GetPreferredFormat(); got != config.AudioFormatMP3 { + t.Errorf("GetPreferredFormat() fallback = %q, want %q", got, config.AudioFormatMP3) + } } func TestIsNonRetryableError(t *testing.T) { diff --git a/internal/station/station.go b/internal/station/station.go index 4617703..47690c7 100644 --- a/internal/station/station.go +++ b/internal/station/station.go @@ -1,6 +1,11 @@ // Package station defines the data structures for SomaFM radio stations. package station +import ( + "sort" + "strings" +) + // Playlist represents a streaming endpoint for a radio station. type Playlist struct { URL string `json:"url"` @@ -41,27 +46,51 @@ func (s *Station) GetBestPlaylistURL() string { return "" } -// GetAllPlaylistURLs returns all playlist URLs sorted by preference: -// MP3 highest quality first, then other MP3, then other formats. -func (s *Station) GetAllPlaylistURLs() []string { - var mp3Highest, mp3Other, other []string +// GetPlaylistURLs returns all playlist URLs sorted by preference. +// The preferred format is ordered first (highest quality first), then the +// alternate known format, then any others. +func (s *Station) GetPlaylistURLs(preferredFormat string) []string { + preferred := strings.ToLower(strings.TrimSpace(preferredFormat)) + if preferred != "aac" { + preferred = "mp3" + } + secondary := "aac" + if preferred == "aac" { + secondary = "mp3" + } - for _, playlist := range s.Playlists { - if playlist.Format == "mp3" { - if playlist.Quality == "highest" { - mp3Highest = append(mp3Highest, playlist.URL) - } else { - mp3Other = append(mp3Other, playlist.URL) - } - } else { - other = append(other, playlist.URL) + // Build a priority for ordering: lower = better. + priority := func(pl Playlist) int { + format := strings.ToLower(strings.TrimSpace(pl.Format)) + qualityBonus := 0 + if pl.Quality == "highest" { + qualityBonus = 1 + } + switch { + case format == preferred: + return 0 - qualityBonus // 0 or -1 (highest first) + case format == secondary: + return 2 - qualityBonus // 2 or 1 + default: + return 4 } } - result := make([]string, 0, len(s.Playlists)) - result = append(result, mp3Highest...) - result = append(result, mp3Other...) - result = append(result, other...) + sorted := make([]Playlist, len(s.Playlists)) + copy(sorted, s.Playlists) + sort.SliceStable(sorted, func(i, j int) bool { + return priority(sorted[i]) < priority(sorted[j]) + }) + result := make([]string, len(sorted)) + for i, pl := range sorted { + result[i] = pl.URL + } return result } + +// GetAllPlaylistURLs returns all playlist URLs sorted by preference: +// MP3 highest quality first, then other MP3, then other formats. +func (s *Station) GetAllPlaylistURLs() []string { + return s.GetPlaylistURLs("mp3") +} diff --git a/internal/station/station_test.go b/internal/station/station_test.go index 03aa9b1..0d833b4 100644 --- a/internal/station/station_test.go +++ b/internal/station/station_test.go @@ -163,6 +163,36 @@ func TestGetAllPlaylistURLs(t *testing.T) { } } +func TestGetPlaylistURLsPreferredAAC(t *testing.T) { + station := Station{ + Playlists: []Playlist{ + {URL: "http://example.com/mp3-low.pls", Format: "mp3", Quality: "low"}, + {URL: "http://example.com/aac-highest.pls", Format: "aac", Quality: "highest"}, + {URL: "http://example.com/aac-low.pls", Format: "aac", Quality: "low"}, + {URL: "http://example.com/mp3-highest.pls", Format: "mp3", Quality: "highest"}, + {URL: "http://example.com/ogg-med.pls", Format: "ogg", Quality: "medium"}, + }, + } + + expected := []string{ + "http://example.com/aac-highest.pls", + "http://example.com/aac-low.pls", + "http://example.com/mp3-highest.pls", + "http://example.com/mp3-low.pls", + "http://example.com/ogg-med.pls", + } + + result := station.GetPlaylistURLs("aac") + if len(result) != len(expected) { + t.Fatalf("GetPlaylistURLs(aac) returned %d items, want %d: got %v", len(result), len(expected), result) + } + for i, url := range result { + if url != expected[i] { + t.Errorf("GetPlaylistURLs(aac)[%d] = %q, want %q", i, url, expected[i]) + } + } +} + func TestStationFields(t *testing.T) { station := Station{ ID: "groovesalad", diff --git a/internal/ui/modals.go b/internal/ui/modals.go index 379b30c..d08f8f8 100644 --- a/internal/ui/modals.go +++ b/internal/ui/modals.go @@ -168,6 +168,7 @@ func (ui *UI) showHelpModal() { [%s]APPLICATION[-] [%s]?[-] Show this help [%s]a[-] About %s + [%s]s[-] Toggle MP3 / AAC stream format [%s]q[-] / [%s]Esc[-] Quit [%s]CONFIG[-]: %s`, @@ -178,7 +179,7 @@ func (ui *UI) showHelpModal() { keyColor, keyColor, keyColor, keyColor, keyColor, - keyColor, keyColor, config.AppName, keyColor, keyColor, + keyColor, keyColor, config.AppName, keyColor, keyColor, keyColor, keyColor, configPath) ui.showInfoModal("Help", helpText) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 542533e..5c91964 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -118,6 +118,7 @@ func NewUI(player *player.Player, stationService *service.StationService, startR ui.colors.modalBackground = config.GetColor(cfg.Theme.ModalBackground) player.SetVolume(cfg.Volume) + player.SetPreferredFormat(cfg.PreferredFormat) log.Debug().Msgf("Loaded volume from config: %d%%", cfg.Volume) ui.statusRenderer = NewStatusRenderer(player) @@ -131,6 +132,7 @@ func (ui *UI) SaveConfig() { if !ui.isMuted { ui.config.Volume = ui.currentVolume } + ui.config.PreferredFormat = ui.player.GetPreferredFormat() if ui.currentStation != nil { ui.config.LastStation = ui.currentStation.ID } @@ -141,6 +143,40 @@ func (ui *UI) SaveConfig() { } } +func (ui *UI) togglePreferredFormat() { + currentFormat := ui.player.GetPreferredFormat() + newFormat := config.AudioFormatAAC + if currentFormat == config.AudioFormatAAC { + newFormat = config.AudioFormatMP3 + } + + ui.player.SetPreferredFormat(newFormat) + ui.config.PreferredFormat = newFormat + ui.SaveConfig() + + if ui.currentStation == nil || (!ui.player.IsPlaying() && !ui.player.IsPaused()) { + return + } + + station := ui.currentStation + ui.player.Stop() + ui.safeCloseChannel() + ui.recreateStopChannel() + ui.startPlayingAnimation() + + go func() { + err := ui.player.Play(station) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + ui.app.QueueUpdateDraw(func() { + ui.showError(err) + }) + } + }() +} + func (ui *UI) safeCloseChannel() { ui.mu.Lock() defer ui.mu.Unlock() @@ -767,6 +803,9 @@ func (ui *UI) globalInputHandler(event *tcell.EventKey) *tcell.EventKey { case 'q', 'Q': ui.stop() return nil + case 's', 'S': + ui.togglePreferredFormat() + return nil case ' ': if ui.player.IsPlaying() || ui.player.IsPaused() { ui.player.TogglePause()