Compare commits

..

3 Commits

Author SHA1 Message Date
a2eaa03090 feat(favorites): per-favorite volume persistence (narrowed to favorited stations only); update Station and Favorites with Volume support and wiring in TUI
All checks were successful
CI / Test (push) Successful in 55s
Release / Create Release (push) Successful in 2m11s
CI / Build (push) Successful in 47s
2026-06-06 11:59:41 +01:00
71bb7c0cf2 docs: document per-favorite volume persistence and narrow scope; prepare v2.1.1 2026-06-06 11:59:28 +01:00
6ed2225a4f chore(todo): add todo/ directory modeled on grokkit; seed with per-station-volume as first queued item
All checks were successful
CI / Test (push) Successful in 55s
CI / Build (push) Successful in 40s
2026-06-06 11:46:24 +01:00
8 changed files with 187 additions and 14 deletions

View File

@ -5,6 +5,19 @@ All notable changes to gostations will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.1] - 2026-06-07
### Added
- Per-favorite volume persistence: the last volume set while playing a station that is in your favorites list is remembered and restored the next time you play that specific favorite. Non-favorited stations continue to use the global last volume (from `radiostations.ini` or the default).
- Volumes for favorites are stored inside `favorites.json` (no separate file), using the existing atomic save/load pattern.
### Changed
- Playback entry now prioritizes: per-favorite saved volume (if the station is favorited) > live session volume (for stickiness across `s`/`x` within a run) > global last volume.
- Volume changes while playing a favorite also update the per-favorite volume (saved immediately, like global).
- Explicit saves of per-favorite volume on `s`/`x` stop and on quit (in addition to per-change saves).
See the `todo/queued/per-station-volume.md` for implementation details and the narrowed scope (favorited stations only).
## [2.1.0] - 2026-06-06
### Added

View File

@ -60,7 +60,7 @@ A compact, Winamp-inspired screen:
- A vertical volume bar to the right of the metadata (dark gray background, green fill from the bottom; same height as the viewer; small gap).
- Keyboard-driven on-screen controls:
- `←` / `→` (or `h`/`l`) — skip back/forward.
- `↑` / `↓` — volume up/down.
- `↑` / `↓` — volume up/down (per-favorite volume is restored when starting a favorited station; changes are saved for that favorite).
- `m` / `M` — mute / unmute.
- `Space` or `p` / `P` — play / pause.
- `s` / `S` / `x` / `X` — stop playback and return to the station list.
@ -113,6 +113,8 @@ Notable keys:
Favorites are stored as JSON (`favorites.json`) in the same directory. The TUI prefers loading favorites first when any exist.
Per-favorite volume preferences are also stored in the same `favorites.json` file (only for stations you have explicitly favorited). When you start playback for a favorited station, its last-used volume is restored (falling back to the global last volume or the default). Volume changes while playing a favorite are persisted for that station. Non-favorites always use the global last volume.
## Develop (for power users & developers)
### Prerequisites
@ -140,10 +142,10 @@ See the `Makefile` for the exact ldflags, per-platform `go mod download`, and th
### Releasing
```bash
./release.sh v2.1.0
./release.sh v2.1.1
# or manually
git tag -a v2.1.0 -m "..."
git push origin v2.1.0
git tag -a v2.1.1 -m "..."
git push origin v2.1.1
```
`release.sh` does a clean-tree check, creates the annotated tag, optionally runs `grokkit changelog` (best-effort), and pushes. The Gitea Actions workflow (`.gitea/workflows/release.yml`) then:

View File

@ -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

View File

@ -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.
}

View File

@ -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

9
todo/README.md Normal file
View File

@ -0,0 +1,9 @@
# Gostations TODO List
This document provides a table of contents for all tasks and features currently tracked in the `todo/` directory.
## Queued
* [1] [per-station-volume.md](./queued/per-station-volume.md) : per-station volume savings
## Completed

4
todo/queued/TODO_ITEM.md Normal file
View File

@ -0,0 +1,4 @@
# TODO ITEM 1
- [ ] 1 step one
- [ ] 2 step two
- [ ] 3 step three

View File

@ -0,0 +1,74 @@
# Per-station volume savings (favorites only)
**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 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 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. **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** (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**:
- No new methods required for basic functionality (we pass `--volume` on `Play`).
- (Optional future) Add `SetVolume` for runtime adjustment after start.
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
| Key | Description |
|-----|-------------|
| (none yet) | Could add `player.per_station_volume=true` (default on) in future |
## Implementation Notes
- **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. 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) |
| 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.