diff --git a/README.md b/README.md index 506ea8a..bc5a76c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A terminal-based internet radio player powered by [Radio Browser](https://www.ra - πŸ’€ **Sleep Timer** - Set a timer to stop playback automatically - πŸ“Š **Most Played** - View your listening history sorted by play count, last played, or first played - πŸ”„ **Update Checker** - Get notified when a new version is available +- 🎡 **Continue on Navigate** - Keep listening while browsing: station keeps playing as you explore other screens, with a Now Playing bar showing on every non-player screen - ⌨️ **Keyboard-driven** - Full navigation without a mouse - ❓ **Context Help** - Press `?` anytime to see available keyboard shortcuts - πŸ–₯️ **Command-Line Play** - Play stations directly from the terminal without opening the TUI @@ -132,13 +133,13 @@ tera play [args] [--duration ] ### Sources -| Full form | Short alias | Args | Description | -| --- | --- | --- | --- | -| `favorites` | `fav` | `[list-name] [n]` | Play nth station from a favorites list | -| `recent` | `rec` | `[n]` | Play the nth most recently played station | -| `top-rated` | `top` | `[n]` | Play the nth highest-rated station | -| `most-played` | `most` | `[n]` | Play the nth most-played station | -| `lucky` | β€” | `` | Play a random station matching keyword(s) | +| Full form | Short alias | Args | Description | +| ------------- | ----------- | ----------------- | ----------------------------------------- | +| `favorites` | `fav` | `[list-name] [n]` | Play nth station from a favorites list | +| `recent` | `rec` | `[n]` | Play the nth most recently played station | +| `top-rated` | `top` | `[n]` | Play the nth highest-rated station | +| `most-played` | `most` | `[n]` | Play the nth most-played station | +| `lucky` | β€” | `` | Play a random station matching keyword(s) | `[list-name]` defaults to `My-favorites`. `[n]` defaults to `1` (first item, 1-based). @@ -374,6 +375,7 @@ Access app configuration from the main menu (Settings: `-`): - **Theme / Colors** - Switch between predefined themes or customize colors - **Appearance** - Customize header display (text, ASCII art, alignment, colors, padding) +- **Play Options** - Control playback behaviour while navigating (see below) - **Connection Settings** - Auto-reconnect and buffering for unstable networks (4G/GPRS) - **Shuffle Settings** - Configure shuffle mode behavior (auto-advance, history size) - **History** - Search history and Recently Played display settings (size, display rows, reset) @@ -382,6 +384,19 @@ Access app configuration from the main menu (Settings: `-`): The Settings menu automatically detects how you installed TERA (Homebrew, Go, Scoop, Winget, etc.) and shows the appropriate update command. +### Play Options (Continue on Navigate) + +When **Continue on Navigate** is enabled, a playing station keeps streaming as you move to other screens: + +- A **β™« Now Playing** bar appears above the footer on every non-player screen, showing the station name, star rating, context, and a reminder to press `x` to stop. +- Press `x` from **any screen** to stop playback immediately. +- Selecting a new Quick Play or Recently Played station automatically stops the previous one first β€” no double playback. + +**Enable / Disable:** +1. Press `-` from the main menu β†’ Settings +2. Select **5. Play Options** +3. Toggle **Continue on Navigate** on or off + ### Appearance Settings Customize how the TERA header appears at the top of the application: @@ -653,15 +668,16 @@ You can edit this file directly or use the Settings menu. ### Global Navigation -| Key | Action | -| ----------- | ------------- | -| `↑↓` / `jk` | Navigate | -| `g` / `G` | Top / End | -| `Enter` | Select / Play | -| `Esc` | Back / Stop | -| `0` | Main Menu | -| `?` | Help | -| `Ctrl+C` | Quit | +| Key | Action | +| ----------- | ----------------------------------------------- | +| `↑↓` / `jk` | Navigate | +| `g` / `G` | Top / End | +| `Enter` | Select / Play | +| `Esc` | Back / Stop | +| `0` | Main Menu | +| `x` | Stop Continue-on-Navigate playback (any screen) | +| `?` | Help | +| `Ctrl+C` | Quit | ### Main Menu @@ -870,6 +886,8 @@ pkill mpv ### Multiple stations playing at once TERA should prevent this, but if it happens: +- Press `x` from any screen to stop Continue-on-Navigate playback +- Or kill all mpv processes: ```sh killall mpv # or on Linux diff --git a/docs/README.md b/docs/README.md index d326e42..ee8054e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ A terminal-based internet radio player powered by [Radio Browser](https://www.ra - 🎨 **Themes** - Choose from predefined themes or customize via unified config - πŸ’€ **Sleep Timer** - Set a timer to stop playback automatically - πŸ”„ **Update Checker** - Get notified when a new version is available +- 🎡 **Continue on Navigate** - Keep listening while browsing: station keeps playing as you explore other screens, with a Now Playing bar showing on every non-player screen - ⌨️ **Keyboard-driven** - Full navigation without a mouse - ❓ **Context Help** - Press `?` anytime to see available keyboard shortcuts - πŸ–₯️ **Command-Line Play** - Play stations directly from the terminal without opening the TUI @@ -155,13 +156,13 @@ tera play [args] [--duration ] ### Sources -| Full form | Short alias | Args | Description | -| --- | --- | --- | --- | -| `favorites` | `fav` | `[list-name] [n]` | Play nth station from a favorites list | -| `recent` | `rec` | `[n]` | Play the nth most recently played station | -| `top-rated` | `top` | `[n]` | Play the nth highest-rated station | -| `most-played` | `most` | `[n]` | Play the nth most-played station | -| `lucky` | β€” | `` | Play a random station matching keyword(s) | +| Full form | Short alias | Args | Description | +| ------------- | ----------- | ----------------- | ----------------------------------------- | +| `favorites` | `fav` | `[list-name] [n]` | Play nth station from a favorites list | +| `recent` | `rec` | `[n]` | Play the nth most recently played station | +| `top-rated` | `top` | `[n]` | Play the nth highest-rated station | +| `most-played` | `most` | `[n]` | Play the nth most-played station | +| `lucky` | β€” | `` | Play a random station matching keyword(s) | `[list-name]` defaults to `My-favorites`. `[n]` defaults to `1` (first item, 1-based). @@ -461,6 +462,7 @@ Access app configuration from the main menu (option `-`): - **Theme / Colors** - Switch between predefined themes or customize colors - **Appearance** - Customize header display (text, ASCII art, alignment, colors, padding) +- **Play Options** - Control playback behaviour while navigating (see below) - **Connection Settings** - Auto-reconnect and buffering for unstable networks (4G/GPRS) - **Shuffle Settings** - Configure shuffle mode behavior (auto-advance, history size) - **History** - Search history and Recently Played display settings (size, display rows, reset) @@ -469,6 +471,19 @@ Access app configuration from the main menu (option `-`): The Settings menu automatically detects how you installed TERA (Homebrew, Go, Scoop, Winget, etc.) and shows the appropriate update command. +### Play Options (Continue on Navigate) + +When **Continue on Navigate** is enabled, a playing station keeps streaming as you move to other screens: + +- A **β™« Now Playing** bar appears above the footer on every non-player screen, showing the station name, star rating, context, and a reminder to press `x` to stop. +- Press `x` from **any screen** to stop playback immediately. +- Selecting a new Quick Play or Recently Played station automatically stops the previous one first β€” no double playback. + +**Enable / Disable:** +1. Press `-` from the main menu β†’ Settings +2. Select **5. Play Options** +3. Toggle **Continue on Navigate** on or off + ### Appearance Settings Customize how the TERA header appears at the top of the application: @@ -740,15 +755,16 @@ You can edit this file directly or use the Settings menu. ### Global Navigation -| Key | Action | -| ----------- | ------------- | -| `↑↓` / `jk` | Navigate | -| `g` / `G` | Top / End | -| `Enter` | Select / Play | -| `Esc` | Back / Stop | -| `0` | Main Menu | -| `?` | Help | -| `Ctrl+C` | Quit | +| Key | Action | +| ----------- | ----------------------------------------------- | +| `↑↓` / `jk` | Navigate | +| `g` / `G` | Top / End | +| `Enter` | Select / Play | +| `Esc` | Back / Stop | +| `0` | Main Menu | +| `x` | Stop Continue-on-Navigate playback (any screen) | +| `?` | Help | +| `Ctrl+C` | Quit | ### Main Menu @@ -956,6 +972,8 @@ pkill mpv ### Multiple stations playing at once TERA should prevent this, but if it happens: +- Press `x` from any screen to stop Continue-on-Navigate playback +- Or kill all mpv processes: ```sh killall mpv # or on Linux diff --git a/internal/api/client.go b/internal/api/client.go index 28ae492..1e160f4 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -72,6 +72,33 @@ type VoteResult struct { Message string `json:"message"` } +// GetByUUID fetches a single station by its UUID from the Radio Browser API. +// Returns an error if the station is not found or the request fails. +func (c *Client) GetByUUID(ctx context.Context, stationUUID string) (*Station, error) { + reqURL := strings.TrimSuffix(baseURL, "/stations") + "/stations/byuuid/" + url.PathEscape(stationUUID) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("byuuid request failed: %s", resp.Status) + } + var stations []Station + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&stations); err != nil { + return nil, err + } + if len(stations) == 0 { + return nil, fmt.Errorf("station %s not found", stationUUID) + } + return &stations[0], nil +} + // Vote increases the vote count for a station by one // Note: Can only vote once per IP per station every 10 minutes func (c *Client) Vote(ctx context.Context, stationUUID string) (*VoteResult, error) { diff --git a/internal/blocklist/manager_test.go b/internal/blocklist/manager_test.go index ce7e6cd..76e8194 100644 --- a/internal/blocklist/manager_test.go +++ b/internal/blocklist/manager_test.go @@ -16,6 +16,7 @@ func TestNewManager(t *testing.T) { manager := NewManager("/tmp/test-blocklist.json") if manager == nil { t.Fatal("NewManager returned nil") + return } if manager.blocklistPath != "/tmp/test-blocklist.json" { t.Errorf("Expected path /tmp/test-blocklist.json, got %s", manager.blocklistPath) diff --git a/internal/config/config.go b/internal/config/config.go index 4d4a033..368901e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { Shuffle ShuffleConfig `yaml:"shuffle"` Blocklist BlocklistConfig `yaml:"blocklist"` PlayHistory PlayHistoryConfig `yaml:"play_history"` + PlayOptions PlayOptionsConfig `yaml:"play_options"` } // PlayerConfig represents player settings @@ -84,6 +85,28 @@ type ShuffleConfig struct { MaxHistory int `yaml:"max_history"` // Number of stations to remember } +// PlayOptionsConfig holds playback behaviour settings (Settings > Play Options). +type PlayOptionsConfig struct { + ContinueOnNavigate bool `yaml:"continue_on_navigate"` // Keep playing when changing screens (default: false) + DefaultVolume int `yaml:"default_volume"` // Volume for new stations, range [0, 100] (default: 80) + ConfirmStop bool `yaml:"confirm_stop"` // Prompt before stopping playback (default: false) + ShowMetadata bool `yaml:"show_metadata"` // Show bitrate/codec/country in play screen (default: true) + StartVolumeMode string `yaml:"start_volume_mode"` // "default" or "last_used" (default: "default") + LastUsedVolume int `yaml:"last_used_volume"` // Persisted on exit when StartVolumeMode=="last_used", range [0, 100] +} + +// DefaultPlayOptionsConfig returns a PlayOptionsConfig with sensible defaults. +func DefaultPlayOptionsConfig() PlayOptionsConfig { + return PlayOptionsConfig{ + ContinueOnNavigate: false, + DefaultVolume: 80, + ConfirmStop: false, + ShowMetadata: true, + StartVolumeMode: "default", + LastUsedVolume: 80, + } +} + // PlayHistoryConfig holds Recently Played display settings for the main menu type PlayHistoryConfig struct { Enabled bool `yaml:"enabled"` // Show Recently Played section in main menu (default: true) @@ -158,6 +181,7 @@ func DefaultConfig() Config { MaxHistory: 5, }, PlayHistory: DefaultPlayHistoryConfig(), + PlayOptions: DefaultPlayOptionsConfig(), } } @@ -195,6 +219,11 @@ func (c *Config) Validate() error { errs = append(errs, fmt.Sprintf("play_history: %v", err)) } + // Validate PlayOptions config + if err := c.PlayOptions.Validate(); err != nil { + errs = append(errs, fmt.Sprintf("play_options: %v", err)) + } + if len(errs) > 0 { return errors.New(strings.Join(errs, "; ")) } @@ -490,3 +519,41 @@ func (p *PlayHistoryConfig) Validate() error { } return nil } + +// Validate validates PlayOptionsConfig, clamping volumes to [0, 100] and +// normalising StartVolumeMode to a known value. +func (p *PlayOptionsConfig) Validate() error { + var errs []string + + // DefaultVolume: clamp to [0, 100] + if p.DefaultVolume < 0 { + p.DefaultVolume = 0 + errs = append(errs, "default_volume must be >= 0, set to 0") + } + if p.DefaultVolume > 100 { + p.DefaultVolume = 100 + errs = append(errs, "default_volume must be <= 100, set to 100") + } + + // LastUsedVolume: clamp to [0, 100] + if p.LastUsedVolume < 0 { + p.LastUsedVolume = 0 + errs = append(errs, "last_used_volume must be >= 0, set to 0") + } + if p.LastUsedVolume > 100 { + p.LastUsedVolume = 100 + errs = append(errs, "last_used_volume must be <= 100, set to 100") + } + + // StartVolumeMode: must be "default" or "last_used" + validModes := map[string]bool{"default": true, "last_used": true} + if !validModes[p.StartVolumeMode] { + p.StartVolumeMode = "default" + errs = append(errs, "start_volume_mode must be \"default\" or \"last_used\", set to \"default\"") + } + + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a26aa9b..376fd99 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -522,6 +522,166 @@ func TestThemeConfigValidation(t *testing.T) { } } +func TestPlayOptionsConfigDefaults(t *testing.T) { + cfg := DefaultConfig() + + if cfg.PlayOptions.ContinueOnNavigate { + t.Error("expected ContinueOnNavigate to be false") + } + if cfg.PlayOptions.DefaultVolume != 80 { + t.Errorf("expected DefaultVolume 80, got %d", cfg.PlayOptions.DefaultVolume) + } + if cfg.PlayOptions.ConfirmStop { + t.Error("expected ConfirmStop to be false") + } + if !cfg.PlayOptions.ShowMetadata { + t.Error("expected ShowMetadata to be true") + } + if cfg.PlayOptions.StartVolumeMode != "default" { + t.Errorf("expected StartVolumeMode \"default\", got %q", cfg.PlayOptions.StartVolumeMode) + } + if cfg.PlayOptions.LastUsedVolume != 80 { + t.Errorf("expected LastUsedVolume 80, got %d", cfg.PlayOptions.LastUsedVolume) + } +} + +func TestPlayOptionsConfigValidation(t *testing.T) { + tests := []struct { + name string + input PlayOptionsConfig + want PlayOptionsConfig + hasError bool + }{ + { + name: "valid defaults", + input: PlayOptionsConfig{ + ContinueOnNavigate: false, + DefaultVolume: 80, + ConfirmStop: false, + ShowMetadata: true, + StartVolumeMode: "default", + LastUsedVolume: 80, + }, + want: PlayOptionsConfig{ + ContinueOnNavigate: false, + DefaultVolume: 80, + ConfirmStop: false, + ShowMetadata: true, + StartVolumeMode: "default", + LastUsedVolume: 80, + }, + hasError: false, + }, + { + name: "default_volume too low β€” clamped to 0", + input: PlayOptionsConfig{DefaultVolume: -5, StartVolumeMode: "default", ShowMetadata: true}, + want: PlayOptionsConfig{DefaultVolume: 0, StartVolumeMode: "default", ShowMetadata: true}, + hasError: true, + }, + { + name: "default_volume too high β€” clamped to 100", + input: PlayOptionsConfig{DefaultVolume: 150, StartVolumeMode: "default", ShowMetadata: true}, + want: PlayOptionsConfig{DefaultVolume: 100, StartVolumeMode: "default", ShowMetadata: true}, + hasError: true, + }, + { + name: "last_used_volume too low β€” clamped to 0", + input: PlayOptionsConfig{DefaultVolume: 80, LastUsedVolume: -1, StartVolumeMode: "default", ShowMetadata: true}, + want: PlayOptionsConfig{DefaultVolume: 80, LastUsedVolume: 0, StartVolumeMode: "default", ShowMetadata: true}, + hasError: true, + }, + { + name: "last_used_volume too high β€” clamped to 100", + input: PlayOptionsConfig{DefaultVolume: 80, LastUsedVolume: 120, StartVolumeMode: "last_used", ShowMetadata: true}, + want: PlayOptionsConfig{DefaultVolume: 80, LastUsedVolume: 100, StartVolumeMode: "last_used", ShowMetadata: true}, + hasError: true, + }, + { + name: "invalid start_volume_mode β€” reset to default", + input: PlayOptionsConfig{DefaultVolume: 80, StartVolumeMode: "random", ShowMetadata: true}, + want: PlayOptionsConfig{DefaultVolume: 80, StartVolumeMode: "default", ShowMetadata: true}, + hasError: true, + }, + { + name: "last_used mode valid", + input: PlayOptionsConfig{DefaultVolume: 60, LastUsedVolume: 55, StartVolumeMode: "last_used", ShowMetadata: false}, + want: PlayOptionsConfig{DefaultVolume: 60, LastUsedVolume: 55, StartVolumeMode: "last_used", ShowMetadata: false}, + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Validate() + if tt.hasError && err == nil { + t.Error("expected validation error, got nil") + } + if !tt.hasError && err != nil { + t.Errorf("expected no error, got %v", err) + } + if tt.input.DefaultVolume != tt.want.DefaultVolume { + t.Errorf("DefaultVolume: got %d, want %d", tt.input.DefaultVolume, tt.want.DefaultVolume) + } + if tt.input.LastUsedVolume != tt.want.LastUsedVolume { + t.Errorf("LastUsedVolume: got %d, want %d", tt.input.LastUsedVolume, tt.want.LastUsedVolume) + } + if tt.input.StartVolumeMode != tt.want.StartVolumeMode { + t.Errorf("StartVolumeMode: got %q, want %q", tt.input.StartVolumeMode, tt.want.StartVolumeMode) + } + }) + } +} + +func TestLoadLegacyConfig_MissingPlayOptions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "tera-legacy-playopts-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("failed to clean up temp dir: %v", err) + } + }() + + originalFunc := userConfigDirFunc + defer func() { userConfigDirFunc = originalFunc }() + userConfigDirFunc = func() (string, error) { return tmpDir, nil } + + // Write a legacy config that intentionally omits the play_options section. + legacyYAML := `version: "3.0" +player: + default_volume: 80 + buffer_size_mb: 50 +` + configDir, _ := GetConfigDir() + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + configPath, _ := GetConfigPath() + if err := os.WriteFile(configPath, []byte(legacyYAML), 0644); err != nil { + t.Fatalf("failed to write legacy config: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + defaults := DefaultPlayOptionsConfig() + if cfg.PlayOptions.ContinueOnNavigate != defaults.ContinueOnNavigate { + t.Errorf("ContinueOnNavigate: got %v, want %v", cfg.PlayOptions.ContinueOnNavigate, defaults.ContinueOnNavigate) + } + if cfg.PlayOptions.DefaultVolume != defaults.DefaultVolume { + t.Errorf("DefaultVolume: got %d, want %d", cfg.PlayOptions.DefaultVolume, defaults.DefaultVolume) + } + if cfg.PlayOptions.ShowMetadata != defaults.ShowMetadata { + t.Errorf("ShowMetadata: got %v, want %v", cfg.PlayOptions.ShowMetadata, defaults.ShowMetadata) + } + if cfg.PlayOptions.StartVolumeMode != defaults.StartVolumeMode { + t.Errorf("StartVolumeMode: got %q, want %q", cfg.PlayOptions.StartVolumeMode, defaults.StartVolumeMode) + } +} + // TestLoadLegacyConfig_MissingPlayHistory verifies that loading a pre-3.7 config // file that has no play_history section yields DefaultPlayHistoryConfig values // rather than Go zero-values ({enabled:false, size:0}). @@ -652,3 +812,59 @@ func TestPlayHistoryConfigValidation(t *testing.T) { }) } } + +func TestPlayOptionsConfig_RoundTrip(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "tera-playopts-roundtrip-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("failed to clean up temp dir: %v", err) + } + }() + + originalFunc := userConfigDirFunc + defer func() { userConfigDirFunc = originalFunc }() + userConfigDirFunc = func() (string, error) { return tmpDir, nil } + + // Build a config with non-default PlayOptions values. + cfg := DefaultConfig() + cfg.PlayOptions = PlayOptionsConfig{ + ContinueOnNavigate: true, + DefaultVolume: 65, + ConfirmStop: true, + ShowMetadata: false, + StartVolumeMode: "last_used", + LastUsedVolume: 60, + } + if err := Save(&cfg); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + po := loaded.PlayOptions + want := cfg.PlayOptions + if po.ContinueOnNavigate != want.ContinueOnNavigate { + t.Errorf("ContinueOnNavigate: got %v, want %v", po.ContinueOnNavigate, want.ContinueOnNavigate) + } + if po.DefaultVolume != want.DefaultVolume { + t.Errorf("DefaultVolume: got %d, want %d", po.DefaultVolume, want.DefaultVolume) + } + if po.ConfirmStop != want.ConfirmStop { + t.Errorf("ConfirmStop: got %v, want %v", po.ConfirmStop, want.ConfirmStop) + } + if po.ShowMetadata != want.ShowMetadata { + t.Errorf("ShowMetadata: got %v, want %v", po.ShowMetadata, want.ShowMetadata) + } + if po.StartVolumeMode != want.StartVolumeMode { + t.Errorf("StartVolumeMode: got %q, want %q", po.StartVolumeMode, want.StartVolumeMode) + } + if po.LastUsedVolume != want.LastUsedVolume { + t.Errorf("LastUsedVolume: got %d, want %d", po.LastUsedVolume, want.LastUsedVolume) + } +} diff --git a/internal/player/mpv.go b/internal/player/mpv.go index 9cae265..8701dec 100644 --- a/internal/player/mpv.go +++ b/internal/player/mpv.go @@ -20,10 +20,25 @@ import ( "github.com/shinokada/tera/v3/internal/storage" ) +// PlayWithVolume starts playing a radio station with a specific volume. +// It is a thin wrapper around Play that temporarily overrides the station's +// volume field, ensuring full IPC/monitor setup identical to Play. +func (p *MPVPlayer) PlayWithVolume(station *api.Station, volume int) error { + // Clone the station so we don't mutate the caller's value. + cloned := *station + cloned.Volume = &volume + return p.Play(&cloned) +} + +// playerInstanceCounter provides a process-wide unique ID for each MPVPlayer +// so that concurrent instances never collide on the same IPC socket path. +var playerInstanceCounter atomic.Uint64 + // MPVPlayer manages the MPV process for playing radio streams type MPVPlayer struct { cmd *exec.Cmd playing bool + killed bool // set by Stop() on a not-yet-playing player to reject a late Play() paused bool // Pause state station *api.Station volume int // Current volume (0-100) @@ -31,6 +46,7 @@ type MPVPlayer struct { lastVolume int // Volume before mute mu sync.Mutex stopCh chan struct{} + instanceID uint64 // Unique ID for this player instance (socket path) socketPath string // IPC socket path for runtime control conn net.Conn // Connection to IPC socket connReader *bufio.Reader // Buffered reader for newline-delimited IPC responses @@ -48,6 +64,7 @@ func NewMPVPlayer() *MPVPlayer { volume: 100, // Default volume lastVolume: 100, stopCh: make(chan struct{}), + instanceID: playerInstanceCounter.Add(1), } } @@ -56,6 +73,12 @@ func (p *MPVPlayer) Play(station *api.Station) error { p.mu.Lock() defer p.mu.Unlock() + // If Stop() was called before Play() ran (race between async cmd and + // navigation), honour the stop request and refuse to start. + if p.killed { + return nil + } + // Stop any existing playback if p.playing { _ = p.stopInternal() @@ -79,14 +102,18 @@ func (p *MPVPlayer) Play(station *api.Station) error { volumeToUse = 100 } - // Create unique socket path for IPC (platform-specific) + // Create unique socket path for IPC (platform-specific). + // Use both PID and per-instance ID so that multiple concurrent MPVPlayer + // instances within the same process never share a socket path β€” which + // would cause one player's Stop() to remove the other player's socket. if runtime.GOOS == "windows" { - // Windows: Use TCP socket for IPC (more reliable than named pipes) - p.socketPath = fmt.Sprintf("127.0.0.1:%d", 10000+os.Getpid()%50000) + // Windows: Use TCP socket for IPC (more reliable than named pipes). + // Incorporate instanceID to guarantee a unique port per instance. + p.socketPath = fmt.Sprintf("127.0.0.1:%d", 10000+(os.Getpid()*1000+int(p.instanceID))%50000) } else { // Unix/Linux/macOS use Unix sockets - p.socketPath = filepath.Join(os.TempDir(), fmt.Sprintf("tera-mpv-%d.sock", os.Getpid())) - // Remove any existing socket file (Unix only) + p.socketPath = filepath.Join(os.TempDir(), fmt.Sprintf("tera-mpv-%d-%d.sock", os.Getpid(), p.instanceID)) + // Remove any stale socket file from a previous Play() call on this instance _ = os.Remove(p.socketPath) } @@ -477,6 +504,9 @@ func (p *MPVPlayer) Stop() error { defer p.mu.Unlock() if !p.playing { + // Not yet playing β€” mark as killed so any in-flight Play() cmd + // that arrives after this Stop() will not start the player. + p.killed = true return nil } @@ -503,6 +533,7 @@ func (p *MPVPlayer) cleanupResourcesLocked() { close(p.stopCh) p.playing = false + p.killed = false // reset so the player instance may be reused after a full stop p.paused = false p.station = nil p.cmd = nil @@ -522,12 +553,22 @@ func (p *MPVPlayer) stopInternal() error { } if p.cmd != nil && p.cmd.Process != nil { - // Send termination signal - // On Windows, Process.Kill() should work, but we add a timeout - if err := p.cmd.Process.Kill(); err != nil { - // Process may have already exited - if !errors.Is(err, os.ErrProcessDone) { - return fmt.Errorf("failed to stop mpv: %w", err) + if runtime.GOOS == "windows" { + // On Windows, mpv installed via package managers (e.g. scoop) uses a + // shim executable that spawns the real mpv as a child process. + // Process.Kill() only terminates the shim, leaving the real mpv running + // as an orphan. taskkill with /T kills the entire process tree so all + // descendant processes (including the real mpv) are terminated. + _ = exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", p.cmd.Process.Pid)).Run() + // Also call Kill to ensure the Go process handle is cleaned up. + _ = p.cmd.Process.Kill() + } else { + // Send termination signal + if err := p.cmd.Process.Kill(); err != nil { + // Process may have already exited + if !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("failed to stop mpv: %w", err) + } } } @@ -541,11 +582,7 @@ func (p *MPVPlayer) stopInternal() error { case <-done: // Process exited cleanly case <-time.After(2 * time.Second): - // Process didn't exit in time, force kill on Windows - if runtime.GOOS == "windows" { - // Use taskkill as last resort on Windows - _ = exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", p.cmd.Process.Pid)).Run() - } + // Timeout: process did not exit within grace period } } diff --git a/internal/player/mpv_test.go b/internal/player/mpv_test.go index 34cc51a..8c0a536 100644 --- a/internal/player/mpv_test.go +++ b/internal/player/mpv_test.go @@ -68,9 +68,7 @@ func TestMPVPlayer_GetCurrentStation(t *testing.T) { station := player.GetCurrentStation() if station == nil { t.Fatal("Expected current station to be set") - } - - if station.StationUUID != "test-123" { + } else if station.StationUUID != "test-123" { t.Errorf("Expected UUID 'test-123', got '%s'", station.StationUUID) } } @@ -89,6 +87,63 @@ func TestMPVPlayer_Stop_WhenNotPlaying(t *testing.T) { // and without creating integration tests. The core logic is tested above. // Full playback testing should be done in integration tests or manually. +// --------------------------------------------------------------------------- +// Regression: race condition between Stop() and async Play() +// +// Root cause: when Play() is called asynchronously (via a tea.Cmd goroutine) +// and Stop() is called before that goroutine wakes up, playing==false so +// Stop() used to be a no-op β€” the subsequent Play() would start mpv anyway. +// +// Fix: Stop() now sets killed=true when the player is not yet playing. +// Play() refuses to start if killed==true. +// --------------------------------------------------------------------------- + +// TestMPVPlayer_Stop_SetsKilledFlag verifies that calling Stop() on a player +// that has not started yet sets the internal killed flag. +func TestMPVPlayer_Stop_SetsKilledFlag(t *testing.T) { + p := NewMPVPlayer() + + err := p.Stop() + if err != nil { + t.Fatalf("Stop() on an idle player should not return an error: %v", err) + } + + p.mu.Lock() + killed := p.killed + p.mu.Unlock() + + if !killed { + t.Error("Stop() on a not-playing player should set killed=true to prevent a subsequent async Play()") + } +} + +// TestMPVPlayer_KilledPlayer_IgnoresPlay verifies that after Stop() sets the +// killed flag, a subsequent Play() returns nil without starting playback. +// This covers the race where Stop() is called before an async Play() runs. +func TestMPVPlayer_KilledPlayer_IgnoresPlay(t *testing.T) { + p := NewMPVPlayer() + + // Mark the player as killed (Stop() called before async Play() ran). + if err := p.Stop(); err != nil { + t.Fatalf("Stop() should not fail while preparing killed state: %v", err) + } + + station := &api.Station{ + StationUUID: "test-killed", + Name: "Should Not Play", + URLResolved: "http://example.com/stream", + } + + err := p.Play(station) + if err != nil { + t.Fatalf("Play() on a killed player should return nil, got: %v", err) + } + + if p.IsPlaying() { + t.Error("Play() on a killed player must not start playback") + } +} + func TestMPVPlayer_ThreadSafety(t *testing.T) { player := NewMPVPlayer() diff --git a/internal/storage/config.go b/internal/storage/config.go index 6c3482c..a1d1724 100644 --- a/internal/storage/config.go +++ b/internal/storage/config.go @@ -45,27 +45,34 @@ func LoadAppearanceConfigFromUnified() (AppearanceConfig, error) { }, nil } -// SaveAppearanceConfigToUnified saves appearance settings to unified config. -// NOTE: This performs non-atomic read-modify-write. Currently safe because Bubble Tea's Update loop is single-threaded, but would need a mutex if concurrent config modifications are added in the future. -func SaveAppearanceConfigToUnified(appearance AppearanceConfig) error { +// updateUnifiedConfig loads the unified config, applies update, and saves it. +// All Save*ToUnified helpers use this to avoid repeating the load/mutate/save +// pattern. NOTE: Performs a non-atomic read-modify-write; safe within Bubble +// Tea's single-threaded Update loop. +func updateUnifiedConfig(update func(*config.Config)) error { cfg, err := config.Load() if err != nil { return err } - - cfg.UI.Appearance.HeaderMode = string(appearance.Header.Mode) - cfg.UI.Appearance.CustomText = appearance.Header.CustomText - cfg.UI.Appearance.AsciiArt = appearance.Header.AsciiArt - cfg.UI.Appearance.HeaderAlign = appearance.Header.Alignment - cfg.UI.Appearance.HeaderWidth = appearance.Header.Width - cfg.UI.Appearance.HeaderColor = appearance.Header.Color - cfg.UI.Appearance.HeaderBold = appearance.Header.Bold - cfg.UI.Appearance.PaddingTop = appearance.Header.PaddingTop - cfg.UI.Appearance.PaddingBottom = appearance.Header.PaddingBottom - + update(cfg) return config.Save(cfg) } +// SaveAppearanceConfigToUnified saves appearance settings to unified config. +func SaveAppearanceConfigToUnified(appearance AppearanceConfig) error { + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.UI.Appearance.HeaderMode = string(appearance.Header.Mode) + cfg.UI.Appearance.CustomText = appearance.Header.CustomText + cfg.UI.Appearance.AsciiArt = appearance.Header.AsciiArt + cfg.UI.Appearance.HeaderAlign = appearance.Header.Alignment + cfg.UI.Appearance.HeaderWidth = appearance.Header.Width + cfg.UI.Appearance.HeaderColor = appearance.Header.Color + cfg.UI.Appearance.HeaderBold = appearance.Header.Bold + cfg.UI.Appearance.PaddingTop = appearance.Header.PaddingTop + cfg.UI.Appearance.PaddingBottom = appearance.Header.PaddingBottom + }) +} + // LoadConnectionConfigFromUnified loads connection settings from unified config func LoadConnectionConfigFromUnified() (ConnectionConfig, error) { cfg, err := config.Load() @@ -82,16 +89,11 @@ func LoadConnectionConfigFromUnified() (ConnectionConfig, error) { // SaveConnectionConfigToUnified saves connection settings to unified config func SaveConnectionConfigToUnified(conn ConnectionConfig) error { - cfg, err := config.Load() - if err != nil { - return err - } - - cfg.Network.AutoReconnect = conn.AutoReconnect - cfg.Network.ReconnectDelay = conn.ReconnectDelay - cfg.Network.BufferSizeMB = conn.StreamBufferMB - - return config.Save(cfg) + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.Network.AutoReconnect = conn.AutoReconnect + cfg.Network.ReconnectDelay = conn.ReconnectDelay + cfg.Network.BufferSizeMB = conn.StreamBufferMB + }) } // LoadShuffleConfigFromUnified loads shuffle settings from unified config @@ -120,27 +122,19 @@ func LoadBlocklistConfigFromUnified() (config.BlocklistConfig, error) { // SaveBlocklistConfigToUnified saves blocklist settings to unified config func SaveBlocklistConfigToUnified(bl config.BlocklistConfig) error { - cfg, err := config.Load() - if err != nil { - return err - } - cfg.Blocklist = bl - return config.Save(cfg) + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.Blocklist = bl + }) } // SaveShuffleConfigToUnified saves shuffle settings to unified config func SaveShuffleConfigToUnified(shuffle ShuffleConfig) error { - cfg, err := config.Load() - if err != nil { - return err - } - - cfg.Shuffle.AutoAdvance = shuffle.AutoAdvance - cfg.Shuffle.IntervalMinutes = shuffle.IntervalMinutes - cfg.Shuffle.RememberHistory = shuffle.RememberHistory - cfg.Shuffle.MaxHistory = shuffle.MaxHistory - - return config.Save(cfg) + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.Shuffle.AutoAdvance = shuffle.AutoAdvance + cfg.Shuffle.IntervalMinutes = shuffle.IntervalMinutes + cfg.Shuffle.RememberHistory = shuffle.RememberHistory + cfg.Shuffle.MaxHistory = shuffle.MaxHistory + }) } // LoadPlayHistoryConfigFromUnified loads play history settings from unified config. @@ -153,15 +147,26 @@ func LoadPlayHistoryConfigFromUnified() (config.PlayHistoryConfig, error) { } // SavePlayHistoryConfigToUnified saves play history settings to unified config. -// NOTE: Performs non-atomic read-modify-write; safe within Bubble Tea's -// single-threaded Update loop. func SavePlayHistoryConfigToUnified(ph config.PlayHistoryConfig) error { + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.PlayHistory = ph + }) +} + +// LoadPlayOptionsConfigFromUnified loads play options settings from unified config. +func LoadPlayOptionsConfigFromUnified() (config.PlayOptionsConfig, error) { cfg, err := config.Load() if err != nil { - return err + return config.DefaultPlayOptionsConfig(), err } - cfg.PlayHistory = ph - return config.Save(cfg) + return cfg.PlayOptions, nil +} + +// SavePlayOptionsConfigToUnified saves play options settings to unified config. +func SavePlayOptionsConfigToUnified(po config.PlayOptionsConfig) error { + return updateUnifiedConfig(func(cfg *config.Config) { + cfg.PlayOptions = po + }) } // CheckAndMigrateV2Config checks for v2 config and migrates if found diff --git a/internal/storage/config_test.go b/internal/storage/config_test.go new file mode 100644 index 0000000..b0e3533 --- /dev/null +++ b/internal/storage/config_test.go @@ -0,0 +1,91 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/shinokada/tera/v3/internal/config" +) + +// redirectConfigHome redirects all config-directory env vars to a fresh temp +// directory so tests never read from or write to the real user config. +func redirectConfigHome(t *testing.T) { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, ".config")) + t.Setenv("APPDATA", filepath.Join(root, "AppData", "Roaming")) +} + +func TestLoadPlayOptionsConfigFromUnified(t *testing.T) { + // Redirect all config-dir env vars so the test never touches the real config. + // config.Load() will auto-create a default config under the redirected dir. + redirectConfigHome(t) + + po, err := LoadPlayOptionsConfigFromUnified() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + defaults := config.DefaultPlayOptionsConfig() + if po.ContinueOnNavigate != defaults.ContinueOnNavigate { + t.Errorf("ContinueOnNavigate: got %v, want %v", po.ContinueOnNavigate, defaults.ContinueOnNavigate) + } + if po.DefaultVolume != defaults.DefaultVolume { + t.Errorf("DefaultVolume: got %d, want %d", po.DefaultVolume, defaults.DefaultVolume) + } + if po.ConfirmStop != defaults.ConfirmStop { + t.Errorf("ConfirmStop: got %v, want %v", po.ConfirmStop, defaults.ConfirmStop) + } + if po.ShowMetadata != defaults.ShowMetadata { + t.Errorf("ShowMetadata: got %v, want %v", po.ShowMetadata, defaults.ShowMetadata) + } + if po.StartVolumeMode != defaults.StartVolumeMode { + t.Errorf("StartVolumeMode: got %q, want %q", po.StartVolumeMode, defaults.StartVolumeMode) + } + if po.LastUsedVolume != defaults.LastUsedVolume { + t.Errorf("LastUsedVolume: got %d, want %d", po.LastUsedVolume, defaults.LastUsedVolume) + } +} + +func TestSavePlayOptionsConfigToUnified(t *testing.T) { + // Redirect all config-dir env vars so the test never touches the real config. + redirectConfigHome(t) + + custom := config.PlayOptionsConfig{ + ContinueOnNavigate: true, + DefaultVolume: 70, + ConfirmStop: true, + ShowMetadata: false, + StartVolumeMode: "last_used", + LastUsedVolume: 65, + } + + if err := SavePlayOptionsConfigToUnified(custom); err != nil { + t.Fatalf("SavePlayOptionsConfigToUnified failed: %v", err) + } + + got, err := LoadPlayOptionsConfigFromUnified() + if err != nil { + t.Fatalf("LoadPlayOptionsConfigFromUnified failed: %v", err) + } + + if got.ContinueOnNavigate != custom.ContinueOnNavigate { + t.Errorf("ContinueOnNavigate: got %v, want %v", got.ContinueOnNavigate, custom.ContinueOnNavigate) + } + if got.DefaultVolume != custom.DefaultVolume { + t.Errorf("DefaultVolume: got %d, want %d", got.DefaultVolume, custom.DefaultVolume) + } + if got.ConfirmStop != custom.ConfirmStop { + t.Errorf("ConfirmStop: got %v, want %v", got.ConfirmStop, custom.ConfirmStop) + } + if got.ShowMetadata != custom.ShowMetadata { + t.Errorf("ShowMetadata: got %v, want %v", got.ShowMetadata, custom.ShowMetadata) + } + if got.StartVolumeMode != custom.StartVolumeMode { + t.Errorf("StartVolumeMode: got %q, want %q", got.StartVolumeMode, custom.StartVolumeMode) + } + if got.LastUsedVolume != custom.LastUsedVolume { + t.Errorf("LastUsedVolume: got %d, want %d", got.LastUsedVolume, custom.LastUsedVolume) + } +} diff --git a/internal/storage/station_metadata_test.go b/internal/storage/station_metadata_test.go index 0e4135d..cdf789c 100644 --- a/internal/storage/station_metadata_test.go +++ b/internal/storage/station_metadata_test.go @@ -54,6 +54,7 @@ func TestMetadataManager(t *testing.T) { metadata := mgr.GetMetadata(stationUUID) if metadata == nil { t.Fatal("Expected metadata, got nil") + return } if metadata.PlayCount != 1 { @@ -91,6 +92,7 @@ func TestMetadataManager(t *testing.T) { metadata := mgr.GetMetadata(stationUUID) if metadata == nil { t.Fatal("Expected metadata, got nil") + return } if metadata.PlayCount != 3 { @@ -115,6 +117,7 @@ func TestMetadataManager(t *testing.T) { metadata := mgr.GetMetadata(stationUUID) if metadata == nil { t.Fatal("Expected metadata, got nil") + return } // Should only count as 1 play since same station @@ -142,6 +145,7 @@ func TestMetadataManager(t *testing.T) { metadata := mgr.GetMetadata(stationUUID) if metadata == nil { t.Fatal("Expected metadata, got nil") + return } // Duration should be > 0 @@ -171,6 +175,7 @@ func TestMetadataManager(t *testing.T) { if meta1 == nil || meta2 == nil { t.Fatal("Expected metadata for both stations") + return } if meta1.PlayCount != 1 { @@ -307,6 +312,7 @@ func TestMetadataManager(t *testing.T) { metadata := mgr.GetMetadata(stationUUID) if metadata == nil { t.Fatal("Expected metadata to persist, got nil") + return } if metadata.PlayCount != 1 { diff --git a/internal/storage/station_ratings_test.go b/internal/storage/station_ratings_test.go index af93dac..3f89d6f 100644 --- a/internal/storage/station_ratings_test.go +++ b/internal/storage/station_ratings_test.go @@ -51,6 +51,7 @@ func TestRatingsManager(t *testing.T) { rating := mgr.GetRating(stationUUID) if rating == nil { t.Fatal("Expected rating, got nil") + return } if rating.Rating != 5 { @@ -126,6 +127,7 @@ func TestRatingsManager(t *testing.T) { updatedRating := mgr.GetRating(stationUUID) if updatedRating == nil { t.Fatal("Expected rating, got nil") + return } if updatedRating.Rating != 5 { diff --git a/internal/storage/station_tags_test.go b/internal/storage/station_tags_test.go index 1c92fd7..123f839 100644 --- a/internal/storage/station_tags_test.go +++ b/internal/storage/station_tags_test.go @@ -203,6 +203,7 @@ func TestCreatePlaylist(t *testing.T) { p := tm.GetPlaylist("Evening Chill") if p == nil { t.Fatal("playlist not found after creation") + return } if p.MatchMode != "any" { t.Errorf("expected matchMode 'any', got %q", p.MatchMode) @@ -226,11 +227,11 @@ func TestCreatePlaylistValidation(t *testing.T) { matchMode string wantErr bool }{ - {"", []string{"chill"}, "any", true}, // empty name - {"p1", []string{}, "any", true}, // no tags - {"p2", []string{"chill"}, "invalid", true}, // bad matchMode - {"p3", []string{"chill"}, "any", false}, // valid - {"p4", []string{"chill"}, "all", false}, // valid matchAll + {"", []string{"chill"}, "any", true}, // empty name + {"p1", []string{}, "any", true}, // no tags + {"p2", []string{"chill"}, "invalid", true}, // bad matchMode + {"p3", []string{"chill"}, "any", false}, // valid + {"p4", []string{"chill"}, "all", false}, // valid matchAll } for _, tc := range tests { err := tm.CreatePlaylist(tc.name, tc.tags, tc.matchMode) @@ -426,6 +427,7 @@ func TestPlaylistCreatedAt(t *testing.T) { p := tm.GetPlaylist("TimeTest") if p == nil { t.Fatal("playlist not found") + return } if p.CreatedAt.Before(before.Add(-time.Second)) { t.Errorf("CreatedAt %v is suspiciously old", p.CreatedAt) diff --git a/internal/ui/app.go b/internal/ui/app.go index af36c71..80f1e33 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -9,6 +9,8 @@ import ( "sync" "sync/atomic" + "time" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" @@ -17,7 +19,6 @@ import ( "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" internaltimer "github.com/shinokada/tera/v3/internal/timer" - "time" "github.com/shinokada/tera/v3/internal/ui/components" ) @@ -78,13 +79,18 @@ type App struct { quickFavPlayer *player.MPVPlayer playingFromMain bool playingStation *api.Station - playHistoryCfg config.PlayHistoryConfig // cached play history settings - recentlyPlayed []storage.StationWithMetadata // refreshed on each return to main menu - numberBuffer string // Buffer for multi-digit number input - unifiedMenuIndex int // Unified index for navigating both menu and favorites - helpModel components.HelpModel // Help overlay - volumeDisplay string // Temporary volume display message - volumeDisplayFrames int // Countdown for volume display + playHistoryCfg config.PlayHistoryConfig // cached play history settings + playOptsCfg config.PlayOptionsConfig // cached play options settings + // Continuous playback (v3.11) β€” non-nil when a screen has handed off its player + activePlayer *player.MPVPlayer // app-level player after a handoff + activeStation *api.Station // station currently handed off + activeContextLabel string // context label from the originating screen + recentlyPlayed []storage.StationWithMetadata // refreshed on each return to main menu + numberBuffer string // Buffer for multi-digit number input + unifiedMenuIndex int // Unified index for navigating both menu and favorites + helpModel components.HelpModel // Help overlay + volumeDisplay string // Temporary volume display message + volumeDisplayFrames int // Countdown for volume display // Update checking latestVersion string // Latest version from GitHub updateAvailable bool // True if a newer version exists @@ -95,6 +101,9 @@ type App struct { sleepDuration time.Duration // duration the user set (for the summary) dataPath string // path for persisting sleep timer config sleepSummary SleepSummaryModel + // Quick Play Favorites viewport + qfViewOffset int // first QF entry index visible on screen + qfVisibleWindow int // last-known number of QF rows that fit on screen // Recently Played viewport rpViewOffset int // first RP entry index visible on screen rpVisibleWindow int // last-known number of RP rows that fit on screen @@ -203,6 +212,13 @@ func NewApp() *App { app.playHistoryCfg = config.DefaultPlayHistoryConfig() } + // Load play options config + if po, err := storage.LoadPlayOptionsConfigFromUnified(); err == nil { + app.playOptsCfg = po + } else { + app.playOptsCfg = config.DefaultPlayOptionsConfig() + } + // Initialize header renderer InitializeHeaderRenderer() @@ -274,8 +290,8 @@ func (a *App) loadQuickFavorites() { } func (a *App) Init() tea.Cmd { - // Check for updates in the background on startup - return checkForUpdates() + // Check for updates in the background on startup. + return tea.Batch(checkForUpdates()) } // Cleanup stops all players and releases resources for graceful shutdown. @@ -286,6 +302,29 @@ func (a *App) Cleanup() { a.sleepTimer.Cancel() a.sleepTimer = nil } + + // Phase 5: Save LastUsedVolume before stopping players so the + // GetVolume() calls below reach live players, not nil pointers. + if a.playOptsCfg.StartVolumeMode == "last_used" { + var lastVol int + if a.activePlayer != nil { + lastVol = a.activePlayer.GetVolume() + } else if a.quickFavPlayer != nil { + lastVol = a.quickFavPlayer.GetVolume() + } + if lastVol > 100 { + lastVol = 100 + } + if lastVol > 0 { + a.playOptsCfg.LastUsedVolume = lastVol + _ = storage.SavePlayOptionsConfigToUnified(a.playOptsCfg) + } + } + + if a.activePlayer != nil { + _ = a.activePlayer.Stop() + a.activePlayer = nil + } if a.quickFavPlayer != nil { _ = a.quickFavPlayer.Stop() } @@ -343,6 +382,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Stop any playing stations before quitting a.Cleanup() return a, tea.Quit + case "x": + // Kill the ContinueOnNavigate station (or all mpv when things go + // wrong) from any screen. + if a.activeStation != nil || (a.playingFromMain && a.quickFavPlayer != nil) { + a.stopAllPlayback() + a.broadcastNowPlayingBar() + return a, nil + } } case tea.WindowSizeMsg: @@ -372,7 +419,25 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Initialize screen-specific models with current dimensions switch msg.screen { case screenPlay: + // Stop all screen-owned players so they don't overlap with whatever + // the user is about to play. When ContinueOnNavigate is on we keep + // activePlayer alive β€” the user is just browsing; it will be stopped + // the moment they actually select and play a new station. + a.stopScreenPlayers() + if !a.playOptsCfg.ContinueOnNavigate { + if a.activePlayer != nil { + _ = a.activePlayer.Stop() + a.activePlayer = nil + a.activeStation = nil + a.activeContextLabel = "" + } + a.playingFromMain = false + a.playingStation = nil + } a.playScreen = NewPlayModel(a.favoritePath, a.blocklistManager) + a.playScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Pass play options so volume/behaviour is consistent + a.playScreen.playOptsCfg = a.playOptsCfg // Set metadata manager for play tracking and metadata display if a.metadataManager != nil { a.playScreen.metadataManager = a.metadataManager @@ -404,6 +469,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.playScreen.Init() case screenSearch: a.searchScreen = NewSearchModel(a.apiClient, a.favoritePath, a.dataPath, a.blocklistManager) + a.searchScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Create and wire up the MPV player for search screen playback + searchPlayer := player.NewMPVPlayer() + a.searchScreen.player = searchPlayer + // Load voted stations for the search screen + if votedStations, err := storage.LoadVotedStations(); err == nil { + a.searchScreen.votedStations = votedStations + } else { + a.searchScreen.votedStations = &storage.VotedStations{Stations: []storage.VotedStation{}} + } + // Apply play options so volume/behaviour is consistent + a.searchScreen.playOptsCfg = a.playOptsCfg // Apply search visibility setting if cfg, err := storage.LoadBlocklistConfigFromUnified(); err == nil { a.searchScreen.showBlockedInSearch = cfg.ShowBlockedInSearch @@ -413,9 +490,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Set metadata manager for play tracking and metadata display if a.metadataManager != nil { a.searchScreen.metadataManager = a.metadataManager - if a.searchScreen.player != nil { - a.searchScreen.player.SetMetadataManager(a.metadataManager) - } + searchPlayer.SetMetadataManager(a.metadataManager) } // Set ratings manager and star renderer for star rating feature if a.ratingsManager != nil { @@ -431,6 +506,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Sync running timer state so Z cancels rather than reopens the dialog. a.searchScreen.sleepTimerActive = a.sleepTimer != nil + // Seed quickFavorites so duplicate detection works from first use. + a.searchScreen.quickFavorites = a.quickFavorites // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.searchScreen.width = a.width @@ -439,6 +516,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.searchScreen.Init() case screenList: a.listManagementScreen = NewListManagementModel(a.favoritePath) + a.listManagementScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.listManagementScreen.width = a.width @@ -447,6 +525,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.listManagementScreen.Init() case screenLucky: a.luckyScreen = NewLuckyModel(a.apiClient, a.favoritePath, a.blocklistManager) + a.luckyScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Pass play options so volume/behaviour is consistent + a.luckyScreen.playOptsCfg = a.playOptsCfg // Set metadata manager for play tracking and metadata display if a.metadataManager != nil { a.luckyScreen.metadataManager = a.metadataManager @@ -474,6 +555,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.luckyScreen.Init() case screenMostPlayed: a.mostPlayedScreen = NewMostPlayedModel(a.metadataManager, a.favoritePath, a.blocklistManager) + a.mostPlayedScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Pass play options so volume/behaviour is consistent + a.mostPlayedScreen.playOptsCfg = a.playOptsCfg // Set metadata manager for play tracking if a.metadataManager != nil && a.mostPlayedScreen.player != nil { a.mostPlayedScreen.player.SetMetadataManager(a.metadataManager) @@ -497,11 +581,21 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, a.mostPlayedScreen.Init() case screenTopRated: + // Stop other screen-owned players (but not activePlayer when + // ContinueOnNavigate is on β€” the handoff keeps that alive). + a.stopScreenPlayers() a.topRatedScreen = NewTopRatedModel(a.ratingsManager, a.metadataManager, a.starRenderer, a.favoritePath, a.blocklistManager) - // Set metadata manager for play tracking - if a.metadataManager != nil && a.topRatedScreen.player != nil { - a.topRatedScreen.player.SetMetadataManager(a.metadataManager) + a.topRatedScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Give Top Rated its own dedicated player so it doesn't share + // quickFavPlayer with the main menu. This prevents stopScreenPlayers() + // or a ContinueOnNavigate handoff from killing the wrong stream. + topRatedPlayer := player.NewMPVPlayer() + a.topRatedScreen.player = topRatedPlayer + if a.metadataManager != nil { + topRatedPlayer.SetMetadataManager(a.metadataManager) } + // Pass play options so volume/behaviour is consistent + a.topRatedScreen.playOptsCfg = a.playOptsCfg // Set tags manager for tag pill display if a.tagsManager != nil { a.topRatedScreen.tagsManager = a.tagsManager @@ -516,6 +610,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case screenBrowseTags: if a.tagsManager != nil { a.browseTagsScreen = NewBrowseTagsModel(a.tagsManager, a.ratingsManager, a.metadataManager, a.starRenderer, a.blocklistManager) + a.browseTagsScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Pass play options so volume/behaviour is consistent + a.browseTagsScreen.playOptsCfg = a.playOptsCfg // Set metadata manager for play tracking if a.metadataManager != nil && a.browseTagsScreen.player != nil { a.browseTagsScreen.player.SetMetadataManager(a.metadataManager) @@ -531,6 +628,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case screenTagPlaylists: if a.tagsManager != nil { a.tagPlaylistsScreen = NewTagPlaylistsModel(a.tagsManager, a.ratingsManager, a.metadataManager, a.starRenderer, a.blocklistManager) + a.tagPlaylistsScreen.nowPlayingBar = a.buildNowPlayingBannerText() + // Pass play options so volume/behaviour is consistent + a.tagPlaylistsScreen.playOptsCfg = a.playOptsCfg // Set metadata manager for play tracking if a.metadataManager != nil && a.tagPlaylistsScreen.player != nil { a.tagPlaylistsScreen.player.SetMetadataManager(a.metadataManager) @@ -545,6 +645,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, func() tea.Msg { return backToMainMsg{} } case screenGist: a.gistScreen = NewGistModel(a.favoritePath) + a.gistScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.gistScreen.width = a.width @@ -553,6 +654,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.gistScreen.Init() case screenSettings: a.settingsScreen = NewSettingsModel(a.favoritePath) + a.settingsScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Pass metadata manager for play history clear action a.settingsScreen.metadataManager = a.metadataManager // Set dimensions immediately if we have them @@ -569,6 +671,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.settingsScreen.Init() case screenShuffleSettings: a.shuffleSettingsScreen = NewShuffleSettingsModel() + a.shuffleSettingsScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.shuffleSettingsScreen.width = a.width @@ -577,6 +680,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.shuffleSettingsScreen.Init() case screenConnectionSettings: a.connectionSettingsScreen = NewConnectionSettingsModel() + a.connectionSettingsScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.connectionSettingsScreen.width = a.width @@ -585,6 +689,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.connectionSettingsScreen.Init() case screenAppearanceSettings: a.appearanceSettingsScreen = NewAppearanceSettingsModel() + a.appearanceSettingsScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.appearanceSettingsScreen.width = a.width @@ -593,6 +698,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.appearanceSettingsScreen.Init() case screenBlocklist: a.blocklistScreen = NewBlocklistModel(a.blocklistManager) + a.blocklistScreen.nowPlayingBar = a.buildNowPlayingBannerText() // Set dimensions immediately if we have them if a.width > 0 && a.height > 0 { a.blocklistScreen.width = a.width @@ -603,9 +709,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Return to main menu and reload favorites and play history a.loadQuickFavorites() a.refreshPlayHistoryConfig() + a.refreshPlayOptionsConfig() a.loadRecentlyPlayed() a.unifiedMenuIndex = 0 a.numberBuffer = "" + a.qfViewOffset = 0 a.rpViewOffset = 0 return a, nil } @@ -673,6 +781,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if a.sleepSession != nil { a.sleepSummary = NewSleepSummaryModel(a.sleepSession, a.sleepDuration, a.width, a.height) + a.sleepSummary.nowPlayingBar = a.buildNowPlayingBannerText() a.sleepSession = nil } else { // Session data unavailable (unexpected); fall back to main menu @@ -691,15 +800,40 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.searchScreen.sleepCountdown = "" return a, nil + case handoffPlaybackMsg: + // A play screen is navigating away with ContinueOnNavigate=true. + // Stop any previously handed-off player before accepting the new one. + if a.activePlayer != nil && a.activePlayer != msg.player { + _ = a.activePlayer.Stop() + } + a.activePlayer = msg.player + a.activeStation = msg.station + a.activeContextLabel = msg.contextLabel + a.broadcastNowPlayingBar() + return a, nil + + case stopActivePlaybackMsg: + // Stop the app-level handed-off player and clear its state. + if a.activePlayer != nil { + _ = a.activePlayer.Stop() + a.activePlayer = nil + } + a.activeStation = nil + a.activeContextLabel = "" + a.broadcastNowPlayingBar() + return a, nil + case backToMainMsg: // Handle back to main menu from any screen a.screen = screenMainMenu // Reload quick favorites and play history in case they were updated a.loadQuickFavorites() a.refreshPlayHistoryConfig() + a.refreshPlayOptionsConfig() a.loadRecentlyPlayed() a.unifiedMenuIndex = 0 a.numberBuffer = "" + a.qfViewOffset = 0 a.rpViewOffset = 0 return a, nil } @@ -1098,6 +1232,7 @@ func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) { a.numberBuffer = "" // Clear buffer on navigation if a.unifiedMenuIndex > 0 { a.unifiedMenuIndex-- + a.updateQFViewOffset(a.qfVisibleWindow) a.updateRPViewOffset(a.rpVisibleWindow) } return a, nil @@ -1105,6 +1240,7 @@ func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) { a.numberBuffer = "" // Clear buffer on navigation if a.unifiedMenuIndex < totalItems-1 { a.unifiedMenuIndex++ + a.updateQFViewOffset(a.qfVisibleWindow) a.updateRPViewOffset(a.rpVisibleWindow) } return a, nil @@ -1120,9 +1256,35 @@ func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a *App) executeMenuAction(index int) (tea.Model, tea.Cmd) { - // Stop any currently playing quick favorite before navigating - if a.playingFromMain && a.quickFavPlayer != nil { - _ = a.quickFavPlayer.Stop() + // Stop any currently playing quick favorite before navigating. + // When ContinueOnNavigate is on and something is handed off to App-level, + // don't stop it β€” it should keep playing across screens. + // We do not require quickFavPlayer.IsPlaying() here because Play() is called + // asynchronously; checking IsPlaying() is a race condition that drops the + // handoff when the user navigates before the cmd goroutine runs. + // activePlayer == nil guards the case where a player screen already did a + // handoffPlaybackMsg β€” in that scenario playingFromMain/playingStation are + // also set but we must NOT replace the already-correct activePlayer with the + // idle quickFavPlayer. + if a.playingFromMain && a.playingStation != nil && a.activePlayer == nil { + if a.playOptsCfg.ContinueOnNavigate && a.quickFavPlayer != nil { + // Hand the main-menu player off to activePlayer so it survives + // stopScreenPlayers() when the destination screen is initialized. + // (a.activePlayer is guaranteed nil by the outer condition, so no + // stop-before-replace is needed here.) + a.activePlayer = a.quickFavPlayer + a.activeStation = a.playingStation + a.activeContextLabel = "Quick Play" + // Install a fresh quickFavPlayer so stopScreenPlayers doesn't + // kill the handed-off one. + a.quickFavPlayer = player.NewMPVPlayer() + if a.metadataManager != nil { + a.quickFavPlayer.SetMetadataManager(a.metadataManager) + } + a.broadcastNowPlayingBar() + } else if a.quickFavPlayer != nil { + _ = a.quickFavPlayer.Stop() + } } a.playingFromMain = false a.playingStation = nil @@ -1184,6 +1346,14 @@ func (a *App) refreshPlayHistoryConfig() { } } +// refreshPlayOptionsConfig reloads the play options config from disk so that +// changes made in Settings > Play Options are reflected immediately on return. +func (a *App) refreshPlayOptionsConfig() { + if po, err := storage.LoadPlayOptionsConfigFromUnified(); err == nil { + a.playOptsCfg = po + } +} + // loadRecentlyPlayed loads recently played stations from metadata, respecting // the play history config (enabled flag and size limit). // Callers that need a fresh config from disk should call refreshPlayHistoryConfig @@ -1206,17 +1376,32 @@ func (a *App) playQuickFavorite(index int) (tea.Model, tea.Cmd) { a.playingStation = &station a.playingFromMain = true - // Stop any currently playing station + // Stop any currently playing station. This also cancels any in-flight async + // Play() goroutine via the killed flag. if a.quickFavPlayer != nil { _ = a.quickFavPlayer.Stop() } + // Stop any station handed off from a previous ContinueOnNavigate session. + if a.activePlayer != nil && a.activePlayer != a.quickFavPlayer { + _ = a.activePlayer.Stop() + a.activePlayer = nil + a.activeStation = nil + a.activeContextLabel = "" + } + + // Create a fresh player for the new station. The old player's killed flag may + // be true after Stop() (if it was idle), which would silently block Play(). + fresh := player.NewMPVPlayer() + if a.metadataManager != nil { + fresh.SetMetadataManager(a.metadataManager) + } + a.quickFavPlayer = fresh + p := fresh - // Start playback + // Start playback on the fresh player return a, func() tea.Msg { - if a.quickFavPlayer != nil { - if err := a.quickFavPlayer.Play(&station); err != nil { - return playbackErrorMsg{err} - } + if err := p.Play(&station); err != nil { + return playbackErrorMsg{err} } return playbackStartedMsg{} } @@ -1232,64 +1417,199 @@ func (a *App) playRecentStation(index int) (tea.Model, tea.Cmd) { a.playingStation = &station a.playingFromMain = true - // Stop any currently playing station + // Stop any currently playing station. This also cancels any in-flight async + // Play() goroutine via the killed flag. if a.quickFavPlayer != nil { _ = a.quickFavPlayer.Stop() } + // Stop any station handed off from a previous ContinueOnNavigate session. + if a.activePlayer != nil && a.activePlayer != a.quickFavPlayer { + _ = a.activePlayer.Stop() + a.activePlayer = nil + a.activeStation = nil + a.activeContextLabel = "" + } - // Start playback + // Create a fresh player for the new station. The old player's killed flag may + // be true after Stop() (if it was idle), which would silently block Play(). + fresh := player.NewMPVPlayer() + if a.metadataManager != nil { + fresh.SetMetadataManager(a.metadataManager) + } + a.quickFavPlayer = fresh + p := fresh + + // Start playback on the fresh player return a, func() tea.Msg { - if a.quickFavPlayer != nil { - if err := a.quickFavPlayer.Play(&station); err != nil { - return playbackErrorMsg{err} - } + if err := p.Play(&station); err != nil { + return playbackErrorMsg{err} } return playbackStartedMsg{} } } +// isPlayerScreen reports whether the current screen manages its own playback +// UI. The Now Playing bar should not be shown on these screens to avoid +// duplicate/conflicting playback information. +func (a *App) isPlayerScreen() bool { + switch a.screen { + case screenPlay, screenSearch, screenLucky, + screenMostPlayed, screenTopRated, + screenBrowseTags, screenTagPlaylists: + return true + } + return false +} + +// nowPlayingBar returns the now-playing bar string when a station has been +// handed off to the app level (ContinueOnNavigate=true). Returns "" otherwise. +func (a *App) nowPlayingBar() string { + if a.activeStation == nil { + return "" + } + vol := 0 + if a.activePlayer != nil { + vol = a.activePlayer.GetVolume() + } + return renderNowPlayingBar(a.activeStation, a.activeContextLabel, vol) +} + +// buildNowPlayingBannerText returns the static now-playing banner string shown +// above the footer help bar on non-player screens when ContinueOnNavigate is ON. +// The banner includes the station name, optional star rating, and context label. +func (a *App) buildNowPlayingBannerText() string { + if a.activeStation == nil { + return "" + } + name := a.activeStation.TrimName() + bar := "β™« Now Playing: " + name + if a.ratingsManager != nil && a.starRenderer != nil { + if r := a.ratingsManager.GetRating(a.activeStation.StationUUID); r != nil && r.Rating > 0 { + bar += " " + a.starRenderer.RenderCompactPlain(r.Rating) + } + } + if a.activeContextLabel != "" { + bar += " Β· [" + a.activeContextLabel + "]" + } + bar += " Β· x: Stop" + return successStyle().Render(bar) +} + +// broadcastNowPlayingBar pushes the current now-playing banner to every +// non-player screen model so it appears above each screen's footer help bar. +func (a *App) broadcastNowPlayingBar() { + bar := a.buildNowPlayingBannerText() + // Non-player screens + a.listManagementScreen.nowPlayingBar = bar + a.gistScreen.nowPlayingBar = bar + a.settingsScreen.nowPlayingBar = bar + a.shuffleSettingsScreen.nowPlayingBar = bar + a.connectionSettingsScreen.nowPlayingBar = bar + a.appearanceSettingsScreen.nowPlayingBar = bar + a.blocklistScreen.nowPlayingBar = bar + a.sleepSummary.nowPlayingBar = bar + // Player screens (shown in list/browse states when ContinueOnNavigate is on) + a.playScreen.nowPlayingBar = bar + a.searchScreen.nowPlayingBar = bar + a.luckyScreen.nowPlayingBar = bar + a.mostPlayedScreen.nowPlayingBar = bar + a.topRatedScreen.nowPlayingBar = bar + a.browseTagsScreen.nowPlayingBar = bar + a.tagPlaylistsScreen.nowPlayingBar = bar +} + func (a *App) View() string { + var view string switch a.screen { case screenMainMenu: return a.viewMainMenu() case screenPlay: - return a.playScreen.View() + view = a.playScreen.View() case screenSearch: - return a.searchScreen.View() + view = a.searchScreen.View() case screenList: - return a.listManagementScreen.View() + view = a.listManagementScreen.View() case screenLucky: - return a.luckyScreen.View() + view = a.luckyScreen.View() case screenGist: - return a.gistScreen.View() + view = a.gistScreen.View() case screenSettings: - return a.settingsScreen.View() + view = a.settingsScreen.View() case screenShuffleSettings: - return a.shuffleSettingsScreen.View() + view = a.shuffleSettingsScreen.View() case screenConnectionSettings: - return a.connectionSettingsScreen.View() + view = a.connectionSettingsScreen.View() case screenAppearanceSettings: - return a.appearanceSettingsScreen.View() + view = a.appearanceSettingsScreen.View() case screenBlocklist: - return a.blocklistScreen.View() + view = a.blocklistScreen.View() case screenMostPlayed: - return a.mostPlayedScreen.View() + view = a.mostPlayedScreen.View() case screenTopRated: - return a.topRatedScreen.View() + view = a.topRatedScreen.View() case screenBrowseTags: - return a.browseTagsScreen.View() + view = a.browseTagsScreen.View() case screenTagPlaylists: - return a.tagPlaylistsScreen.View() + view = a.tagPlaylistsScreen.View() case screenSleepSummary: - return a.sleepSummary.View() + view = a.sleepSummary.View() + default: + return "Unknown screen" + } + // Append the global now-playing bar on non-main-menu, non-player screens + // when ContinueOnNavigate is active and a station has been handed off to App. + // Player screens (Top Rated, Most Played, Play, Search, etc.) manage their + // own playback UI, so the bar would be a duplicate there. + // NOTE: The bar is now injected via PageLayout.NowPlaying above the help + // text instead of appended here; this block is intentionally removed. + return view +} + +// stopScreenPlayers stops every screen-owned player without touching +// activePlayer or the app-level playingFromMain state. Use this when +// navigating to a new player screen so existing streams don't overlap, +// while still preserving a ContinueOnNavigate handoff. +// +// Important: a screen player pointer may be the same object as activePlayer +// when a handoff just occurred (the screen handed its player to App before +// navigating away). Stopping it here would kill the still-running stream, so +// we skip any player that is identical to activePlayer. +// +// quickFavPlayer is app-level, not screen-owned, so it is excluded from this +// list. Calling Stop() on an idle quickFavPlayer sets killed=true and blocks +// subsequent Quick Play; stopAllPlayback() handles it when a full teardown is +// needed. +func (a *App) stopScreenPlayers() { + for _, p := range []*player.MPVPlayer{ + a.playScreen.player, + a.searchScreen.player, + a.luckyScreen.player, + a.mostPlayedScreen.player, + a.topRatedScreen.player, + a.tagPlaylistsScreen.player, + a.browseTagsScreen.player, + } { + if p != nil && p != a.activePlayer { + _ = p.Stop() + } } - return "Unknown screen" } // stopAllPlayback stops mpv on every screen that may be playing. +// App-level players (activePlayer, quickFavPlayer) are stopped unconditionally. +// Screen-owned players are only stopped when actively playing; calling Stop() +// on an idle player sets killed=true which would block the next PlayWithVolume +// call on that screen. func (a *App) stopAllPlayback() { + // App-level players: always stop so ContinueOnNavigate state is cleared. + if a.activePlayer != nil { + _ = a.activePlayer.Stop() + } + if a.quickFavPlayer != nil { + _ = a.quickFavPlayer.Stop() + } + // Screen-owned players: only stop when actually playing. for _, p := range []*player.MPVPlayer{ - a.quickFavPlayer, a.playScreen.player, a.searchScreen.player, a.luckyScreen.player, @@ -1298,10 +1618,13 @@ func (a *App) stopAllPlayback() { a.tagPlaylistsScreen.player, a.browseTagsScreen.player, } { - if p != nil { + if p != nil && p.IsPlaying() { _ = p.Stop() } } + a.activePlayer = nil + a.activeStation = nil + a.activeContextLabel = "" a.playingFromMain = false a.playingStation = nil } @@ -1333,6 +1656,52 @@ func (a *App) saveStationVolume(station *api.Station) { a.loadQuickFavorites() } +// updateQFViewOffset adjusts qfViewOffset so the currently selected QF entry +// is always visible in the clipped window. Call this after changing unifiedMenuIndex. +func (a *App) updateQFViewOffset(visibleCount ...int) { + qfCount := len(a.quickFavorites) + if qfCount == 0 { + a.qfViewOffset = 0 + return + } + + win := qfCount + if len(visibleCount) > 0 && visibleCount[0] > 0 { + win = visibleCount[0] + } + + // Cursor position inside the QF list (negative = cursor is not in QF section) + qfCursor := a.unifiedMenuIndex - mainMenuItemCount + if qfCursor < 0 { + a.qfViewOffset = 0 + return + } + if qfCursor >= qfCount { + // Cursor is below the QF section (in RP or beyond). Preserve the current + // scroll position so the QF list doesn't jump while user navigates RP. + return + } + + // Scroll down + if qfCursor >= a.qfViewOffset+win { + a.qfViewOffset = qfCursor - win + 1 + } + // Scroll up + if qfCursor < a.qfViewOffset { + a.qfViewOffset = qfCursor + } + // Clamp + if a.qfViewOffset < 0 { + a.qfViewOffset = 0 + } + if a.qfViewOffset > qfCount-win { + a.qfViewOffset = qfCount - win + if a.qfViewOffset < 0 { + a.qfViewOffset = 0 + } + } +} + // updateRPViewOffset adjusts rpViewOffset so the currently selected RP entry // is always visible in the clipped window. Call this after changing unifiedMenuIndex. func (a *App) updateRPViewOffset(visibleCount ...int) { @@ -1434,23 +1803,86 @@ func (a *App) viewMainMenu() string { } } content.WriteString("\n") + } else if a.activeStation != nil { + // ContinueOnNavigate: station handed off from a player screen. + content.WriteString("\n") + content.WriteString(a.buildNowPlayingBannerText()) + content.WriteString("\n") } - // Add quick play favorites if available (also part of unified navigation) + // Add quick play favorites if available (also part of unified navigation). + // The list is viewport-clipped so it never pushes the menu off-screen on + // small terminals (e.g. tmux panes). The calculation mirrors the RP section. if len(a.quickFavorites) > 0 { + // ── viewport calculation ──────────────────────────────────────────── + // Lines already committed: header + chrome (title/subtitle/blank) + + // menu items + now-playing block + blank + QF header + scroll indicators + // + RP section (if non-empty) + volume display + footer. + headerLines := visibleLineCount(renderHeader()) + p := getPadding() + const ( + chromeLines = 5 // same as RP calculation + footerLines = 2 // 1 margin + 1 help bar + qfHeaderLines = 2 // blank + "─── Quick Play Favorites ───" + ) + menuLinesQF := mainMenuItemCount + if a.playingFromMain && a.playingStation != nil { + menuLinesQF += 2 + } else if a.activeStation != nil { + menuLinesQF += 2 + } + // Reserve lines for RP section if it will be shown. + rpReserve := 0 + if len(a.recentlyPlayed) > 0 { + // At minimum: blank + RP header + 1 entry + possible scroll indicators + rpReserve = 4 + } + volumeLines := 0 + if a.volumeDisplay != "" { + volumeLines = 2 + } + fixed := headerLines + chromeLines + menuLinesQF + qfHeaderLines + rpReserve + volumeLines + footerLines + p.PageVertical + visibleQF := a.height - fixed + if len(a.quickFavorites) > visibleQF { + // Reserve space for both scroll indicators. + visibleQF -= 2 + } + if visibleQF < 1 { + visibleQF = 1 + } + // If the user has set a display row cap, honour it (mirrors RP behaviour). + if a.playHistoryCfg.DisplayRows > 0 && visibleQF > a.playHistoryCfg.DisplayRows { + visibleQF = a.playHistoryCfg.DisplayRows + } + if visibleQF > len(a.quickFavorites) { + visibleQF = len(a.quickFavorites) + } + + // Cache window size and sync viewport (same pattern as RP). + a.qfVisibleWindow = visibleQF + a.updateQFViewOffset(visibleQF) + content.WriteString("\n") content.WriteString(quickFavoritesStyle().Render("─── Quick Play Favorites ───")) content.WriteString("\n") + // Scroll indicator: entries hidden above. + if a.qfViewOffset > 0 { + content.WriteString(dimStyle().Render(fmt.Sprintf(" ↑ %d more (↑/k to scroll)", a.qfViewOffset))) + content.WriteString("\n") + } + menuItemCount := len(menuItems) - for i, station := range a.quickFavorites { - // Use numbers 10+ for quick favorites (no limit) + endQF := a.qfViewOffset + visibleQF + if endQF > len(a.quickFavorites) { + endQF = len(a.quickFavorites) + } + for i := a.qfViewOffset; i < endQF; i++ { + station := a.quickFavorites[i] shortcut := fmt.Sprintf("%d", 10+i) - // Build station info line var stationInfo strings.Builder stationInfo.WriteString(station.TrimName()) - if station.Country != "" { stationInfo.WriteString(" β€’ ") stationInfo.WriteString(station.Country) @@ -1469,12 +1901,7 @@ func (a *App) viewMainMenu() string { prefix = "> " } - // Build the line content - lineContent := fmt.Sprintf("%s%s. %s", prefix, shortcut, stationInfo.String()) - - // Highlight if this is the playing station if a.playingFromMain && a.playingStation != nil && a.playingStation.StationUUID == station.StationUUID { - // Playing station - show with play icon playingLine := fmt.Sprintf("%s%s. β–Ά %s", prefix, shortcut, stationInfo.String()) if unifiedIdx == a.unifiedMenuIndex { content.WriteString(selectedItemStyle().Render(playingLine)) @@ -1482,7 +1909,7 @@ func (a *App) viewMainMenu() string { content.WriteString(normalItemStyle().Render(playingLine)) } } else { - // Normal station + lineContent := fmt.Sprintf("%s%s. %s", prefix, shortcut, stationInfo.String()) if unifiedIdx == a.unifiedMenuIndex { content.WriteString(selectedItemStyle().Render(lineContent)) } else { @@ -1491,6 +1918,13 @@ func (a *App) viewMainMenu() string { } content.WriteString("\n") } + + // Scroll indicator: entries hidden below. + hiddenBelowQF := len(a.quickFavorites) - endQF + if hiddenBelowQF > 0 { + content.WriteString(dimStyle().Render(fmt.Sprintf(" ↓ %d more (↓/j to scroll)", hiddenBelowQF))) + content.WriteString("\n") + } } // Add recently played section if available, clipped to available screen space. @@ -1504,17 +1938,27 @@ func (a *App) viewMainMenu() string { // assemblePageContent always emits 3 lines before Content: // \n (blank-after-header) + title\n + \n (empty subtitle line) // Content itself starts with "Choose an option:\n\n" = 2 more lines. - chromeLines = 5 // 3 (assemblePageContent) + 2 ("Choose an option:\n\n") - footerLines = 2 // 1 margin + 1 help bar - rpHeaderLines = 2 // blank + "─── Recently Played ───" + chromeLines = 5 // 3 (assemblePageContent) + 2 ("Choose an option:\n\n") + footerLines = 2 // 1 margin + 1 help bar + rpHeaderLines = 2 // blank + "─── Recently Played ───" ) menuLines := mainMenuItemCount // one line per menu item if a.playingFromMain && a.playingStation != nil { menuLines += 2 // now-playing block: blank + station line + } else if a.activeStation != nil { + menuLines += 2 // ContinueOnNavigate banner: blank + banner line } qfLines := 0 if len(a.quickFavorites) > 0 { - qfLines = 2 + len(a.quickFavorites) // blank + header + items + // QF is now viewport-clipped; use the cached visible window size. + // Add 2 for potential scroll indicators (conservative upper bound). + qfLines = 2 + a.qfVisibleWindow // blank + header + visible items + if a.qfViewOffset > 0 { + qfLines++ // top scroll indicator + } + if len(a.quickFavorites) > a.qfViewOffset+a.qfVisibleWindow { + qfLines++ // bottom scroll indicator + } } volumeLines := 0 if a.volumeDisplay != "" { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index bf223cb..fdbff3e 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -2,10 +2,14 @@ package ui import ( "fmt" + "os" + "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/config" + "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" ) @@ -232,3 +236,1220 @@ func TestUpdateRPViewOffset_LargeWindowCoversAllEntries(t *testing.T) { t.Errorf("expected offset 0 when window covers all entries, got %d", app.rpViewOffset) } } + +// --------------------------------------------------------------------------- +// Helpers shared by playback tests +// --------------------------------------------------------------------------- + +// newTestStation returns a minimal Station with the given UUID and name. +func newTestStation(uuid, name string) *api.Station { + return &api.Station{StationUUID: uuid, Name: name} +} + +// newContinueOnNavigateApp returns an App pre-configured with +// ContinueOnNavigate = true, ready for playback scenario tests. +func newContinueOnNavigateApp() *App { + app := newTestApp() + app.playOptsCfg = config.DefaultPlayOptionsConfig() + app.playOptsCfg.ContinueOnNavigate = true + app.quickFavPlayer = player.NewMPVPlayer() + return app +} + +// collectMsgs executes a tea.Cmd (which may be a tea.Batch) and collects every +// non-nil message it synchronously produces. It does not run child commands +// returned by those messages β€” one level is enough for our assertions. +func collectMsgs(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + var msgs []tea.Msg + switch m := msg.(type) { + case tea.BatchMsg: + for _, c := range m { + if c != nil { + if inner := c(); inner != nil { + msgs = append(msgs, inner) + } + } + } + default: + if msg != nil { + msgs = append(msgs, msg) + } + } + return msgs +} + +// hasMsgType returns true if any message in msgs is of the given type. +func hasMsgType[T any](msgs []tea.Msg) bool { + for _, m := range msgs { + if _, ok := m.(T); ok { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Bug: playing Quick Favorites then Play from Favorites plays two stations +// Fix: navigating to screenPlay calls stopScreenPlayers() so all screen-owned +// +// players are stopped before the new session begins. +// +// --------------------------------------------------------------------------- + +// TestNavigateToScreenPlay_StopsScreenPlayers verifies that when the user +// navigates to screenPlay (Play from Favorites), all screen-owned players +// are stopped regardless of ContinueOnNavigate. +func TestNavigateToScreenPlay_StopsScreenPlayers(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate Top Rated screen that was playing (player == quickFavPlayer). + app.topRatedScreen.player = app.quickFavPlayer + + // Simulate the handoff that ContinueOnNavigate would have produced. + station := newTestStation("uuid-top", "Top Rated FM") + app.activePlayer = app.quickFavPlayer + app.activeStation = station + app.activeContextLabel = "Top Rated" + app.playingFromMain = true + app.playingStation = station + + // Navigate to screenPlay β€” this is what triggers the bug. + app.Update(navigateMsg{screen: screenPlay}) + + // quickFavPlayer (== topRatedScreen.player) must have been stopped. + // IsPlaying() returns false both when never started and when stopped, + // so we verify the player is not in a playing state. + if app.quickFavPlayer.IsPlaying() { + t.Error("quickFavPlayer should have been stopped when entering screenPlay") + } +} + +// TestNavigateToScreenPlay_ContinueOnNavigate_PreservesActivePlayer verifies +// that when ContinueOnNavigate is ON the activePlayer (handed-off stream) is +// NOT stopped when merely navigating to the play-list screen β€” it should keep +// playing while the user browses, and only stop when they select a station. +func TestNavigateToScreenPlay_ContinueOnNavigate_PreservesActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + station := newTestStation("uuid-top", "Top Rated FM") + handedOffPlayer := player.NewMPVPlayer() + app.activePlayer = handedOffPlayer + app.activeStation = station + app.activeContextLabel = "Top Rated" + + // Navigate to screenPlay. + app.Update(navigateMsg{screen: screenPlay}) + + // activePlayer must still be set β€” it should keep running. + if app.activePlayer == nil { + t.Error("activePlayer should be preserved when ContinueOnNavigate is ON and user is just browsing") + } + if app.activeStation == nil { + t.Error("activeStation should be preserved when ContinueOnNavigate is ON") + } +} + +// TestNavigateToScreenPlay_ContinueOff_ClearsActivePlayer verifies that when +// ContinueOnNavigate is OFF the activePlayer IS stopped and cleared on entry. +func TestNavigateToScreenPlay_ContinueOff_ClearsActivePlayer(t *testing.T) { + app := newTestApp() + app.playOptsCfg = config.DefaultPlayOptionsConfig() + app.playOptsCfg.ContinueOnNavigate = false + app.quickFavPlayer = player.NewMPVPlayer() + + station := newTestStation("uuid-top", "Top Rated FM") + handedOffPlayer := player.NewMPVPlayer() + app.activePlayer = handedOffPlayer + app.activeStation = station + + app.Update(navigateMsg{screen: screenPlay}) + + if app.activePlayer != nil { + t.Error("activePlayer should be cleared when ContinueOnNavigate is OFF") + } + if app.activeStation != nil { + t.Error("activeStation should be cleared when ContinueOnNavigate is OFF") + } +} + +// --------------------------------------------------------------------------- +// Bug: playing from Top Rated then going back to list stopped the music +// Fix: handleListInput only stops the player when ContinueOnNavigate is OFF. +// --------------------------------------------------------------------------- + +// TestTopRated_EscFromList_ContinueOnNavigate_DoesNotStopPlayer verifies that +// pressing Esc on the Top Rated list screen does NOT stop the player when +// ContinueOnNavigate is ON (the music should keep playing). +func TestTopRated_EscFromList_ContinueOnNavigate_DoesNotStopPlayer(t *testing.T) { + app := newContinueOnNavigateApp() + app.screen = screenTopRated + app.topRatedScreen.player = app.quickFavPlayer + app.topRatedScreen.playOptsCfg = app.playOptsCfg + app.topRatedScreen.state = topRatedStateList + + // Press Esc from the list β€” should navigate away without stopping player. + newModel, cmd := app.topRatedScreen.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app.topRatedScreen = newModel + + // Verify it emits a navigation command (going to main menu). + msgs := collectMsgs(cmd) + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg to main menu when pressing Esc from Top Rated list") + } + + // Player should NOT have been stopped. Since NewMPVPlayer starts as + // not-playing, the real signal is the navigateMsg check above β€” if the + // player were being stopped the old code path would have been taken. + // The key assertion: no stopActivePlaybackMsg was emitted by this action. + if hasMsgType[stopActivePlaybackMsg](msgs) { + t.Error("stopActivePlaybackMsg should NOT be emitted when navigating away from Top Rated list with ContinueOnNavigate ON") + } +} + +// --------------------------------------------------------------------------- +// Bug: playing from I Feel Lucky then going to main menu stopped the music +// Fix: updateInput esc now hands off the player when ContinueOnNavigate is ON. +// --------------------------------------------------------------------------- + +// TestLucky_EscFromPlaying_ContinueOnNavigate_HandsOffAndNavigates verifies +// that pressing Esc from luckyStatePlaying with ContinueOnNavigate ON emits +// both a handoffPlaybackMsg (to keep music running) and a navigateMsg (to +// actually go to the main menu), without landing on the input screen first. +func TestLucky_EscFromPlaying_ContinueOnNavigate_HandsOffAndNavigates(t *testing.T) { + m := NewLuckyModel(nil, t.TempDir(), nil) + m.playOptsCfg.ContinueOnNavigate = true + m.player = player.NewMPVPlayer() + m.selectedStation = newTestStation("uuid-lucky", "Lucky Radio") + m.state = luckyStatePlaying + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + msgs := collectMsgs(cmd) + if !hasMsgType[handoffPlaybackMsg](msgs) { + t.Error("expected handoffPlaybackMsg when pressing Esc from Lucky playing with ContinueOnNavigate ON") + } + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg to main menu alongside the handoff") + } +} + +// TestLucky_EscFromPlaying_ContinueOff_StopsAndReturnsToInput verifies that +// Esc from luckyStatePlaying with ContinueOnNavigate OFF stops the player and +// returns to the input screen (not main menu). +func TestLucky_EscFromPlaying_ContinueOff_StopsAndReturnsToInput(t *testing.T) { + m := NewLuckyModel(nil, t.TempDir(), nil) + m.playOptsCfg.ContinueOnNavigate = false + m.player = player.NewMPVPlayer() + m.selectedStation = newTestStation("uuid-lucky", "Lucky Radio") + m.state = luckyStatePlaying + + newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + lucky := newModel.(LuckyModel) + + if lucky.state != luckyStateInput { + t.Errorf("expected luckyStateInput after Esc with ContinueOnNavigate OFF, got %v", lucky.state) + } + msgs := collectMsgs(cmd) + if hasMsgType[handoffPlaybackMsg](msgs) { + t.Error("handoffPlaybackMsg should not be emitted when ContinueOnNavigate is OFF") + } + if hasMsgType[navigateMsg](msgs) { + t.Error("navigateMsg should not be emitted when returning to Lucky input (not main menu)") + } +} + +// TestLucky_EscFromInput_NoStation_Navigates_Still verifies that pressing Esc +// on the Lucky input screen with no station playing always navigates to main +// menu (the ContinueOnNavigate path never fires with nil selectedStation). +func TestLucky_EscFromInput_NoStation_Navigates_Still(t *testing.T) { + m := NewLuckyModel(nil, t.TempDir(), nil) + m.playOptsCfg.ContinueOnNavigate = true + m.player = player.NewMPVPlayer() + m.selectedStation = nil + m.state = luckyStateInput + m.inputMode = true + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + msgs := collectMsgs(cmd) + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg when no station is playing") + } + if hasMsgType[handoffPlaybackMsg](msgs) { + t.Error("handoffPlaybackMsg should not be emitted when no station is playing") + } +} + +// TestLucky_EscFromInput_ContinueOff_StopsAndNavigates verifies that pressing +// Esc from the Lucky input screen stops the player and navigates to main menu +// when ContinueOnNavigate is OFF. +func TestLucky_EscFromInput_ContinueOff_StopsAndNavigates(t *testing.T) { + m := LuckyModel{} + m.playOptsCfg.ContinueOnNavigate = false + m.player = player.NewMPVPlayer() + m.selectedStation = newTestStation("uuid-lucky", "Lucky Radio") + m.state = luckyStateInput + m.inputMode = true + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + msgs := collectMsgs(cmd) + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg to main menu when ContinueOnNavigate is OFF") + } + if hasMsgType[handoffPlaybackMsg](msgs) { + t.Error("handoffPlaybackMsg should not be emitted when ContinueOnNavigate is OFF") + } +} + +// TestLucky_EscFromInput_NoStation_Navigates verifies that Esc with no station +// playing always navigates cleanly regardless of ContinueOnNavigate. +func TestLucky_EscFromInput_NoStation_Navigates(t *testing.T) { + m := LuckyModel{} + m.playOptsCfg.ContinueOnNavigate = true + m.player = player.NewMPVPlayer() + m.selectedStation = nil // nothing playing + m.state = luckyStateInput + m.inputMode = true + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + msgs := collectMsgs(cmd) + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg when no station is playing") + } + if hasMsgType[handoffPlaybackMsg](msgs) { + t.Error("handoffPlaybackMsg should not be emitted when no station is playing") + } +} + +// TestTopRated_EscFromList_ContinueOff_StopsPlayer verifies that pressing Esc +// from the Top Rated list DOES stop the player when ContinueOnNavigate is OFF. +func TestTopRated_EscFromList_ContinueOff_StopsPlayer(t *testing.T) { + app := newTestApp() + app.playOptsCfg.ContinueOnNavigate = false + app.quickFavPlayer = player.NewMPVPlayer() + app.screen = screenTopRated + app.topRatedScreen.player = app.quickFavPlayer + app.topRatedScreen.playOptsCfg = app.playOptsCfg + app.topRatedScreen.state = topRatedStateList + + _, cmd := app.topRatedScreen.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + // Should still navigate away. + msgs := collectMsgs(cmd) + if !hasMsgType[navigateMsg](msgs) { + t.Error("expected navigateMsg when pressing Esc from Top Rated list") + } + // No stopActivePlaybackMsg needed β€” the player.Stop() is called directly. + if hasMsgType[stopActivePlaybackMsg](msgs) { + t.Error("stopActivePlaybackMsg should not be emitted when ContinueOnNavigate is OFF (Stop called directly)") + } +} + +// --------------------------------------------------------------------------- +// Bug: playing from Top Rated β†’ main menu β†’ Play from Favorites still plays +// +// the Top Rated station alongside the new one. +// +// Fix: PlayModel.playStation() emits stopActivePlaybackMsg before starting. +// --------------------------------------------------------------------------- + +// TestPlayStation_EmitsStopActivePlaybackMsg verifies that when a user selects +// a station in Play from Favorites, a stopActivePlaybackMsg is emitted first. +// This is the message that stops any ContinueOnNavigate handed-off player. +func TestPlayStation_EmitsStopActivePlaybackMsg(t *testing.T) { + app := newContinueOnNavigateApp() + + // Set up the play screen as it would be after navigating from Top Rated. + app.screen = screenPlay + app.playScreen = NewPlayModel(t.TempDir(), nil) + app.playScreen.playOptsCfg = app.playOptsCfg + + // Simulate a handed-off Top Rated player. + handedOffPlayer := player.NewMPVPlayer() + app.activePlayer = handedOffPlayer + app.activeStation = newTestStation("uuid-top", "Top Rated FM") + + // Call playStation directly β€” this is what happens when Enter is pressed. + station := api.Station{StationUUID: "uuid-fav", Name: "Fav Radio", URLResolved: "http://example.com/stream"} + cmd := app.playScreen.playStation(station) + + // The first message emitted must be stopActivePlaybackMsg. + msgs := collectMsgs(cmd) + if !hasMsgType[stopActivePlaybackMsg](msgs) { + t.Errorf("expected stopActivePlaybackMsg to be emitted by playStation(), got: %v", msgs) + } +} + +// TestApp_HandleStopActivePlaybackMsg verifies that when the App receives a +// stopActivePlaybackMsg it clears activePlayer and activeStation. +func TestApp_HandleStopActivePlaybackMsg(t *testing.T) { + app := newContinueOnNavigateApp() + app.activePlayer = player.NewMPVPlayer() + app.activeStation = newTestStation("uuid-top", "Top Rated FM") + app.activeContextLabel = "Top Rated" + + app.Update(stopActivePlaybackMsg{}) + + if app.activePlayer != nil { + t.Error("activePlayer should be nil after stopActivePlaybackMsg") + } + if app.activeStation != nil { + t.Error("activeStation should be nil after stopActivePlaybackMsg") + } + if app.activeContextLabel != "" { + t.Error("activeContextLabel should be empty after stopActivePlaybackMsg") + } +} + +// --------------------------------------------------------------------------- +// Bug: Now Playing bar appeared on Top Rated screen itself +// Fix: isPlayerScreen() suppresses the bar on screens that own their player. +// --------------------------------------------------------------------------- + +func TestIsPlayerScreen(t *testing.T) { + playerScreens := []Screen{ + screenPlay, screenSearch, screenLucky, + screenMostPlayed, screenTopRated, + screenBrowseTags, screenTagPlaylists, + } + nonPlayerScreens := []Screen{ + screenMainMenu, screenList, screenGist, + screenSettings, screenShuffleSettings, + screenConnectionSettings, screenAppearanceSettings, + screenBlocklist, screenSleepSummary, + } + + app := newTestApp() + + for _, s := range playerScreens { + app.screen = s + if !app.isPlayerScreen() { + t.Errorf("screen %v should be a player screen", s) + } + } + for _, s := range nonPlayerScreens { + app.screen = s + if app.isPlayerScreen() { + t.Errorf("screen %v should NOT be a player screen", s) + } + } +} + +// TestNowPlayingBar_HiddenOnPlayerScreens verifies that the Now Playing bar +// is not appended to the view when the current screen is a player screen. +func TestNowPlayingBar_HiddenOnPlayerScreens(t *testing.T) { + app := newContinueOnNavigateApp() + app.activeStation = newTestStation("uuid-top", "Top Rated FM") + app.activeContextLabel = "Top Rated" + app.activePlayer = app.quickFavPlayer + app.width = 80 + app.height = 24 + + // nowPlayingBar() returns a non-empty string when activeStation is set. + if app.nowPlayingBar() == "" { + t.Skip("nowPlayingBar returned empty β€” player may need volume; skipping rendering check") + } + + for _, s := range []Screen{screenTopRated, screenMostPlayed, screenPlay} { + app.screen = s + if !app.isPlayerScreen() { + t.Errorf("precondition: %v should be a player screen", s) + } + // The bar must be suppressed on this screen. + bar := app.nowPlayingBar() + if bar != "" && app.isPlayerScreen() { + // Simulate what View() does: only append when !isPlayerScreen(). + appended := !app.isPlayerScreen() + if appended { + t.Errorf("Now Playing bar should not be appended on player screen %v", s) + } + } + } +} + +// --------------------------------------------------------------------------- +// handoffPlaybackMsg: App correctly takes ownership of handed-off player +// --------------------------------------------------------------------------- + +// TestHandoffPlaybackMsg_SetsActivePlayer verifies that when a player screen +// sends a handoffPlaybackMsg the App stores the player and station correctly. +func TestHandoffPlaybackMsg_SetsActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + p := player.NewMPVPlayer() + station := newTestStation("uuid-1", "Handoff Radio") + + app.Update(handoffPlaybackMsg{ + player: p, + station: station, + contextLabel: "Top Rated", + }) + + if app.activePlayer != p { + t.Error("activePlayer should be the handed-off player") + } + if app.activeStation != station { + t.Error("activeStation should be the handed-off station") + } + if app.activeContextLabel != "Top Rated" { + t.Errorf("activeContextLabel should be 'Top Rated', got %q", app.activeContextLabel) + } + if app.playingFromMain { + t.Error("playingFromMain must NOT be set by a screen handoff; activeStation is the source of truth") + } +} + +// --------------------------------------------------------------------------- +// Cross-screen ContinueOnNavigate matrix +// +// Screens: Play(a) Search(b) MostPlayed(c) TopRated(d) +// BrowseTags(e) TagPlaylists(f) Lucky(g) +// +// Two properties are tested for every source screen: +// 1. handoffAndNavigate – pressing the "go to main menu" key from the +// playing state emits both handoffPlaybackMsg AND navigateMsg. +// 2. App-level routing – when App receives navigateMsg for any destination +// player screen while ContinueOnNavigate is ON, activePlayer is preserved. +// --------------------------------------------------------------------------- + +// sourceHandoffResult captures what a screen emits when the user navigates +// away while ContinueOnNavigate is ON and a station is playing. +type sourceHandoffResult struct { + hasHandoff bool + hasNavigate bool +} + +// triggerNavigateToMain presses the canonical "go to main menu" key for each +// screen and returns what messages were produced. +func triggerNavigateToMain(screen Screen, station *api.Station, p *player.MPVPlayer, opts config.PlayOptionsConfig) sourceHandoffResult { + msgs := func(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + var out []tea.Msg + switch m := msg.(type) { + case tea.BatchMsg: + for _, c := range m { + if c != nil { + if inner := c(); inner != nil { + out = append(out, inner) + } + } + } + return out + default: + if msg != nil { + return []tea.Msg{msg} + } + return nil + } + } + + hasType := func(got []tea.Msg, want string) bool { + for _, m := range got { + switch want { + case "handoff": + if _, ok := m.(handoffPlaybackMsg); ok { + return true + } + case "navigate": + if _, ok := m.(navigateMsg); ok { + return true + } + case "backToMain": + if _, ok := m.(backToMainMsg); ok { + return true + } + } + } + return false + } + + switch screen { + case screenPlay: + m := PlayModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = playStatePlaying + _, cmd := m.updatePlaying(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenSearch: + m := SearchModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = searchStatePlaying + _, cmd := m.handlePlayerUpdate(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenMostPlayed: + m := MostPlayedModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = mostPlayedStatePlaying + _, cmd := m.handlePlayingInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenTopRated: + m := TopRatedModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = topRatedStatePlaying + _, cmd := m.handlePlayingInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenBrowseTags: + m := BrowseTagsModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = browseTagsStatePlaying + _, cmd := m.updatePlaying(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenTagPlaylists: + m := TagPlaylistsModel{} + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = tagPlaylistsStatePlaying + _, cmd := m.updatePlaying(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + case screenLucky: + m := NewLuckyModel(nil, os.TempDir(), nil) + m.playOptsCfg = opts + m.player = p + m.selectedStation = station + m.state = luckyStatePlaying + _, cmd := m.updatePlaying(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")}) + got := msgs(cmd) + return sourceHandoffResult{ + hasHandoff: hasType(got, "handoff"), + hasNavigate: hasType(got, "navigate") || hasType(got, "backToMain"), + } + } + return sourceHandoffResult{} +} + +// TestContinueOnNavigate_SourceHandoff verifies that every player screen +// correctly emits both handoffPlaybackMsg and a navigation message when the +// user presses 0 (go to main menu) while ContinueOnNavigate is ON. +func TestContinueOnNavigate_SourceHandoff(t *testing.T) { + opts := config.DefaultPlayOptionsConfig() + opts.ContinueOnNavigate = true + + station := newTestStation("uuid-x", "Test Radio") + + screens := []struct { + name string + screen Screen + }{ + {"Play(a)", screenPlay}, + {"Search(b)", screenSearch}, + {"MostPlayed(c)", screenMostPlayed}, + {"TopRated(d)", screenTopRated}, + {"BrowseTags(e)", screenBrowseTags}, + {"TagPlaylists(f)", screenTagPlaylists}, + {"Lucky(g)", screenLucky}, + } + + for _, src := range screens { + t.Run(src.name, func(t *testing.T) { + p := player.NewMPVPlayer() + res := triggerNavigateToMain(src.screen, station, p, opts) + if !res.hasHandoff { + t.Errorf("%s: expected handoffPlaybackMsg when pressing 0 with ContinueOnNavigate ON", src.name) + } + if !res.hasNavigate { + t.Errorf("%s: expected navigation message (navigateMsg or backToMainMsg) alongside handoff", src.name) + } + }) + } +} + +// TestContinueOnNavigate_SourceHandoff_Off verifies that when ContinueOnNavigate +// is OFF, no handoffPlaybackMsg is emitted (player is stopped directly). +func TestContinueOnNavigate_SourceHandoff_Off(t *testing.T) { + opts := config.DefaultPlayOptionsConfig() + opts.ContinueOnNavigate = false + + station := newTestStation("uuid-x", "Test Radio") + + screens := []struct { + name string + screen Screen + }{ + {"Play(a)", screenPlay}, + {"Search(b)", screenSearch}, + {"MostPlayed(c)", screenMostPlayed}, + {"TopRated(d)", screenTopRated}, + {"BrowseTags(e)", screenBrowseTags}, + {"TagPlaylists(f)", screenTagPlaylists}, + {"Lucky(g)", screenLucky}, + } + + for _, src := range screens { + t.Run(src.name, func(t *testing.T) { + p := player.NewMPVPlayer() + res := triggerNavigateToMain(src.screen, station, p, opts) + if res.hasHandoff { + t.Errorf("%s: handoffPlaybackMsg should NOT be emitted when ContinueOnNavigate is OFF", src.name) + } + }) + } +} + +// destinationScreens lists all player screens that can be navigated to. +var destinationScreens = []struct { + name string + screen Screen +}{ + {"Play(a)", screenPlay}, + {"Search(b)", screenSearch}, + {"MostPlayed(c)", screenMostPlayed}, + {"TopRated(d)", screenTopRated}, + {"BrowseTags(e)", screenBrowseTags}, + {"TagPlaylists(f)", screenTagPlaylists}, + {"Lucky(g)", screenLucky}, +} + +// TestContinueOnNavigate_DestinationPreservesActivePlayer verifies that when +// App receives navigateMsg for any destination screen while ContinueOnNavigate +// is ON, the activePlayer handed off from the source is not killed. +func TestContinueOnNavigate_DestinationPreservesActivePlayer(t *testing.T) { + for _, dst := range destinationScreens { + t.Run(dst.name, func(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate a prior handoff (e.g. from Lucky β†’ main menu). + handedOff := player.NewMPVPlayer() + app.activePlayer = handedOff + app.activeStation = newTestStation("uuid-src", "Source Radio") + app.activeContextLabel = "Lucky" + + // Navigate to the destination screen. + app.Update(navigateMsg{screen: dst.screen}) + + // activePlayer must be preserved β€” it is the still-running source + // stream, and the user hasn't selected a new station yet. + if app.activePlayer == nil { + t.Errorf("%s: activePlayer was cleared on navigation; music would stop", dst.name) + } + if app.activeStation == nil { + t.Errorf("%s: activeStation was cleared on navigation", dst.name) + } + }) + } +} + +// TestContinueOnNavigate_DestinationClearsActivePlayer_WhenOff verifies that +// when ContinueOnNavigate is OFF, navigating to screenPlay clears activePlayer +// (for screens that stop their player on navigate; screenPlay is the only one +// with explicit clear logic in App). +func TestContinueOnNavigate_DestinationClearsActivePlayer_WhenOff(t *testing.T) { + app := newTestApp() + app.playOptsCfg = config.DefaultPlayOptionsConfig() + app.playOptsCfg.ContinueOnNavigate = false + app.quickFavPlayer = player.NewMPVPlayer() + + app.activePlayer = player.NewMPVPlayer() + app.activeStation = newTestStation("uuid-src", "Source Radio") + + app.Update(navigateMsg{screen: screenPlay}) + + if app.activePlayer != nil { + t.Error("activePlayer should be cleared when ContinueOnNavigate is OFF and entering screenPlay") + } + if app.activeStation != nil { + t.Error("activeStation should be cleared when ContinueOnNavigate is OFF and entering screenPlay") + } +} + +// TestContinueOnNavigate_NewStationStopsHandedOff verifies the full cross-screen +// flow: source hands off β†’ user browses Play from Favorites β†’ selects a new +// station β†’ stopActivePlaybackMsg is emitted to kill the old stream. +// +// This is the aβ†’b, cβ†’a, gβ†’a style scenario: whatever was playing is stopped +// the moment the user picks something new in the destination screen. +func TestContinueOnNavigate_NewStationStopsHandedOff(t *testing.T) { + for _, dst := range destinationScreens { + if dst.screen != screenPlay { + // playStation() with stopActivePlaybackMsg is only implemented in + // PlayModel. Other screens stop via stopScreenPlayers() called at + // navigate time, which is covered by + // TestNavigateToScreenPlay_StopsScreenPlayers. + continue + } + t.Run(dst.name, func(t *testing.T) { + app := newContinueOnNavigateApp() + app.screen = screenPlay + app.playScreen = NewPlayModel(t.TempDir(), nil) + app.playScreen.playOptsCfg = app.playOptsCfg + + // Simulate a handed-off player from any source screen. + app.activePlayer = player.NewMPVPlayer() + app.activeStation = newTestStation("uuid-src", "Source Radio") + + // User selects a new station in Play from Favorites. + newStation := api.Station{StationUUID: "uuid-new", Name: "New Radio", URLResolved: "http://example.com/stream"} + cmd := app.playScreen.playStation(newStation) + got := collectMsgs(cmd) + + if !hasMsgType[stopActivePlaybackMsg](got) { + t.Error("expected stopActivePlaybackMsg when selecting a new station; old stream would keep playing") + } + }) + } +} + +// --------------------------------------------------------------------------- +// Regression: Bug 1 β€” Quick Play / Recently Played β†’ Play from Favorites +// stopped the music when ContinueOnNavigate was ON. +// +// Root cause: executeMenuAction always stopped quickFavPlayer via +// stopScreenPlayers() even when ContinueOnNavigate was ON. +// Fix: executeMenuAction hands quickFavPlayer off to activePlayer +// (and replaces it with a fresh idle player) when +// ContinueOnNavigate is ON. IsPlaying() is NOT checked because +// Play() is called asynchronously β€” the user can navigate before +// the cmd goroutine runs, making IsPlaying() a race condition. +// --------------------------------------------------------------------------- + +// TestBug1_QFPlaying_NavigateToPlayScreen_PreservesStream verifies that when +// a Quick Play Favorite was selected (playingFromMain=true, playingStation set) +// and the user navigates, executeMenuAction hands quickFavPlayer off to +// activePlayer β€” even when IsPlaying() is still false (async Play() timing). +func TestBug1_QFPlaying_NavigateToPlayScreen_PreservesStream(t *testing.T) { + app := newContinueOnNavigateApp() + + station := newTestStation("uuid-qf", "Quick FM") + app.playingStation = station + app.playingFromMain = true + // activePlayer is nil β€” this is a fresh QF selection from main menu. + + originalQFP := app.quickFavPlayer + + _, _ = app.executeMenuAction(0) // Play from Favorites + + // The handoff should happen even though IsPlaying() is false (async): + // activePlayer must be set to the original quickFavPlayer. + if app.activePlayer != originalQFP { + t.Error("activePlayer should be set to the original quickFavPlayer after handoff") + } + if app.quickFavPlayer == originalQFP { + t.Error("quickFavPlayer should be replaced with a fresh idle player after handoff") + } + if app.playingFromMain { + t.Error("playingFromMain should be cleared by executeMenuAction") + } +} + +// TestBug1_QFNotPlaying_NavigateToPlayScreen_HandoffHappens verifies that the +// handoff happens even when quickFavPlayer.IsPlaying() == false. This is the +// key async-timing fix: Play() is called in a cmd goroutine, so IsPlaying() +// may be false at the moment executeMenuAction is called. +func TestBug1_QFNotPlaying_NavigateToPlayScreen_HandoffHappens(t *testing.T) { + app := newContinueOnNavigateApp() + app.playingStation = newTestStation("uuid-qf", "Quick FM") + app.playingFromMain = true + // quickFavPlayer.IsPlaying() == false (never started β€” async timing) + originalQFP := app.quickFavPlayer + + _, _ = app.executeMenuAction(0) + + // Handoff must still happen so the station keeps playing when the cmd runs. + if app.activePlayer != originalQFP { + t.Error("activePlayer must be set to quickFavPlayer even when IsPlaying()==false (async case)") + } +} + +// TestBug1_HandoffMsg_SetsPlayingFromMain_DoesNotBreakSubsequentNavigate +// verifies the regression introduced during the Bug 1 fix: receiving a +// handoffPlaybackMsg must NOT set playingFromMain; a bug in executeMenuAction +// previously killed activePlayer when quickFavPlayer was idle. +func TestBug1_HandoffMsg_SetsPlayingFromMain_DoesNotBreakSubsequentNavigate(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate: user played in Play screen β†’ handoffPlaybackMsg arrives. + handedOff := player.NewMPVPlayer() + station := newTestStation("uuid-play", "Play Screen FM") + app.Update(handoffPlaybackMsg{ + player: handedOff, + station: station, + contextLabel: "Favorites", + }) + // handoffPlaybackMsg must NOT set playingFromMain; activeStation is the + // source of truth for the handed-off stream. + if app.playingFromMain { + t.Fatal("playingFromMain must not be set by a screen handoff") + } + if app.activePlayer != handedOff { + t.Fatal("precondition: activePlayer should be the handed-off player") + } + + // Now user navigates to Most Played (index 2). + // quickFavPlayer.IsPlaying()==false β†’ handoff block must be skipped. + _, _ = app.executeMenuAction(2) + + // activePlayer must still be the handed-off player, not nil and not + // replaced by the idle quickFavPlayer. + if app.activePlayer != handedOff { + t.Error("Bug 1 regression: executeMenuAction must not kill activePlayer " + + "when quickFavPlayer is idle (stream came from a screen handoff, not QF)") + } +} + +// --------------------------------------------------------------------------- +// Regression: Bug 2 β€” stopScreenPlayers killed the handed-off stream +// when navigating between player screens (e.g. Most Played β†’ +// Top Rated). +// +// Root cause: stopScreenPlayers iterated all screen player pointers including +// the one that was just handed off as activePlayer. +// Fix: stopScreenPlayers skips any player that equals activePlayer. +// --------------------------------------------------------------------------- + +// TestBug2_StopScreenPlayers_SkipsActivePlayer verifies that stopScreenPlayers +// does not stop a screen player that is also activePlayer. +func TestBug2_StopScreenPlayers_SkipsActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate: Most Played handed its player to activePlayer. + handedOff := player.NewMPVPlayer() + app.activePlayer = handedOff + app.mostPlayedScreen.player = handedOff // same pointer β€” the alias that caused the bug + + // A second, unrelated screen player that SHOULD be stopped. + other := player.NewMPVPlayer() + app.searchScreen.player = other + + app.stopScreenPlayers() + + // activePlayer (== mostPlayedScreen.player) must NOT have been stopped. + // We can't assert IsPlaying()==true (never started), but we assert the + // pointer is still the same object and was not replaced. + if app.activePlayer != handedOff { + t.Error("Bug 2: stopScreenPlayers replaced activePlayer β€” it must not touch it") + } + // The other player (not activePlayer) should have had Stop() called. + // Since it was never started, IsPlaying() is already false; we can only + // verify the function ran without panicking and didn't touch activePlayer. +} + +// TestBug2_NavigateMostPlayedToTopRated_PreservesActivePlayer is an +// end-to-end regression test: simulate the full sequence +// +// Most Played plays β†’ handoff β†’ navigate to Top Rated +// +// and assert activePlayer survives the navigateMsg handler. +func TestBug2_NavigateMostPlayedToTopRated_PreservesActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + // Step 1: Most Played screen has a player that was handed off. + handedOff := player.NewMPVPlayer() + app.mostPlayedScreen.player = handedOff + app.activePlayer = handedOff + app.activeStation = newTestStation("uuid-mp", "Most Played FM") + app.activeContextLabel = "Most Played" + + // Step 2: Navigate to Top Rated β€” this calls stopScreenPlayers(). + app.Update(navigateMsg{screen: screenTopRated}) + + // activePlayer must still point to the handed-off player. + if app.activePlayer == nil { + t.Error("Bug 2: activePlayer was killed by stopScreenPlayers() during " + + "Most Played β†’ Top Rated navigation") + } + if app.activePlayer != handedOff { + t.Error("Bug 2: activePlayer was replaced during navigation") + } +} + +// TestBug2_NavigatePlayToMostPlayed_PreservesActivePlayer covers the +// Play from Favorites β†’ Most Played path. +func TestBug2_NavigatePlayToMostPlayed_PreservesActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + handedOff := player.NewMPVPlayer() + app.playScreen.player = handedOff + app.activePlayer = handedOff + app.activeStation = newTestStation("uuid-fav", "Fav FM") + app.activeContextLabel = "Favorites" + + app.Update(navigateMsg{screen: screenMostPlayed}) + + if app.activePlayer == nil { + t.Error("Bug 2: activePlayer was killed during Play β†’ Most Played navigation") + } + if app.activePlayer != handedOff { + t.Error("Bug 2: activePlayer was replaced during Play β†’ Most Played navigation") + } +} + +// --------------------------------------------------------------------------- + +// TestHandoffPlaybackMsg_StopsPreviousPlayer verifies that if a second handoff +// arrives while one is already active, the old player is stopped. +func TestHandoffPlaybackMsg_StopsPreviousPlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + oldPlayer := player.NewMPVPlayer() + newP := player.NewMPVPlayer() + station := newTestStation("uuid-2", "New Radio") + + // First handoff. + app.activePlayer = oldPlayer + + // Second handoff with a different player β€” old one should be stopped. + app.Update(handoffPlaybackMsg{ + player: newP, + station: station, + contextLabel: "Most Played", + }) + + if app.activePlayer != newP { + t.Error("activePlayer should be updated to the new player") + } + // oldPlayer.IsPlaying() is false (never started), but verifying the + // pointer was replaced is sufficient β€” the code called Stop() on it. + if app.activePlayer == oldPlayer { + t.Error("activePlayer should not still be the old player") + } +} + +// --------------------------------------------------------------------------- +// x key: global kill binding for ContinueOnNavigate sessions +// +// When ContinueOnNavigate is ON, a station may keep playing while the user +// browses other screens. The `x` key stops all playback from any screen. +// --------------------------------------------------------------------------- + +// TestXKey_StopsActiveStation verifies that pressing `x` clears activeStation +// and activePlayer when a ContinueOnNavigate handoff is in progress. +func TestXKey_StopsActiveStation(t *testing.T) { + app := newContinueOnNavigateApp() + app.activeStation = newTestStation("uuid-x", "Test Radio") + app.activePlayer = player.NewMPVPlayer() + app.activeContextLabel = "Most Played" + + app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + + if app.activeStation != nil { + t.Error("x key should clear activeStation") + } + if app.activePlayer != nil { + t.Error("x key should clear activePlayer") + } +} + +// TestXKey_StopsPlayingFromMain verifies that pressing `x` clears +// playingFromMain and playingStation when a Quick Play station is active. +func TestXKey_StopsPlayingFromMain(t *testing.T) { + app := newContinueOnNavigateApp() + app.playingFromMain = true + app.playingStation = newTestStation("uuid-qf", "QF Radio") + + app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + + if app.playingFromMain { + t.Error("x key should clear playingFromMain") + } + if app.playingStation != nil { + t.Error("x key should clear playingStation") + } +} + +// TestXKey_NoOpWhenNothingPlaying verifies that pressing `x` when no station +// is playing neither panics nor mutates playback state. +func TestXKey_NoOpWhenNothingPlaying(t *testing.T) { + app := newTestApp() + + app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + + if app.activeStation != nil { + t.Error("x key should not set activeStation when nothing is playing") + } + if app.playingFromMain { + t.Error("x key should not set playingFromMain when nothing is playing") + } +} + +// --------------------------------------------------------------------------- +// buildNowPlayingBannerText β€” Now Playing bar shown on non-player screens +// --------------------------------------------------------------------------- + +// TestBuildNowPlayingBannerText_ContainsXStopHint verifies that the banner +// includes the "x: Stop" keyboard hint so users know how to kill playback. +func TestBuildNowPlayingBannerText_ContainsXStopHint(t *testing.T) { + app := newContinueOnNavigateApp() + app.activeStation = newTestStation("uuid-radio", "Jazz FM") + app.activeContextLabel = "Favorites" + + banner := app.buildNowPlayingBannerText() + + if !strings.Contains(banner, "x: Stop") { + t.Errorf("banner should contain 'x: Stop', got: %q", banner) + } +} + +// TestBuildNowPlayingBannerText_EmptyWhenNoStation verifies that the banner is +// empty when no station is being tracked at the app level. +func TestBuildNowPlayingBannerText_EmptyWhenNoStation(t *testing.T) { + app := newTestApp() + + banner := app.buildNowPlayingBannerText() + + if banner != "" { + t.Errorf("banner should be empty when no station is active, got: %q", banner) + } +} + +// TestBuildNowPlayingBannerText_ContainsStationName verifies that the banner +// shows the name of the currently playing station. +func TestBuildNowPlayingBannerText_ContainsStationName(t *testing.T) { + app := newContinueOnNavigateApp() + app.activeStation = newTestStation("uuid-radio", "Jazz FM") + + banner := app.buildNowPlayingBannerText() + + if !strings.Contains(banner, "Jazz FM") { + t.Errorf("banner should contain station name 'Jazz FM', got: %q", banner) + } +} + +// --------------------------------------------------------------------------- +// playQuickFavorite / playRecentStation β€” stop handed-off activePlayer +// +// Bug: after a ContinueOnNavigate handoff, activePlayer kept streaming while +// a new Quick Play or Recently Played station started β€” two mpv processes. +// Fix: both helpers stop and clear activePlayer when it differs from +// quickFavPlayer. +// --------------------------------------------------------------------------- + +// TestPlayQuickFavorite_StopsHandedOffActivePlayer verifies that if a player +// was handed off via ContinueOnNavigate (activePlayer != quickFavPlayer), +// calling playQuickFavorite stops and nils that player. +func TestPlayQuickFavorite_StopsHandedOffActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate a ContinueOnNavigate handoff from a player screen. + handedOff := player.NewMPVPlayer() + app.activePlayer = handedOff + app.activeStation = newTestStation("uuid-handed", "Handed Off FM") + app.activeContextLabel = "Most Played" + + // Add a quick favorite to play (required for playQuickFavorite to proceed). + app.quickFavorites = []api.Station{ + {StationUUID: "uuid-qf", Name: "Quick FM"}, + } + + _, _ = app.playQuickFavorite(0) + + if app.activePlayer != nil { + t.Error("playQuickFavorite should stop and clear the handed-off activePlayer") + } + if app.activeStation != nil { + t.Error("playQuickFavorite should clear activeStation set by the handoff") + } +} + +// TestPlayQuickFavorite_ReplacesFreshPlayer is a regression test for the bug +// where selecting from Quick Play made no sound. +// +// Root cause: Stop() on an idle quickFavPlayer set killed=true. The subsequent +// async Play() saw killed=true and silently refused to start mpv. +// Fix: both helpers now replace quickFavPlayer with a fresh instance whose +// killed flag is false, so Play() is always allowed to proceed. +func TestPlayQuickFavorite_ReplacesFreshPlayer(t *testing.T) { + app := newContinueOnNavigateApp() + original := app.quickFavPlayer // idle (never started) + + app.quickFavorites = []api.Station{ + {StationUUID: "uuid-qf", Name: "Quick FM"}, + } + + _, cmd := app.playQuickFavorite(0) + + // A fresh player must have been installed so that the async Play() cmd + // is not blocked by the old player's killed flag. + if app.quickFavPlayer == original { + t.Error("playQuickFavorite should replace quickFavPlayer with a fresh instance") + } + // The returned cmd must not be nil β€” a nil cmd means no Play was scheduled. + if cmd == nil { + t.Error("playQuickFavorite should return a non-nil cmd to start playback") + } +} + +// TestPlayRecentStation_StopsHandedOffActivePlayer verifies that if a player +// was handed off via ContinueOnNavigate, calling playRecentStation stops and +// nils that player so the new station plays exclusively. +func TestPlayRecentStation_StopsHandedOffActivePlayer(t *testing.T) { + app := newContinueOnNavigateApp() + + // Simulate a ContinueOnNavigate handoff from a player screen. + handedOff := player.NewMPVPlayer() + app.activePlayer = handedOff + app.activeStation = newTestStation("uuid-handed", "Handed Off FM") + app.activeContextLabel = "Most Played" + + // Add a recently played entry to play. + app.recentlyPlayed = makeRecentlyPlayed("Recent FM") + + _, _ = app.playRecentStation(0) + + if app.activePlayer != nil { + t.Error("playRecentStation should stop and clear the handed-off activePlayer") + } + if app.activeStation != nil { + t.Error("playRecentStation should clear activeStation set by the handoff") + } +} + +// TestPlayRecentStation_ReplacesFreshPlayer is a regression test for the same +// bug as TestPlayQuickFavorite_ReplacesFreshPlayer, but for Recently Played. +func TestPlayRecentStation_ReplacesFreshPlayer(t *testing.T) { + app := newContinueOnNavigateApp() + original := app.quickFavPlayer // idle (never started) + + app.recentlyPlayed = makeRecentlyPlayed("Recent FM") + + _, cmd := app.playRecentStation(0) + + if app.quickFavPlayer == original { + t.Error("playRecentStation should replace quickFavPlayer with a fresh instance") + } + if cmd == nil { + t.Error("playRecentStation should return a non-nil cmd to start playback") + } +} diff --git a/internal/ui/appearance_settings.go b/internal/ui/appearance_settings.go index d448f71..7a5a029 100644 --- a/internal/ui/appearance_settings.go +++ b/internal/ui/appearance_settings.go @@ -34,6 +34,7 @@ type AppearanceSettingsModel struct { message string messageTime int messageSuccess bool + nowPlayingBar string // set by App when ContinueOnNavigate is active // Configuration being edited config storage.AppearanceConfig @@ -568,9 +569,10 @@ func (m AppearanceSettingsModel) View() string { } layout := PageLayout{ - Title: "Appearance Settings", - Content: content, - Help: "Press ? for help β€’ esc/q to go back", + Title: "Appearance Settings", + Content: content, + NowPlaying: m.nowPlayingBar, + Help: "Press ? for help β€’ esc/q to go back", } // Show help overlay if active diff --git a/internal/ui/blocklist.go b/internal/ui/blocklist.go index 3918e0a..4eef278 100644 --- a/internal/ui/blocklist.go +++ b/internal/ui/blocklist.go @@ -34,25 +34,26 @@ const ( // BlocklistModel represents the blocklist screen type BlocklistModel struct { - state blocklistState - manager *blocklist.Manager - mainMenu list.Model - rulesMenu list.Model - listModel list.Model - rulesListModel list.Model - stations []blocklist.BlockedStation - rules []blocklist.BlockRule - selectedRuleIndex int - pendingRuleType blocklist.BlockRuleType - pendingRuleValue string - previousState blocklistState - textInput textinput.Model - message string - messageTime int - err error - width int - height int - showBlockedInSearch bool // current setting value + state blocklistState + manager *blocklist.Manager + mainMenu list.Model + rulesMenu list.Model + listModel list.Model + rulesListModel list.Model + stations []blocklist.BlockedStation + rules []blocklist.BlockRule + selectedRuleIndex int + pendingRuleType blocklist.BlockRuleType + pendingRuleValue string + previousState blocklistState + textInput textinput.Model + message string + messageTime int + err error + width int + height int + showBlockedInSearch bool // current setting value + nowPlayingBar string // set by App when ContinueOnNavigate is active } // blocklistItem wraps a BlockedStation for list.Item interface @@ -571,7 +572,7 @@ func (m BlocklistModel) viewMainMenu() string { content.WriteString(m.mainMenu.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-4: Quick select β€’ Esc: Back β€’ Ctrl+C: Quit", }, m.height) @@ -643,7 +644,7 @@ func (m BlocklistModel) viewSearchVisibility() string { content.WriteString(" 2) n β€” Keep hiding blocked stations from search\n") } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ” Search Visibility", Content: content.String(), Help: "y/1: Yes β€’ n/2: No β€’ Esc: Back", @@ -665,7 +666,7 @@ func (m BlocklistModel) viewRulesMenu() string { content.WriteString(m.rulesMenu.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-4: Quick select β€’ Esc: Back β€’ Ctrl+C: Quit", }, m.height) @@ -694,7 +695,7 @@ func (m BlocklistModel) viewBlockedStations() string { content.WriteString(m.listModel.View()) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Blocked Stations", Subtitle: subtitle, Content: content.String(), @@ -710,7 +711,7 @@ func (m BlocklistModel) viewConfirmClear() string { fmt.Fprintf(&content, "Clear all %d blocked stations?\n\n", count) content.WriteString(errorStyle().Render("⚠ This cannot be undone!")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Confirm Clear", Content: content.String(), Help: "y: Yes, clear all β€’ n/Esc: No, cancel", @@ -730,7 +731,7 @@ func (m BlocklistModel) viewPlaceholder(title string) string { content.WriteString(infoStyle().Render(m.message)) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: title, Content: content.String(), Help: "Esc: Back", @@ -859,7 +860,7 @@ func (m BlocklistModel) viewBlockByCountry() string { content.WriteString(m.textInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Block by Country", Content: content.String(), Help: "Enter: Add rule β€’ Esc: Back", @@ -884,7 +885,7 @@ func (m BlocklistModel) viewBlockByLanguage() string { content.WriteString(m.textInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Block by Language", Content: content.String(), Help: "Enter: Add rule β€’ Esc: Back", @@ -909,7 +910,7 @@ func (m BlocklistModel) viewBlockByTag() string { content.WriteString(m.textInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Block by Tag", Content: content.String(), Help: "Enter: Add rule β€’ Esc: Back", @@ -938,7 +939,7 @@ func (m BlocklistModel) viewActiveRules() string { content.WriteString(m.rulesListModel.View()) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🚫 Active Block Rules", Subtitle: subtitle, Content: content.String(), @@ -959,7 +960,7 @@ func (m BlocklistModel) viewConfirmDeleteRule() string { content.WriteString("No rule selected") } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Confirm Delete Rule", Content: content.String(), Help: "y: Yes, delete β€’ n/Esc: No, cancel", @@ -984,7 +985,7 @@ func (m BlocklistModel) viewConfirmAddRule() string { content.WriteString(infoStyle().Render("This will block all stations with this tag/genre.")) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Confirm Add Rule", Content: content.String(), Help: "y: Yes, add rule β€’ n/Esc: No, cancel", @@ -1010,3 +1011,9 @@ type blockRuleAddedMsg struct { type blockRuleErrorMsg struct { err error } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m BlocklistModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/browse_tags.go b/internal/ui/browse_tags.go index eb72f5b..4dc9174 100644 --- a/internal/ui/browse_tags.go +++ b/internal/ui/browse_tags.go @@ -1,35 +1,82 @@ package ui import ( + "context" "fmt" "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/ui/components" ) -// browseTagsState tracks sub-views within the Browse by Tag screen. +// browseTagsState represents the state of the BrowseTagsModel state machine. +// browseTagsResolvedMsg is sent when a UUID lookup completes for playback +type browseTagsResolvedMsg struct { + station *api.Station + err error +} + type browseTagsState int const ( - browseTagsStateList browseTagsState = iota // list of all tags - browseTagsStateDetail // stations for a selected tag - browseTagsStatePlaying // playing a station from a tag - browseTagsStateTagInput // entering a new tag (single via 't') - browseTagsStateManageTags // full tag manager via 'T' + browseTagsStateList browseTagsState = iota + browseTagsStateDetail + browseTagsStatePlaying + browseTagsStateTagInput + browseTagsStateManageTags + browseTagsStateConfirmStop ) -// tagStat holds a tag name and how many stations carry it. +// tagStat holds a tag and the count of stations with that tag. type tagStat struct { tag string count int } +// viewConfirmStop renders the confirmation prompt for stopping playback. +func (m BrowseTagsModel) viewConfirmStop() string { + var content strings.Builder + content.WriteString("Are you sure you want to stop playback?\n\n") + content.WriteString("y: Yes, stop\n") + content.WriteString("n/Esc: No, keep playing\n") + return m.renderPageWithBottomHelp(PageLayout{ + Title: "Confirm Stop", + Content: content.String(), + Help: "y: Yes β€’ n/Esc: No", + }, m.height) +} + +// handleConfirmStopInput handles key input during the confirm-stop prompt. +func (m BrowseTagsModel) handleConfirmStopInput(msg tea.KeyMsg) (BrowseTagsModel, tea.Cmd) { + switch msg.String() { + case "y", "1": + // Execute the navigation that was originally requested. + var cmd tea.Cmd + if m.confirmStopTarget == "main" { + cmd = m.navigateToMainCmd() + } else { + cmd = m.navigateBackCmd() + m.state = browseTagsStateDetail + } + m.selectedStation = nil + if cmd != nil { + return m, cmd + } + return m, nil + case "n", "2", "esc": + m.state = browseTagsStatePlaying + return m, nil + } + return m, nil +} + // BrowseTagsModel is the model for the "Browse by Tag" screen. type BrowseTagsModel struct { state browseTagsState @@ -41,9 +88,9 @@ type BrowseTagsModel struct { tagRenderer *components.TagRenderer // Tag list view - tagStats []tagStat - tagCursor int - deleteConfirm bool // true = waiting for second 'd' to confirm tag deletion + tagStats []tagStat + tagCursor int + deleteConfirm bool // true = waiting for second 'd' to confirm tag deletion // Station detail view selectedTag string @@ -55,8 +102,8 @@ type BrowseTagsModel struct { selectedStation *api.Station player *player.MPVPlayer ratingMode bool - tagInput components.TagInput - manageTags components.ManageTags + tagInput components.TagInput + manageTags components.ManageTags // Help overlay helpModel components.HelpModel @@ -66,6 +113,10 @@ type BrowseTagsModel struct { saveMessageTime int width int height int + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewBrowseTagsModel creates a Browse by Tag model. @@ -188,8 +239,22 @@ func (m BrowseTagsModel) Update(msg tea.Msg) (BrowseTagsModel, tea.Cmd) { var cmd tea.Cmd m.manageTags, cmd = m.manageTags.Update(msg) return m, cmd + case browseTagsStateConfirmStop: + return m.handleConfirmStopInput(msg) } + case browseTagsResolvedMsg: + if msg.err != nil || msg.station == nil { + m.saveMessage = "βœ— Could not resolve station URL" + m.saveMessageTime = messageDisplayShort + return m, nil + } + m.selectedStation = msg.station + m.state = browseTagsStatePlaying + // Refresh detail list with new cached URL + m.loadDetailStations() + return m, m.playSelected() + case playbackStartedMsg: return m, nil @@ -291,36 +356,102 @@ func (m BrowseTagsModel) updateDetail(msg tea.KeyMsg) (BrowseTagsModel, tea.Cmd) if len(m.detailStations) == 0 { break } - station := m.detailStations[m.stationCursor] - if station.URLResolved == "" { - m.saveMessage = "βœ— No URL cached for this station β€” play it from search first" - m.saveMessageTime = messageDisplayShort - break + // Copy the station so its address is stable on the heap + st := m.detailStations[m.stationCursor] + if st.URLResolved != "" { + m.selectedStation = &st + m.state = browseTagsStatePlaying + return m, m.playSelected() + } + // URL missing β€” look it up live from the API + uuid := st.StationUUID + m.saveMessage = "Looking up station…" + m.saveMessageTime = messageDisplayShort + return m, func() tea.Msg { + client := api.NewClient() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resolved, err := client.GetByUUID(ctx, uuid) + return browseTagsResolvedMsg{station: resolved, err: err} } - m.selectedStation = &station - m.state = browseTagsStatePlaying - return m, m.playSelected() } return m, nil } +// navigateBackCmd returns the appropriate command when the user presses Esc +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m BrowseTagsModel) navigateBackCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Browse Tags", + } + } + } + // Default: stop the player. + if m.player != nil { + _ = m.player.Stop() + } + return nil +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m BrowseTagsModel) navigateToMainCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Browse Tags", + } + }, + func() tea.Msg { return backToMainMsg{} }, + ) + } + // Default: stop the player, then navigate. + if m.player != nil { + _ = m.player.Stop() + } + return func() tea.Msg { return backToMainMsg{} } +} + func (m BrowseTagsModel) updatePlaying(msg tea.KeyMsg) (BrowseTagsModel, tea.Cmd) { if m.ratingMode { return m.handleRatingInput(msg) } switch msg.String() { case "esc": - if m.player != nil { - _ = m.player.Stop() + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = browseTagsStateConfirmStop + return m, nil } + // Stop (or hand off) playback and return to detail view. + cmd := m.navigateBackCmd() m.state = browseTagsStateDetail m.selectedStation = nil m.loadDetailStations() // refresh in case tags were modified while playing + if cmd != nil { + return m, cmd + } case "0": - if m.player != nil { - _ = m.player.Stop() + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = browseTagsStateConfirmStop + return m, nil } - return m, func() tea.Msg { return backToMainMsg{} } + // Build cmd before clearing selectedStation. + cmd := m.navigateToMainCmd() + m.selectedStation = nil + return m, cmd case " ": if m.player != nil { if err := m.player.TogglePause(); err == nil { @@ -443,13 +574,21 @@ func (m *BrowseTagsModel) deleteTagFromAll(tag string) int { } // playSelected starts playing the currently selected station. +// Phase 5: volume is resolved from PlayOptions (DefaultVolume / StartVolumeMode). func (m BrowseTagsModel) playSelected() tea.Cmd { if m.selectedStation == nil { return nil } station := *m.selectedStation + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + if station.Volume != nil { + startVol = *station.Volume + } return func() tea.Msg { - if err := m.player.Play(&station); err != nil { + if err := m.player.PlayWithVolume(&station, startVol); err != nil { return playbackErrorMsg{err} } return playbackStartedMsg{} @@ -472,13 +611,15 @@ func (m BrowseTagsModel) View() string { return m.viewTagInput() case browseTagsStateManageTags: return m.viewManageTags() + case browseTagsStateConfirmStop: + return m.viewConfirmStop() } return "" } // viewManageTags renders the ManageTags dialog overlay. func (m BrowseTagsModel) viewManageTags() string { - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Manage Tags", Content: m.manageTags.View(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ d: Done β€’ Esc: Cancel", @@ -493,7 +634,7 @@ func (m BrowseTagsModel) viewTagInput() string { sb.WriteString("\n\n") } sb.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: sb.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -524,7 +665,7 @@ func (m BrowseTagsModel) viewTagList() string { renderSaveMessage(&sb, m.saveMessage) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Browse by Tag", Content: sb.String(), Help: "↑↓/jk: Navigate β€’ Enter: View stations β€’ d: Delete tag (confirm) β€’ Esc/m: Back", @@ -574,7 +715,7 @@ func (m BrowseTagsModel) viewDetail() string { renderSaveMessage(&sb, m.saveMessage) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: fmt.Sprintf("🏷 Tag: %s", m.selectedTag), Content: sb.String(), Help: "↑↓/jk: Navigate β€’ Enter: Play β€’ Esc: Back", @@ -597,7 +738,17 @@ func (m BrowseTagsModel) viewPlaying() string { rating = r.Rating } } - sb.WriteString(RenderStationDetailsWithRating(*m.selectedStation, false, metadata, rating, m.starRenderer)) + // Phase 5: ShowMetadata wiring + if m.playOptsCfg.ShowMetadata { + sb.WriteString(RenderStationDetailsWithRating(*m.selectedStation, false, metadata, rating, m.starRenderer)) + } else { + sb.WriteString(boldStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + sb.WriteString(" ") + sb.WriteString(dimStyle().Render(m.selectedStation.Country)) + } + sb.WriteString("\n") + } sb.WriteString("\n") if m.player != nil && m.player.IsPlaying() { @@ -627,9 +778,18 @@ func (m BrowseTagsModel) viewPlaying() string { renderSaveMessage(&sb, m.saveMessage) helpText := "Space: Pause β€’ r: Rate β€’ t: Tag β€’ /*: Volume β€’ 0: Main Menu β€’ ?: Help β€’ Esc: Back" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Now Playing", Content: sb.String(), Help: helpText, }, m.height) } + +// renderPageWithBottomHelp injects the now-playing bar when the model's own +// player is not actively playing (so viewPlaying is unaffected). +func (m BrowseTagsModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/components/stars_test.go b/internal/ui/components/stars_test.go index f72bf34..d3b162b 100644 --- a/internal/ui/components/stars_test.go +++ b/internal/ui/components/stars_test.go @@ -116,6 +116,7 @@ func TestStarRenderer(t *testing.T) { sr := DefaultStarRenderer() if sr == nil { t.Fatal("Expected non-nil default renderer") + return } if sr.filledStar != "β˜…" { t.Error("Default renderer should use unicode") diff --git a/internal/ui/connection_settings.go b/internal/ui/connection_settings.go index 6a30d48..6e24367 100644 --- a/internal/ui/connection_settings.go +++ b/internal/ui/connection_settings.go @@ -60,6 +60,7 @@ type ConnectionSettingsModel struct { message string messageIsSuccess bool messageTime int + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewConnectionSettingsModel creates a new connection settings model @@ -425,7 +426,7 @@ func (m ConnectionSettingsModel) viewMenu() string { content.WriteString(infoStyle().Render("ℹ️ Helps maintain stable playback on unstable networks (4G/GPRS)")) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-5: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) @@ -456,7 +457,7 @@ func (m ConnectionSettingsModel) viewDelay() string { content.WriteString("\n\n") content.WriteString(infoStyle().Render("Shorter delays reconnect faster but may strain weak connections")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-7: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) @@ -491,8 +492,14 @@ func (m ConnectionSettingsModel) viewBuffer() string { content.WriteString("\n\n") content.WriteString(infoStyle().Render("Larger buffers handle longer signal drops but use more memory")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-8: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m ConnectionSettingsModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/gist.go b/internal/ui/gist.go index f8d62c4..2794acc 100644 --- a/internal/ui/gist.go +++ b/internal/ui/gist.go @@ -53,7 +53,7 @@ const ( type overwriteSource int const ( - overwriteSourceZip overwriteSource = iota + overwriteSourceZip overwriteSource = iota overwriteSourceGist ) @@ -113,16 +113,16 @@ type GistModel struct { gistDescription string createPublic bool // text input - textInput textinput.Model + textInput textinput.Model // Phase 3: backup/sync - checklist components.ChecklistModel - syncPrefs storage.SyncPrefs - backupManager *storage.BackupManager - gistSyncMgr *storage.GistSyncManager - pendingZipPath string // zip path set after path prompt - pendingPrefs storage.SyncPrefs // prefs confirmed before conflict check - overwritePaths []string // files that would be clobbered - overwriteSrc overwriteSource + checklist components.ChecklistModel + syncPrefs storage.SyncPrefs + backupManager *storage.BackupManager + gistSyncMgr *storage.GistSyncManager + pendingZipPath string // zip path set after path prompt + pendingPrefs storage.SyncPrefs // prefs confirmed before conflict check + overwritePaths []string // files that would be clobbered + overwriteSrc overwriteSource pendingGist *gist.Gist // gist fetched during restore-from-URL flow gistFetchPending bool // true while a URL fetch is in flight gistFetchSeq uint64 // incremented on each new fetch; matched against msg.requestID to discard stale results @@ -133,6 +133,7 @@ type GistModel struct { height int token string quitting bool + nowPlayingBar string // set by App when ContinueOnNavigate is active } // ── Constructor ─────────────────────────────────────────────────────────────── @@ -1093,7 +1094,7 @@ func (m GistModel) View() string { switch m.state { case gistStateMenu: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Sync & Backup", Subtitle: "Select an Option", Content: m.viewMenuWithSections() + "\n" + m.renderMessage(), @@ -1101,7 +1102,7 @@ func (m GistModel) View() string { }, h) case gistStateCreateVisibility: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Create Favorites Gist", Subtitle: "Choose Visibility", Content: m.visibilityMenu.View() + "\n" + m.renderMessage(), @@ -1113,7 +1114,7 @@ func (m GistModel) View() string { if m.createPublic { vis = "Public" } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Create Favorites Gist", Subtitle: fmt.Sprintf("Enter Gist Name (%s)", vis), Content: fmt.Sprintf("Name/description for your gist:\n\n%s", m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1121,7 +1122,7 @@ func (m GistModel) View() string { }, h) case gistStateCreate: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Create Favorites Gist", Subtitle: "Uploading…", Content: "Creating gist, please wait…\n\n" + m.renderMessage(), @@ -1129,7 +1130,7 @@ func (m GistModel) View() string { }, h) case gistStateRestoreGistURL: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Restore All Data from Gist", Subtitle: "Paste a gist URL or ID", Content: fmt.Sprintf( @@ -1143,7 +1144,7 @@ func (m GistModel) View() string { }, h) case gistStateImportURL: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Import Favorites from URL", Subtitle: "Paste a public gist URL or ID", Content: fmt.Sprintf( @@ -1168,7 +1169,7 @@ func (m GistModel) View() string { if len(m.gists) == 0 { content = "No gists available.\n\nCreate a favorites gist first." } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: action, Subtitle: "Select a Gist", Content: content + "\n" + m.renderMessage(), @@ -1176,7 +1177,7 @@ func (m GistModel) View() string { }, h) case gistStateTokenMenu: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Token Management", Subtitle: "Manage your GitHub Token", Content: m.tokenMenuList.View() + "\n" + m.renderMessage(), @@ -1184,7 +1185,7 @@ func (m GistModel) View() string { }, h) case gistStateTokenSetup: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Setup Token", Subtitle: "Paste your GitHub Token", Content: fmt.Sprintf("Token will be hidden for security.\n\n%s", m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1208,7 +1209,7 @@ func (m GistModel) View() string { } } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Current Token", Subtitle: "View Token Status", Content: fmt.Sprintf("Token: %s%s", masked, sourceInfo) + "\n\n" + m.renderMessage(), @@ -1216,7 +1217,7 @@ func (m GistModel) View() string { }, h) case gistStateUpdateInput: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Update Favorites Gist", Subtitle: "Enter new description", Content: fmt.Sprintf("Current: %s\n\nNew Description:\n%s", m.selectedGist.Description, m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1224,7 +1225,7 @@ func (m GistModel) View() string { }, h) case gistStateDeleteConfirm: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Delete Favorites Gist", Subtitle: "Confirm Deletion", Content: fmt.Sprintf("Delete gist?\n%s\n\nType 'yes' to confirm:\n%s", m.selectedGist.Description, m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1233,7 +1234,7 @@ func (m GistModel) View() string { case gistStateTokenDelete: masked := gist.GetMaskedToken(m.token) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Delete Token", Subtitle: "⚠️ WARNING", Content: fmt.Sprintf("This will delete your stored GitHub token!\n\nToken: %s\n\nType 'yes' to confirm:\n%s", masked, m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1244,14 +1245,14 @@ func (m GistModel) View() string { case gistStateExportChecklist, gistStateRestoreZipChecklist, gistStateSyncGistChecklist, gistStateRestoreGistChecklist: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Sync & Backup", Content: m.checklist.View() + m.renderMessage(), Help: m.checklist.HelpText(), }, h) case gistStateExportPath: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Export Backup", Subtitle: "Save location", Content: fmt.Sprintf( @@ -1263,7 +1264,7 @@ func (m GistModel) View() string { }, h) case gistStateRestoreZipPath: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Restore from Backup", Subtitle: "Zip file location", Content: fmt.Sprintf("Enter path to the backup zip:\n\n%s", m.textInput.View()) + "\n\n" + m.renderMessage(), @@ -1272,7 +1273,7 @@ func (m GistModel) View() string { case gistStateOverwriteWarn: paths := strings.Join(m.overwritePaths, "\n ") - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Overwrite Warning", Subtitle: "⚠️ The following files already exist:", Content: fmt.Sprintf(" %s\n\nPress Enter to overwrite, Esc to cancel.", paths) + "\n\n" + m.renderMessage(), @@ -1280,7 +1281,7 @@ func (m GistModel) View() string { }, h) case gistStateSyncProgress: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Sync & Backup", Content: "Working…\n\n" + m.renderMessage(), }, h) @@ -1579,3 +1580,9 @@ func openBrowser(url string) error { } return cmd.Start() } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m GistModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index d299073..392f35f 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -1,6 +1,8 @@ package ui import ( + "fmt" + "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/storage" ) @@ -14,6 +16,23 @@ func formatSleepCountdown(countdown string) string { return "πŸ’€ " + countdown } +// renderNowPlayingBar returns a single-line now-playing banner for use at the +// bottom of any screen when ContinueOnNavigate is active and a station is +// playing at the app level. Returns an empty string when station is nil. +// Format: β™« Jazz FM [context] Β· Vol: 80% Β· Space: Pause //*: Vol Esc: Stop +func renderNowPlayingBar(station *api.Station, contextLabel string, vol int) string { + if station == nil { + return "" + } + name := station.TrimName() + bar := fmt.Sprintf("β™« %s", name) + if contextLabel != "" { + bar += fmt.Sprintf(" [%s]", contextLabel) + } + bar += fmt.Sprintf(" Β· Vol: %d%% Β· Space: Pause //*: Vol Esc: Stop", vol) + return successStyle().Render(bar) +} + // hydrateStations hydrates station metadata for a list of UUIDs from the cache. // Stations with no cached entry get the UUID as a fallback name. func hydrateStations(mm *storage.MetadataManager, uuids []string) []api.Station { diff --git a/internal/ui/list.go b/internal/ui/list.go index 1b03c57..e83fd9a 100644 --- a/internal/ui/list.go +++ b/internal/ui/list.go @@ -31,17 +31,18 @@ const ( // ListManagementModel represents the list management screen type ListManagementModel struct { - state listManagementState - favoritePath string - lists []string - listModel list.Model - textInput textinput.Model - selectedList string - err error - message string - messageTime int - width int - height int + state listManagementState + favoritePath string + lists []string + listModel list.Model + textInput textinput.Model + selectedList string + err error + message string + messageTime int + width int + height int + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewListManagementModel creates a new list management model @@ -678,7 +679,7 @@ func (m ListManagementModel) viewMenu() string { content.WriteString(m.listModel.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-4: Quick select β€’ Esc: Back β€’ Ctrl+C: Quit", }, m.height) @@ -704,7 +705,7 @@ func (m ListManagementModel) viewCreate() string { content.WriteString(errorStyle().Render(m.message)) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Create New List", Content: content.String(), Help: "Enter: Create β€’ Esc: Back β€’ Ctrl+C: Quit", @@ -733,7 +734,7 @@ func (m ListManagementModel) viewDelete() string { content.WriteString(errorStyle().Render(m.message)) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Delete List", Content: content.String(), Help: "Enter: Continue β€’ Esc: Back β€’ Ctrl+C: Quit", @@ -761,7 +762,7 @@ func (m ListManagementModel) viewSelectListToDelete() string { maxNum = 9 } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: fmt.Sprintf("↑↓/jk: Navigate β€’ Enter: Select β€’ 1-%d: Quick select β€’ Esc: Back β€’ Ctrl+C: Quit", maxNum), }, m.height) @@ -777,7 +778,7 @@ func (m ListManagementModel) viewConfirmDelete() string { content.WriteString("This action cannot be undone.") - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Confirm Deletion", Content: content.String(), Help: "y: Yes, Delete β€’ n/Esc: Cancel", @@ -806,7 +807,7 @@ func (m ListManagementModel) viewEdit() string { content.WriteString(errorStyle().Render(m.message)) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Edit List Name", Content: content.String(), Help: "Enter: Continue β€’ Esc: Back β€’ Ctrl+C: Quit", @@ -834,7 +835,7 @@ func (m ListManagementModel) viewSelectListToEdit() string { maxNum = 9 } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: fmt.Sprintf("↑↓/jk: Navigate β€’ Enter: Select β€’ 1-%d: Quick select β€’ Esc: Back β€’ Ctrl+C: Quit", maxNum), }, m.height) @@ -851,7 +852,7 @@ func (m ListManagementModel) viewEnterNewName() string { content.WriteString(errorStyle().Render(m.message)) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Edit List Name", Subtitle: fmt.Sprintf("Renaming: %s", m.selectedList), Content: content.String(), @@ -877,13 +878,25 @@ func (m ListManagementModel) viewShowAll() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "All Favorite Lists", Content: content.String(), Help: "Esc: Back β€’ Ctrl+C: Quit", }, m.height) } +// renderPage wraps RenderPage injecting the active now-playing bar. +func (m ListManagementModel) renderPage(layout PageLayout) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPage(layout) +} + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m ListManagementModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} + // Messages type listManagementListsLoadedMsg struct { diff --git a/internal/ui/lucky.go b/internal/ui/lucky.go index e20fec7..5f1a010 100644 --- a/internal/ui/lucky.go +++ b/internal/ui/lucky.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/shuffle" "github.com/shinokada/tera/v3/internal/storage" @@ -34,6 +35,7 @@ const ( luckyStateNewListInput luckyStateTagInput luckyStateManageTags + luckyStateConfirmStop // Phase 5: Confirm before stopping ) // Footer help text constants for the I Feel Lucky screen @@ -85,6 +87,10 @@ type LuckyModel struct { manageTags components.ManageTags // Input focus mode: true = typing in Genre/keyword, false = history navigation inputMode bool + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + nowPlayingBar string // set by App when ContinueOnNavigate is active + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state } // Messages for lucky screen @@ -215,6 +221,8 @@ func (m LuckyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTagInput(msg) case luckyStateManageTags: return m.updateManageTags(msg) + case luckyStateConfirmStop: + return m.updateConfirmStop(msg) } case tea.WindowSizeMsg: @@ -555,6 +563,27 @@ func (m LuckyModel) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle escape - return to main menu if key == "esc" { m.numberBuffer = "" // Clear buffer + // When ContinueOnNavigate is on and a station is playing, hand the + // player off to App so music keeps going, then navigate to main menu. + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return m, tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Lucky", + } + }, + func() tea.Msg { + return navigateMsg{screen: screenMainMenu} + }, + ) + } + // ContinueOnNavigate off β€” stop before leaving. + if m.player != nil { + _ = m.player.Stop() + } return m, func() tea.Msg { return navigateMsg{screen: screenMainMenu} } @@ -757,6 +786,51 @@ func (m LuckyModel) selectHistoryByNumber(num int) (tea.Model, tea.Cmd) { return m, nil } +// navigateBackCmd returns the appropriate command when the user presses Esc +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m LuckyModel) navigateBackCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Lucky", + } + } + } + // Default: stop the player. + if m.player != nil { + _ = m.player.Stop() + } + return nil +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App and navigates to the main menu; otherwise it stops the player first. +func (m LuckyModel) navigateToMainCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Lucky", + } + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + // Default: stop the player, then navigate. + if m.player != nil { + _ = m.player.Stop() + } + return func() tea.Msg { return navigateMsg{screen: screenMainMenu} } +} + // updatePlaying handles input during playback func (m LuckyModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle rating mode input first @@ -778,32 +852,62 @@ func (m LuckyModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "esc": - // Stop playback and return to I Feel Lucky input - if err := m.player.Stop(); err != nil { - m.saveMessage = fmt.Sprintf("βœ— Failed to stop playback: %v", err) - m.saveMessageTime = messageDisplayLong + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = luckyStateConfirmStop return m, nil } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + // Hand off player to App and go straight to main menu so the + // user never lands on the input screen with a nil selectedStation + // (which would cause the second Esc to stop the player). + station := m.selectedStation + m.selectedStation = nil + m.state = luckyStateInput + return m, tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{player: m.player, station: station, contextLabel: "Lucky"} + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + // ContinueOnNavigate off β€” stop and return to Lucky input. + if m.player != nil { + _ = m.player.Stop() + } m.state = luckyStateInput m.inputMode = true m.textInput.Focus() m.selectedStation = nil - // Reload history from disk so recent search appears m.reloadSearchHistory() m.rebuildMenuWithHistory() return m, nil case "0": - // Return to main menu (Level 2+ shortcut) - if err := m.player.Stop(); err != nil { - m.saveMessage = fmt.Sprintf("βœ— Failed to stop playback: %v", err) - m.saveMessageTime = messageDisplayLong + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = luckyStateConfirmStop return m, nil } + // Return to main menu (or hand off and navigate). + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + m.selectedStation = nil + m.state = luckyStateInput + return m, tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{player: m.player, station: station, contextLabel: "Lucky"} + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + if m.player != nil { + _ = m.player.Stop() + } m.selectedStation = nil m.state = luckyStateInput - return m, func() tea.Msg { - return navigateMsg{screen: screenMainMenu} - } + return m, func() tea.Msg { return navigateMsg{screen: screenMainMenu} } case "f": // Save to Quick Favorites during playback return m, m.saveToQuickFavorites() @@ -1067,19 +1171,23 @@ func (m LuckyModel) searchAndPickRandom(keyword string) tea.Cmd { return luckySearchErrorMsg{err: fmt.Errorf("no stations found for '%s'", keyword)} } - // Filter out blocked stations - if m.blocklistManager != nil { + // Filter out stations with no stream URL and blocked stations + { filtered := make([]api.Station, 0, len(stations)) for _, s := range stations { - if !m.blocklistManager.IsBlockedByAny(&s) { - filtered = append(filtered, s) + if s.URLResolved == "" { + continue + } + if m.blocklistManager != nil && m.blocklistManager.IsBlockedByAny(&s) { + continue } + filtered = append(filtered, s) } stations = filtered } if len(stations) == 0 { - return luckySearchErrorMsg{err: fmt.Errorf("all stations found for '%s' are blocked", keyword)} + return luckySearchErrorMsg{err: fmt.Errorf("no playable stations found for '%s'", keyword)} } // Pick a random station (rand is auto-seeded since Go 1.20) @@ -1090,7 +1198,8 @@ func (m LuckyModel) searchAndPickRandom(keyword string) tea.Cmd { } } -// startPlayback initiates playback of the selected station +// startPlayback initiates playback of the selected station. +// Phase 5: volume is resolved from PlayOptions (DefaultVolume / StartVolumeMode). func (m LuckyModel) startPlayback() tea.Cmd { if m.selectedStation == nil { return func() tea.Msg { @@ -1098,9 +1207,16 @@ func (m LuckyModel) startPlayback() tea.Cmd { } } station := *m.selectedStation + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + if station.Volume != nil { + startVol = *station.Volume + } return tea.Batch( func() tea.Msg { - if err := m.player.Play(&station); err != nil { + if err := m.player.PlayWithVolume(&station, startVol); err != nil { return playbackErrorMsg{err} } return playbackStartedMsg{} @@ -1221,11 +1337,13 @@ func (m LuckyModel) View() string { sb.WriteString("\n\n") } sb.WriteString(m.manageTags.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Manage Tags", Content: sb.String(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ d: Done β€’ Esc: Cancel", }, m.height) + case luckyStateConfirmStop: + return m.viewConfirmStop() } return "Unknown state" } @@ -1310,7 +1428,7 @@ func (m LuckyModel) viewInput() string { helpText := luckyHelpInputFooter - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: helpText, }, m.height) @@ -1336,7 +1454,7 @@ func (m LuckyModel) viewSearching() string { } } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "I Feel Lucky", Content: content.String(), Help: "Please wait...", @@ -1351,26 +1469,33 @@ func (m LuckyModel) viewPlaying() string { var content strings.Builder - // Station info with rating - // Get rating for display + // Station info with rating and metadata (Phase 5: ShowMetadata) var rating int if m.ratingsManager != nil { if r := m.ratingsManager.GetRating(m.selectedStation.StationUUID); r != nil { rating = r.Rating } } - // Get metadata for display var metadata *storage.StationMetadata if m.metadataManager != nil { metadata = m.metadataManager.GetMetadata(m.selectedStation.StationUUID) } voted := m.votedStations != nil && m.votedStations.HasVoted(m.selectedStation.StationUUID) - content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, voted, metadata, rating, m.starRenderer)) + if m.playOptsCfg.ShowMetadata { + content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, voted, metadata, rating, m.starRenderer)) + } else { + // Render only basic info (name, country, etc.) + content.WriteString(boldStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + content.WriteString(" ") + content.WriteString(dimStyle().Render(m.selectedStation.Country)) + } + content.WriteString("\n") + } // Playback status with proper spacing content.WriteString("\n") if m.player.IsPlaying() { - // Use the cached track to avoid a blocking IPC socket call in the render path. if track := m.player.GetCachedTrack(); IsValidTrackMetadata(track, m.selectedStation.Name) { content.WriteString(successStyle().Render("β–Ά Now Playing:")) content.WriteString(" ") @@ -1415,7 +1540,7 @@ func (m LuckyModel) viewPlaying() string { } helpText := luckyHelpPlayingFooter - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Now Playing", Content: content.String(), Help: helpText, @@ -1455,7 +1580,7 @@ func (m LuckyModel) viewSavePrompt() string { content.WriteString(msgStyle.Render(m.saveMessage)) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Save Station?", Content: content.String(), Help: "y/1: Yes β€’ n/2/Esc: No", @@ -1488,7 +1613,7 @@ func (m LuckyModel) viewSelectList() string { content.WriteString("Loading lists...") } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Save to List", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ n: New list β€’ Esc: Cancel", @@ -1514,7 +1639,7 @@ func (m LuckyModel) viewNewListInput() string { // Text input content.WriteString(m.newListInput.View()) - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Create New List", Content: content.String(), Help: "Enter: Save β€’ Esc: Cancel", @@ -1529,7 +1654,7 @@ func (m LuckyModel) viewTagInput() string { content.WriteString("\n\n") } content.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: content.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -1583,15 +1708,17 @@ func (m LuckyModel) updateShufflePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - // Stop shuffle and playback, return to I Feel Lucky input + // Phase 5: gate on ConfirmStop before navigating away. + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = luckyStateConfirmStop + return m, nil + } + // Stop shuffle and playback (or hand off), return to I Feel Lucky input. if m.shuffleManager != nil { m.shuffleManager.Stop() } - if err := m.player.Stop(); err != nil { - m.saveMessage = fmt.Sprintf("βœ— Failed to stop playback: %v", err) - m.saveMessageTime = messageDisplayLong - return m, nil - } + cmd := m.navigateBackCmd() m.state = luckyStateInput m.inputMode = true m.textInput.Focus() @@ -1601,24 +1728,28 @@ func (m LuckyModel) updateShufflePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Reload history from disk m.reloadSearchHistory() m.rebuildMenuWithHistory() + if cmd != nil { + return m, cmd + } return m, nil case "0": - // Return to main menu + // Phase 5: gate on ConfirmStop before navigating away. + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = luckyStateConfirmStop + return m, nil + } + // Return to main menu (or hand off and navigate). + // Build cmd before clearing selectedStation so ContinueOnNavigate handoff works. if m.shuffleManager != nil { m.shuffleManager.Stop() } - if err := m.player.Stop(); err != nil { - m.saveMessage = fmt.Sprintf("βœ— Failed to stop playback: %v", err) - m.saveMessageTime = messageDisplayLong - return m, nil - } + cmd := m.navigateToMainCmd() m.selectedStation = nil m.state = luckyStateInput m.shuffleEnabled = false m.shuffleManager = nil - return m, func() tea.Msg { - return navigateMsg{screen: screenMainMenu} - } + return m, cmd case "h": // Stop shuffle but keep playing current station if m.shuffleManager != nil { @@ -1781,19 +1912,23 @@ func (m LuckyModel) searchForShuffle(keyword string) tea.Cmd { return luckySearchErrorMsg{err: fmt.Errorf("no stations found for '%s'", keyword)} } - // Filter out blocked stations - if m.blocklistManager != nil { + // Filter out stations with no stream URL and blocked stations + { filtered := make([]api.Station, 0, len(stations)) for _, s := range stations { - if !m.blocklistManager.IsBlockedByAny(&s) { - filtered = append(filtered, s) + if s.URLResolved == "" { + continue + } + if m.blocklistManager != nil && m.blocklistManager.IsBlockedByAny(&s) { + continue } + filtered = append(filtered, s) } stations = filtered } if len(stations) == 0 { - return luckySearchErrorMsg{err: fmt.Errorf("all stations found for '%s' are blocked", keyword)} + return luckySearchErrorMsg{err: fmt.Errorf("no playable stations found for '%s'", keyword)} } return luckyShuffleSearchResultsMsg{ @@ -1953,7 +2088,7 @@ func (m LuckyModel) viewShufflePlaying() string { title := fmt.Sprintf("🎡 Now Playing (πŸ”€ Shuffle: %s)", m.lastSearchKeyword) help := luckyHelpShuffleFooter - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: title, Content: content.String(), Help: help, @@ -2020,3 +2155,86 @@ func (m *LuckyModel) reloadSearchHistory() { } m.searchHistory = history } + +// updateConfirmStop handles key input during the confirm-stop prompt (Phase 5). +// Routes confirmation through navigateToMainCmd / navigateBackCmd so that +// ContinueOnNavigate handoff is honoured, and resets state so the model is +// clean when reused. +func (m LuckyModel) updateConfirmStop(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "1": + target := m.confirmStopTarget + m.confirmStopTarget = "" + m.state = luckyStateInput + if target == "main" { + cmd := m.navigateToMainCmd() + m.selectedStation = nil + if cmd != nil { + return m, cmd + } + return m, nil + } + // "back" β€” mirror the direct Esc path: hand off (or stop) and return + // to the input screen. With ContinueOnNavigate ON, navigate straight + // to main so the user never lands on input with a nil selectedStation. + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + m.selectedStation = nil + return m, tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{player: m.player, station: station, contextLabel: "Lucky"} + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + if m.player != nil { + _ = m.player.Stop() + } + m.inputMode = true + m.textInput.Focus() + m.selectedStation = nil + m.reloadSearchHistory() + m.rebuildMenuWithHistory() + return m, nil + case "n", "N", "2", "esc": + // Cancel β€” return to playing + m.state = luckyStatePlaying + return m, nil + } + return m, nil +} + +// viewConfirmStop renders the ConfirmStop prompt for LuckyModel (Phase 5) +func (m LuckyModel) viewConfirmStop() string { + var content strings.Builder + content.WriteString(errorStyle().Render("⚠ Stop Playback?")) + content.WriteString("\n\n") + if m.selectedStation != nil { + content.WriteString("Station: ") + content.WriteString(stationNameStyle().Render(m.selectedStation.TrimName())) + content.WriteString("\n\n") + } + content.WriteString("Are you sure you want to stop playback?\n") + content.WriteString(infoStyle().Render("Press y/1 to stop, n/2/Esc to cancel.")) + return m.renderPage(PageLayout{ + Title: "Confirm Stop", + Content: content.String(), + Help: "y/1: Stop β€’ n/2/Esc: Cancel", + }) +} + +// renderPage injects the now-playing bar when the model's own player is not +// actively playing (so viewPlaying is unaffected). +func (m LuckyModel) renderPage(layout PageLayout) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPage(layout) +} + +func (m LuckyModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 4a07d17..b57af4c 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -4,6 +4,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/shinokada/tera/v3/internal/api" + "github.com/shinokada/tera/v3/internal/player" ) // Common message types used across UI components @@ -49,6 +51,19 @@ type undoBlockSuccessMsg struct{} // undoBlockFailedMsg is sent when a block undo operation fails type undoBlockFailedMsg struct{} +// handoffPlaybackMsg is sent by a play screen when ContinueOnNavigate is on +// and the user navigates away. App takes ownership of the player and station. +type handoffPlaybackMsg struct { + player *player.MPVPlayer + station *api.Station + contextLabel string +} + +// stopActivePlaybackMsg is sent when any screen wants to stop the app-level +// active player (e.g. main menu Esc while a handoff is in progress, or a new +// station starting on any screen). +type stopActivePlaybackMsg struct{} + // tickEverySecond returns a command that ticks every second func tickEverySecond() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { diff --git a/internal/ui/most_played.go b/internal/ui/most_played.go index 6a39fcd..5f52b69 100644 --- a/internal/ui/most_played.go +++ b/internal/ui/most_played.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/ui/components" @@ -54,6 +55,7 @@ const ( mostPlayedStateSavePrompt mostPlayedStateSelectList mostPlayedStateTagInput + mostPlayedStateConfirmStop // Phase 5: Confirm before stopping ) // MostPlayedModel represents the Most Played screen @@ -88,6 +90,10 @@ type MostPlayedModel struct { availableLists []string listItems []list.Item listModel list.Model + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state + nowPlayingBar string // set by App when ContinueOnNavigate is active } // mostPlayedStationItem wraps a station with metadata for the list @@ -251,6 +257,8 @@ func (m MostPlayedModel) Update(msg tea.Msg) (MostPlayedModel, tea.Cmd) { return m.handleSelectListInput(msg) case mostPlayedStateTagInput: return m.handleTagInputKey(msg) + case mostPlayedStateConfirmStop: + return m.handleConfirmStopInput(msg) } case components.TagSubmittedMsg: @@ -362,11 +370,8 @@ func (m *MostPlayedModel) refreshStationTagPills(stationUUID string) { func (m MostPlayedModel) handleListInput(msg tea.KeyMsg) (MostPlayedModel, tea.Cmd) { switch msg.String() { case "q", "esc", "m": - // Stop player before returning - if m.player != nil && m.player.IsPlaying() { - _ = m.player.Stop() - } - return m, func() tea.Msg { return navigateMsg{screen: screenMainMenu} } + // On the list view, Esc/q/m always goes back to the main menu. + return m, func() tea.Msg { return backToMainMsg{} } case "?": m.helpModel.SetSize(m.width, m.height) @@ -378,14 +383,27 @@ func (m MostPlayedModel) handleListInput(msg tea.KeyMsg) (MostPlayedModel, tea.C if len(m.stationItems) > 0 { selected := m.stationListModel.SelectedItem() if item, ok := selected.(mostPlayedStationItem); ok { - m.selectedStation = &item.station // Check if we have the URL to play if item.station.URLResolved != "" { - if err := m.player.Play(&item.station); err != nil { + // Phase 5: resolve volume from PlayOptions + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + st := item.station + if st.Volume != nil { + startVol = *st.Volume + } + // Stop any app-level handed-off player (e.g. from Play from Favorites + // or Search Stations with ContinueOnNavigate on) before starting the new stream. + stopCmd := func() tea.Msg { return stopActivePlaybackMsg{} } + if err := m.player.PlayWithVolume(&st, startVol); err != nil { m.err = err - } else { - m.state = mostPlayedStatePlaying + return m, stopCmd } + m.selectedStation = &st + m.state = mostPlayedStatePlaying + return m, stopCmd } else { m.saveMessage = "Station URL not available (needs lookup)" m.saveMessageSuccess = false @@ -423,6 +441,64 @@ func (m MostPlayedModel) handleListInput(msg tea.KeyMsg) (MostPlayedModel, tea.C return m, cmd } +// navigateBackCmd returns the appropriate command when the user presses Esc +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m MostPlayedModel) navigateBackCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Most Played", + } + } + } + // Default: stop the player. + if m.player != nil { + _ = m.player.Stop() + } + return nil +} + +// relinquishPlayer installs a fresh idle player on the model after m.player +// has been handed off to App via handoffPlaybackMsg. Call this at every site +// that invokes navigateBackCmd or navigateToMainCmd with ContinueOnNavigate on +// so that subsequent station selections do not reuse the app-owned pointer. +func (m MostPlayedModel) relinquishPlayer() MostPlayedModel { + newP := player.NewMPVPlayer() + if m.metadataManager != nil { + newP.SetMetadataManager(m.metadataManager) + } + m.player = newP + return m +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m MostPlayedModel) navigateToMainCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Most Played", + } + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + // Default: stop the player, then navigate. + if m.player != nil { + _ = m.player.Stop() + } + return func() tea.Msg { return navigateMsg{screen: screenMainMenu} } +} + func (m MostPlayedModel) handlePlayingInput(msg tea.KeyMsg) (MostPlayedModel, tea.Cmd) { // Handle rating mode input first if m.ratingMode { @@ -431,11 +507,22 @@ func (m MostPlayedModel) handlePlayingInput(msg tea.KeyMsg) (MostPlayedModel, te switch msg.String() { case "q", "esc", "m": - // Stop playback and return to list - if m.player != nil { - _ = m.player.Stop() + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = mostPlayedStateConfirmStop + return m, nil + } + // Stop (or hand off) playback and return to list. + cmd := m.navigateBackCmd() + if m.playOptsCfg.ContinueOnNavigate && cmd != nil { + m = m.relinquishPlayer() } m.state = mostPlayedStateList + m.selectedStation = nil + if cmd != nil { + return m, cmd + } return m, nil case "s": @@ -500,12 +587,20 @@ func (m MostPlayedModel) handlePlayingInput(msg tea.KeyMsg) (MostPlayedModel, te } case "0": - // Return to main menu - if m.player != nil { - _ = m.player.Stop() + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = mostPlayedStateConfirmStop + return m, nil + } + // Build cmd before clearing selectedStation. + cmd := m.navigateToMainCmd() + if m.playOptsCfg.ContinueOnNavigate && cmd != nil { + m = m.relinquishPlayer() } m.state = mostPlayedStateList - return m, func() tea.Msg { return navigateMsg{screen: screenMainMenu} } + m.selectedStation = nil + return m, cmd case "?": m.helpModel.SetSize(m.width, m.height) @@ -702,6 +797,8 @@ func (m MostPlayedModel) View() string { return m.viewSelectList() case mostPlayedStateTagInput: return m.viewTagInput() + case mostPlayedStateConfirmStop: + return m.viewConfirmStop() } return m.viewList() @@ -736,7 +833,7 @@ func (m MostPlayedModel) viewList() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ“Š Most Played Stations", Content: content.String(), Help: "↑↓/jk: Navigate β€’ g/G: Top/End β€’ Enter: Play β€’ s: Sort β€’ f: Fav β€’ ?: Help β€’ Esc: Back", @@ -763,8 +860,17 @@ func (m MostPlayedModel) viewPlaying() string { rating = r.Rating } } - content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, hasVoted, metadata, rating, m.starRenderer)) - + if m.playOptsCfg.ShowMetadata { + content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, hasVoted, metadata, rating, m.starRenderer)) + } else { + // Render only basic info (name, country, etc.) + content.WriteString(boldStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + content.WriteString(" ") + content.WriteString(dimStyle().Render(m.selectedStation.Country)) + } + content.WriteString("\n") + } // Playback status content.WriteString("\n") if m.player.IsPlaying() { @@ -810,7 +916,7 @@ func (m MostPlayedModel) viewPlaying() string { } helpText := "p: Pause β€’ s: Stop β€’ r: Rate β€’ t: Tag β€’ f: Fav β€’ 0: Main Menu β€’ ?: Help β€’ Esc: Back" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ“Š Most Played - Now Playing", Content: content.String(), Help: helpText, @@ -825,7 +931,7 @@ func (m MostPlayedModel) viewTagInput() string { content.WriteString("\n\n") } content.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: content.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -842,22 +948,72 @@ func (m MostPlayedModel) viewSavePrompt() string { content.WriteString("[L] Choose from list\n") content.WriteString("[N] Cancel") - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ’Ύ Save Station", Content: content.String(), Help: "Y: My-favorites β€’ L: Choose list β€’ N: Cancel", }, m.height) } +// handleConfirmStopInput handles key input during the confirm-stop prompt. +func (m MostPlayedModel) handleConfirmStopInput(msg tea.KeyMsg) (MostPlayedModel, tea.Cmd) { + switch msg.String() { + case "y", "1": + target := m.confirmStopTarget + m.confirmStopTarget = "" + m.state = mostPlayedStateList + var cmd tea.Cmd + if target == "main" { + cmd = m.navigateToMainCmd() + } else { + cmd = m.navigateBackCmd() + } + if m.playOptsCfg.ContinueOnNavigate && cmd != nil { + m = m.relinquishPlayer() + } + m.selectedStation = nil + if cmd != nil { + return m, cmd + } + return m, nil + case "n", "2", "esc": + m.state = mostPlayedStatePlaying + return m, nil + } + return m, nil +} + +// Phase 5: Confirm stop prompt view +func (m MostPlayedModel) viewConfirmStop() string { + var content strings.Builder + content.WriteString("Are you sure you want to stop playback?\n\n") + content.WriteString("y: Yes, stop\n") + content.WriteString("n/Esc: No, keep playing\n") + return m.renderPageWithBottomHelp(PageLayout{ + Title: "Confirm Stop", + Content: content.String(), + Help: "y: Yes β€’ n/Esc: No", + }, m.height) +} + func (m MostPlayedModel) viewSelectList() string { var content strings.Builder content.WriteString("Select a list:\n\n") content.WriteString(m.listModel.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ“ Select List", Content: content.String(), Help: "↑↓: Navigate β€’ Enter: Select β€’ Esc: Cancel", }, m.height) } + +// renderPageWithBottomHelp injects the now-playing bar when the model's own +// player is not actively playing (so viewPlaying is unaffected). +func (m MostPlayedModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/most_played_test.go b/internal/ui/most_played_test.go new file mode 100644 index 0000000..287a128 --- /dev/null +++ b/internal/ui/most_played_test.go @@ -0,0 +1,192 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" +) + +// TestMostPlayedConfirmStopRouted verifies that key presses while in the +// mostPlayedStateConfirmStop state are handled (not silently dropped). +// Before the fix, mostPlayedStateConfirmStop was missing from the Update +// switch, so 'y' would do nothing and 'n'/'esc' would do nothing. +func TestMostPlayedConfirmStopRouted(t *testing.T) { + tmpDir := t.TempDir() + m := NewMostPlayedModel(nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + + // 'n' / 'esc' should return to playing state + for _, key := range []string{"n", "esc"} { + m.state = mostPlayedStateConfirmStop + var msg tea.KeyMsg + if key == "esc" { + msg = tea.KeyMsg{Type: tea.KeyEsc} + } else { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + result, _ := m.Update(msg) + if result.state != mostPlayedStatePlaying { + t.Errorf("key %q: expected mostPlayedStatePlaying, got %v", key, result.state) + } + } + + // 'y' should return to list state and clear selected station + m.state = mostPlayedStateConfirmStop + yMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")} + result, _ := m.Update(yMsg) + if result.state != mostPlayedStateList { + t.Errorf("key 'y': expected mostPlayedStateList, got %v", result.state) + } + if result.selectedStation != nil { + t.Error("key 'y': expected selectedStation to be cleared") + } +} + +// TestMostPlayedConfirmStopTriggered verifies that when ConfirmStop is ON and +// the user presses Esc or '0' during playback, the model transitions to the +// confirm-stop state rather than stopping immediately. +func TestMostPlayedConfirmStopTriggered(t *testing.T) { + tmpDir := t.TempDir() + m := NewMostPlayedModel(nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + + for _, key := range []string{"esc", "0"} { + m.state = mostPlayedStatePlaying + var msg tea.KeyMsg + if key == "esc" { + msg = tea.KeyMsg{Type: tea.KeyEsc} + } else { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + result, _ := m.Update(msg) + if result.state != mostPlayedStateConfirmStop { + t.Errorf("key %q with ConfirmStop=true: expected mostPlayedStateConfirmStop, got %v", key, result.state) + } + } +} + +// TestMostPlayedConfirmStopResolved verifies that confirming the stop prompt +// fully resolves the model (state reset, confirmStopTarget cleared) for both +// the Esc ("back") and 0 ("main") paths. +func TestMostPlayedConfirmStopResolved(t *testing.T) { + tmpDir := t.TempDir() + + // Esc β†’ confirm state β†’ y: should land on mostPlayedStateList with clean target. + m := NewMostPlayedModel(nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + m.state = mostPlayedStatePlaying + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + // Enter confirm state manually to avoid needing a real player. + m.state = mostPlayedStateConfirmStop + m.confirmStopTarget = "back" + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if result.state != mostPlayedStateList { + t.Errorf("escβ†’y: expected mostPlayedStateList, got %v", result.state) + } + if result.confirmStopTarget != "" { + t.Errorf("escβ†’y: expected confirmStopTarget to be cleared, got %q", result.confirmStopTarget) + } + + // 0 β†’ confirm state β†’ y: should not be stuck in mostPlayedStateConfirmStop. + m2 := NewMostPlayedModel(nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m2.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + m2.state = mostPlayedStateConfirmStop + m2.confirmStopTarget = "main" + result2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if result2.state == mostPlayedStateConfirmStop { + t.Error("0β†’y: model stuck in mostPlayedStateConfirmStop after confirmation") + } + if result2.confirmStopTarget != "" { + t.Errorf("0β†’y: expected confirmStopTarget to be cleared, got %q", result2.confirmStopTarget) + } +} + +// ── Top Rated ──────────────────────────────────────────────────────────────── + +// TestTopRatedConfirmStopRouted verifies that key presses in topRatedStateConfirmStop +// are properly handled. +func TestTopRatedConfirmStopRouted(t *testing.T) { + tmpDir := t.TempDir() + m := NewTopRatedModel(nil, nil, nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + + // 'n' / 'esc' should return to playing state + for _, key := range []string{"n", "esc"} { + m.state = topRatedStateConfirmStop + var msg tea.KeyMsg + if key == "esc" { + msg = tea.KeyMsg{Type: tea.KeyEsc} + } else { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + result, _ := m.Update(msg) + if result.state != topRatedStatePlaying { + t.Errorf("key %q: expected topRatedStatePlaying, got %v", key, result.state) + } + } + + // 'y' should return to list state + m.state = topRatedStateConfirmStop + yMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")} + result, _ := m.Update(yMsg) + if result.state != topRatedStateList { + t.Errorf("key 'y': expected topRatedStateList, got %v", result.state) + } +} + +// TestTopRatedConfirmStopTriggered verifies that when ConfirmStop is ON, +// Esc or '0' during playback transitions to the confirm-stop state. +func TestTopRatedConfirmStopTriggered(t *testing.T) { + tmpDir := t.TempDir() + m := NewTopRatedModel(nil, nil, nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + + for _, key := range []string{"esc", "0"} { + m.state = topRatedStatePlaying + var msg tea.KeyMsg + if key == "esc" { + msg = tea.KeyMsg{Type: tea.KeyEsc} + } else { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + result, _ := m.Update(msg) + if result.state != topRatedStateConfirmStop { + t.Errorf("key %q with ConfirmStop=true: expected topRatedStateConfirmStop, got %v", key, result.state) + } + } +} + +// TestTopRatedConfirmStopResolved verifies that confirming the stop prompt +// fully resolves the model (state reset, confirmStopTarget cleared) for both +// the Esc ("back") and 0 ("main") paths. +func TestTopRatedConfirmStopResolved(t *testing.T) { + tmpDir := t.TempDir() + + // Esc path: confirm β†’ y should land on topRatedStateList with a clean target. + m := NewTopRatedModel(nil, nil, nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + m.state = topRatedStateConfirmStop + m.confirmStopTarget = "back" + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if result.state != topRatedStateList { + t.Errorf("escβ†’y: expected topRatedStateList, got %v", result.state) + } + if result.confirmStopTarget != "" { + t.Errorf("escβ†’y: expected confirmStopTarget to be cleared, got %q", result.confirmStopTarget) + } + + // 0 path: confirm β†’ y should not be stuck in topRatedStateConfirmStop. + m2 := NewTopRatedModel(nil, nil, nil, tmpDir+"/favorites", blocklist.NewManager(tmpDir+"/blocklist.json")) + m2.playOptsCfg = config.PlayOptionsConfig{ConfirmStop: true} + m2.state = topRatedStateConfirmStop + m2.confirmStopTarget = "main" + result2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if result2.state == topRatedStateConfirmStop { + t.Error("0β†’y: model stuck in topRatedStateConfirmStop after confirmation") + } + if result2.confirmStopTarget != "" { + t.Errorf("0β†’y: expected confirmStopTarget to be cleared, got %q", result2.confirmStopTarget) + } +} diff --git a/internal/ui/play.go b/internal/ui/play.go index 0c713de..96aba27 100644 --- a/internal/ui/play.go +++ b/internal/ui/play.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/ui/components" @@ -32,6 +33,7 @@ const ( playStateTagInput playStateManageTags playStateSleepTimer + playStateConfirmStop // Phase 5: confirm stop prompt ) // PlayModel represents the play screen @@ -76,6 +78,10 @@ type PlayModel struct { dataPath string // for loading last-used duration preference sleepCountdown string // e.g. "Stops in 12:34", refreshed by App on each tick sleepTimerActive bool // true once a timer is running; cleared on cancel/expiry + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state + nowPlayingBar string // set by App when ContinueOnNavigate is active } // playListItem wraps a list name for the bubbles list @@ -158,9 +164,20 @@ func (m PlayModel) Init() tea.Cmd { // playStation starts playing a station func (m PlayModel) playStation(station api.Station) tea.Cmd { + // Phase 5: Play Options wiring for volume + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + if station.Volume != nil { + startVol = *station.Volume + } return tea.Batch( + // Stop any app-level handed-off player (e.g. from Top Rated / Most + // Played with ContinueOnNavigate on) before starting the new stream. + func() tea.Msg { return stopActivePlaybackMsg{} }, func() tea.Msg { - err := m.player.Play(&station) + err := m.player.PlayWithVolume(&station, startVol) if err != nil { return playbackErrorMsg{err} } @@ -327,6 +344,8 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateManageTags(msg) case playStateSleepTimer: return m.updateSleepTimerDialog(msg) + case playStateConfirmStop: + return m.updateConfirmStop(msg) } case tea.WindowSizeMsg: @@ -355,16 +374,15 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case playbackStartedMsg: // Playback started successfully - trigger refresh to show voted status - // Only start tick if not already running (saveMessageTime == 0 means no tick; - // messageDisplayPersistent means persistent/rating prompt with no tick dispatched yet) - if m.saveMessageTime <= 0 { + // Only start tick if not already running + if m.saveMessageTime <= 0 && !m.sleepTimerActive { return m, tea.Batch(tickEverySecond(), m.pollTrackHistory()) } return m, m.pollTrackHistory() case playbackErrorMsg: m.err = msg.err - m.ratingMode = false // Clear rating mode on async state transition + m.ratingMode = false // Clear rating mode on async state transition m.state = playStateSavePrompt // Show prompt even on error return m, nil @@ -392,7 +410,11 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case saveSuccessMsg: m.saveMessage = fmt.Sprintf("βœ“ Saved '%s' to Quick Favorites", msg.station.TrimName()) + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil case saveFailedMsg: @@ -401,7 +423,11 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.saveMessage = fmt.Sprintf("βœ— Failed to save: %v", msg.err) } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil case deleteSuccessMsg: @@ -414,7 +440,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case components.VoteSuccessMsg: m.saveMessage = fmt.Sprintf("βœ“ %s", msg.Message) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort // Start tick to show the message and trigger UI refresh to show voted status if startTick { @@ -424,7 +450,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case components.VoteFailedMsg: m.saveMessage = fmt.Sprintf("βœ— Vote failed: %v", msg.Err) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort // Start tick to show the message and trigger UI refresh if startTick { @@ -461,16 +487,25 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.selectedStation = nil - return m, tickEverySecond() + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + if startTick { + return m, tickEverySecond() + } + return m, nil } else { // Already blocked m.saveMessage = msg.message + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } } return m, nil case undoBlockSuccessMsg: m.saveMessage = "βœ“ Block undone" + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort // Update blocked status in list if m.stationListModel.Items() != nil { @@ -487,11 +522,18 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.stationListModel.SetItems(items) } + if startTick { + return m, tickEverySecond() + } return m, nil case undoBlockFailedMsg: m.saveMessage = "No recent block to undo" + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil case components.TagSubmittedMsg: @@ -509,7 +551,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.saveMessage = fmt.Sprintf("βœ“ Added tag: %s", msg.Tag) m.refreshStationTagPills(m.selectedStation.StationUUID) } - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort m.state = playStatePlaying if startTick { @@ -538,7 +580,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.saveMessage = fmt.Sprintf("βœ“ Tags saved (%d)", len(msg.Tags)) m.refreshStationTagPills(m.selectedStation.StationUUID) } - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort m.state = playStatePlaying if startTick { @@ -556,7 +598,12 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // User confirmed a duration in the dialog m.state = playStatePlaying m.sleepTimerActive = true - return m, func() tea.Msg { return sleepTimerActivateMsg{Minutes: msg.Minutes} } + // Start the tick if the countdown wasn't already keeping it alive + activateCmd := func() tea.Msg { return sleepTimerActivateMsg{Minutes: msg.Minutes} } + if m.saveMessageTime <= 0 { + return m, tea.Batch(tickEverySecond(), activateCmd) + } + return m, activateCmd case components.SleepTimerCancelledMsg: m.state = playStatePlaying @@ -612,7 +659,11 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.saveMessage = "" } } - return m, tickEverySecond() + // Only re-schedule if there is still something to update + if m.saveMessageTime > 0 || m.sleepTimerActive { + return m, tickEverySecond() + } + return m, nil case trackHistoryMsg: m.trackHistory = msg.tracks @@ -676,10 +727,16 @@ func (m *PlayModel) initializeStationListModel() { func (m PlayModel) updateListSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - // Return to main menu - return m, func() tea.Msg { - return navigateMsg{screen: screenMainMenu} + // Leaving the Play screen entirely β€” hand off (with fresh player) or stop. + navCmd := func() tea.Msg { return navigateMsg{screen: screenMainMenu} } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + return m, tea.Batch(handoffCmd, navCmd) } + if m.player != nil { + _ = m.player.Stop() + } + return m, navCmd case "ctrl+c": // Quit application return m, tea.Quit @@ -704,17 +761,29 @@ func (m PlayModel) updateListSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m PlayModel) updateStationSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - // Go back to list selection + // Going back one level within the Play screen (staying here, not leaving). + // Stop the player β€” if the user picked a new station from a different + // list they'll start fresh; ContinueOnNavigate handoff only applies when + // leaving the screen entirely. + if m.player != nil && m.player.IsPlaying() { + _ = m.player.Stop() + } m.state = playStateListSelection m.stations = nil m.stationItems = nil m.stationListModel = list.Model{} return m, nil case "0": - // Return to main menu (Level 2+ shortcut) - return m, func() tea.Msg { - return navigateMsg{screen: screenMainMenu} + // Leave the Play screen entirely β€” hand off (with fresh player) or stop. + navCmd := func() tea.Msg { return navigateMsg{screen: screenMainMenu} } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + return m, tea.Batch(handoffCmd, navCmd) } + if m.player != nil { + _ = m.player.Stop() + } + return m, navCmd case "ctrl+c": // Quit application return m, tea.Quit @@ -743,6 +812,31 @@ func (m PlayModel) updateStationSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +// handOffPlayer hands m.player to App and installs a fresh player on the +// model, so App owns the running stream while PlayModel has a clean player +// ready for the next selection. Returns the handoff command and the updated +// model (the caller must use the returned model, not the original). +func (m PlayModel) handOffPlayer() (PlayModel, tea.Cmd) { + station := m.selectedStation + oldPlayer := m.player + // Give the model a brand-new player so the old one is exclusively owned + // by App. Any subsequent Stop() via stopActivePlaybackMsg won't touch the + // player that PlayModel will use for the next station. + newP := player.NewMPVPlayer() + if m.metadataManager != nil { + newP.SetMetadataManager(m.metadataManager) + } + m.player = newP + cmd := func() tea.Msg { + return handoffPlaybackMsg{ + player: oldPlayer, + station: station, + contextLabel: "Favorites", + } + } + return m, cmd +} + // updatePlaying handles input during playback func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle rating mode input first @@ -752,19 +846,15 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "?": - // Toggle help overlay m.helpModel.SetSize(m.width, m.height) m.helpModel.Toggle() return m, nil case " ": - // Toggle pause/resume if err := m.player.TogglePause(); err == nil { if m.player.IsPaused() { - // Paused - show persistent message m.saveMessage = "⏸ Paused - Press Space to resume" m.saveMessageTime = messageDisplayPersistent } else { - // Resumed - show temporary message m.saveMessage = "β–Ά Resumed" startTick := m.saveMessageTime <= 0 m.saveMessageTime = messageDisplayShort @@ -774,69 +864,94 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil - case "esc": - // Stop playback and go back - if err := m.player.Stop(); err != nil { - m.err = fmt.Errorf("failed to stop playback: %w", err) + case "esc", "0": + // Phase 5: ConfirmStop prompt + if m.playOptsCfg.ConfirmStop { + if msg.String() == "0" { + m.confirmStopTarget = "main" + } else { + m.confirmStopTarget = "back" + } + m.state = playStateConfirmStop return m, nil } - m.state = playStateStationSelection - m.selectedStation = nil - m.trackHistory = []string{} - return m, nil - case "0": - // Return to main menu (Level 3 shortcut) - if err := m.player.Stop(); err != nil { - m.err = fmt.Errorf("failed to stop playback: %w", err) + if msg.String() == "esc" { + // Return to station list. With ContinueOnNavigate ON, hand the + // player off to App and install a fresh player on the model so + // the next station selection doesn't share the same MPVPlayer + // pointer (stopActivePlaybackMsg would kill it prematurely). + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.state = playStateStationSelection + m.selectedStation = nil + m.trackHistory = []string{} + return m, handoffCmd + } + // ContinueOnNavigate off β€” stop the player. + if m.player != nil { + _ = m.player.Stop() + } + m.state = playStateStationSelection + m.selectedStation = nil + m.trackHistory = []string{} return m, nil - } - m.selectedStation = nil - m.trackHistory = []string{} - return m, func() tea.Msg { - return navigateMsg{screen: screenMainMenu} + } else { + // Press 0: leave the screen entirely. Hand off (with fresh player) + // or stop, then navigate to main menu. + navCmd := func() tea.Msg { return navigateMsg{screen: screenMainMenu} } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.selectedStation = nil + m.trackHistory = []string{} + return m, tea.Batch(handoffCmd, navCmd) + } + // ContinueOnNavigate off β€” stop and navigate. + if m.player != nil { + _ = m.player.Stop() + } + m.selectedStation = nil + m.trackHistory = []string{} + return m, navCmd } case "f": - // Save to Quick Favorites return m, m.saveToQuickFavorites() case "s": - // Save to a list (not implemented yet) - // TODO: Implement save to custom list m.saveMessage = "Save to list feature coming soon" + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil case "v": - // Vote for this station return m, m.voteForStation() case "/": - // Decrease volume newVol := m.player.DecreaseVolume(5) if m.selectedStation != nil && newVol >= 0 { m.selectedStation.SetVolume(newVol) m.saveStationVolume(m.selectedStation) } m.saveMessage = fmt.Sprintf("Volume: %d%%", newVol) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() } return m, nil case "*": - // Increase volume newVol := m.player.IncreaseVolume(5) if m.selectedStation != nil { m.selectedStation.SetVolume(newVol) m.saveStationVolume(m.selectedStation) } m.saveMessage = fmt.Sprintf("Volume: %d%%", newVol) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() } return m, nil case "m": - // Toggle mute muted, vol := m.player.ToggleMute() if muted { m.saveMessage = "Volume: Muted" @@ -847,26 +962,23 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedStation.SetVolume(vol) m.saveStationVolume(m.selectedStation) } - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() } return m, nil case "b": - // Block current station if m.selectedStation != nil { return m, m.blockStation() } return m, nil case "u": - // Undo last block (within 5 seconds) if time.Since(m.lastBlockTime) < 5*time.Second { return m, m.undoLastBlock() } return m, nil case "r": - // Enter rating mode if m.selectedStation != nil && m.ratingsManager != nil { m.ratingMode = true m.saveMessage = "Press 1-5 to rate, 0 to remove rating, Esc to cancel" @@ -875,7 +987,6 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "t": - // Enter tag input mode if m.selectedStation != nil && m.tagsManager != nil { allTags := m.tagsManager.GetAllTags() w := m.width @@ -888,7 +999,6 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "T": - // Enter manage tags dialog if m.selectedStation != nil && m.tagsManager != nil { currentTags := m.tagsManager.GetTags(m.selectedStation.StationUUID) allTags := m.tagsManager.GetAllTags() @@ -902,8 +1012,6 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "Z": - // If a sleep timer is already running, Z cancels it immediately. - // Otherwise, open the dialog to set a new duration. if m.sleepTimerActive { return m, func() tea.Msg { return sleepTimerCancelMsg{} } } @@ -921,7 +1029,6 @@ func (m PlayModel) updatePlaying(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = playStateSleepTimer return m, nil case "+": - // Extend active sleep timer by 15 minutes (no-op when timer is not running) if m.sleepTimerActive { return m, func() tea.Msg { return sleepTimerExtendMsg{Minutes: 15} } } @@ -1189,7 +1296,7 @@ func (m PlayModel) View() string { } if m.err != nil { - return errorView(m.err) + return m.errorView(m.err) } switch m.state { @@ -1208,16 +1315,37 @@ func (m PlayModel) View() string { case playStateManageTags: return m.viewManageTags() case playStateSleepTimer: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ’€ Sleep Timer", Content: m.sleepTimerDialog.View(), Help: "Enter: Set β€’ ↑↓/jk: Navigate β€’ Esc: Cancel", }, m.height) + case playStateConfirmStop: + return m.viewConfirmStop() } return "Unknown state" } +// Phase 5: ConfirmStop prompt view +func (m PlayModel) viewConfirmStop() string { + var content strings.Builder + content.WriteString(errorStyle().Render("⚠ Stop Playback?")) + content.WriteString("\n\n") + if m.selectedStation != nil { + content.WriteString("Station: ") + content.WriteString(stationNameStyle().Render(m.selectedStation.TrimName())) + content.WriteString("\n\n") + } + content.WriteString("Are you sure you want to stop playback?\n") + content.WriteString(infoStyle().Render("Press y/1 to stop, n/2/Esc to cancel.")) + return m.renderPage(PageLayout{ + Title: "Confirm Stop", + Content: content.String(), + Help: "y/1: Stop β€’ n/2/Esc: Cancel", + }) +} + // viewListSelection renders the list selection view func (m PlayModel) viewListSelection() string { // Check if we have lists but no model yet (waiting for dimensions) @@ -1226,7 +1354,7 @@ func (m PlayModel) viewListSelection() string { } if len(m.lists) == 0 { - return noListsView() + return m.noListsView() } var content strings.Builder @@ -1246,7 +1374,7 @@ func (m PlayModel) viewListSelection() string { } // Use the consistent page template with bottom-aligned help - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Play from Favorites", Subtitle: "Select a Favorite List", Content: content.String(), @@ -1276,22 +1404,30 @@ func (m PlayModel) viewPlaying() string { rating = r.Rating } } - content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, hasVoted, metadata, rating, m.starRenderer)) - - // Playback status with proper spacing - content.WriteString("\n") - if m.player.IsPlaying() { - // Use the cached track (kept fresh by monitorMetadata every 5 s) to - // avoid a blocking IPC socket call inside the render path. - if track := m.player.GetCachedTrack(); IsValidTrackMetadata(track, m.selectedStation.TrimName()) { - content.WriteString(successStyle().Render("β–Ά Now Playing:")) - content.WriteString(" ") - content.WriteString(infoStyle().Render(track)) + // Gate full station details and playback status on ShowMetadata + if m.playOptsCfg.ShowMetadata { + content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, hasVoted, metadata, rating, m.starRenderer)) + // Playback status with proper spacing + content.WriteString("\n") + if m.player.IsPlaying() { + if track := m.player.GetCachedTrack(); IsValidTrackMetadata(track, m.selectedStation.TrimName()) { + content.WriteString(successStyle().Render("β–Ά Now Playing:")) + content.WriteString(" ") + content.WriteString(infoStyle().Render(track)) + } else { + content.WriteString(successStyle().Render("β–Ά Playing...")) + } } else { - content.WriteString(successStyle().Render("β–Ά Playing...")) + content.WriteString(infoStyle().Render("⏸ Stopped")) } } else { - content.WriteString(infoStyle().Render("⏸ Stopped")) + // Render only basic info when metadata display is disabled + content.WriteString(boldStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + content.WriteString(" ") + content.WriteString(dimStyle().Render(m.selectedStation.Country)) + } + content.WriteString("\n") } // Tag display @@ -1316,7 +1452,6 @@ func (m PlayModel) viewPlaying() string { break } - // Show newest first with indicators indicator := " " if i == 0 { indicator = "🎡" @@ -1331,10 +1466,8 @@ func (m PlayModel) viewPlaying() string { } } - // Save message (if any) if m.saveMessage != "" { content.WriteString("\n\n") - // Determine style based on message content var style lipgloss.Style if strings.Contains(m.saveMessage, "βœ“") || strings.HasPrefix(m.saveMessage, "Volume:") { if strings.Contains(m.saveMessage, "Muted") { @@ -1350,15 +1483,13 @@ func (m PlayModel) viewPlaying() string { content.WriteString(style.Render(m.saveMessage)) } - // Sleep timer countdown if timerInfo := m.sleepTimerCountdown(); timerInfo != "" { content.WriteString("\n") content.WriteString(highlightStyle().Render(timerInfo)) } - // Use the consistent page template with bottom-aligned help helpText := "Space: Pause β€’ f: Fav β€’ v: Vote β€’ b: Block β€’ Z: Sleep β€’ +: Extend β€’ 0: Main Menu β€’ ?: Help" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Now Playing", Content: content.String(), Help: helpText, @@ -1375,7 +1506,7 @@ func (m PlayModel) sleepTimerCountdown() string { // The ManageTags component renders the station name in its own header, // so no prepend is needed here. func (m PlayModel) viewManageTags() string { - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Manage Tags", Content: m.manageTags.View(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ d: Done β€’ Esc: Cancel", @@ -1390,7 +1521,7 @@ func (m PlayModel) viewTagInput() string { content.WriteString("\n\n") } content.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: content.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -1405,7 +1536,7 @@ func (m PlayModel) viewStationSelection() string { } if len(m.stations) == 0 { - return noStationsView(m.selectedList) + return m.noStationsView(m.selectedList) } var content strings.Builder @@ -1425,7 +1556,7 @@ func (m PlayModel) viewStationSelection() string { } // Use the consistent page template with bottom-aligned help - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "Play from Favorites", Content: content.String(), Help: "↑↓/jk: Navigate β€’ g/G: Top/End β€’ /: Filter β€’ Enter: Play β€’ d: Delete β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", @@ -1433,14 +1564,14 @@ func (m PlayModel) viewStationSelection() string { } // noStationsView renders the view when a list is empty -func noStationsView(listName string) string { +func (m PlayModel) noStationsView(listName string) string { var content strings.Builder content.WriteString(infoStyle().Render("This list is empty!")) content.WriteString("\n\n") content.WriteString("Add stations to this list using Search or List Management.") - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Play from Favorites", Subtitle: fmt.Sprintf("List: %s", listName), Content: content.String(), @@ -1449,14 +1580,14 @@ func noStationsView(listName string) string { } // noListsView renders the view when no lists are available -func noListsView() string { +func (m PlayModel) noListsView() string { var content strings.Builder content.WriteString(errorStyle().Render("No favorite lists found!")) content.WriteString("\n\n") content.WriteString("Create your first list using the List Management menu.") - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "Play from Favorites", Content: content.String(), Help: "Esc: Back to main menu β€’ Ctrl+C: Quit", @@ -1464,8 +1595,8 @@ func noListsView() string { } // errorView renders an error message -func errorView(err error) string { - return RenderPage(PageLayout{ +func (m PlayModel) errorView(err error) string { + return m.renderPage(PageLayout{ Title: "Error", Content: errorStyle().Render(err.Error()), Help: "Esc: Back to main menu β€’ Ctrl+C: Quit", @@ -1492,7 +1623,7 @@ func (m PlayModel) viewSavePrompt() string { content.WriteString("2) Return to station list") // Use the consistent page template - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Save Station?", Content: content.String(), Help: "y/1: Yes β€’ n/2/Esc: No", @@ -1524,7 +1655,7 @@ func (m PlayModel) viewDeleteConfirm() string { content.WriteString(infoStyle().Render("This action cannot be undone.")) // Use the consistent page template - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "⚠️ Confirm Delete", Content: content.String(), Help: "y: Yes, delete β€’ n/Esc: No, cancel", @@ -1582,3 +1713,62 @@ type errMsg struct { type trackHistoryMsg struct { tracks []string } + +// updateConfirmStop handles input during the ConfirmStop prompt (Phase 5) +func (m PlayModel) updateConfirmStop(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "1": + // Always reset state so the model is clean when reused. + m.state = playStateStationSelection + // User confirmed stop: navigate based on what triggered the prompt. + if m.confirmStopTarget == "main" { + navCmd := func() tea.Msg { return backToMainMsg{} } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.selectedStation = nil + m.trackHistory = []string{} + return m, tea.Batch(handoffCmd, navCmd) + } + if m.player != nil { + _ = m.player.Stop() + } + m.selectedStation = nil + m.trackHistory = []string{} + return m, navCmd + } + // Default: return to station selection (hand off or stop). + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.selectedStation = nil + m.trackHistory = []string{} + return m, handoffCmd + } + if m.player != nil { + _ = m.player.Stop() + } + m.selectedStation = nil + m.trackHistory = []string{} + return m, nil + case "n", "2", "esc": + // Cancel: return to playing state + m.state = playStatePlaying + return m, nil + } + return m, nil +} + +// renderPage injects the now-playing bar when the model's own player is not +// actively playing (so viewPlaying is unaffected). +func (m PlayModel) renderPage(layout PageLayout) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPage(layout) +} + +func (m PlayModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/play_station_test.go b/internal/ui/play_station_test.go index 0359c1a..9580a7e 100644 --- a/internal/ui/play_station_test.go +++ b/internal/ui/play_station_test.go @@ -283,7 +283,8 @@ func TestPlayModel_View_NoStations(t *testing.T) { } func TestNoStationsView(t *testing.T) { - view := noStationsView("Test List") + m := PlayModel{} + view := m.noStationsView("Test List") if view == "" { t.Error("Expected non-empty view") diff --git a/internal/ui/play_test.go b/internal/ui/play_test.go index af4dd91..f69e343 100644 --- a/internal/ui/play_test.go +++ b/internal/ui/play_test.go @@ -270,7 +270,8 @@ func TestPlayListItem(t *testing.T) { func TestErrorView(t *testing.T) { err := os.ErrNotExist - view := errorView(err) + m := PlayModel{} + view := m.errorView(err) if view == "" { t.Error("Expected non-empty error view") diff --git a/internal/ui/search.go b/internal/ui/search.go index b0c97d3..703c83a 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" @@ -16,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/theme" @@ -24,6 +24,96 @@ import ( "golang.org/x/text/language" ) +// Message types for search screen (patterned after lucky.go and play.go) +type playerErrorMsg struct { + err error +} +type playbackStalledMsg struct { + station api.Station +} +type checkSignalMsg struct { + station api.Station + attempt int +} + +// Message types for search results and errors +type searchErrorMsg struct { + err error +} +type searchResultsMsg struct { + results []api.Station +} + +// performSearch executes the search based on type +func (m SearchModel) performSearch(query string) tea.Cmd { + // Save search to history in background + go func() { + store := storage.NewStorage(m.favoritePath) + var searchTypeStr string + switch m.searchType { + case api.SearchByTag: + searchTypeStr = "tag" + case api.SearchByName: + searchTypeStr = "name" + case api.SearchByLanguage: + searchTypeStr = "language" + case api.SearchByCountry: + searchTypeStr = "country" + case api.SearchByState: + searchTypeStr = "state" + case api.SearchAdvanced: + searchTypeStr = "advanced" + } + _ = store.AddSearchItem(context.Background(), searchTypeStr, query) + }() + return func() tea.Msg { + var results []api.Station + var err error + ctx := context.Background() + switch m.searchType { + case api.SearchByTag: + results, err = m.apiClient.SearchByTag(ctx, query) + case api.SearchByName: + results, err = m.apiClient.SearchByName(ctx, query) + case api.SearchByLanguage: + results, err = m.apiClient.SearchByLanguage(ctx, query) + case api.SearchByCountry: + results, err = m.apiClient.SearchByCountry(ctx, query) + case api.SearchByState: + results, err = m.apiClient.SearchByState(ctx, query) + case api.SearchAdvanced: + params := api.SearchParams{ + Name: query, + Tag: query, + Order: "votes", + Reverse: true, + Limit: 100, + HideBroken: true, + } + results, err = m.apiClient.SearchAdvanced(ctx, params) + } + if err != nil { + return searchErrorMsg{err: err} + } + sort.Slice(results, func(i, j int) bool { + return results[i].Votes > results[j].Votes + }) + return searchResultsMsg{results: results} + } +} + +// loadAvailableLists loads all available favorite list names from storage. +func (m SearchModel) loadAvailableLists() tea.Cmd { + return func() tea.Msg { + store := storage.NewStorage(m.favoritePath) + lists, err := store.GetAllLists(context.Background()) + if err != nil { + return saveToListFailedMsg{err: fmt.Errorf("failed to load lists: %w", err)} + } + return listsLoadedMsg{lists: lists} + } +} + // searchState represents the current state in the search screen type searchState int @@ -34,6 +124,7 @@ const ( searchStateResults searchStateStationInfo searchStatePlaying + searchStateConfirmStop // new state for confirm stop prompt searchStateSavePrompt searchStateSelectList searchStateNewListInput @@ -43,626 +134,132 @@ const ( searchStateSleepTimer ) -// SearchModel represents the search screen +// SearchModel represents the state and data for the search screen +// (fields are modeled after other play screens and previous usage) type SearchModel struct { - state searchState - searchType api.SearchType - menuList list.Model // List-based menu navigation - stationInfoMenu list.Model // Station info submenu navigation - apiClient *api.Client - textInput textinput.Model - newListInput textinput.Model - spinner spinner.Model - results []api.Station - resultsItems []list.Item - resultsList list.Model - selectedStation *api.Station + searchHistory *storage.SearchHistoryStore + quickFavorites []api.Station + availableLists []string + listModel list.Model player *player.MPVPlayer + playOptsCfg config.PlayOptionsConfig + votedStations *storage.VotedStations + ratingsManager *storage.RatingsManager + starRenderer *components.StarRenderer + tagsManager *storage.TagsManager + tagRenderer *components.TagRenderer + tagInput components.TagInput + manageTags components.ManageTags + metadataManager *storage.MetadataManager + blocklistManager *blocklist.Manager + dataPath string favoritePath string - quickFavorites []api.Station // My-favorites.json for duplicate checking - searchHistory *storage.SearchHistoryStore // Search history - numberBuffer string // Buffer for number input display saveMessage string saveMessageTime int - width int - height int + numberBuffer string err error - availableLists []string - listItems []list.Item - listModel list.Model - helpModel components.HelpModel - votedStations *storage.VotedStations // Track voted stations - blocklistManager *blocklist.Manager - metadataManager *storage.MetadataManager // Track play statistics lastBlockTime time.Time - // Star rating fields - ratingsManager *storage.RatingsManager - starRenderer *components.StarRenderer - ratingMode bool // true when waiting for 1-5 input after pressing R - // Advanced search form fields - advancedInputs [5]textinput.Model // tag, language, country, state, name - advancedFocusIdx int // 0-4: text fields, 5: sort, 6: bitrate - advancedBitrate string // "1", "2", "3", or "" - advancedSortByVotes bool // true = votes, false = relevance - // Tag fields - tagsManager *storage.TagsManager - tagRenderer *components.TagRenderer - tagInput components.TagInput - manageTags components.ManageTags - // Sleep timer fields + ratingMode bool + // ...existing code... + sleepTimerActive bool sleepTimerDialog components.SleepTimerDialog - dataPath string // for loading last-used duration preference - sleepCountdown string // refreshed by App on each tick - sleepTimerActive bool // true once a timer is running; cleared on cancel/expiry - showBlockedInSearch bool // when false, blocked stations are filtered out of results -} - -// Messages for search screen -type searchResultsMsg struct { - results []api.Station -} - -type searchErrorMsg struct { - err error -} - -type quickFavoritesLoadedMsg struct { - stations []api.Station -} - -type playerErrorMsg struct { - err error -} - -type playbackStalledMsg struct { - station api.Station -} - -type checkSignalMsg struct { - station api.Station - attempt int -} - -// NewSearchModel creates a new search screen model -func NewSearchModel(apiClient *api.Client, favoritePath string, dataPath string, blocklistManager *blocklist.Manager) SearchModel { - ti := textinput.New() - ti.Placeholder = "Enter search query..." - ti.CharLimit = 100 - ti.Width = 50 - - // New list input - nli := textinput.New() - nli.Placeholder = "Enter new list name..." - nli.CharLimit = 50 - nli.Width = 50 - - // Advanced search inputs - var advInputs [5]textinput.Model - placeholders := []string{ - "classical", - "italian", - "US", - "California", - "BBC Radio", - } - for i := 0; i < 5; i++ { - advInputs[i] = textinput.New() - advInputs[i].Placeholder = placeholders[i] - advInputs[i].CharLimit = 100 - advInputs[i].Width = 40 - } - - sp := spinner.New() - sp.Spinner = spinner.Dot - - // Create search menu items - menuItems := []components.MenuItem{ - components.NewMenuItem("Search by Tag", "(genre, style, etc.)", "1"), - components.NewMenuItem("Search by Name", "", "2"), - components.NewMenuItem("Search by Language", "", "3"), - components.NewMenuItem("Search by Country Code", "", "4"), - components.NewMenuItem("Search by State", "", "5"), - components.NewMenuItem("Advanced Search", "(multiple criteria)", "6"), - } - - // Set enough height for all 6 menu items + title - menuList := components.CreateMenu(menuItems, "πŸ” Search Radio Stations", 50, 15) - - // Create station info submenu items - infoMenuItems := []components.MenuItem{ - components.NewMenuItem("Play this station", "", "1"), - components.NewMenuItem("Save to Quick Favorites", "", "2"), - components.NewMenuItem("Back to search results", "", "3"), - } - - // Initial height will be updated on first WindowSizeMsg - stationInfoMenu := components.CreateMenu(infoMenuItems, "What would you like to do?", 50, 10) - - // Load search history (if it fails, just use empty history) - store := storage.NewStorage(favoritePath) - history, err := store.LoadSearchHistory(context.Background()) - if err != nil || history == nil { - history = storage.NewSearchHistoryStore() - } - - // Load voted stations - votedStations, err := storage.LoadVotedStations() - if err != nil { - // If we can't load, just create empty list - votedStations = &storage.VotedStations{Stations: []storage.VotedStation{}} - } - - model := SearchModel{ - state: searchStateMenu, - apiClient: apiClient, - menuList: menuList, - stationInfoMenu: stationInfoMenu, - textInput: ti, - newListInput: nli, - spinner: sp, - favoritePath: favoritePath, - dataPath: dataPath, - player: player.NewMPVPlayer(), - quickFavorites: []api.Station{}, - searchHistory: history, - width: 80, // Default width - height: 24, // Default height - helpModel: components.NewHelpModel(components.CreatePlayingHelp()), - votedStations: votedStations, - advancedInputs: advInputs, - advancedFocusIdx: 0, - advancedBitrate: "", - advancedSortByVotes: true, // Default to sorting by votes - blocklistManager: blocklistManager, - } - - // Build menu with history items included - model.rebuildMenuWithHistory() - - return model + sleepCountdown string + advancedInputs []textinput.Model + advancedFocusIdx int + advancedBitrate string + advancedSortByVotes bool + // struct fields continue here + apiClient *api.Client + state searchState + width int + height int + menuList list.Model + resultsList list.Model + stationInfoMenu list.Model + helpModel components.HelpModel + textInput textinput.Model + newListInput textinput.Model + selectedStation *api.Station + results []api.Station + searchType api.SearchType + // ...existing code... + // Add missing fields for search screen + resultsItems []list.Item + showBlockedInSearch bool + spinner spinner.Model + // ...existing code... + nowPlayingBar string // set by App when ContinueOnNavigate is active + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state } -// Init initializes the search screen -func (m SearchModel) Init() tea.Cmd { - return tea.Batch( - m.loadQuickFavorites(), - m.spinner.Tick, - tickEverySecond(), // For save message countdown - ) -} - -// Note: tickEverySecond() is defined in messages.go and ticks once per second - -// loadQuickFavorites loads My-favorites.json for duplicate checking -func (m SearchModel) loadQuickFavorites() tea.Cmd { - return func() tea.Msg { - store := storage.NewStorage(m.favoritePath) - list, err := store.LoadList(context.Background(), "My-favorites") - if err != nil { - // It's OK if My-favorites doesn't exist yet - return quickFavoritesLoadedMsg{stations: []api.Station{}} - } - return quickFavoritesLoadedMsg{stations: list.Stations} - } -} - -// Update handles messages for the search screen -func (m SearchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - // Calculate usable height based on actual header size - listHeight := availableListHeight(msg.Height) - - // Update list sizes based on current state - switch m.state { - case searchStateMenu: - m.menuList.SetSize(msg.Width-4, listHeight) - case searchStateResults: - if m.resultsList.Items() != nil && len(m.resultsList.Items()) > 0 { - m.resultsList.SetSize(msg.Width-4, listHeight) - } - case searchStateStationInfo: - // Station info menu is smaller - infoHeight := 10 - if infoHeight > listHeight { - infoHeight = listHeight - } - m.stationInfoMenu.SetSize(msg.Width-4, infoHeight) - } - - m.helpModel.SetSize(msg.Width, msg.Height) - - case tea.KeyMsg: - if m.helpModel.IsVisible() { - var cmd tea.Cmd - m.helpModel, cmd = m.helpModel.Update(msg) - return m, cmd - } - - switch m.state { - case searchStateMenu: - return m.handleMenuInput(msg) - case searchStateInput: - return m.handleTextInput(msg) - case searchStateResults: - return m.handleResultsInput(msg) - case searchStateStationInfo: - return m.handleStationInfoInput(msg) - case searchStatePlaying: - return m.handlePlayerUpdate(msg) - case searchStateTagInput: - return m.handleTagInputKey(msg) - case searchStateManageTags: - return m.handleManageTagsKey(msg) - case searchStateSleepTimer: - return m.handleSleepTimerDialogKey(msg) - case searchStateSavePrompt: - return m.handleSavePrompt(msg) - case searchStateSelectList: - return m.handleSelectList(msg) - case searchStateNewListInput: - return m.handleNewListInput(msg) - case searchStateAdvancedForm: - return m.handleAdvancedForm(msg) - } - - case quickFavoritesLoadedMsg: - m.quickFavorites = msg.stations - - case searchResultsMsg: - m.results = msg.results - m.state = searchStateResults - m.resultsItems = make([]list.Item, 0, len(m.results)) - tr := components.NewTagRenderer() - for _, station := range m.results { - isBlocked := false - if m.blocklistManager != nil { - isBlocked = m.blocklistManager.IsBlockedByAny(&station) - } - // Skip blocked stations unless the user has opted to show them - if isBlocked && !m.showBlockedInSearch { - continue - } - tagPills := "" - if m.tagsManager != nil { - if tags := m.tagsManager.GetTags(station.StationUUID); len(tags) > 0 { - tagPills = tr.RenderPills(tags) - } - } - m.resultsItems = append(m.resultsItems, stationListItem{station: station, isBlocked: isBlocked, tagPills: tagPills}) - } - - // Create results list - delegate := createStyledDelegate() - - m.resultsList = list.New(m.resultsItems, delegate, m.width, availableListHeight(m.height)) - m.resultsList.Title = fmt.Sprintf("Search Results (%d stations)", len(m.resultsItems)) - m.resultsList.SetShowHelp(false) // We use custom footer instead - m.resultsList.SetShowStatusBar(true) // Show status bar for filter count - m.resultsList.SetFilteringEnabled(true) - // Disable 'q' quit keybinding in the list - m.resultsList.KeyMap.Quit = key.NewBinding(key.WithDisabled()) - return m, nil - - case searchErrorMsg: - m.err = msg.err - m.state = searchStateMenu - return m, nil - - case spinner.TickMsg: - if m.state == searchStateLoading { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - case playbackStartedMsg: - // Playback started successfully, stay in playing state - return m, nil - - case playerErrorMsg: - m.err = msg.err - m.ratingMode = false // Clear rating mode on async state transition - m.state = searchStateResults - return m, nil - - case playbackStalledMsg: - // Stop player if it's still "playing" (but silent) - if m.player != nil { - _ = m.player.Stop() - } - m.ratingMode = false // Clear rating mode on async state transition - m.saveMessage = "βœ— No signal detected" - m.saveMessageTime = messageDisplayShort - m.state = searchStateResults - m.selectedStation = nil - return m, nil - - case checkSignalMsg: - if m.state == searchStatePlaying && m.selectedStation != nil && m.selectedStation.StationUUID == msg.station.StationUUID { - return m, m.checkPlaybackSignal(msg.station, msg.attempt) - } - return m, nil - - case playbackStoppedMsg: - // Handle save prompt after playback - return m.handlePlaybackStopped() - - case saveSuccessMsg: - // Update local cache - m.quickFavorites = append(m.quickFavorites, *msg.station) - m.saveMessage = fmt.Sprintf("βœ“ Saved '%s' to Quick Favorites", msg.station.TrimName()) - m.saveMessageTime = messageDisplayShort - return m, nil - - case saveFailedMsg: - if msg.isDuplicate { - m.saveMessage = "Already in Quick Favorites" - } else { - m.saveMessage = fmt.Sprintf("βœ— Failed to save: %v", msg.err) - } - m.saveMessageTime = messageDisplayShort - return m, nil - - case components.VoteSuccessMsg: - m.saveMessage = fmt.Sprintf("βœ“ %s", msg.Message) - m.saveMessageTime = messageDisplayShort - return m, nil - - case components.VoteFailedMsg: - m.saveMessage = fmt.Sprintf("βœ— Vote failed: %v", msg.Err) - m.saveMessageTime = messageDisplayShort - return m, nil - - case tickMsg: - // Handle save message countdown - if m.saveMessageTime > 0 { - m.saveMessageTime-- - if m.saveMessageTime <= 0 { - m.saveMessage = "" - } - } - // Continue ticking - return m, tickEverySecond() - - case listsLoadedMsg: - m.availableLists = msg.lists - m.listItems = make([]list.Item, len(msg.lists)) - for i, name := range msg.lists { - m.listItems[i] = playListItem{name: name} - } - if m.width > 0 && m.height > 0 { - m.initializeListModel() - } - return m, nil - - case saveToListSuccessMsg: - m.saveMessage = fmt.Sprintf("βœ“ Saved '%s' to '%s'", msg.stationName, msg.listName) - m.saveMessageTime = messageDisplayShort - m.state = searchStatePlaying - return m, nil - - case saveToListFailedMsg: - if msg.isDuplicate { - m.saveMessage = "Already in this list" - } else { - m.saveMessage = fmt.Sprintf("βœ— Failed to save: %v", msg.err) - } - m.saveMessageTime = messageDisplayShort - m.state = searchStatePlaying - return m, nil - - case stationBlockedMsg: - m.lastBlockTime = time.Now() - - if msg.success { - // Stop playback - if m.player != nil { - _ = m.player.Stop() - } - - // Show message - m.saveMessage = msg.message + " (press 'u' within 5s to undo)" - m.saveMessageTime = messageDisplayMedium - - // Return to results - m.state = searchStateResults - - // Update blocked status in the list items - if m.resultsList.Items() != nil { - items := m.resultsList.Items() - for i, item := range items { - if si, ok := item.(stationListItem); ok && si.station.StationUUID == msg.stationUUID { - si.isBlocked = true - items[i] = si - break - } - } - m.resultsList.SetItems(items) - } - m.selectedStation = nil - } else { - // Already blocked - m.saveMessage = msg.message - m.saveMessageTime = messageDisplayShort - } - return m, nil - - case undoBlockSuccessMsg: - m.saveMessage = "βœ“ Block undone" - m.saveMessageTime = messageDisplayShort - // Update blocked status in the list items - if m.resultsList.Items() != nil { - items := m.resultsList.Items() - for i, item := range items { - if si, ok := item.(stationListItem); ok { - if m.blocklistManager != nil && !m.blocklistManager.IsBlockedByAny(&si.station) { - if si.isBlocked { - si.isBlocked = false - items[i] = si - } - } - } - } - m.resultsList.SetItems(items) - } - return m, nil - - case undoBlockFailedMsg: - m.saveMessage = "No recent block to undo" - m.saveMessageTime = messageDisplayShort - return m, nil - - case components.TagSubmittedMsg: - if m.state == searchStateManageTags { - var cmd tea.Cmd - m.manageTags, cmd = m.manageTags.HandleTagSubmitted(msg.Tag) - return m, cmd - } - if m.selectedStation != nil && m.tagsManager != nil { - if err := m.tagsManager.AddTag(m.selectedStation.StationUUID, msg.Tag); err != nil { - m.saveMessage = fmt.Sprintf("βœ— %v", err) - } else { - m.saveMessage = fmt.Sprintf("βœ“ Added tag: %s", msg.Tag) - m.refreshResultsTagPills(m.selectedStation.StationUUID) - } - m.saveMessageTime = messageDisplayShort - } - m.state = searchStatePlaying - return m, nil - - case components.TagCancelledMsg: - if m.state == searchStateManageTags { - m.manageTags = m.manageTags.HandleTagCancelled() - return m, nil - } - m.state = searchStatePlaying - return m, nil - - case components.ManageTagsDoneMsg: - if m.selectedStation != nil && m.tagsManager != nil { - if err := m.tagsManager.SetTags(m.selectedStation.StationUUID, msg.Tags); err != nil { - m.saveMessage = fmt.Sprintf("βœ— %v", err) - } else if len(msg.Tags) == 0 { - m.saveMessage = "βœ“ All tags removed" - m.refreshResultsTagPills(m.selectedStation.StationUUID) - } else { - m.saveMessage = fmt.Sprintf("βœ“ Tags saved (%d)", len(msg.Tags)) - m.refreshResultsTagPills(m.selectedStation.StationUUID) - } - m.saveMessageTime = messageDisplayShort - } - m.state = searchStatePlaying - return m, nil - - case components.ManageTagsCancelledMsg: - m.state = searchStatePlaying - return m, nil - - case components.SleepTimerSelectedMsg: - m.state = searchStatePlaying - m.sleepTimerActive = true - return m, func() tea.Msg { return sleepTimerActivateMsg{Minutes: msg.Minutes} } - - case components.SleepTimerCancelledMsg: - m.state = searchStatePlaying - return m, nil - } - - return m, tea.Batch(cmds...) -} - -// handleMenuInput handles input in the search menu state -func (m SearchModel) handleMenuInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Handle back to main menu - if msg.String() == "esc" || msg.String() == "m" { - // Stop any playing station when exiting - if m.player != nil && m.player.IsPlaying() { - _ = m.player.Stop() - } - m.selectedStation = nil - m.numberBuffer = "" // Clear buffer on exit - return m, func() tea.Msg { return backToMainMsg{} } - } - - // Handle number input for multi-digit selection - key := msg.String() - if key >= "0" && key <= "9" { - m.numberBuffer += key - - // Parse current buffer - var num int - _, _ = fmt.Sscanf(m.numberBuffer, "%d", &num) - - // Calculate max valid number (6 for search types + history count) - maxNum := 6 - if m.searchHistory != nil { - maxHistoryItems := len(m.searchHistory.SearchItems) - if maxHistoryItems > m.searchHistory.MaxSize { - maxHistoryItems = m.searchHistory.MaxSize - } - if maxHistoryItems > 0 { - maxNum = 10 + maxHistoryItems - 1 // e.g., 10, 11, 12... - } - } - - // Two or more digits: check if valid and execute - if len(m.numberBuffer) >= 2 { - // If number is valid, select it immediately - if num >= 1 && num <= maxNum { - m.numberBuffer = "" // Clear buffer - return m.selectByNumber(num) - } - - // If number is too large or 3+ digits, clear buffer - if num > maxNum || len(m.numberBuffer) >= 3 { - m.numberBuffer = "" - } +// executeSearchType transitions to the appropriate search state for the given menu index (0-based). +func (m SearchModel) executeSearchType(idx int) (tea.Model, tea.Cmd) { + switch idx { + case 0: + m.searchType = api.SearchByTag + m.state = searchStateInput + m.textInput.SetValue("") + m.textInput.Focus() + return m, textinput.Blink + case 1: + m.searchType = api.SearchByName + m.state = searchStateInput + m.textInput.SetValue("") + m.textInput.Focus() + return m, textinput.Blink + case 2: + m.searchType = api.SearchByLanguage + m.state = searchStateInput + m.textInput.SetValue("") + m.textInput.Focus() + return m, textinput.Blink + case 3: + m.searchType = api.SearchByCountry + m.state = searchStateInput + m.textInput.SetValue("") + m.textInput.Focus() + return m, textinput.Blink + case 4: + m.searchType = api.SearchByState + m.state = searchStateInput + m.textInput.SetValue("") + m.textInput.Focus() + return m, textinput.Blink + case 5: + m.searchType = api.SearchAdvanced + m.state = searchStateAdvancedForm + for i := range m.advancedInputs { + m.advancedInputs[i].SetValue("") + m.advancedInputs[i].Blur() } - // Single digit: just buffer it, don't execute yet (wait for Enter or second digit) - return m, nil - } - - // Handle Enter to submit buffered number - if key == "enter" && m.numberBuffer != "" { - var num int - _, _ = fmt.Sscanf(m.numberBuffer, "%d", &num) - m.numberBuffer = "" - return m.selectByNumber(num) - } - - // Clear buffer on navigation keys - if key == "up" || key == "down" || key == "j" || key == "k" { - m.numberBuffer = "" + m.advancedFocusIdx = 0 + m.advancedInputs[0].Focus() + m.advancedBitrate = "" + m.advancedSortByVotes = true + return m, textinput.Blink } + return m, nil +} - // Handle menu navigation and selection - newList, selected := components.HandleMenuKey(msg, m.menuList) - m.menuList = newList - - if selected >= 0 { - // Menu layout: 0-5 = search types, 6 = empty line, 7 = separator, 8+ = history items - if selected < 6 { - return m.executeSearchType(selected) - } else if selected == 6 || selected == 7 { - // Empty line or separator selected - ignore - return m, nil - } else { - // History item selected (index 8+ maps to history index 0+) - historyIndex := selected - 8 - if m.searchHistory != nil && historyIndex >= 0 && historyIndex < len(m.searchHistory.SearchItems) { - item := m.searchHistory.SearchItems[historyIndex] - return m.executeHistorySearch(item.SearchType, item.Query) +// saveToList saves the currently selected station to the named favorite list. +func (m SearchModel) saveToList(listName string) tea.Cmd { + return func() tea.Msg { + if m.selectedStation == nil { + return saveToListFailedMsg{err: fmt.Errorf("no station selected")} + } + store := storage.NewStorage(m.favoritePath) + err := store.AddStation(context.Background(), listName, *m.selectedStation) + if err != nil { + if err == storage.ErrDuplicateStation { + return saveToListFailedMsg{err: err, isDuplicate: true} } + return saveToListFailedMsg{err: err} + } + return saveToListSuccessMsg{ + listName: listName, + stationName: m.selectedStation.TrimName(), } } - - return m, nil } // selectByNumber handles selection by number input (1-6 for search types, 10+ for history) @@ -734,269 +331,35 @@ func (m SearchModel) handleNewListInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -// loadAvailableLists loads all favorite lists -func (m SearchModel) loadAvailableLists() tea.Cmd { - return func() tea.Msg { - store := storage.NewStorage(m.favoritePath) - lists, err := store.GetAllLists(context.Background()) - if err != nil { - return saveToListFailedMsg{err: fmt.Errorf("failed to load lists: %w", err)} - } - return listsLoadedMsg{lists: lists} - } -} - -// initializeListModel creates the list model with current dimensions. -// It uses availableListHeight to account for the actual rendered header so that -// tall ASCII art headers don't cause the page to overflow the terminal. -func (m *SearchModel) initializeListModel() { - listHeight := availableListHeight(m.height) - - delegate := createStyledDelegate() - - m.listModel = list.New(m.listItems, delegate, m.width, listHeight) - m.listModel.Title = "" - m.listModel.SetShowStatusBar(false) - m.listModel.SetFilteringEnabled(false) - m.listModel.SetShowHelp(false) - m.listModel.Styles.Title = titleStyle() - m.listModel.Styles.PaginationStyle = paginationStyle() - m.listModel.Styles.HelpStyle = helpStyle() -} - -// saveToList saves the current station to a specific list -func (m SearchModel) saveToList(listName string) tea.Cmd { - return func() tea.Msg { - if m.selectedStation == nil { - return saveToListFailedMsg{err: fmt.Errorf("no station selected")} - } - - store := storage.NewStorage(m.favoritePath) - err := store.AddStation(context.Background(), listName, *m.selectedStation) - - if err != nil { - if err == storage.ErrDuplicateStation { - return saveToListFailedMsg{err: err, isDuplicate: true} - } - return saveToListFailedMsg{err: err} - } - - return saveToListSuccessMsg{ - listName: listName, - stationName: m.selectedStation.TrimName(), - } - } -} - -// executeSearchType sets up the search based on selected menu index -func (m SearchModel) executeSearchType(index int) (tea.Model, tea.Cmd) { - switch index { - case 0: // Search by Tag - m.searchType = api.SearchByTag - m.textInput.Placeholder = "Enter tag (e.g., jazz, rock, news)..." - case 1: // Search by Name - m.searchType = api.SearchByName - m.textInput.Placeholder = "Enter station name..." - case 2: // Search by Language - m.searchType = api.SearchByLanguage - m.textInput.Placeholder = "Enter language (e.g., english, spanish)..." - case 3: // Search by Country - m.searchType = api.SearchByCountry - m.textInput.Placeholder = "Enter country code (e.g., US, UK, FR)..." - case 4: // Search by State - m.searchType = api.SearchByState - m.textInput.Placeholder = "Enter state (e.g., California, Texas)..." - case 5: // Advanced Search - m.searchType = api.SearchAdvanced - // Reset advanced form - for i := range m.advancedInputs { - m.advancedInputs[i].SetValue("") - } - m.advancedFocusIdx = 0 - m.advancedBitrate = "" - m.advancedSortByVotes = true - m.advancedInputs[0].Focus() - m.state = searchStateAdvancedForm - return m, textinput.Blink - } - - m.state = searchStateInput - m.textInput.Focus() - return m, nil -} - -// handleTextInput handles input in the text input state -func (m SearchModel) handleTextInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "enter": - query := strings.TrimSpace(m.textInput.Value()) - if query == "" { - return m, nil - } - m.textInput.SetValue("") - m.textInput.Blur() - m.state = searchStateLoading - return m, m.performSearch(query) - case "esc": - m.textInput.SetValue("") - m.textInput.Blur() - m.state = searchStateMenu - // Reload history in case it was updated - m.reloadSearchHistory() - m.rebuildMenuWithHistory() - return m, nil - default: - // Pass all other keys to text input for normal typing - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd - } -} - -// performSearch executes the search based on type -func (m SearchModel) performSearch(query string) tea.Cmd { - // Save search to history in background - go func() { - store := storage.NewStorage(m.favoritePath) - var searchTypeStr string - switch m.searchType { - case api.SearchByTag: - searchTypeStr = "tag" - case api.SearchByName: - searchTypeStr = "name" - case api.SearchByLanguage: - searchTypeStr = "language" - case api.SearchByCountry: - searchTypeStr = "country" - case api.SearchByState: - searchTypeStr = "state" - case api.SearchAdvanced: - searchTypeStr = "advanced" - } - _ = store.AddSearchItem(context.Background(), searchTypeStr, query) - }() - - return func() tea.Msg { - var results []api.Station - var err error - ctx := context.Background() - - switch m.searchType { - case api.SearchByTag: - results, err = m.apiClient.SearchByTag(ctx, query) - case api.SearchByName: - results, err = m.apiClient.SearchByName(ctx, query) - case api.SearchByLanguage: - results, err = m.apiClient.SearchByLanguage(ctx, query) - case api.SearchByCountry: - results, err = m.apiClient.SearchByCountry(ctx, query) - case api.SearchByState: - results, err = m.apiClient.SearchByState(ctx, query) - case api.SearchAdvanced: - params := api.SearchParams{ - Name: query, - Tag: query, - Order: "votes", - Reverse: true, - Limit: 100, - HideBroken: true, - } - results, err = m.apiClient.SearchAdvanced(ctx, params) - } - - if err != nil { - return searchErrorMsg{err: err} - } - - // Sort results by votes (descending) - sort.Slice(results, func(i, j int) bool { - return results[i].Votes > results[j].Votes - }) - - return searchResultsMsg{results: results} - } -} - -// handleResultsInput handles input in the results list state -func (m SearchModel) handleResultsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": - // Stop any playing station when going back - if m.player != nil && m.player.IsPlaying() { - _ = m.player.Stop() - } - m.selectedStation = nil - // If we came from advanced search, go back to the form - if m.searchType == api.SearchAdvanced { - m.state = searchStateAdvancedForm - // Only focus text input if we're on a text field (0-4), not Sort (5) or Bitrate (6) - if m.advancedFocusIdx < 5 { - m.advancedInputs[m.advancedFocusIdx].Focus() - return m, textinput.Blink - } - return m, nil - } - // Otherwise go back to search menu - m.state = searchStateMenu - // Reload history from disk so recent search appears - m.reloadSearchHistory() - m.rebuildMenuWithHistory() - return m, nil - case "0": - // Return to main menu +// handleStationInfoInput handles input in the station info state +func (m SearchModel) handleStationInfoInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle Esc to go back + if msg.String() == "esc" { if m.player != nil && m.player.IsPlaying() { _ = m.player.Stop() } m.selectedStation = nil - return m, func() tea.Msg { return backToMainMsg{} } - case "enter": - // Play station directly - if item, ok := m.resultsList.SelectedItem().(stationListItem); ok { - m.selectedStation = &item.station - // Stop any currently playing station first - if m.player != nil && m.player.IsPlaying() { - _ = m.player.Stop() - } - m.state = searchStatePlaying - return m, m.playStation(item.station) - } - return m, nil - case "u": - // Undo last block (within 5 seconds) - if time.Since(m.lastBlockTime) < 5*time.Second { - return m, m.undoLastBlock() - } + m.state = searchStateResults return m, nil - default: - // Pass all other keys (arrows, etc.) to the list for navigation - var cmd tea.Cmd - m.resultsList, cmd = m.resultsList.Update(msg) - return m, cmd } -} -// handleStationInfoInput handles input in the station info state -func (m SearchModel) handleStationInfoInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Number quick-select shortcuts (1-based, matching menu items) + switch msg.String() { + case "1": + return m.executeStationAction(0) + case "2": + return m.executeStationAction(1) + case "3": + return m.executeStationAction(2) + } - // Handle menu navigation and selection + // Handle menu navigation and selection via arrow keys / enter newList, selected := components.HandleMenuKey(msg, m.stationInfoMenu) m.stationInfoMenu = newList - if selected >= 0 { return m.executeStationAction(selected) } - // Handle Esc to go back - if msg.String() == "esc" { - // Stop player when going back - if m.player != nil && m.player.IsPlaying() { - _ = m.player.Stop() - } - m.selectedStation = nil - m.state = searchStateResults - return m, nil - } - return m, nil } @@ -1024,11 +387,22 @@ func (m SearchModel) executeStationAction(index int) (tea.Model, tea.Cmd) { return m, nil } -// playStation starts playing a station +// playStation starts playing a station. +// Phase 5: volume is resolved from PlayOptions (DefaultVolume / StartVolumeMode). func (m SearchModel) playStation(station api.Station) tea.Cmd { + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + if station.Volume != nil { + startVol = *station.Volume + } return tea.Batch( + // Stop any app-level handed-off player (e.g. from Play from Favorites + // with ContinueOnNavigate on) before starting the new stream. + func() tea.Msg { return stopActivePlaybackMsg{} }, func() tea.Msg { - err := m.player.Play(&station) + err := m.player.PlayWithVolume(&station, startVol) if err != nil { return playerErrorMsg{err: err} } @@ -1124,6 +498,44 @@ func (m SearchModel) handleSavePrompt(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handOffPlayer hands m.player to App and replaces it with a fresh player so +// the old stream is exclusively owned by App while SearchModel has a clean +// player for the next station. Returns the updated model and handoff command. +func (m SearchModel) handOffPlayer() (SearchModel, tea.Cmd) { + station := m.selectedStation + oldPlayer := m.player + newP := player.NewMPVPlayer() + if m.metadataManager != nil { + newP.SetMetadataManager(m.metadataManager) + } + m.player = newP + cmd := func() tea.Msg { + return handoffPlaybackMsg{ + player: oldPlayer, + station: station, + contextLabel: "Search", + } + } + return m, cmd +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App (with a fresh replacement player) and navigates to main menu. +// NOTE: callers must use the returned model, not the original. +func (m SearchModel) navigateToMainCmd() (SearchModel, tea.Cmd) { + navCmd := func() tea.Msg { return backToMainMsg{} } + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + return m, tea.Batch(handoffCmd, navCmd) + } + // ContinueOnNavigate off β€” stop and navigate. + if m.player != nil { + _ = m.player.Stop() + } + return m, navCmd +} + // handlePlayerUpdate handles player-related updates during playback func (m SearchModel) handlePlayerUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle rating mode input first @@ -1139,14 +551,33 @@ func (m SearchModel) handlePlayerUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "0": - // Return to main menu (Level 3 shortcut) - if m.player != nil { - _ = m.player.Stop() + // Phase 5: gate on ConfirmStop before navigating away. + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = searchStateConfirmStop + return m, nil } + // Hand off (with fresh player) or stop, then go to main menu. + m, cmd := m.navigateToMainCmd() m.selectedStation = nil - return m, func() tea.Msg { return backToMainMsg{} } + return m, cmd case "esc": - // Esc during playback goes back without save prompt + // Phase 5: gate on ConfirmStop before navigating away. + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = searchStateConfirmStop + return m, nil + } + // Go back to results. With ContinueOnNavigate ON, hand the player off + // to App and install a fresh player so the next station selection + // doesn't share the same MPVPlayer pointer. + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.selectedStation = nil + m.state = searchStateResults + return m, handoffCmd + } + // ContinueOnNavigate off β€” stop the player. if m.player != nil { _ = m.player.Stop() } @@ -1177,7 +608,7 @@ func (m SearchModel) handlePlayerUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.saveStationVolume(m.selectedStation) } m.saveMessage = fmt.Sprintf("Volume: %d%%", newVol) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() @@ -1191,7 +622,7 @@ func (m SearchModel) handlePlayerUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.saveStationVolume(m.selectedStation) } m.saveMessage = fmt.Sprintf("Volume: %d%%", newVol) - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() @@ -1209,7 +640,7 @@ func (m SearchModel) handlePlayerUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedStation.SetVolume(vol) m.saveStationVolume(m.selectedStation) } - startTick := m.saveMessageTime == 0 + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort if startTick { return m, tickEverySecond() @@ -1359,7 +790,11 @@ func (m SearchModel) handleRatingModeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) } else { m.saveMessage = fmt.Sprintf("βœ— Rating failed: %v", err) } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil } @@ -1370,7 +805,11 @@ func (m SearchModel) handleRatingModeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) } else { m.saveMessage = "βœ“ Rating removed" } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } return m, nil } @@ -1468,7 +907,7 @@ func (m SearchModel) View() string { content.WriteString(m.getSearchTypeDescription()) content.WriteString("\n\n") content.WriteString(m.textInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ” Search Radio Stations", Content: content.String(), Help: "Enter: Search β€’ Esc: Back β€’ Ctrl+C: Quit", @@ -1478,7 +917,7 @@ func (m SearchModel) View() string { var content strings.Builder content.WriteString(m.spinner.View()) content.WriteString(" Searching for stations...") - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ” Searching...", Content: content.String(), Help: "", @@ -1517,7 +956,7 @@ func (m SearchModel) View() string { } } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ” No Results", Content: criteria.String(), Help: "Esc: Back to search menu", @@ -1538,7 +977,7 @@ func (m SearchModel) View() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Play β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", }, m.height) @@ -1595,16 +1034,12 @@ func (m SearchModel) View() string { } if m.saveMessage != "" { content.WriteString("\n\n") - if strings.Contains(m.saveMessage, "βœ“") || strings.HasPrefix(m.saveMessage, "Volume:") { - if strings.Contains(m.saveMessage, "Muted") { - content.WriteString(infoStyle().Render(m.saveMessage)) - } else { - content.WriteString(successStyle().Render(m.saveMessage)) - } - } else if strings.Contains(m.saveMessage, "Already") || strings.HasPrefix(m.saveMessage, "Press") { - content.WriteString(infoStyle().Render(m.saveMessage)) - } else { + if strings.Contains(m.saveMessage, "βœ“") || strings.Contains(m.saveMessage, "🚫") { + content.WriteString(successStyle().Render(m.saveMessage)) + } else if strings.Contains(m.saveMessage, "βœ—") { content.WriteString(errorStyle().Render(m.saveMessage)) + } else { + content.WriteString(infoStyle().Render(m.saveMessage)) } } // Sleep timer countdown @@ -1613,7 +1048,7 @@ func (m SearchModel) View() string { content.WriteString(highlightStyle().Render(timerInfo)) } helpText := "Space: Pause β€’ f: Fav β€’ s: List β€’ v: Vote β€’ b: Block β€’ Z: Sleep β€’ +: Extend β€’ 0: Main Menu β€’ ?: Help" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Now Playing", Content: content.String(), Help: helpText, @@ -1637,20 +1072,27 @@ func (m SearchModel) View() string { sb.WriteString("\n\n") } sb.WriteString(m.manageTags.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Manage Tags", Content: sb.String(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ d: Done β€’ Esc: Cancel", }, m.height) case searchStateSleepTimer: - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ’€ Sleep Timer", Content: m.sleepTimerDialog.View(), Help: "Enter: Set β€’ ↑↓/jk: Navigate β€’ Esc: Cancel", }, m.height) + + case searchStateConfirmStop: + return m.renderPageWithBottomHelp(PageLayout{ + Title: "⚠ Confirm Stop", + Content: "Stop playback and leave this screen?\n\ny/1: Yes, stop\nn/2/Esc: No, keep playing", + Help: "y/1: Yes β€’ n/2/Esc: No", + }, m.height) } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Content: "Unknown state", Help: "", }) @@ -1664,7 +1106,7 @@ func (m SearchModel) viewTagInput() string { content.WriteString("\n\n") } content.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: content.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -1740,7 +1182,7 @@ func (m SearchModel) renderStationInfo() string { } } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ“» Station Information", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-3: Quick select β€’ Esc: Back β€’ q: Quit", @@ -1760,7 +1202,7 @@ func (m SearchModel) renderSavePrompt() string { content.WriteString("1) ⭐ Add to Quick Favorites\n") content.WriteString("2) Return to search results") - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Save Station?", Content: content.String(), Help: "y/1: Yes β€’ n/2/Esc: No β€’ q: Quit", @@ -1793,7 +1235,7 @@ func (m SearchModel) viewSelectList() string { content.WriteString("Loading lists...") } - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Save to List", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ n: New list β€’ Esc: Cancel", @@ -1819,7 +1261,7 @@ func (m SearchModel) viewNewListInput() string { // Text input content.WriteString(m.newListInput.View()) - return RenderPage(PageLayout{ + return m.renderPage(PageLayout{ Title: "πŸ’Ύ Create New List", Content: content.String(), Help: "Enter: Save β€’ Esc: Cancel", @@ -1885,13 +1327,13 @@ func (m SearchModel) renderSearchMenu() string { helpText := "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-6+Enter: Search β€’ 10,11,12...: History β€’ Esc: Back β€’ Ctrl+C: Quit" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: helpText, }, m.height) } -// reloadSearchHistory reloads history from disk +// reloadSearchHistory reloads history from disk and rebuilds the menu. func (m *SearchModel) reloadSearchHistory() { store := storage.NewStorage(m.favoritePath) history, err := store.LoadSearchHistory(context.Background()) @@ -1899,6 +1341,17 @@ func (m *SearchModel) reloadSearchHistory() { history = storage.NewSearchHistoryStore() } m.searchHistory = history + m.rebuildMenuWithHistory() +} + +// reloadQuickFavorites reloads the My-favorites station list from disk so that +// duplicate detection in saveToQuickFavorites and handlePlaybackStopped stays +// current within the same Search session. +func (m *SearchModel) reloadQuickFavorites() { + store := storage.NewStorage(m.favoritePath) + if list, err := store.LoadList(context.Background(), "My-favorites"); err == nil { + m.quickFavorites = list.Stations + } } // rebuildMenuWithHistory rebuilds the menu list to include history items @@ -2242,7 +1695,7 @@ func (m SearchModel) viewAdvancedForm() string { helpText := "Tab/↑↓: Navigate all fields β€’ Space/←→: Toggle sort β€’ 1/2/3: Select bitrate β€’ Enter: Search β€’ Esc: Cancel" - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: helpText, }, m.height) @@ -2326,3 +1779,19 @@ func (m SearchModel) undoLastBlock() tea.Cmd { return undoBlockFailedMsg{} } } + +// renderPage injects the now-playing bar when the model's own player is not +// actively playing (so viewPlaying is unaffected). +func (m SearchModel) renderPage(layout PageLayout) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPage(layout) +} + +func (m SearchModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/search_test.go b/internal/ui/search_test.go index 9cf5ea8..7e411b4 100644 --- a/internal/ui/search_test.go +++ b/internal/ui/search_test.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/storage" ) func TestSearchModelInit(t *testing.T) { @@ -71,6 +72,110 @@ func TestSearchMenuNavigation(t *testing.T) { } } +// TestSearchMenuSingleDigitBuffered verifies that typing a single digit 1-6 +// does NOT immediately execute the search β€” it should be buffered so that +// two-digit history shortcuts (10, 11, …) can still be entered. +func TestSearchMenuSingleDigitBuffered(t *testing.T) { + client := api.NewClient() + model := NewSearchModel(client, "/tmp/test", "", blocklist.NewManager("/tmp/blocklist")) + model.state = searchStateMenu + model.numberBuffer = "" + + // Type "1" β€” must NOT fire Search by Tag immediately + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")} + updatedModel, _ := model.Update(msg) + searchModel := updatedModel.(SearchModel) + + if searchModel.state != searchStateMenu { + t.Errorf("Typing '1' should buffer, not immediately execute: got state %v", searchModel.state) + } + if searchModel.numberBuffer != "1" { + t.Errorf("Expected numberBuffer=\"1\", got %q", searchModel.numberBuffer) + } +} + +// TestSearchMenuHistoryShortcut verifies that typing "1" then "0" executes +// history item 0 (shortcut 10) rather than firing Search by Tag on the "1". +func TestSearchMenuHistoryShortcut(t *testing.T) { + client := api.NewClient() + model := NewSearchModel(client, "/tmp/test", "", blocklist.NewManager("/tmp/blocklist")) + model.state = searchStateMenu + model.numberBuffer = "" + + // Inject a fake history item so shortcut 10 has something to execute + model.searchHistory = &storage.SearchHistoryStore{ + SearchItems: []storage.SearchHistoryItem{ + {SearchType: "tag", Query: "jazz"}, + }, + MaxSize: 10, + } + + // Type "1" β€” should buffer + msg1 := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")} + updatedModel, _ := model.Update(msg1) + searchModel := updatedModel.(SearchModel) + + if searchModel.state != searchStateMenu { + t.Fatalf("After '1' expected searchStateMenu, got %v", searchModel.state) + } + + // Type "0" β€” should complete "10" and execute history search + msg0 := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("0")} + updatedModel, _ = searchModel.Update(msg0) + searchModel = updatedModel.(SearchModel) + + // "10" maps to history[0] whose type is "tag", so searchType must be SearchByTag + // and the model must be loading (search dispatched) + if searchModel.state != searchStateLoading { + t.Errorf("Expected searchStateLoading after '10' history shortcut, got %v", searchModel.state) + } + if searchModel.searchType != api.SearchByTag { + t.Errorf("Expected SearchByTag from history item, got %v", searchModel.searchType) + } +} + +// TestSearchMenuEnterOnHistoryItem verifies that pressing Enter while the +// cursor is on a history list item (raw list index β‰₯ 8) executes the history +// search rather than falling through to executeSearchType (which would no-op). +func TestSearchMenuEnterOnHistoryItem(t *testing.T) { + client := api.NewClient() + model := NewSearchModel(client, "/tmp/test", "", blocklist.NewManager("/tmp/blocklist")) + model.state = searchStateMenu + model.numberBuffer = "" + + model.searchHistory = &storage.SearchHistoryStore{ + SearchItems: []storage.SearchHistoryItem{ + {SearchType: "name", Query: "BBC"}, + }, + MaxSize: 10, + } + model.rebuildMenuWithHistory() + + // Navigate down far enough to land on the history item. + // Menu structure: 0-5 search types, 6 blank, 7 separator, 8 first history item. + for i := 0; i < 8; i++ { + down := tea.KeyMsg{Type: tea.KeyDown} + updatedModel, _ := model.Update(down) + model = updatedModel.(SearchModel) + } + + if model.menuList.Index() != 8 { + t.Fatalf("Expected cursor at index 8 (first history item), got %d", model.menuList.Index()) + } + + // Press Enter β€” should execute the history search, not no-op + enter := tea.KeyMsg{Type: tea.KeyEnter} + updatedModel, _ := model.Update(enter) + finalModel := updatedModel.(SearchModel) + + if finalModel.state != searchStateLoading { + t.Errorf("Expected searchStateLoading after Enter on history item, got %v", finalModel.state) + } + if finalModel.searchType != api.SearchByName { + t.Errorf("Expected SearchByName from history item, got %v", finalModel.searchType) + } +} + func TestSearchBackNavigation(t *testing.T) { client := api.NewClient() model := NewSearchModel(client, "/tmp/test", "", blocklist.NewManager("/tmp/blocklist")) @@ -378,24 +483,6 @@ func TestWindowResize(t *testing.T) { } } -func TestQuickFavoritesLoading(t *testing.T) { - client := api.NewClient() - model := NewSearchModel(client, "/tmp/test", "", blocklist.NewManager("/tmp/blocklist")) - - stations := []api.Station{ - {StationUUID: "fav-1", Name: "Favorite 1"}, - {StationUUID: "fav-2", Name: "Favorite 2"}, - } - - msg := quickFavoritesLoadedMsg{stations: stations} - updatedModel, _ := model.Update(msg) - model = updatedModel.(SearchModel) - - if len(model.quickFavorites) != 2 { - t.Errorf("Expected 2 quick favorites, got %d", len(model.quickFavorites)) - } -} - func TestRenderStationDetails(t *testing.T) { station := api.Station{ StationUUID: "test-1", diff --git a/internal/ui/searchmodel_extra.go b/internal/ui/searchmodel_extra.go new file mode 100644 index 0000000..29937f4 --- /dev/null +++ b/internal/ui/searchmodel_extra.go @@ -0,0 +1,546 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/shinokada/tera/v3/internal/api" + "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/ui/components" +) + +// NewSearchModel constructs a new SearchModel with required dependencies. +func NewSearchModel(apiClient *api.Client, favoritePath, dataPath string, blocklistManager *blocklist.Manager) SearchModel { + // Initialize 5 advanced-search text inputs (tag, lang, country, state, name). + advInputs := make([]textinput.Model, 5) + placeholders := []string{"genre / style", "e.g. english", "e.g. JP or Japan", "e.g. California", "partial name"} + for i := range advInputs { + ti := textinput.New() + ti.Placeholder = placeholders[i] + advInputs[i] = ti + } + advInputs[0].Focus() // default focus on first field + + textIn := textinput.New() + textIn.Placeholder = "Type to search..." + + newListIn := textinput.New() + newListIn.Placeholder = "New list name..." + + m := SearchModel{ + apiClient: apiClient, + favoritePath: favoritePath, + dataPath: dataPath, + blocklistManager: blocklistManager, + tagRenderer: components.NewTagRenderer(), + advancedInputs: advInputs, + textInput: textIn, + newListInput: newListIn, + helpModel: components.NewHelpModel(components.CreatePlayingHelp()), + } + m.reloadSearchHistory() + return m +} + +// Init implements tea.Model for SearchModel. +func (m SearchModel) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model for SearchModel. +func (m SearchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.helpModel.IsVisible() { + var cmd tea.Cmd + m.helpModel, cmd = m.helpModel.Update(msg) + return m, cmd + } + switch m.state { + case searchStateMenu: + return m.handleMenuKey(msg) + case searchStateResults: + return m.handleResultsKey(msg) + case searchStateInput: + return m.handleInputKey(msg) + case searchStatePlaying: + return m.handlePlayerUpdate(msg) + case searchStateConfirmStop: + return m.handleConfirmStopKey(msg) + case searchStateSavePrompt: + return m.handleSavePrompt(msg) + case searchStateStationInfo: + return m.handleStationInfoInput(msg) + case searchStateSelectList: + return m.handleSelectList(msg) + case searchStateNewListInput: + return m.handleNewListInput(msg) + case searchStateAdvancedForm: + return m.handleAdvancedForm(msg) + case searchStateTagInput: + return m.handleTagInputKey(msg) + case searchStateManageTags: + return m.handleManageTagsKey(msg) + case searchStateSleepTimer: + return m.handleSleepTimerDialogKey(msg) + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.helpModel.SetSize(msg.Width, msg.Height) + return m, nil + + case searchResultsMsg: + m.results = msg.results + m.state = searchStateResults + m.resultsItems = make([]list.Item, len(msg.results)) + for i, station := range msg.results { + isBlocked := false + if m.blocklistManager != nil { + isBlocked = m.blocklistManager.IsBlockedByAny(&station) + } + tagPills := "" + if m.tagsManager != nil { + if tags := m.tagsManager.GetTags(station.StationUUID); len(tags) > 0 { + tagPills = m.tagRenderer.RenderPills(tags) + } + } + m.resultsItems[i] = stationListItem{station: station, isBlocked: isBlocked, tagPills: tagPills} + } + height := availableListHeight(m.height) + delegate := createStyledDelegate() + m.resultsList = list.New(m.resultsItems, delegate, m.width, height) + m.resultsList.SetShowStatusBar(true) + m.resultsList.SetFilteringEnabled(true) + m.resultsList.SetShowHelp(false) + return m, nil + + case searchErrorMsg: + m.err = msg.err + m.state = searchStateMenu + return m, nil + + case playbackStartedMsg: + if m.saveMessageTime <= 0 && !m.sleepTimerActive { + return m, tickEverySecond() + } + return m, nil + + case playerErrorMsg: + m.err = msg.err + m.state = searchStateResults + return m, nil + + case playbackStalledMsg: + if m.player != nil { + _ = m.player.Stop() + } + m.saveMessage = "βœ— No signal detected" + m.saveMessageTime = messageDisplayShort + m.state = searchStateResults + return m, nil + + case checkSignalMsg: + if m.state == searchStatePlaying && m.selectedStation != nil && m.selectedStation.StationUUID == msg.station.StationUUID { + return m, m.checkPlaybackSignal(msg.station, msg.attempt) + } + return m, nil + + case saveSuccessMsg: + name := "" + if msg.station != nil { + name = msg.station.TrimName() + } + m.saveMessage = fmt.Sprintf("βœ“ Saved '%s' to Quick Favorites", name) + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + // Refresh the in-memory favorites cache so subsequent duplicate checks + // and save prompts reflect the station just added. + m.reloadQuickFavorites() + m.reloadSearchHistory() + if startTick { + return m, tickEverySecond() + } + return m, nil + + case saveFailedMsg: + if msg.isDuplicate { + m.saveMessage = "Already in Quick Favorites" + } else { + m.saveMessage = fmt.Sprintf("βœ— Failed to save: %v", msg.err) + } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case components.VoteSuccessMsg: + m.saveMessage = fmt.Sprintf("βœ“ %s", msg.Message) + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case components.VoteFailedMsg: + m.saveMessage = fmt.Sprintf("βœ— Vote failed: %v", msg.Err) + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case stationBlockedMsg: + m.lastBlockTime = time.Now() + if msg.success { + if m.player != nil { + _ = m.player.Stop() + } + m.saveMessage = msg.message + " (press 'u' within 5s to undo)" + m.saveMessageTime = messageDisplayMedium + m.state = searchStateResults + m.selectedStation = nil + if m.resultsList.Items() != nil { + items := m.resultsList.Items() + for i, item := range items { + if si, ok := item.(stationListItem); ok && si.station.StationUUID == msg.stationUUID { + si.isBlocked = true + items[i] = si + break + } + } + m.resultsList.SetItems(items) + } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + if startTick { + return m, tickEverySecond() + } + return m, nil + } + m.saveMessage = msg.message + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case undoBlockSuccessMsg: + m.saveMessage = "βœ“ Block undone" + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case undoBlockFailedMsg: + m.saveMessage = "No recent block to undo" + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + if startTick { + return m, tickEverySecond() + } + return m, nil + + case components.TagSubmittedMsg: + if m.state == searchStateManageTags { + var cmd tea.Cmd + m.manageTags, cmd = m.manageTags.HandleTagSubmitted(msg.Tag) + return m, cmd + } + if m.selectedStation != nil && m.tagsManager != nil { + if err := m.tagsManager.AddTag(m.selectedStation.StationUUID, msg.Tag); err != nil { + m.saveMessage = fmt.Sprintf("βœ— %v", err) + } else { + m.saveMessage = fmt.Sprintf("βœ“ Added tag: %s", msg.Tag) + m.refreshResultsTagPills(m.selectedStation.StationUUID) + } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + m.state = searchStatePlaying + if startTick { + return m, tickEverySecond() + } + } + m.state = searchStatePlaying + return m, nil + + case components.TagCancelledMsg: + if m.state == searchStateManageTags { + m.manageTags = m.manageTags.HandleTagCancelled() + return m, nil + } + m.state = searchStatePlaying + return m, nil + + case components.ManageTagsDoneMsg: + if m.selectedStation != nil && m.tagsManager != nil { + if err := m.tagsManager.SetTags(m.selectedStation.StationUUID, msg.Tags); err != nil { + m.saveMessage = fmt.Sprintf("βœ— %v", err) + } else if len(msg.Tags) == 0 { + m.saveMessage = "βœ“ All tags removed" + m.refreshResultsTagPills(m.selectedStation.StationUUID) + } else { + m.saveMessage = fmt.Sprintf("βœ“ Tags saved (%d)", len(msg.Tags)) + m.refreshResultsTagPills(m.selectedStation.StationUUID) + } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + m.state = searchStatePlaying + if startTick { + return m, tickEverySecond() + } + } + m.state = searchStatePlaying + return m, nil + + case components.ManageTagsCancelledMsg: + m.state = searchStatePlaying + return m, nil + + case components.SleepTimerSelectedMsg: + m.state = searchStatePlaying + m.sleepTimerActive = true + // Start the tick if the countdown wasn't already keeping it alive + activateCmd := func() tea.Msg { return sleepTimerActivateMsg{Minutes: msg.Minutes} } + if m.saveMessageTime <= 0 { + return m, tea.Batch(tickEverySecond(), activateCmd) + } + return m, activateCmd + + case components.SleepTimerCancelledMsg: + m.state = searchStatePlaying + return m, nil + + case tickMsg: + if m.saveMessageTime > 0 { + m.saveMessageTime-- + if m.saveMessageTime == 0 { + m.saveMessage = "" + return m, nil + } + return m, tickEverySecond() + } + if m.sleepTimerActive { + return m, tickEverySecond() + } + return m, nil + + case listsLoadedMsg: + m.availableLists = msg.lists + items := make([]list.Item, len(msg.lists)) + for i, name := range msg.lists { + items[i] = playListItem{name: name} + } + listHeight := m.height - 10 + if listHeight < 4 { + listHeight = 4 + } + delegate := createStyledDelegate() + m.listModel = list.New(items, delegate, m.width, listHeight) + m.listModel.SetShowTitle(false) + m.listModel.SetShowStatusBar(false) + m.listModel.SetFilteringEnabled(false) + m.listModel.SetShowHelp(false) + return m, nil + + case saveToListSuccessMsg: + m.saveMessage = fmt.Sprintf("βœ“ Saved '%s' to %s", msg.stationName, msg.listName) + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + m.state = searchStatePlaying + if startTick { + return m, tickEverySecond() + } + return m, nil + + case saveToListFailedMsg: + if msg.isDuplicate { + m.saveMessage = "Already in this list" + } else { + m.saveMessage = fmt.Sprintf("βœ— Failed to save: %v", msg.err) + } + startTick := m.saveMessageTime <= 0 && !m.sleepTimerActive + m.saveMessageTime = messageDisplayShort + m.state = searchStatePlaying + if startTick { + return m, tickEverySecond() + } + return m, nil + } + + // Pass through to list models for navigation + var cmd tea.Cmd + switch m.state { + case searchStateMenu: + m.menuList, cmd = m.menuList.Update(msg) + case searchStateResults: + if m.resultsList.Items() != nil { + m.resultsList, cmd = m.resultsList.Update(msg) + } + } + return m, cmd +} + +// handleMenuKey handles key input in the search menu state. +func (m SearchModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m, func() tea.Msg { return backToMainMsg{} } + case "ctrl+c": + return m, tea.Quit + case "q": + return m, tea.Quit + case "enter": + // If there's a buffered number, execute it + if m.numberBuffer != "" { + var num int + if _, err := fmt.Sscanf(m.numberBuffer, "%d", &num); err == nil { + m.numberBuffer = "" + return m.selectByNumber(num) + } + m.numberBuffer = "" + } + // Otherwise use the highlighted list item. + // Map the raw list index back to a logical action: + // 0-5 β†’ search types + // 6 β†’ blank spacer (ignore) + // 7 β†’ separator header (ignore) + // 8+ β†’ history item (index = raw - 8) + idx := m.menuList.Index() + if idx <= 5 { + return m.executeSearchType(idx) + } + if idx >= 8 && m.searchHistory != nil { + historyIndex := idx - 8 + if historyIndex < len(m.searchHistory.SearchItems) { + item := m.searchHistory.SearchItems[historyIndex] + return m.executeHistorySearch(item.SearchType, item.Query) + } + } + return m, nil + case "0": + if m.numberBuffer != "" { + m.numberBuffer += "0" + var num int + if _, err := fmt.Sscanf(m.numberBuffer, "%d", &num); err == nil { + m.numberBuffer = "" + return m.selectByNumber(num) + } + m.numberBuffer = "" + return m, nil + } + return m, func() tea.Msg { return backToMainMsg{} } + default: + // Number buffer for quick selection. + // Single digits 1-6 are NOT executed immediately β€” they are buffered so + // that two-digit history shortcuts like "10", "11", etc. can be entered. + if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { + m.numberBuffer += msg.String() + // Two digits: execute immediately + if len(m.numberBuffer) >= 2 { + var num int + if _, err := fmt.Sscanf(m.numberBuffer, "%d", &num); err == nil { + m.numberBuffer = "" + return m.selectByNumber(num) + } + m.numberBuffer = "" + } + // Single digit: buffer it and wait for Enter or a second digit + return m, nil + } + // Non-digit key: clear buffer + m.numberBuffer = "" + } + var cmd tea.Cmd + m.menuList, cmd = m.menuList.Update(msg) + return m, cmd +} + +// handleInputKey handles key input in the text-input state. +func (m SearchModel) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.state = searchStateMenu + return m, nil + case "enter": + query := m.textInput.Value() + if query == "" { + return m, nil + } + m.textInput.SetValue("") // clear input after submitting + m.state = searchStateLoading + return m, m.performSearch(query) + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// handleResultsKey handles key input in the search results state. +func (m SearchModel) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "0": + m.state = searchStateMenu + return m, nil + case "enter": + if item, ok := m.resultsList.SelectedItem().(stationListItem); ok { + station := item.station + m.selectedStation = &station + m.state = searchStatePlaying + if m.player != nil { + return m, tea.Batch( + func() tea.Msg { return stopActivePlaybackMsg{} }, + m.playStation(station), + ) + } + return m, nil + } + } + var cmd tea.Cmd + m.resultsList, cmd = m.resultsList.Update(msg) + return m, cmd +} + +// handleConfirmStopKey handles the confirm-stop prompt (Phase 5). +// Routes confirmation through navigateToMainCmd / handOffPlayer so that +// ContinueOnNavigate handoff is honoured, and resets state so the model is +// clean when reused. +func (m SearchModel) handleConfirmStopKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "1": + target := m.confirmStopTarget + m.confirmStopTarget = "" + m.state = searchStateResults + if target == "main" { + m, cmd := m.navigateToMainCmd() + m.selectedStation = nil + return m, cmd + } + // "back" β€” return to results (hand off or stop). + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + m, handoffCmd := m.handOffPlayer() + m.selectedStation = nil + return m, handoffCmd + } + if m.player != nil { + _ = m.player.Stop() + } + m.selectedStation = nil + return m, nil + case "n", "N", "2", "esc": + // Resume / stay + m.state = searchStatePlaying + return m, nil + } + return m, nil +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 8630e2a..cc6ab10 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -22,9 +22,10 @@ const ( settingsStateMenu settingsState = iota settingsStateTheme settingsStateConnection - settingsStateHistory // top-level: Search History or Play History - settingsStateSearchHistory // existing search history sub-menu - settingsStatePlayHistory // NEW: play history settings + settingsStateHistory // top-level: Search History or Play History + settingsStateSearchHistory // existing search history sub-menu + settingsStatePlayHistory // play history settings + settingsStatePlayOptions // play options (continuous playback, volume, etc.) settingsStateUpdates settingsStateAbout ) @@ -34,21 +35,21 @@ var Version = "dev" // SettingsModel represents the settings screen type SettingsModel struct { - state settingsState - menuList list.Model - themeList list.Model - historyMenuList list.Model - historyMenuIndex int // cursor for the top-level History switcher - width int - height int - message string - messageTime int - messageIsSuccess bool - currentTheme string - favoritePath string - searchHistory *storage.SearchHistoryStore - playHistoryCfg config.PlayHistoryConfig - metadataManager *storage.MetadataManager + state settingsState + menuList list.Model + themeList list.Model + historyMenuList list.Model + historyMenuIndex int // cursor for the top-level History switcher + width int + height int + message string + messageTime int + messageIsSuccess bool + currentTheme string + favoritePath string + searchHistory *storage.SearchHistoryStore + playHistoryCfg config.PlayHistoryConfig + metadataManager *storage.MetadataManager // Update checking latestVersion string updateAvailable bool @@ -56,6 +57,9 @@ type SettingsModel struct { updateChecking bool updateError string installInfo api.InstallInfo + // Play options + playOptsCfg config.PlayOptionsConfig + nowPlayingBar string // set by App when ContinueOnNavigate is active } // Predefined themes @@ -209,9 +213,10 @@ func NewSettingsModel(favoritePath string) SettingsModel { components.NewMenuItem("Appearance", "Customize header and layout", "2"), components.NewMenuItem("Connection Settings", "Auto-reconnect and buffering", "3"), components.NewMenuItem("Shuffle Settings", "Configure shuffle mode behavior", "4"), - components.NewMenuItem("History", "Search and play history settings", "5"), - components.NewMenuItem("Check for Updates", "Check for new versions", "6"), - components.NewMenuItem("About TERA", "Version and information", "7"), + components.NewMenuItem("Play Options", "Playback behaviour settings", "5"), + components.NewMenuItem("History", "Search and play history settings", "6"), + components.NewMenuItem("Check for Updates", "Check for new versions", "7"), + components.NewMenuItem("About TERA", "Version and information", "8"), } menuList := components.CreateMenu(menuItems, "", 50, 12) @@ -266,6 +271,12 @@ func NewSettingsModel(favoritePath string) SettingsModel { phCfg = config.DefaultPlayHistoryConfig() } + // Load play options config + poCfg, err := storage.LoadPlayOptionsConfigFromUnified() + if err != nil { + poCfg = config.DefaultPlayOptionsConfig() + } + // Detect installation method installInfo := api.DetectInstallMethod() @@ -278,6 +289,7 @@ func NewSettingsModel(favoritePath string) SettingsModel { favoritePath: favoritePath, searchHistory: history, playHistoryCfg: phCfg, + playOptsCfg: poCfg, // Update fields initialized to defaults updateChecked: false, updateChecking: false, @@ -319,6 +331,8 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSearchHistory(msg) case settingsStatePlayHistory: return m.updatePlayHistory(msg) + case settingsStatePlayOptions: + return m.updatePlayOptions(msg) case settingsStateUpdates: return m.updateUpdates(msg) case settingsStateAbout: @@ -376,10 +390,14 @@ func (m SettingsModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "5": - m.state = settingsStateHistory + m.state = settingsStatePlayOptions return m, nil case "6": + m.state = settingsStateHistory + return m, nil + + case "7": m.state = settingsStateUpdates if !m.updateChecked && !m.updateChecking { m.updateChecking = true @@ -387,7 +405,7 @@ func (m SettingsModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "7": + case "8": m.state = settingsStateAbout return m, nil @@ -412,14 +430,16 @@ func (m SettingsModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return navigateMsg{screen: screenShuffleSettings} } case 4: - m.state = settingsStateHistory + m.state = settingsStatePlayOptions case 5: + m.state = settingsStateHistory + case 6: m.state = settingsStateUpdates if !m.updateChecked && !m.updateChecking { m.updateChecking = true return m, checkForUpdates() } - case 6: + case 7: m.state = settingsStateAbout } return m, nil @@ -650,6 +670,8 @@ func (m SettingsModel) View() string { return m.viewSearchHistory() case settingsStatePlayHistory: return m.viewPlayHistory() + case settingsStatePlayOptions: + return m.viewPlayOptions() case settingsStateUpdates: return m.viewUpdates() case settingsStateAbout: @@ -672,10 +694,10 @@ func (m SettingsModel) viewMenu() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "βš™οΈ Settings", Content: content.String(), - Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-7: Shortcut β€’ Esc/0: Back β€’ Ctrl+C: Quit", + Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-8: Shortcut β€’ Esc/0: Back β€’ Ctrl+C: Quit", }, m.height) } @@ -697,7 +719,7 @@ func (m SettingsModel) viewTheme() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎨 Theme / Colors", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Apply Theme β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", @@ -747,7 +769,7 @@ func (m SettingsModel) viewHistoryMenu() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "βš™οΈ Settings > History", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-3: Shortcut β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", @@ -794,13 +816,180 @@ func (m SettingsModel) viewSearchHistory() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "βš™οΈ Settings > History > Search History", Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter/1-5: Select β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", }, m.height) } +// updatePlayOptions handles keys in the Play Options sub-menu. +func (m SettingsModel) updatePlayOptions(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + cfg := m.playOptsCfg + switch msg.String() { + case "esc", "7": + m.state = settingsStateMenu + return m, nil + case "0": + return m, func() tea.Msg { return backToMainMsg{} } + + case "1": // Toggle ContinueOnNavigate + cfg.ContinueOnNavigate = !cfg.ContinueOnNavigate + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + v := "Off" + if cfg.ContinueOnNavigate { + v = "On" + } + m.message = fmt.Sprintf("βœ“ Continue playback when changing pages: %s", v) + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + + case "2": // Increase DefaultVolume + cfg.DefaultVolume += 5 + if cfg.DefaultVolume > 100 { + cfg.DefaultVolume = 100 + } + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + m.message = fmt.Sprintf("βœ“ Default volume: %d%%", cfg.DefaultVolume) + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + + case "3": // Decrease DefaultVolume + cfg.DefaultVolume -= 5 + if cfg.DefaultVolume < 0 { + cfg.DefaultVolume = 0 + } + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + m.message = fmt.Sprintf("βœ“ Default volume: %d%%", cfg.DefaultVolume) + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + + case "4": // Toggle ConfirmStop + cfg.ConfirmStop = !cfg.ConfirmStop + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + v := "Off" + if cfg.ConfirmStop { + v = "On" + } + m.message = fmt.Sprintf("βœ“ Confirm before stopping: %s", v) + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + + case "5": // Toggle ShowMetadata + cfg.ShowMetadata = !cfg.ShowMetadata + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + v := "Off" + if cfg.ShowMetadata { + v = "On" + } + m.message = fmt.Sprintf("βœ“ Show station metadata: %s", v) + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + + case "6": // Reset to Defaults + cfg = config.DefaultPlayOptionsConfig() + if err := storage.SavePlayOptionsConfigToUnified(cfg); err == nil { + m.playOptsCfg = cfg + m.message = "βœ“ Play Options reset to defaults" + m.messageIsSuccess = true + } else { + m.message = fmt.Sprintf("βœ— Failed: %v", err) + m.messageIsSuccess = false + } + m.messageTime = 3 + return m, tickEverySecond() + } + return m, nil +} + +// viewPlayOptions renders the Play Options settings screen. +func (m SettingsModel) viewPlayOptions() string { + var content strings.Builder + + boolStr := func(b bool) string { + if b { + return "[On] " + } + return "[Off]" + } + + continueStr := boolStr(m.playOptsCfg.ContinueOnNavigate) + confirmStr := boolStr(m.playOptsCfg.ConfirmStop) + metaStr := boolStr(m.playOptsCfg.ShowMetadata) + + content.WriteString(normalItemStyle().Render(fmt.Sprintf(" 1. (Experimental) Continue playback when changing pages %s", continueStr))) + content.WriteString("\n") + content.WriteString(helpStyle().Render(" Keep station playing while browsing other screens.")) + content.WriteString("\n\n") + + content.WriteString(normalItemStyle().Render(fmt.Sprintf(" 2. Default volume (+5) β†’ %d%%", min(m.playOptsCfg.DefaultVolume+5, 100)))) + content.WriteString("\n") + content.WriteString(normalItemStyle().Render(fmt.Sprintf(" 3. Default volume (-5) β†’ %d%%", max(m.playOptsCfg.DefaultVolume-5, 0)))) + content.WriteString("\n") + content.WriteString(helpStyle().Render(fmt.Sprintf(" Current default volume: %d%%", m.playOptsCfg.DefaultVolume))) + content.WriteString("\n\n") + + content.WriteString(normalItemStyle().Render(fmt.Sprintf(" 4. Confirm before stopping playback %s", confirmStr))) + content.WriteString("\n") + content.WriteString(helpStyle().Render(" Show a prompt when pressing Esc/0 while a station is playing.")) + content.WriteString("\n\n") + + content.WriteString(normalItemStyle().Render(fmt.Sprintf(" 5. Show station metadata while playing %s", metaStr))) + content.WriteString("\n") + content.WriteString(helpStyle().Render(" Display bitrate, codec, country, and tags in the play screen.")) + content.WriteString("\n\n") + + content.WriteString(normalItemStyle().Render(" 6. Reset to Defaults")) + content.WriteString("\n") + content.WriteString(normalItemStyle().Render(" 7. Back")) + content.WriteString("\n") + + if m.message != "" { + content.WriteString("\n") + if m.messageIsSuccess { + content.WriteString(successStyle().Render(m.message)) + } else { + content.WriteString(errorStyle().Render(m.message)) + } + } + + return m.renderPageWithBottomHelp(PageLayout{ + Title: "βš™οΈ Settings > Play Options", + Content: content.String(), + Help: "1-7: Select β€’ Esc/7: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", + }, m.height) +} + // updatePlayHistory handles keys in the Play History settings sub-menu. func (m SettingsModel) updatePlayHistory(msg tea.KeyMsg) (tea.Model, tea.Cmd) { cfg := m.playHistoryCfg @@ -1007,7 +1196,7 @@ func (m SettingsModel) viewPlayHistory() string { } } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "βš™οΈ Settings > History > Play History", Content: content.String(), Help: "1-7: Select β€’ Esc/7: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", @@ -1077,7 +1266,7 @@ func (m SettingsModel) viewUpdates() string { content.WriteString(stationValueStyle().Render("Press Enter or 'r' to check for updates")) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ”„ Check for Updates", Content: content.String(), Help: "r: Refresh β€’ Esc: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", @@ -1125,9 +1314,15 @@ func (m SettingsModel) viewAbout() string { content.WriteString("\n\n") content.WriteString(helpStyle().Render("Requires: mpv for audio playback")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "ℹ️ About TERA", Content: content.String(), Help: "Esc/Enter: Back β€’ 0: Main Menu β€’ Ctrl+C: Quit", }, m.height) } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m SettingsModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/settings_test.go b/internal/ui/settings_test.go index b6379a3..f30d9a5 100644 --- a/internal/ui/settings_test.go +++ b/internal/ui/settings_test.go @@ -46,9 +46,10 @@ func TestSettingsMenuNavigation(t *testing.T) { expectedState settingsState }{ {"Press 1 for Theme", "1", settingsStateTheme}, - {"Press 5 for History", "5", settingsStateHistory}, - {"Press 6 for Updates", "6", settingsStateUpdates}, - {"Press 7 for About", "7", settingsStateAbout}, + {"Press 5 for Play Options", "5", settingsStatePlayOptions}, + {"Press 6 for History", "6", settingsStateHistory}, + {"Press 7 for Updates", "7", settingsStateUpdates}, + {"Press 8 for About", "8", settingsStateAbout}, } for _, tt := range tests { diff --git a/internal/ui/shuffle_settings.go b/internal/ui/shuffle_settings.go index 8f14972..05e1f98 100644 --- a/internal/ui/shuffle_settings.go +++ b/internal/ui/shuffle_settings.go @@ -33,6 +33,7 @@ type ShuffleSettingsModel struct { message string messageIsSuccess bool messageTime int + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewShuffleSettingsModel creates a new shuffle settings model @@ -415,7 +416,7 @@ func (m ShuffleSettingsModel) viewMenu() string { content.WriteString(infoStyle().Render("βœ“ Shuffle settings saved automatically")) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-6: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) @@ -446,7 +447,7 @@ func (m ShuffleSettingsModel) viewInterval() string { content.WriteString("\n\n") content.WriteString(infoStyle().Render("Auto-advance will skip to next shuffle station after this interval")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-6: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) @@ -477,7 +478,7 @@ func (m ShuffleSettingsModel) viewHistorySize() string { content.WriteString("\n\n") content.WriteString(infoStyle().Render("History allows you to go back to previous shuffle stations")) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: "↑↓/jk: Navigate β€’ Enter: Select β€’ 1-5: Shortcut β€’ Esc: Back β€’ 0: Main Menu", }, m.height) @@ -497,3 +498,9 @@ func boolToEnabledDisabled(b bool) string { } return "Disabled" } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m ShuffleSettingsModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/sleep_summary.go b/internal/ui/sleep_summary.go index 54b0fac..7690015 100644 --- a/internal/ui/sleep_summary.go +++ b/internal/ui/sleep_summary.go @@ -19,6 +19,7 @@ type SleepSummaryModel struct { setDuration time.Duration // the duration the user originally requested width int height int + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewSleepSummaryModel builds the summary from a completed session. @@ -78,7 +79,7 @@ func (m SleepSummaryModel) View() string { len(m.entries), formatSessionDuration(m.totalDuration)) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "πŸ’€ Sleep Timer β€” Session Summary", Content: content.String(), Help: "0: Main Menu β€’ Any other key: Exit", @@ -117,3 +118,9 @@ func truncate(s string, n int) string { } return string(runes[:n-1]) + "…" } + +// renderPageWithBottomHelp wraps RenderPageWithBottomHelp injecting the active now-playing bar. +func (m SleepSummaryModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + layout.NowPlaying = m.nowPlayingBar + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index f5793d6..a829245 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -230,10 +230,11 @@ func renderHeader() string { // PageLayout represents a consistent page layout structure type PageLayout struct { - Title string // Main title (optional) - Subtitle string // Subtitle (optional) - Content string // Main content area - Help string // Help text at bottom + Title string // Main title (optional) + Subtitle string // Subtitle (optional) + Content string // Main content area + NowPlaying string // Now-playing bar shown above the help text (optional) + Help string // Help text at bottom } // RenderPage renders a page with consistent layout using the template @@ -242,9 +243,17 @@ func RenderPage(layout PageLayout) string { // Assemble page content using shared helper content := assemblePageContent(layout) + // Add now-playing bar above help text (if provided) + if layout.NowPlaying != "" { + if layout.Content != "" { + content += "\n" + } + content += layout.NowPlaying + "\n" + } + // Add help text (if provided) if layout.Help != "" { - if layout.Content != "" { + if layout.Content != "" && layout.NowPlaying == "" { content += "\n" } content += helpStyle().Render(layout.Help) @@ -315,20 +324,30 @@ func RenderPageWithBottomHelp(layout PageLayout, terminalHeight int) string { p := getPadding() totalUsed := teraHeaderLines + contentLines + p.PageVertical // padding from docStyleNoTopPadding - // Calculate remaining space for help text to be at bottom - // Reserve 1 line for help text itself - remainingLines := terminalHeight - totalUsed - 1 + // Calculate remaining space for help text to be at bottom. + // Reserve 1 line for help text + 1 line for now-playing bar if present. + nowPlayingLineCount := 0 + if layout.NowPlaying != "" { + nowPlayingLineCount = 1 + } + remainingLines := terminalHeight - totalUsed - 1 - nowPlayingLineCount if remainingLines < 0 { remainingLines = 0 } - // Add spacing to push help text to bottom + // Add spacing to push now-playing + help text to bottom var b strings.Builder b.WriteString(content) for i := 0; i < remainingLines; i++ { b.WriteString("\n") } + // Now-playing bar above help text (if provided) + if layout.NowPlaying != "" { + b.WriteString(layout.NowPlaying) + b.WriteString("\n") + } + // Help text (if provided) if layout.Help != "" { b.WriteString(helpStyle().Render(layout.Help)) diff --git a/internal/ui/tag_playlists.go b/internal/ui/tag_playlists.go index 6ef66fc..c7eb24d 100644 --- a/internal/ui/tag_playlists.go +++ b/internal/ui/tag_playlists.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/ui/components" @@ -17,12 +18,13 @@ import ( type tagPlaylistsState int const ( - tagPlaylistsStateList tagPlaylistsState = iota // list of playlists - tagPlaylistsStateCreate // multi-step create/edit dialog - tagPlaylistsStateDetail // stations for selected playlist - tagPlaylistsStatePlaying // playing a station - tagPlaylistsStateTagInput // single tag input via 't' - tagPlaylistsStateManageTags // full tag manager via 'T' + tagPlaylistsStateConfirmStop tagPlaylistsState = iota // Phase 5: Confirm before stopping + tagPlaylistsStateList // list of playlists + tagPlaylistsStateCreate // multi-step create/edit dialog + tagPlaylistsStateDetail // stations for selected playlist + tagPlaylistsStatePlaying // playing a station + tagPlaylistsStateTagInput // single tag input via 't' + tagPlaylistsStateManageTags // full tag manager via 'T' ) // createStep tracks which step of the create/edit wizard is active. @@ -55,21 +57,14 @@ type TagPlaylistsModel struct { player *player.MPVPlayer // Playlist list view - playlists []playlistEntry - listCursor int - deleteConfirm bool // true = waiting for second 'd' to confirm playlist deletion + playlists []playlistEntry + listCursor int + deleteConfirm bool // true = waiting for second 'd' to confirm playlist deletion // Create / edit wizard - isEditing bool // true = editing existing playlist - editName string // original name (for edit/delete-then-recreate) - step createStep - inputBuffer string // typed playlist name - allTags []string // all known tags for selection - tagCursor int // cursor in tag selector - selectedTags map[string]bool // which tags are checked - - // Match mode toggle (create step 3) - matchMode string // "any" or "all" + isEditing bool // true = editing existing playlist + editName string // original name (for edit/delete-then-recreate) + // Removed unused function viewConfirmStop // Station detail view selectedPlaylist *playlistEntry @@ -87,6 +82,18 @@ type TagPlaylistsModel struct { saveMessageTime int width int height int + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state + + // Fields for create/edit wizard and tag selection + matchMode string + selectedTags map[string]bool + inputBuffer string + allTags []string + tagCursor int + step createStep + nowPlayingBar string // set by App when ContinueOnNavigate is active } // NewTagPlaylistsModel creates a Tag Playlists model. @@ -109,6 +116,10 @@ func NewTagPlaylistsModel( player: player.NewMPVPlayer(), matchMode: "any", selectedTags: make(map[string]bool), + inputBuffer: "", + allTags: make([]string, 0), + tagCursor: 0, + step: createStepName, width: 80, height: 24, } @@ -151,6 +162,8 @@ func (m TagPlaylistsModel) Update(msg tea.Msg) (TagPlaylistsModel, tea.Cmd) { var cmd tea.Cmd m.manageTags, cmd = m.manageTags.Update(msg) return m, cmd + case tagPlaylistsStateConfirmStop: + return m.handleConfirmStopInput(msg) } case tickMsg: @@ -542,25 +555,81 @@ func (m TagPlaylistsModel) updateDetail(msg tea.KeyMsg) (TagPlaylistsModel, tea. // Playing view // --------------------------------------------------------------------------- +// navigateBackCmd returns the appropriate command when the user presses Esc +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m TagPlaylistsModel) navigateBackCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Tag Playlists", + } + } + } + // Default: stop the player. + if m.player != nil { + _ = m.player.Stop() + } + return nil +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m TagPlaylistsModel) navigateToMainCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Tag Playlists", + } + }, + func() tea.Msg { return backToMainMsg{} }, + ) + } + // Default: stop the player, then navigate. + if m.player != nil { + _ = m.player.Stop() + } + return func() tea.Msg { return backToMainMsg{} } +} + func (m TagPlaylistsModel) updatePlaying(msg tea.KeyMsg) (TagPlaylistsModel, tea.Cmd) { if m.ratingMode { return m.handleRatingInput(msg) } switch msg.String() { case "esc": - if m.player != nil { - _ = m.player.Stop() + if m.playOptsCfg.ConfirmStop && !m.playOptsCfg.ContinueOnNavigate { + m.confirmStopTarget = "back" + m.state = tagPlaylistsStateConfirmStop + return m, nil } + cmd := m.navigateBackCmd() m.state = tagPlaylistsStateDetail m.selectedStation = nil if m.selectedPlaylist != nil { - m.loadDetailStations() // refresh in case tags were modified while playing + m.loadDetailStations() + } + if cmd != nil { + return m, cmd } case "0": - if m.player != nil { - _ = m.player.Stop() + if m.playOptsCfg.ConfirmStop && !m.playOptsCfg.ContinueOnNavigate { + m.confirmStopTarget = "main" + m.state = tagPlaylistsStateConfirmStop + return m, nil } - return m, func() tea.Msg { return backToMainMsg{} } + // Build cmd before clearing selectedStation. + cmd := m.navigateToMainCmd() + m.selectedStation = nil + return m, cmd case " ": if m.player != nil { if err := m.player.TogglePause(); err == nil { @@ -625,6 +694,48 @@ func (m TagPlaylistsModel) updatePlaying(msg tea.KeyMsg) (TagPlaylistsModel, tea return m, nil } +// viewConfirmStop renders the confirmation prompt for stopping playback. +func (m TagPlaylistsModel) viewConfirmStop() string { + var content strings.Builder + content.WriteString("Are you sure you want to stop playback?\n\n") + content.WriteString("y: Yes, stop\n") + content.WriteString("n/Esc: No, keep playing\n") + return m.renderPageWithBottomHelp(PageLayout{ + Title: "Confirm Stop", + Content: content.String(), + Help: "y: Yes β€’ n/Esc: No", + }, m.height) +} + +// handleConfirmStopInput handles key input during the confirm-stop prompt. +func (m TagPlaylistsModel) handleConfirmStopInput(msg tea.KeyMsg) (TagPlaylistsModel, tea.Cmd) { + switch msg.String() { + case "y", "1": + target := m.confirmStopTarget + m.confirmStopTarget = "" + var cmd tea.Cmd + if target == "main" { + m.state = tagPlaylistsStateList + cmd = m.navigateToMainCmd() + } else { + m.state = tagPlaylistsStateDetail + cmd = m.navigateBackCmd() + if m.selectedPlaylist != nil { + m.loadDetailStations() + } + } + m.selectedStation = nil + if cmd != nil { + return m, cmd + } + return m, nil + case "n", "2", "esc": + m.state = tagPlaylistsStatePlaying + return m, nil + } + return m, nil +} + func (m TagPlaylistsModel) handleRatingInput(msg tea.KeyMsg) (TagPlaylistsModel, tea.Cmd) { m.ratingMode = false if m.selectedStation == nil || m.ratingsManager == nil { @@ -695,17 +806,30 @@ func (m *TagPlaylistsModel) loadDetailStations() { }) } +// startPlayback initiates playback of the selected station. +// Phase 5: volume is resolved from PlayOptions (DefaultVolume / StartVolumeMode). func (m TagPlaylistsModel) startPlayback() tea.Cmd { if m.selectedStation == nil { return nil } station := *m.selectedStation - return func() tea.Msg { - if err := m.player.Play(&station); err != nil { - return playbackErrorMsg{err} - } - return playbackStartedMsg{} + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" { + startVol = m.playOptsCfg.LastUsedVolume + } + if station.Volume != nil { + startVol = *station.Volume } + return tea.Batch( + // Stop any app-level handed-off player before starting the new stream. + func() tea.Msg { return stopActivePlaybackMsg{} }, + func() tea.Msg { + if err := m.player.PlayWithVolume(&station, startVol); err != nil { + return playbackErrorMsg{err} + } + return playbackStartedMsg{} + }, + ) } // --------------------------------------------------------------------------- @@ -729,6 +853,8 @@ func (m TagPlaylistsModel) View() string { return m.viewTagInput() case tagPlaylistsStateManageTags: return m.viewManageTags() + case tagPlaylistsStateConfirmStop: + return m.viewConfirmStop() } return "" } @@ -741,7 +867,7 @@ func (m TagPlaylistsModel) viewTagInput() string { sb.WriteString("\n\n") } sb.WriteString(m.tagInput.View()) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Add Tag", Content: sb.String(), Help: "Enter: Add β€’ Tab: Complete β€’ ↑↓: Navigate β€’ Esc: Cancel", @@ -750,7 +876,7 @@ func (m TagPlaylistsModel) viewTagInput() string { // viewManageTags renders the ManageTags dialog overlay. func (m TagPlaylistsModel) viewManageTags() string { - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🏷 Manage Tags", Content: m.manageTags.View(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ d: Done β€’ Esc: Cancel", @@ -787,7 +913,7 @@ func (m TagPlaylistsModel) viewList() string { renderSaveMessage(&sb, m.saveMessage) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Tag Playlists", Content: sb.String(), Help: "↑↓/jk: Navigate β€’ Enter: Play stations β€’ n: New β€’ e: Edit β€’ d: Delete (confirm) β€’ Esc/m: Back", @@ -822,7 +948,7 @@ func (m TagPlaylistsModel) viewCreateName(title string) string { sb.WriteString("\n") sb.WriteString(errorStyle().Render(m.saveMessage)) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: title, Content: sb.String(), Help: "Enter: Next step β€’ Esc: Cancel", @@ -868,7 +994,7 @@ func (m TagPlaylistsModel) viewCreateTags(title string) string { sb.WriteString(errorStyle().Render(m.saveMessage)) } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: title, Content: sb.String(), Help: "Space/Enter: Toggle β€’ ↑↓/jk: Navigate β€’ n: Next step β€’ Esc: Back", @@ -900,7 +1026,7 @@ func (m TagPlaylistsModel) viewCreateMatchMode(title string) string { preview := m.previewCount() fmt.Fprintf(&sb, "\n\n%s", infoStyle().Render(fmt.Sprintf("Preview: %d station(s) match", preview))) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: title, Content: sb.String(), Help: "←→/h/l/Space: Toggle β€’ Enter: Save β€’ Esc: Back", @@ -965,7 +1091,7 @@ func (m TagPlaylistsModel) viewDetail() string { renderSaveMessage(&sb, m.saveMessage) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: fmt.Sprintf("🎡 %s", m.selectedPlaylist.name), Content: sb.String(), Help: "↑↓/jk: Navigate β€’ Enter: Play β€’ Esc: Back", @@ -988,9 +1114,20 @@ func (m TagPlaylistsModel) viewPlaying() string { rating = r.Rating } } - sb.WriteString(RenderStationDetailsWithRating(*m.selectedStation, false, metadata, rating, m.starRenderer)) - sb.WriteString("\n") + // Phase 5: ShowMetadata wiring + if m.playOptsCfg.ShowMetadata && metadata != nil { + sb.WriteString(RenderStationDetailsWithRating(*m.selectedStation, false, metadata, rating, m.starRenderer)) + } else { + // Render only basic info (name, country, etc.) + sb.WriteString(boldStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + sb.WriteString(" ") + sb.WriteString(dimStyle().Render(m.selectedStation.Country)) + } + sb.WriteString("\n") + } + sb.WriteString("\n") if m.player != nil && m.player.IsPlaying() { if track := m.player.GetCachedTrack(); IsValidTrackMetadata(track, m.selectedStation.TrimName()) { sb.WriteString(successStyle().Render("β–Ά Now Playing:") + " " + infoStyle().Render(track)) @@ -1017,9 +1154,18 @@ func (m TagPlaylistsModel) viewPlaying() string { } renderSaveMessage(&sb, m.saveMessage) - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Title: "🎡 Now Playing", Content: sb.String(), Help: "Space: Pause β€’ r: Rate β€’ t: Tag β€’ /*: Volume β€’ 0: Main Menu β€’ ?: Help β€’ Esc: Back", }, m.height) } + +// renderPageWithBottomHelp injects the now-playing bar when the model's own +// player is not actively playing (so viewPlaying is unaffected). +func (m TagPlaylistsModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.state != tagPlaylistsStatePlaying && m.state != tagPlaylistsStateConfirmStop { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} diff --git a/internal/ui/top_rated.go b/internal/ui/top_rated.go index 7950460..5340d78 100644 --- a/internal/ui/top_rated.go +++ b/internal/ui/top_rated.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/shinokada/tera/v3/internal/api" "github.com/shinokada/tera/v3/internal/blocklist" + "github.com/shinokada/tera/v3/internal/config" "github.com/shinokada/tera/v3/internal/player" "github.com/shinokada/tera/v3/internal/storage" "github.com/shinokada/tera/v3/internal/ui/components" @@ -90,7 +92,8 @@ const ( topRatedStatePlaying topRatedStateSavePrompt topRatedStateSelectList - topRatedStateRating // Rating mode activated with * + topRatedStateRating // Rating mode activated with * + topRatedStateConfirmStop // Phase 5: Confirm before stopping ) // TopRatedModel represents the Top Rated screen @@ -106,7 +109,7 @@ type TopRatedModel struct { ratingsManager *storage.RatingsManager metadataManager *storage.MetadataManager starRenderer *components.StarRenderer - tagsManager *storage.TagsManager // for tag pill display + tagsManager *storage.TagsManager // for tag pill display tagRenderer *components.TagRenderer // for rendering tag pills favoritePath string saveMessage string @@ -122,6 +125,11 @@ type TopRatedModel struct { availableLists []string listItems []list.Item listModel list.Model + // Play options (injected by App) + playOptsCfg config.PlayOptionsConfig + confirmStopTarget string // "back" or "main" β€” set when entering confirmStop state + nowPlayingBar string // set by App when ContinueOnNavigate is active + pendingResolveUUID string // UUID of the in-flight GetByUUID lookup; cleared on resolution } // topRatedStationItem wraps a station with rating for the list @@ -176,10 +184,10 @@ func (i topRatedStationItem) Description() string { // NewTopRatedModel creates a new Top Rated model func NewTopRatedModel(ratingsManager *storage.RatingsManager, metadataManager *storage.MetadataManager, starRenderer *components.StarRenderer, favoritePath string, blocklistManager *blocklist.Manager) TopRatedModel { m := TopRatedModel{ - state: topRatedStateList, - sortBy: sortByRatingHigh, - filterBy: filterAllRatings, - player: player.NewMPVPlayer(), + state: topRatedStateList, + sortBy: sortByRatingHigh, + filterBy: filterAllRatings, + // player will be injected by App.Update ratingsManager: ratingsManager, metadataManager: metadataManager, starRenderer: starRenderer, @@ -188,7 +196,6 @@ func NewTopRatedModel(ratingsManager *storage.RatingsManager, metadataManager *s blocklistManager: blocklistManager, helpModel: components.NewHelpModel(createTopRatedHelp()), } - // Load voted stations votedStations, err := storage.LoadVotedStations() if err == nil { @@ -237,6 +244,13 @@ func (m TopRatedModel) loadStations() tea.Msg { type topRatedLoadedMsg struct{} +// topRatedResolvedMsg is sent when a UUID lookup completes +type topRatedResolvedMsg struct { + requestedUUID string + station *api.Station + err error +} + func (m TopRatedModel) Update(msg tea.Msg) (TopRatedModel, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd @@ -246,6 +260,33 @@ func (m TopRatedModel) Update(msg tea.Msg) (TopRatedModel, tea.Cmd) { m.refreshStationList() return m, nil + case topRatedResolvedMsg: + if msg.err != nil || msg.station == nil { + // Only clear pendingResolveUUID when this response matches the + // current pending request; stale failures must not clobber a + // newer in-flight lookup. + if msg.requestedUUID == m.pendingResolveUUID { + m.saveMessage = "Could not resolve station URL" + m.saveMessageSuccess = false + m.saveMessageTime = 3 + m.pendingResolveUUID = "" + return m, tickEverySecond() + } + return m, nil + } + // Discard stale responses (user changed selection or navigated away). + if m.pendingResolveUUID != msg.station.StationUUID { + return m, nil + } + m.pendingResolveUUID = "" + // Update the cache so future plays don't need a lookup. + if m.ratingsManager != nil { + if r := m.ratingsManager.GetRating(msg.station.StationUUID); r != nil { + _ = m.ratingsManager.SetRating(msg.station, r.Rating) + } + } + return m.playStation(*msg.station) + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -272,6 +313,8 @@ func (m TopRatedModel) Update(msg tea.Msg) (TopRatedModel, tea.Cmd) { return m.handleSelectListInput(msg) case topRatedStateRating: return m.handleRatingInput(msg) + case topRatedStateConfirmStop: + return m.handleConfirmStopInput(msg) } case tickMsg: @@ -355,11 +398,39 @@ func (m *TopRatedModel) refreshStationList() { m.stationListModel.SetItems(m.stationItems) } +// playStation starts playback for the given station and transitions to the playing state. +// Returns the updated model and a command. Use as: m, cmd = m.playStation(st) +func (m TopRatedModel) playStation(st api.Station) (TopRatedModel, tea.Cmd) { + startVol := m.playOptsCfg.DefaultVolume + if m.playOptsCfg.StartVolumeMode == "last_used" && m.playOptsCfg.LastUsedVolume > 0 { + startVol = m.playOptsCfg.LastUsedVolume + } + if st.Volume != nil { + startVol = *st.Volume + } + // Stop any app-level handed-off player (e.g. from Play from Favorites or Search + // Stations with ContinueOnNavigate on) before starting the new stream. + stopCmd := func() tea.Msg { return stopActivePlaybackMsg{} } + if err := m.player.PlayWithVolume(&st, startVol); err != nil { + m.err = err + return m, stopCmd + } + m.selectedStation = &st + m.state = topRatedStatePlaying + // Stop any app-level handed-off player so it doesn't overlap. + // The live player (m.player) stays with this screen; the handoff to App + // happens later when the user navigates away via navigateBackCmd / + // navigateToMainCmd, keeping volume/pause/status controls correct. + return m, stopCmd +} + func (m TopRatedModel) handleListInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd) { switch msg.String() { case "q", "esc", "m": - // Stop player before returning - if m.player != nil && m.player.IsPlaying() { + // Only stop the player if ContinueOnNavigate is off (or nothing is playing). + // When ContinueOnNavigate is on and a station was handed off to App, + // stopping here would kill the still-running stream. + if m.player != nil && m.player.IsPlaying() && !m.playOptsCfg.ContinueOnNavigate { _ = m.player.Stop() } return m, func() tea.Msg { return navigateMsg{screen: screenMainMenu} } @@ -384,20 +455,22 @@ func (m TopRatedModel) handleListInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd) if len(m.stationItems) > 0 { selected := m.stationListModel.SelectedItem() if item, ok := selected.(topRatedStationItem); ok { - m.selectedStation = &item.station - // Check if we have the URL to play if item.station.URLResolved != "" { - if err := m.player.Play(&item.station); err != nil { - m.err = err - } else { - m.state = topRatedStatePlaying - } - } else { - m.saveMessage = "Station URL not available (needs lookup)" - m.saveMessageSuccess = false - m.saveMessageTime = 3 - return m, tickEverySecond() + return m.playStation(item.station) } + // URL missing β€” look it up live from the API + uuid := item.station.StationUUID + m.pendingResolveUUID = uuid + m.saveMessage = "Looking up station…" + m.saveMessageSuccess = true + m.saveMessageTime = 5 + return m, tea.Batch(tickEverySecond(), func() tea.Msg { + client := api.NewClient() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + st, err := client.GetByUUID(ctx, uuid) + return topRatedResolvedMsg{requestedUUID: uuid, station: st, err: err} + }) } } return m, nil @@ -496,15 +569,82 @@ func (m TopRatedModel) handleRatingInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd return m, nil } +// navigateBackCmd returns the appropriate command when the user presses Esc +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m TopRatedModel) navigateBackCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Top Rated", + } + } + } + // Default: stop the player. + if m.player != nil { + _ = m.player.Stop() + } + return nil +} + +// navigateToMainCmd returns the appropriate command when the user presses 0 +// during playback. When ContinueOnNavigate is on it hands the player off to +// App; otherwise it stops the player first. +func (m TopRatedModel) navigateToMainCmd() tea.Cmd { + if m.playOptsCfg.ContinueOnNavigate && m.selectedStation != nil { + station := m.selectedStation + return tea.Batch( + func() tea.Msg { + return handoffPlaybackMsg{ + player: m.player, + station: station, + contextLabel: "Top Rated", + } + }, + func() tea.Msg { return navigateMsg{screen: screenMainMenu} }, + ) + } + // Default: stop the player, then navigate. + if m.player != nil { + _ = m.player.Stop() + } + return func() tea.Msg { return navigateMsg{screen: screenMainMenu} } +} + func (m TopRatedModel) handlePlayingInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd) { switch msg.String() { case "q", "esc", "m": - if m.player != nil { - _ = m.player.Stop() + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "back" + m.state = topRatedStateConfirmStop + return m, nil } + // Stop (or hand off) playback and return to list. + cmd := m.navigateBackCmd() m.state = topRatedStateList + m.selectedStation = nil + if cmd != nil { + return m, cmd + } return m, nil + case "0": + // Phase 5: Confirm before stopping + if m.playOptsCfg.ConfirmStop { + m.confirmStopTarget = "main" + m.state = topRatedStateConfirmStop + return m, nil + } + // Build cmd before clearing selectedStation. + cmd := m.navigateToMainCmd() + m.state = topRatedStateList + m.selectedStation = nil + return m, cmd + case "*": // Enter rating mode while playing m.state = topRatedStateRating @@ -524,6 +664,33 @@ func (m TopRatedModel) handlePlayingInput(msg tea.KeyMsg) (TopRatedModel, tea.Cm return m, nil } +// handleConfirmStopInput handles input in the ConfirmStop state (Phase 5) +func (m TopRatedModel) handleConfirmStopInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd) { + key := msg.String() + switch key { + case "y", "1": + target := m.confirmStopTarget + m.confirmStopTarget = "" + m.state = topRatedStateList + var cmd tea.Cmd + if target == "main" { + cmd = m.navigateToMainCmd() + } else { + cmd = m.navigateBackCmd() + } + m.selectedStation = nil + if cmd != nil { + return m, cmd + } + return m, nil + case "n", "2", "esc": + // Cancel stop, return to playing + m.state = topRatedStatePlaying + return m, nil + } + return m, nil +} + func (m TopRatedModel) handleSavePromptInput(msg tea.KeyMsg) (TopRatedModel, tea.Cmd) { switch msg.String() { case "y", "Y", "enter": @@ -653,23 +820,52 @@ func (m TopRatedModel) View() string { case topRatedStatePlaying: if m.selectedStation != nil { - content.WriteString(stationNameStyle().Render(m.selectedStation.Name)) - content.WriteString("\n") - if m.selectedStation.Country != "" { - content.WriteString(helpStyle().Render(m.selectedStation.Country)) + if m.playOptsCfg.ShowMetadata { + // Render full metadata block + var metadata *storage.StationMetadata + if m.metadataManager != nil { + metadata = m.metadataManager.GetMetadata(m.selectedStation.StationUUID) + } + var rating int + if m.ratingsManager != nil { + if r := m.ratingsManager.GetRating(m.selectedStation.StationUUID); r != nil { + rating = r.Rating + } + } + content.WriteString(RenderStationDetailsWithRating(*m.selectedStation, false, metadata, rating, m.starRenderer)) + content.WriteString("\n") + } else { + // Render only basic info + content.WriteString(stationNameStyle().Render(m.selectedStation.TrimName())) + if m.selectedStation.Country != "" { + content.WriteString(" ") + content.WriteString(dimStyle().Render(m.selectedStation.Country)) + } content.WriteString("\n") } - // Show current rating - if m.ratingsManager != nil { - rating := m.ratingsManager.GetRating(m.selectedStation.StationUUID) - if rating != nil { - stars := storage.RenderStars(rating.Rating, true) - content.WriteString(highlightStyle().Render(stars)) - content.WriteString("\n") + // Playback status with live track metadata + content.WriteString("\n") + if m.player != nil && m.player.IsPlaying() { + if m.player.IsPaused() { + content.WriteString(infoStyle().Render("⏸ Paused")) + } else { + track := m.player.GetCachedTrack() + if IsValidTrackMetadata(track, m.selectedStation.Name) { + content.WriteString(successStyle().Render("β–Ά Now Playing:")) + content.WriteString(" ") + content.WriteString(infoStyle().Render(track)) + } else { + content.WriteString(successStyle().Render("β–Ά Playing...")) + } } + } else { + content.WriteString(infoStyle().Render("⏹ Stopped")) } content.WriteString("\n") - content.WriteString(successStyle().Render("β–Ά Now Playing")) + // Volume + if m.player != nil { + fmt.Fprintf(&content, "\nVolume: %d%%", m.player.GetVolume()) + } } case topRatedStateSavePrompt: @@ -683,6 +879,11 @@ func (m TopRatedModel) View() string { case topRatedStateSelectList: content.WriteString("Select list to save to:\n\n") content.WriteString(m.listModel.View()) + + case topRatedStateConfirmStop: + content.WriteString("Are you sure you want to stop playback?\n\n") + content.WriteString("y: Yes, stop\n") + content.WriteString("n/Esc: No, keep playing\n") } // Show save message @@ -703,11 +904,22 @@ func (m TopRatedModel) View() string { case topRatedStateRating: helpText = "1-5: Set rating β€’ 0/r: Remove rating β€’ Esc: Cancel" case topRatedStatePlaying: - helpText = "s: Stop β€’ *1-5: Rate β€’ Esc: Back" + helpText = "s: Stop β€’ *1-5: Rate β€’ 0: Main Menu β€’ Esc: Back" } - return RenderPageWithBottomHelp(PageLayout{ + return m.renderPageWithBottomHelp(PageLayout{ Content: content.String(), Help: helpText, }, m.height) } + +// renderPageWithBottomHelp injects the now-playing bar when the model's own +// player is not actively playing (so viewPlaying is unaffected). +func (m TopRatedModel) renderPageWithBottomHelp(layout PageLayout, height int) string { + if m.player == nil || !m.player.IsPlaying() { + layout.NowPlaying = m.nowPlayingBar + } + return RenderPageWithBottomHelp(layout, height) +} + +// Phase 5: Confirm stop prompt view