Skip to content

Commit ff5387e

Browse files
authored
Use explicit realtime stream policy for pause/unpause (#99)
1 parent 1ba4d02 commit ff5387e

8 files changed

Lines changed: 106 additions & 21 deletions

File tree

external/radio/provider.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ func (p *Provider) Tracks(id string) ([]playlist.Track, error) {
7272
}
7373
s := p.stations[idx]
7474
return []playlist.Track{{
75-
Path: s.url,
76-
Title: s.name,
77-
Stream: true,
75+
Path: s.url,
76+
Title: s.name,
77+
Stream: true,
78+
Realtime: true,
7879
}}, nil
7980
}
8081

@@ -127,4 +128,3 @@ func loadStations(path string) ([]station, error) {
127128
}
128129
return stations, nil
129130
}
130-

playlist/playlist.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type Track struct {
3939
Year int
4040
TrackNumber int
4141
Stream bool // true for HTTP/HTTPS URLs
42+
Realtime bool // true for real-time/live streams (e.g. radio)
4243
DurationSecs int // known duration in seconds (0 = unknown)
4344
NavidromeID string // Subsonic song ID; empty for non-Navidrome tracks
4445
}
@@ -235,7 +236,7 @@ func trackFromURL(rawURL string) Track {
235236

236237
// IsLive reports whether the track is a live stream (e.g. Icecast radio)
237238
func (t Track) IsLive() bool {
238-
return t.Stream && t.DurationSecs == 0
239+
return t.Realtime
239240
}
240241

241242
// DisplayName returns a formatted display string for the track.

resolve/m3u.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,26 @@ func parseM3U(r io.Reader, baseDir string) ([]m3uEntry, error) {
8383

8484
// m3uEntryToTrack converts a parsed M3U entry to a playlist.Track.
8585
func m3uEntryToTrack(e m3uEntry) playlist.Track {
86+
isURL := playlist.IsURL(e.Path)
87+
duration := 0
88+
if e.Duration > 0 {
89+
duration = e.Duration
90+
}
91+
realtime := isURL && e.Duration < 0
92+
8693
if e.Title != "" {
8794
return playlist.Track{
88-
Path: e.Path,
89-
Title: e.Title,
90-
Stream: playlist.IsURL(e.Path),
95+
Path: e.Path,
96+
Title: e.Title,
97+
Stream: isURL,
98+
Realtime: realtime,
99+
DurationSecs: duration,
91100
}
92101
}
93-
return playlist.TrackFromPath(e.Path)
102+
t := playlist.TrackFromPath(e.Path)
103+
t.Realtime = realtime
104+
t.DurationSecs = duration
105+
return t
94106
}
95107

96108
// entriesToTracks converts parsed M3U entries to playlist tracks.

resolve/pls.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ func plsEntriesToTracks(entries []plsEntry) []playlist.Track {
9797
return []playlist.Track{playlist.TrackFromPath(e.File)}
9898
}
9999
return []playlist.Track{{
100-
Path: e.File,
101-
Title: title,
102-
Stream: true,
100+
Path: e.File,
101+
Title: title,
102+
Stream: true,
103+
Realtime: true,
103104
}}
104105
}
105106

ui/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ func resolveWrapperURLs(tracks []playlist.Track) []playlist.Track {
201201
if resolved[i].Artist == "" {
202202
resolved[i].Artist = t.Artist
203203
}
204+
if t.Realtime {
205+
resolved[i].Realtime = true
206+
}
204207
}
205208
out = append(out, resolved...)
206209
continue

ui/keys.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func (m *Model) quit() tea.Cmd {
2020
// Only save resume for seekable tracks:
2121
// - local files (not stream)
2222
// - HTTP streams with known duration (podcast MP3s, seek-by-reconnect)
23-
// Exclude YTDL (position unreliable) and live streams (no duration).
23+
// Exclude YTDL (position unreliable) and real-time live streams.
2424
if track, _ := m.playlist.Current(); track.Path != "" &&
2525
!playlist.IsYTDL(track.Path) && !track.IsLive() &&
2626
m.player.IsPlaying() {
@@ -528,8 +528,6 @@ func (m *Model) saveTrack() tea.Cmd {
528528
return nil
529529
}
530530

531-
532-
533531
func (m *Model) resetJumpInput() {
534532
m.jumpInput = ""
535533
}
@@ -1018,4 +1016,3 @@ func (m *Model) handleQueueKey(msg tea.KeyMsg) tea.Cmd {
10181016
}
10191017
return nil
10201018
}
1021-

ui/model.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
type focusArea int
2323

2424
const (
25-
focusPlaylist focusArea = iota
25+
focusPlaylist focusArea = iota
2626
focusEQ
2727
focusProvPill
2828
focusSearch
@@ -117,7 +117,7 @@ type Model struct {
117117

118118
// Provider state
119119
provider playlist.Provider
120-
localProvider *local.Provider // direct ref for write operations (add-to-playlist)
120+
localProvider *local.Provider // direct ref for write operations (add-to-playlist)
121121
providerLists []playlist.PlaylistInfo
122122
provCursor int
123123
provLoading bool
@@ -156,8 +156,8 @@ type Model struct {
156156
feedLoading bool
157157

158158
// Async stream buffering (true while HTTP connect is in progress)
159-
buffering bool
160-
bufferingAt time.Time // when buffering started, for elapsed display
159+
buffering bool
160+
bufferingAt time.Time // when buffering started, for elapsed display
161161

162162
// resume holds the path and position to seek to when the matching track
163163
// starts playing. Cleared after the seek is performed.
@@ -1392,7 +1392,7 @@ func (m *Model) togglePlayPause() tea.Cmd {
13921392
}
13931393
if m.player.IsPaused() {
13941394
track, idx := m.playlist.Current()
1395-
if idx >= 0 && track.IsLive() {
1395+
if shouldReconnectOnUnpause(track, idx) {
13961396
m.player.Stop()
13971397
return m.playTrack(track)
13981398
}
@@ -1401,6 +1401,12 @@ func (m *Model) togglePlayPause() tea.Cmd {
14011401
return nil
14021402
}
14031403

1404+
// shouldReconnectOnUnpause reports whether unpausing should reconnect and
1405+
// restart instead of resuming buffered audio.
1406+
func shouldReconnectOnUnpause(track playlist.Track, idx int) bool {
1407+
return idx >= 0 && track.IsLive()
1408+
}
1409+
14041410
// lyricsArtistTitle resolves the best artist and title for a lyrics lookup.
14051411
// For streams with ICY metadata ("Artist - Song"), it parses the stream title.
14061412
// For regular tracks, it uses the track's metadata fields.

ui/model_pause_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package ui
2+
3+
import (
4+
"testing"
5+
6+
"cliamp/playlist"
7+
)
8+
9+
func TestShouldReconnectOnUnpause(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
track playlist.Track
13+
idx int
14+
want bool
15+
}{
16+
{
17+
name: "live http stream reconnects",
18+
track: playlist.Track{
19+
Path: "https://radio.example.com/stream",
20+
Stream: true,
21+
Realtime: true,
22+
},
23+
idx: 0,
24+
want: true,
25+
},
26+
{
27+
name: "regular stream does not reconnect",
28+
track: playlist.Track{
29+
Path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
30+
Stream: true,
31+
},
32+
idx: 0,
33+
want: false,
34+
},
35+
{
36+
name: "invalid current index does not reconnect",
37+
track: playlist.Track{
38+
Path: "https://radio.example.com/stream",
39+
Stream: true,
40+
Realtime: true,
41+
},
42+
idx: -1,
43+
want: false,
44+
},
45+
{
46+
name: "known duration live stream still reconnects",
47+
track: playlist.Track{
48+
Path: "https://radio.example.com/show.mp3",
49+
Stream: true,
50+
Realtime: true,
51+
DurationSecs: 120,
52+
},
53+
idx: 0,
54+
want: true,
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
if got := shouldReconnectOnUnpause(tt.track, tt.idx); got != tt.want {
61+
t.Fatalf("shouldReconnectOnUnpause(...) = %v, want %v", got, tt.want)
62+
}
63+
})
64+
}
65+
}

0 commit comments

Comments
 (0)