diff --git a/internal/data/favorites.go b/internal/data/favorites.go index 8d55745..8663ef6 100644 --- a/internal/data/favorites.go +++ b/internal/data/favorites.go @@ -146,6 +146,35 @@ func (f *Favorites) List() []radio.Station { return out } +// SetVolume sets the preferred volume for a favorited station (by URL). +// Does nothing if the station is not currently favorited. Marks dirty. +func (f *Favorites) SetVolume(url string, vol int) { + if vol < 0 { + vol = 0 + } + if vol > 100 { + vol = 100 + } + f.mu.Lock() + defer f.mu.Unlock() + if s, ok := f.stations[url]; ok { + s.Volume = vol + f.stations[url] = s + f.dirty = true + } +} + +// GetVolume returns the preferred volume for a favorited station, or 0 if none set +// or the station is not favorited. +func (f *Favorites) GetVolume(url string) int { + f.mu.RLock() + defer f.mu.RUnlock() + if s, ok := f.stations[url]; ok { + return s.Volume + } + return 0 +} + // Path returns the JSON file location (for diagnostics / "config path" style cmds). func (f *Favorites) Path() string { return f.path diff --git a/internal/radio/radio.go b/internal/radio/radio.go index 471ff7e..f88ce32 100644 --- a/internal/radio/radio.go +++ b/internal/radio/radio.go @@ -25,6 +25,7 @@ type Station struct { Tags string `json:"tags"` Url string `json:"url"` Lastcheck int `json:"lastcheckok"` + Volume int `json:"volume,omitempty"` // per-favorite preferred volume (0 = use global) // Future: UUID string `json:"stationuuid"` etc for stable favorites. } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 3fad5c3..85b7a39 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -227,6 +227,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = a.player.Stop() } config.SetLastVolume(a.currentVolume) + if a.favs != nil && a.playingItem.station.Url != "" { + if a.favs.Contains(a.playingItem.station.Url) { + a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume) + _ = a.favs.Save() + } + } a.quitting = true return a, tea.Quit case "s", "S", "x", "X": @@ -304,6 +310,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Save immediately on user action so it is persisted even if // the user stops playback before the next poll. config.SetLastVolume(a.currentVolume) + // If this is a favorited station, also persist the per-station volume. + if a.favs != nil && a.playingItem.station.Url != "" { + if a.favs.Contains(a.playingItem.station.Url) { + a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume) + _ = a.favs.Save() + } + } // Schedule a message to clear the flash highlight shortly after. clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg { return clearVolFlashMsg{up: isUp} @@ -339,14 +352,23 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.nowPlaying = i.station.Name a.paused = false a.muted = false - // Prefer live currentVolume from previous playback in this session - // (so volume is sticky across s/x + new station). - // Fall back to the persisted last volume from the ini only if we - // haven't played anything yet in this run. - if a.currentVolume == 0 { - a.currentVolume = config.LastVolume() + // Priority for volume when starting a station: + // 1. Per-favorite saved volume (if this station is in favorites and has one). + // 2. Live session currentVolume (stickiness across s/x for non-favorites or + // favorites that don't have their own saved volume yet). + // 3. Global last volume from the ini. + desired := config.LastVolume() + if a.favs != nil { + if v := a.favs.GetVolume(i.station.Url); v > 0 { + desired = v + } } - desired := a.currentVolume + if a.currentVolume > 0 && desired == config.LastVolume() { + // Only fall back to live session value if we didn't have a specific + // per-favorite preference for this station. + desired = a.currentVolume + } + a.currentVolume = desired if a.player != nil { // Pass the desired volume as an extra arg for this specific @@ -380,7 +402,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if wasFav { a.favs.Remove(url) } else { - a.favs.Add(sel.station) + toAdd := sel.station + // If we're adding a station we recently played (or are playing), + // capture the current volume so it becomes the per-favorite default. + if a.currentVolume > 0 && toAdd.Url == a.playingItem.station.Url { + toAdd.Volume = a.currentVolume + } + a.favs.Add(toAdd) } if saveErr := a.favs.Save(); saveErr != nil { statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error()) @@ -437,6 +465,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // file on every 600ms poll tick. config.SetLastVolume(a.currentVolume) } + // If playing a favorited station, persist its per-station volume too. + if a.favs != nil && a.playingItem.station.Url != "" { + if a.favs.Contains(a.playingItem.station.Url) { + a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume) + _ = a.favs.Save() + } + } } if a.playing && a.player != nil { return a, volumePollCmd(a.player) @@ -457,9 +492,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil case stopPlaybackMsg: - // Save the current volume on explicit stop (in addition to the - // saves on every change) for belt-and-suspenders. + // Save the current volume on explicit stop for both global and (if favorite) + // per-station. config.SetLastVolume(a.currentVolume) + if a.favs != nil && a.playingItem.station.Url != "" { + if a.favs.Contains(a.playingItem.station.Url) { + a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume) + _ = a.favs.Save() + } + } // Perform the delayed transition out of playback now that the // stop button flash has been visible. a.playing = false diff --git a/todo/queued/per-station-volume.md b/todo/queued/per-station-volume.md index c4139b6..d6fcc21 100644 --- a/todo/queued/per-station-volume.md +++ b/todo/queued/per-station-volume.md @@ -1,45 +1,48 @@ -# Per-station volume savings +# Per-station volume savings (favorites only) -**Description**: Persist the last-used volume level on a per-station basis (keyed by stream URL), so that returning to a favorite or previously-played station restores the volume the user last set for *that specific station*, rather than always falling back to the global last-volume. +**Description**: Persist the last-used volume level *only for stations in your favorites list* (keyed by stream URL). When you return to a favorited station, it restores the volume you last set for *that specific station* (falling back to the global last-volume if none saved for it). Non-favorited stations always use the global last-volume. ## Problem It Solves -Currently only a single global `player.last_volume` is saved in `radiostations.ini`. When a user fine-tunes the volume while listening to Station A (e.g. to 35), stops, then plays Station B, the volume is reset to whatever was last saved globally (or the default 70). Users have to re-adjust the volume every time they switch stations. +Currently only a single global `player.last_volume` is saved in `radiostations.ini`. When a user fine-tunes the volume while listening to a favorited Station A (e.g. to 35), stops, then plays another favorited Station B, the volume resets to the global value (or default 70). Users have to re-adjust every time they switch between their own favorites. We deliberately limit this to favorited stations only (no unbounded per-station data for the entire radio-browser corpus). ## Benefits -- **Natural UX**: Volume preference is station-specific (a quiet classical station vs. a loud rock station). -- **Seamless resumption**: Pick a favorite → volume is already where you left it. -- **Low friction**: No extra UI; the existing volume controls + persistence just become smarter. -- **Backward compatible**: Falls back to global last-volume when no per-station entry exists. -- **Leverages existing patterns**: Same atomic JSON storage + XDG path as `favorites.json`. +- **Natural UX**: Volume preference is specific to your favorited stations (e.g. a quiet classical vs. a loud rock station). +- **Seamless resumption**: Pick a favorite from your list → volume is already where you left it for *that* station. +- **Low friction**: No extra UI; the existing volume controls + persistence just become smarter for your own list. +- **Backward compatible**: Non-favorites always use global last-volume. Falls back gracefully. +- **Leverages existing patterns**: Volumes live inside the favorites data (no new file or unbounded store). Same atomic JSON + XDG path. ## High-Level Implementation -1. **New data store** (`internal/data/volumes.go`): - - `type Volumes struct { ... }` (map[url]volume, mutex, dirty flag, path to `volumes.json`). - - `NewVolumes()`, `Load()`, `Save()` (exact same atomic tmp+rename pattern as Favorites). - - `Get(url string) (int, bool)`, `Set(url string, vol int)`, `Remove(url string)`. +1. **Extend the existing Favorites store** (no new file needed): + - Add `Volume int `json:"volume,omitempty"`` field to `radio.Station`. + - Add `SetVolume(url string, vol int)` and `GetVolume(url string) int` to `*data.Favorites`. + - When `Add`ing or updating a favorite while a volume is active, capture it on the station. + - Volumes are automatically persisted inside `favorites.json` (only for stations you have favorited). + - `Remove` naturally drops the volume data for that station. -2. **Wire into TUI / playback start** (in `internal/ui/ui.go`): - - On entering playback for a station: - - `if vol, ok := volumes.Get(station.Url); ok { desired = vol } else { desired = config.LastVolume() }` - - Pass `desired` as `--volume=...` extra arg (or call a new `player.SetVolume(desired)` once IPC is ready). - - On every volume change (in `volumeMsg` handler or on keypress): - - `volumes.Set(currentStation.Url, newVol)` - - `volumes.Save()` (or mark dirty and save on stop/quit for batching). - - On `s`/`x` stop or app quit: ensure pending volume for the current station is saved. +2. **Wire into TUI / playback** (in `internal/ui/ui.go`): + - On entering playback for a station (Enter in list): + - `desired := config.LastVolume()` + - `if v := a.favs.GetVolume(station.Url); v > 0 { desired = v }` + - Prefer live `a.currentVolume` for session stickiness across s/x within the same run. + - Pass `desired` via `--volume=...` extra arg to `Play()`. + - On volume change (key handler and `volumeMsg`): + - After updating `currentVolume`, if the current station's URL is in favorites: `a.favs.SetVolume(url, currentVolume)` and `Save()`. + - Always still call `config.SetLastVolume` for the global fallback. + - On `s`/`x` stop or quit: ensure the current station's per-fav volume (if any) is saved via `favs.SetVolume` + `Save()`. -3. **Player interface extension** (optional but clean): - - Add `SetVolume(v int) error` to `Player` interface. - - `mpvPlayer` implements it with `set_property volume X` + local cache update. - - `legacyPlayer` is a no-op (or could try command-line args on next Play). +3. **Player interface**: + - No new methods required for basic functionality (we pass `--volume` on `Play`). + - (Optional future) Add `SetVolume` for runtime adjustment after start. -4. **Config / init**: - - Load volumes in `NewApp()` or lazily when first needed (same as favorites). - - On station removal from favorites (if desired): optionally prune the volume entry. - -5. **Persistence file**: `~/.config/gostations/volumes.json` (array of `{url, volume}` or map for simplicity). +4. **Scope is deliberately narrow**: + - Only stations that exist in the user's favorites list get per-station volumes. + - No unbounded storage for the entire radio-browser catalog. + - When you unfavorite a station, its volume preference is dropped. + - Non-favorites always use global `last_volume` (or 70). ## Flags / Config @@ -49,23 +52,23 @@ Currently only a single global `player.last_volume` is saved in `radiostations.i ## Implementation Notes -- **Key choice**: Use the station's `Url` (same as Favorites). Stable enough for the use-case. -- **When to persist**: Save on every change (cheap) or only on stop/quit. Current global volume already saves on change; we can do the same for per-station or batch on exit. -- **mpv timing**: The `--volume=XX` extra arg passed to `Play()` is the simplest reliable way (command-line wins). IPC `set_property` after connect is a good fallback/override. -- **Legacy player**: Per-station volumes will be ignored for now (documented limitation). -- **First-time migration**: Existing global last-volume becomes the fallback; no automatic per-station entries are created until user actually changes volume on a station. -- **Tests**: Add to `stations_test.go` or new `volumes_test.go`; mock the volumes store. -- **Effort**: Medium (~200-300 LOC new + wiring). Mostly data layer + 3-4 call sites in the TUI. +- **Key choice**: Use the station's `Url` (same as Favorites). Volumes only exist for stations the user has explicitly favorited. +- **When to persist**: Save on volume change for the favorite (immediate, like global), plus on stop/quit. Non-favorites never create per-station entries. +- **mpv timing**: The `--volume=XX` extra arg passed to `Play()` is the simplest reliable way (command-line wins for the launch of that station). +- **Legacy player**: Per-station volumes are ignored (documented limitation). +- **First-time migration**: Existing global last-volume is the fallback. No per-station entries until the user actually changes volume while playing a favorite. +- **Tests**: Extend existing favorites tests and playback tests. +- **Effort**: Low-to-medium. Reuses the Favorites store and persistence; mostly wiring in the TUI playback entry and volume handlers. No new files. ## ROI -**High**. Volume is one of the most frequently adjusted controls in a radio player. Making it "stick" per station removes a constant micro-friction and makes the TUI feel more thoughtful and polished. +**High**. Volume is one of the most frequently adjusted controls. Making it "stick" for the stations *you actually care about* (your favorites) removes friction without the complexity of tracking every station ever played. | Stage | Covered | |-------|---------| -| Global last-volume | ✓ (already shipped in 2.1) | -| Per-station | **new** | -| Favorites | ✓ (existing) | +| Global last-volume | ✓ (already shipped) | +| Per-favorite volume | **new** (narrowed scope) | +| Favorites integration | ✓ (reuses existing store) | | TUI playback | ✓ | Users will notice it immediately the second time they return to a station they previously tuned.