Skip to content

Commit eaa7f85

Browse files
committed
Add startup session restore and yt-dlp resume support
1 parent ff5387e commit eaa7f85

8 files changed

Lines changed: 140 additions & 20 deletions

File tree

config.toml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ shuffle = false
1313
# Start with mono output (L+R downmix)
1414
mono = false
1515

16+
# Restore last playlist/track/position when reopening with no CLI args
17+
resume_session = true
18+
1619
# Shift+Left/Right seek jump in seconds (6-600)
1720
seek_large_step_sec = 30
1821

config/config.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string
108108

109109
// Config holds user preferences loaded from the config file.
110110
type Config struct {
111-
Volume float64 // dB, range [-30, +6]
112-
EQ [10]float64 // per-band gain in dB, range [-12, +12]
113-
EQPreset string // preset name, or "" for custom
114-
Repeat string // "off", "all", or "one"
111+
Volume float64 // dB, range [-30, +6]
112+
EQ [10]float64 // per-band gain in dB, range [-12, +12]
113+
EQPreset string // preset name, or "" for custom
114+
Repeat string // "off", "all", or "one"
115115
Shuffle bool
116116
Mono bool
117117
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
@@ -123,6 +123,7 @@ type Config struct {
123123
ResampleQuality int // beep resample quality factor (1–4)
124124
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32
125125
Compact bool // compact mode: cap frame width at 80 columns
126+
ResumeSession bool // restore last playlist/index/position on startup when no args
126127
Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials
127128
Spotify SpotifyConfig // optional Spotify provider (requires Premium)
128129
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
@@ -136,6 +137,7 @@ func Default() Config {
136137
return Config{
137138
Repeat: "off",
138139
SeekStepLarge: 30,
140+
ResumeSession: true,
139141
SampleRate: 0,
140142
BufferMs: 100,
141143
ResampleQuality: 4,
@@ -271,6 +273,8 @@ func Load() (Config, error) {
271273
}
272274
case "compact":
273275
cfg.Compact = val == "true"
276+
case "resume_session":
277+
cfg.ResumeSession = val == "true"
274278
}
275279
}
276280
}

docs/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ shuffle = false
2222
# Start with mono output (L+R downmix)
2323
mono = false
2424

25+
# Restore last playlist/track/position when reopening with no CLI args
26+
resume_session = true
27+
2528
# Shift+Left/Right seek jump in seconds
2629
seek_large_step_sec = 30
2730

internal/resume/resume.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ import (
66
"encoding/json"
77
"os"
88
"path/filepath"
9+
"strings"
910

1011
"cliamp/internal/appdir"
12+
"cliamp/playlist"
1113
)
1214

1315
// State holds enough information to resume a previous playback session.
1416
type State struct {
15-
Path string `json:"path"`
16-
PositionSec int `json:"position_sec"`
17+
Path string `json:"path"`
18+
PositionSec int `json:"position_sec"`
19+
Tracks []playlist.Track `json:"tracks,omitempty"`
20+
CurrentIndex int `json:"current_index,omitempty"`
21+
Shuffle bool `json:"shuffle,omitempty"`
22+
Repeat string `json:"repeat,omitempty"` // "off", "all", "one"
1723
}
1824

1925
func stateFile() (string, error) {
@@ -31,11 +37,24 @@ func Save(path string, positionSec int) {
3137
if path == "" || positionSec <= 0 {
3238
return
3339
}
40+
SaveState(State{Path: path, PositionSec: positionSec})
41+
}
42+
43+
// SaveState writes the full resume/session state to disk.
44+
// It no-ops only when there is no meaningful data to persist.
45+
func SaveState(s State) {
46+
if s.Path == "" && len(s.Tracks) == 0 {
47+
f, err := stateFile()
48+
if err == nil {
49+
_ = os.Remove(f)
50+
}
51+
return
52+
}
3453
f, err := stateFile()
3554
if err != nil {
3655
return
3756
}
38-
data, err := json.Marshal(State{Path: path, PositionSec: positionSec})
57+
data, err := json.Marshal(s)
3958
if err != nil {
4059
return
4160
}
@@ -58,5 +77,12 @@ func Load() State {
5877
if err := json.Unmarshal(data, &s); err != nil {
5978
return State{}
6079
}
80+
// Normalize repeat for older/corrupt files.
81+
s.Repeat = strings.ToLower(s.Repeat)
82+
switch s.Repeat {
83+
case "off", "all", "one":
84+
default:
85+
s.Repeat = ""
86+
}
6187
return s
6288
}

main.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ func run(overrides config.Overrides, positional []string) error {
128128
if err != nil {
129129
return err
130130
}
131+
resumeState := resume.State{}
132+
if cfg.ResumeSession {
133+
resumeState = resume.Load()
134+
}
135+
restoredSession := false
136+
if cfg.ResumeSession && len(positional) == 0 && len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 && len(resumeState.Tracks) > 0 {
137+
resolved.Tracks = append(resolved.Tracks, resumeState.Tracks...)
138+
restoredSession = true
139+
}
131140

132141
// Determine default provider key.
133142
defaultProvider := cfg.Provider
@@ -136,7 +145,7 @@ func run(overrides config.Overrides, positional []string) error {
136145
}
137146

138147
// No args + radio provider: stream the built-in radio directly.
139-
if len(positional) == 0 && defaultProvider == "radio" {
148+
if len(positional) == 0 && defaultProvider == "radio" && !restoredSession {
140149
resolved.Pending = append(resolved.Pending, "https://radio.cliamp.stream/streams.m3u")
141150
}
142151

@@ -174,6 +183,11 @@ func run(overrides config.Overrides, positional []string) error {
174183

175184
cfg.ApplyPlayer(p)
176185
cfg.ApplyPlaylist(pl)
186+
if restoredSession {
187+
pl.SetShuffle(resumeState.Shuffle)
188+
pl.SetRepeat(repeatModeFromSaved(resumeState.Repeat))
189+
pl.SetIndex(resumeState.CurrentIndex)
190+
}
177191

178192
themes := theme.LoadAll()
179193

@@ -200,8 +214,8 @@ func run(overrides config.Overrides, positional []string) error {
200214
}
201215

202216
// PositionSec == 0 is indistinguishable from "never played"; skip resume.
203-
if rs := resume.Load(); rs.Path != "" && rs.PositionSec > 0 {
204-
m.SetResume(rs.Path, rs.PositionSec)
217+
if cfg.ResumeSession && resumeState.Path != "" && resumeState.PositionSec > 0 {
218+
m.SetResume(resumeState.Path, resumeState.PositionSec)
205219
}
206220

207221
prog := tea.NewProgram(m, tea.WithAltScreen())
@@ -224,14 +238,37 @@ func run(overrides config.Overrides, positional []string) error {
224238
}
225239
_ = config.Save("theme", fmt.Sprintf("%q", themeName))
226240

227-
if path, secs := fm.ResumeState(); path != "" && secs > 0 {
228-
resume.Save(path, secs)
241+
if cfg.ResumeSession {
242+
path, secs := fm.ResumeState()
243+
tracks, idx, shuffle, repeat := fm.SessionState()
244+
if len(tracks) == 0 {
245+
tracks, idx, shuffle, repeat = fm.CurrentSessionState()
246+
}
247+
resume.SaveState(resume.State{
248+
Path: path,
249+
PositionSec: secs,
250+
Tracks: tracks,
251+
CurrentIndex: idx,
252+
Shuffle: shuffle,
253+
Repeat: repeat.String(),
254+
})
229255
}
230256
}
231257

232258
return nil
233259
}
234260

261+
func repeatModeFromSaved(v string) playlist.RepeatMode {
262+
switch strings.ToLower(v) {
263+
case "all":
264+
return playlist.RepeatAll
265+
case "one":
266+
return playlist.RepeatOne
267+
default:
268+
return playlist.RepeatOff
269+
}
270+
}
271+
235272
const helpText = `cliamp — retro terminal music player
236273
237274
Usage: cliamp [flags] <file|folder|url> [...]

playlist/playlist.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,14 @@ func (p *Playlist) ToggleShuffle() {
481481
p.pos = cur
482482
}
483483

484+
// SetShuffle enables or disables shuffle mode.
485+
func (p *Playlist) SetShuffle(enabled bool) {
486+
if p.shuffle == enabled {
487+
return
488+
}
489+
p.ToggleShuffle()
490+
}
491+
484492
func (p *Playlist) doShuffle() {
485493
cur := p.order[p.pos]
486494
others := make([]int, 0, len(p.tracks)-1)
@@ -504,6 +512,16 @@ func (p *Playlist) CycleRepeat() {
504512
p.repeat = (p.repeat + 1) % 3
505513
}
506514

515+
// SetRepeat sets repeat mode directly.
516+
func (p *Playlist) SetRepeat(mode RepeatMode) {
517+
switch mode {
518+
case RepeatAll, RepeatOne:
519+
p.repeat = mode
520+
default:
521+
p.repeat = RepeatOff
522+
}
523+
}
524+
507525
// Shuffled returns whether shuffle is enabled.
508526
func (p *Playlist) Shuffled() bool { return p.shuffle }
509527

ui/keys.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,21 @@ import (
1717

1818
// quit shuts down the player and signals the TUI to exit.
1919
func (m *Model) quit() tea.Cmd {
20-
// Only save resume for seekable tracks:
21-
// - local files (not stream)
22-
// - HTTP streams with known duration (podcast MP3s, seek-by-reconnect)
23-
// Exclude YTDL (position unreliable) and real-time live streams.
20+
// Snapshot session state for startup restore.
21+
tracks := m.playlist.Tracks()
22+
m.exitSession.tracks = append([]playlist.Track(nil), tracks...)
23+
m.exitSession.index = m.playlist.Index()
24+
m.exitSession.shuffle = m.playlist.Shuffled()
25+
m.exitSession.repeat = m.playlist.Repeat()
26+
27+
// Only save resume for tracks we can seek back into:
28+
// - local files
29+
// - seekable HTTP streams (known duration)
30+
// - yt-dlp tracks (seek-by-restart)
31+
// Exclude real-time live streams.
2432
if track, _ := m.playlist.Current(); track.Path != "" &&
25-
!playlist.IsYTDL(track.Path) && !track.IsLive() &&
33+
!track.IsLive() &&
34+
(m.player.Seekable() || m.player.IsYTDLSeek()) &&
2635
m.player.IsPlaying() {
2736
if secs := int(m.player.Position().Seconds()); secs > 0 {
2837
m.exitResume.path = track.Path

ui/model.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ type Model struct {
172172
path string
173173
secs int
174174
}
175+
exitSession struct {
176+
tracks []playlist.Track
177+
index int
178+
shuffle bool
179+
repeat playlist.RepeatMode
180+
}
175181

176182
// preloading is true while a preloadStreamCmd goroutine is in-flight.
177183
preloading bool
@@ -307,6 +313,19 @@ func (m Model) ResumeState() (path string, secs int) {
307313
return m.exitResume.path, m.exitResume.secs
308314
}
309315

316+
// SessionState returns the playlist/session state captured at exit.
317+
func (m Model) SessionState() (tracks []playlist.Track, index int, shuffle bool, repeat playlist.RepeatMode) {
318+
return m.exitSession.tracks, m.exitSession.index, m.exitSession.shuffle, m.exitSession.repeat
319+
}
320+
321+
// CurrentSessionState returns session state from the active playlist model.
322+
// This is used as a fallback when quit() wasn't the shutdown path.
323+
func (m Model) CurrentSessionState() (tracks []playlist.Track, index int, shuffle bool, repeat playlist.RepeatMode) {
324+
src := m.playlist.Tracks()
325+
tracks = append([]playlist.Track(nil), src...)
326+
return tracks, m.playlist.Index(), m.playlist.Shuffled(), m.playlist.Repeat()
327+
}
328+
310329
// ThemeName returns the current theme name.
311330
func (m Model) ThemeName() string {
312331
if m.themeIdx < 0 || m.themeIdx >= len(m.themes) {
@@ -1217,9 +1236,10 @@ func (m *Model) applyResume() {
12171236
if track.Path != m.resume.path {
12181237
return
12191238
}
1220-
// Only seek if the player reports the stream is seekable; otherwise the
1221-
// seek is a no-op that returns nil, which we must not mistake for success.
1222-
if !m.player.Seekable() {
1239+
// Allow resume when:
1240+
// - the current source is seekable, or
1241+
// - yt-dlp seek-by-restart is available.
1242+
if !m.player.Seekable() && !m.player.IsYTDLSeek() {
12231243
return
12241244
}
12251245
target := time.Duration(m.resume.secs) * time.Second

0 commit comments

Comments
 (0)