Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e63308e
feat(config): add PlayOptionsConfig for v3.11 Play Options feature
shinokada Mar 12, 2026
3482e70
feat(ui): wire app-level active player and play options config for v3.11
shinokada Mar 12, 2026
f757fca
feat(ui): Phase 3 β€” add navigateBackCmd/navigateToMainCmd to all play…
shinokada Mar 13, 2026
a8a59a6
fix(ui): add 0β†’main-menu key to TopRated playing state; fixes unused …
shinokada Mar 13, 2026
f1e74a8
feat(settings): add Play Options screen (Phase 4)
shinokada Mar 13, 2026
eb14f21
fix(test): update settings menu key tests to reflect 8-item menu
shinokada Mar 13, 2026
a887b8f
feat: Phase 5 play options wiring (partial) - volume + metadata + con…
shinokada Mar 13, 2026
e693c62
fix: On Windows, call taskkill /F /T /PID <PID> first. The /T flag te…
shinokada Mar 24, 2026
48ff599
feat: Play options
shinokada Mar 25, 2026
e9bd056
fix: isolate config env in tests, rm dead branch, fix docs shortcuts
shinokada Mar 25, 2026
3dfd6dc
fix: lucky confirmStop handler, search ConfirmStop gate, Cleanup vol …
shinokada Mar 25, 2026
e9372aa
fix: coderabbit suggestions
shinokada Mar 25, 2026
9ebfdf8
fix: clip Quick Play Favorites list to terminal height to prevent men…
shinokada Mar 25, 2026
dd81c44
fix: coderabbit suggestions
shinokada Mar 25, 2026
010d7e7
fix: patch 8 playback bugs (handoff, dedupe, sleep timer, player swap)
shinokada Mar 25, 2026
cf2ef74
fix: cap lastVol at 100 and exclude quickFavPlayer from stopScreenPla…
shinokada Mar 25, 2026
caf1697
fix: search help overlay, QF viewport scrolling and DisplayRows cap
shinokada Mar 25, 2026
db004a5
fix(ui): gate tickEverySecond to stop idle 1Hz re-render loop
shinokada Mar 25, 2026
1ea56a8
fix: use infoStyle for non-error messages in search; gate RenderStati…
shinokada Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,13 +133,13 @@ tera play <source> [args] [--duration <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` | β€” | `<keyword ...>` | 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` | β€” | `<keyword ...>` | Play a random station matching keyword(s) |

`[list-name]` defaults to `My-favorites`. `[n]` defaults to `1` (first item, 1-based).

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
50 changes: 34 additions & 16 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -155,13 +156,13 @@ tera play <source> [args] [--duration <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` | β€” | `<keyword ...>` | 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` | β€” | `<keyword ...>` | Play a random station matching keyword(s) |

`[list-name]` defaults to `My-favorites`. `[n]` defaults to `1` (first item, 1-based).

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions internal/blocklist/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -158,6 +181,7 @@ func DefaultConfig() Config {
MaxHistory: 5,
},
PlayHistory: DefaultPlayHistoryConfig(),
PlayOptions: DefaultPlayOptionsConfig(),
}
}

Expand Down Expand Up @@ -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, "; "))
}
Expand Down Expand Up @@ -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
}
Loading
Loading