feat(favorites): per-favorite volume persistence (narrowed to favorited stations only); update Station and Favorites with Volume support and wiring in TUI
This commit is contained in:
parent
71bb7c0cf2
commit
a2eaa03090
@ -146,6 +146,35 @@ func (f *Favorites) List() []radio.Station {
|
|||||||
return out
|
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).
|
// Path returns the JSON file location (for diagnostics / "config path" style cmds).
|
||||||
func (f *Favorites) Path() string {
|
func (f *Favorites) Path() string {
|
||||||
return f.path
|
return f.path
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type Station struct {
|
|||||||
Tags string `json:"tags"`
|
Tags string `json:"tags"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Lastcheck int `json:"lastcheckok"`
|
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.
|
// Future: UUID string `json:"stationuuid"` etc for stable favorites.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -227,6 +227,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
_ = a.player.Stop()
|
_ = a.player.Stop()
|
||||||
}
|
}
|
||||||
config.SetLastVolume(a.currentVolume)
|
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
|
a.quitting = true
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
case "s", "S", "x", "X":
|
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
|
// Save immediately on user action so it is persisted even if
|
||||||
// the user stops playback before the next poll.
|
// the user stops playback before the next poll.
|
||||||
config.SetLastVolume(a.currentVolume)
|
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.
|
// Schedule a message to clear the flash highlight shortly after.
|
||||||
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
|
||||||
return clearVolFlashMsg{up: isUp}
|
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.nowPlaying = i.station.Name
|
||||||
a.paused = false
|
a.paused = false
|
||||||
a.muted = false
|
a.muted = false
|
||||||
// Prefer live currentVolume from previous playback in this session
|
// Priority for volume when starting a station:
|
||||||
// (so volume is sticky across s/x + new station).
|
// 1. Per-favorite saved volume (if this station is in favorites and has one).
|
||||||
// Fall back to the persisted last volume from the ini only if we
|
// 2. Live session currentVolume (stickiness across s/x for non-favorites or
|
||||||
// haven't played anything yet in this run.
|
// favorites that don't have their own saved volume yet).
|
||||||
if a.currentVolume == 0 {
|
// 3. Global last volume from the ini.
|
||||||
a.currentVolume = config.LastVolume()
|
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 {
|
if a.player != nil {
|
||||||
// Pass the desired volume as an extra arg for this specific
|
// 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 {
|
if wasFav {
|
||||||
a.favs.Remove(url)
|
a.favs.Remove(url)
|
||||||
} else {
|
} 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 {
|
if saveErr := a.favs.Save(); saveErr != nil {
|
||||||
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
|
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.
|
// file on every 600ms poll tick.
|
||||||
config.SetLastVolume(a.currentVolume)
|
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 {
|
if a.playing && a.player != nil {
|
||||||
return a, volumePollCmd(a.player)
|
return a, volumePollCmd(a.player)
|
||||||
@ -457,9 +492,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case stopPlaybackMsg:
|
case stopPlaybackMsg:
|
||||||
// Save the current volume on explicit stop (in addition to the
|
// Save the current volume on explicit stop for both global and (if favorite)
|
||||||
// saves on every change) for belt-and-suspenders.
|
// per-station.
|
||||||
config.SetLastVolume(a.currentVolume)
|
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
|
// Perform the delayed transition out of playback now that the
|
||||||
// stop button flash has been visible.
|
// stop button flash has been visible.
|
||||||
a.playing = false
|
a.playing = false
|
||||||
|
|||||||
@ -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
|
## 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
|
## Benefits
|
||||||
|
|
||||||
- **Natural UX**: Volume preference is station-specific (a quiet classical station vs. a loud rock station).
|
- **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 → volume is already where you left it.
|
- **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.
|
- **Low friction**: No extra UI; the existing volume controls + persistence just become smarter for your own list.
|
||||||
- **Backward compatible**: Falls back to global last-volume when no per-station entry exists.
|
- **Backward compatible**: Non-favorites always use global last-volume. Falls back gracefully.
|
||||||
- **Leverages existing patterns**: Same atomic JSON storage + XDG path as `favorites.json`.
|
- **Leverages existing patterns**: Volumes live inside the favorites data (no new file or unbounded store). Same atomic JSON + XDG path.
|
||||||
|
|
||||||
## High-Level Implementation
|
## High-Level Implementation
|
||||||
|
|
||||||
1. **New data store** (`internal/data/volumes.go`):
|
1. **Extend the existing Favorites store** (no new file needed):
|
||||||
- `type Volumes struct { ... }` (map[url]volume, mutex, dirty flag, path to `volumes.json`).
|
- Add `Volume int `json:"volume,omitempty"`` field to `radio.Station`.
|
||||||
- `NewVolumes()`, `Load()`, `Save()` (exact same atomic tmp+rename pattern as Favorites).
|
- Add `SetVolume(url string, vol int)` and `GetVolume(url string) int` to `*data.Favorites`.
|
||||||
- `Get(url string) (int, bool)`, `Set(url string, vol int)`, `Remove(url string)`.
|
- 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`):
|
2. **Wire into TUI / playback** (in `internal/ui/ui.go`):
|
||||||
- On entering playback for a station:
|
- On entering playback for a station (Enter in list):
|
||||||
- `if vol, ok := volumes.Get(station.Url); ok { desired = vol } else { desired = config.LastVolume() }`
|
- `desired := config.LastVolume()`
|
||||||
- Pass `desired` as `--volume=...` extra arg (or call a new `player.SetVolume(desired)` once IPC is ready).
|
- `if v := a.favs.GetVolume(station.Url); v > 0 { desired = v }`
|
||||||
- On every volume change (in `volumeMsg` handler or on keypress):
|
- Prefer live `a.currentVolume` for session stickiness across s/x within the same run.
|
||||||
- `volumes.Set(currentStation.Url, newVol)`
|
- Pass `desired` via `--volume=...` extra arg to `Play()`.
|
||||||
- `volumes.Save()` (or mark dirty and save on stop/quit for batching).
|
- On volume change (key handler and `volumeMsg`):
|
||||||
- On `s`/`x` stop or app quit: ensure pending volume for the current station is saved.
|
- 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):
|
3. **Player interface**:
|
||||||
- Add `SetVolume(v int) error` to `Player` interface.
|
- No new methods required for basic functionality (we pass `--volume` on `Play`).
|
||||||
- `mpvPlayer` implements it with `set_property volume X` + local cache update.
|
- (Optional future) Add `SetVolume` for runtime adjustment after start.
|
||||||
- `legacyPlayer` is a no-op (or could try command-line args on next Play).
|
|
||||||
|
|
||||||
4. **Config / init**:
|
4. **Scope is deliberately narrow**:
|
||||||
- Load volumes in `NewApp()` or lazily when first needed (same as favorites).
|
- Only stations that exist in the user's favorites list get per-station volumes.
|
||||||
- On station removal from favorites (if desired): optionally prune the volume entry.
|
- No unbounded storage for the entire radio-browser catalog.
|
||||||
|
- When you unfavorite a station, its volume preference is dropped.
|
||||||
5. **Persistence file**: `~/.config/gostations/volumes.json` (array of `{url, volume}` or map for simplicity).
|
- Non-favorites always use global `last_volume` (or 70).
|
||||||
|
|
||||||
## Flags / Config
|
## Flags / Config
|
||||||
|
|
||||||
@ -49,23 +52,23 @@ Currently only a single global `player.last_volume` is saved in `radiostations.i
|
|||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
- **Key choice**: Use the station's `Url` (same as Favorites). Stable enough for the use-case.
|
- **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 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.
|
- **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). IPC `set_property` after connect is a good fallback/override.
|
- **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 will be ignored for now (documented limitation).
|
- **Legacy player**: Per-station volumes are ignored (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.
|
- **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**: Add to `stations_test.go` or new `volumes_test.go`; mock the volumes store.
|
- **Tests**: Extend existing favorites tests and playback tests.
|
||||||
- **Effort**: Medium (~200-300 LOC new + wiring). Mostly data layer + 3-4 call sites in the TUI.
|
- **Effort**: Low-to-medium. Reuses the Favorites store and persistence; mostly wiring in the TUI playback entry and volume handlers. No new files.
|
||||||
|
|
||||||
## ROI
|
## 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 |
|
| Stage | Covered |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| Global last-volume | ✓ (already shipped in 2.1) |
|
| Global last-volume | ✓ (already shipped) |
|
||||||
| Per-station | **new** |
|
| Per-favorite volume | **new** (narrowed scope) |
|
||||||
| Favorites | ✓ (existing) |
|
| Favorites integration | ✓ (reuses existing store) |
|
||||||
| TUI playback | ✓ |
|
| TUI playback | ✓ |
|
||||||
|
|
||||||
Users will notice it immediately the second time they return to a station they previously tuned.
|
Users will notice it immediately the second time they return to a station they previously tuned.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user