detached 8096c50

feat: implement player lifecycle semantics

commit
8096c50dfdf34794f015ae71aead42eef6cd48af
author
brice · · 2026-04-09 15:24 UTC
parent
2f38a8b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +497 -33
M internal/core/player.go +177 -6
@@ -6,29 +6,39 @@ import (
66 "sync/atomic"
77 )
88
9-var ErrInvalidPlayerState = errors.New("invalid player state transition")
9+var (
10+ ErrInvalidPlayerState = errors.New("invalid player state transition")
11+ ErrPlayerClosed = errors.New("player is closed")
12+)
1013
11-// playerConfig holds configuration for the player
12-type playerConfig struct {
14+// PlayerConfig holds configuration for the player
15+type PlayerConfig struct {
1316 // Minimal for now, will be extended in future tasks
1417 }
1518
16-// playerCallbacks holds callbacks for player events
17-type playerCallbacks struct {
19+// PlayerCallbacks holds callbacks for player events
20+type PlayerCallbacks struct {
1821 OnStateChange func(PlayerState)
1922 }
2023
24+// playerConfig is the internal alias for PlayerConfig
25+type playerConfig = PlayerConfig
26+
27+// playerCallbacks is the internal alias for PlayerCallbacks
28+type playerCallbacks = PlayerCallbacks
29+
2130 // player is the internal player shell with state machine
2231 type player struct {
2332 config playerConfig
2433 callbacks playerCallbacks
2534 state atomic.Int32
2635 terminal atomic.Bool
36+ paused atomic.Bool
2737 mu sync.Mutex
2838 }
2939
3040 // newPlayer creates a new player instance
31-func newPlayer(config playerConfig, callbacks playerCallbacks) *player {
41+func newPlayer(config PlayerConfig, callbacks PlayerCallbacks) *player {
3242 p := &player{
3343 config: config,
3444 callbacks: callbacks,
@@ -98,3 +108,164 @@ func isValidTransition(current, next PlayerState) bool {
98108 return false
99109 }
100110 }
111+
112+// Start begins playback, transitioning from Idle or Stopped to Playing
113+func (p *player) Start() error {
114+ // Check if player is in terminal state
115+ current := p.State()
116+ if current == PlayerStateClosed || current == PlayerStateClosing {
117+ return ErrPlayerClosed
118+ }
119+
120+ // Only valid from Idle or Stopped
121+ if current != PlayerStateIdle && current != PlayerStateStopped {
122+ return ErrInvalidPlayerState
123+ }
124+
125+ // Ensure runtime is available (placeholder)
126+ if err := p.ensureRuntime(); err != nil {
127+ return err
128+ }
129+
130+ // Transition to Starting then Playing
131+ if err := p.transition(PlayerStateStarting); err != nil {
132+ return err
133+ }
134+
135+ // Clear paused flag
136+ p.setPaused(false)
137+
138+ // Transition to Playing
139+ return p.transition(PlayerStatePlaying)
140+}
141+
142+// Pause temporarily pauses playback
143+func (p *player) Pause() error {
144+ current := p.State()
145+ if current == PlayerStateClosed || current == PlayerStateClosing {
146+ return ErrPlayerClosed
147+ }
148+
149+ // Only valid from Playing
150+ if current != PlayerStatePlaying {
151+ return ErrInvalidPlayerState
152+ }
153+
154+ p.setPaused(true)
155+ return p.transition(PlayerStatePaused)
156+}
157+
158+// Stop stops playback but allows restart
159+func (p *player) Stop() error {
160+ current := p.State()
161+ if current == PlayerStateClosed || current == PlayerStateClosing {
162+ return ErrPlayerClosed
163+ }
164+
165+ // Valid from Playing, Paused, or Idle
166+ if current != PlayerStatePlaying && current != PlayerStatePaused && current != PlayerStateIdle {
167+ return ErrInvalidPlayerState
168+ }
169+
170+ // Clear paused flag
171+ p.setPaused(false)
172+
173+ // Deactivate stream (placeholder)
174+ p.deactivateStream()
175+
176+ return p.transition(PlayerStateStopped)
177+}
178+
179+// Close permanently shuts down the player
180+func (p *player) Close() error {
181+ current := p.State()
182+
183+ // Idempotent on already closed
184+ if current == PlayerStateClosed {
185+ return nil
186+ }
187+
188+ // Transition to Closing if not already there
189+ if current != PlayerStateClosing {
190+ if err := p.transition(PlayerStateClosing); err != nil {
191+ return err
192+ }
193+ }
194+
195+ // Teardown resources (placeholder)
196+ p.teardown()
197+
198+ // Transition to Closed
199+ return p.transition(PlayerStateClosed)
200+}
201+
202+// ensureRuntime ensures the runtime is available (placeholder)
203+func (p *player) ensureRuntime() error {
204+ // Minimal placeholder - will be implemented in future tasks
205+ return nil
206+}
207+
208+// setPaused sets the paused flag
209+func (p *player) setPaused(paused bool) {
210+ p.paused.Store(paused)
211+}
212+
213+// deactivateStream deactivates the stream (placeholder)
214+func (p *player) deactivateStream() {
215+ // Minimal placeholder - will be implemented in future tasks
216+}
217+
218+// teardown performs cleanup (placeholder)
219+func (p *player) teardown() {
220+ // Minimal placeholder - will be implemented in future tasks
221+}
222+
223+// Player is the exported wrapper for the internal player
224+type Player struct {
225+ p *player
226+}
227+
228+// NewPlayer creates a new exported Player instance
229+func NewPlayer(config playerConfig, callbacks playerCallbacks) *Player {
230+ return &Player{p: newPlayer(config, callbacks)}
231+}
232+
233+// State returns the current player state
234+func (p *Player) State() PlayerState {
235+ if p.p == nil {
236+ return PlayerStateIdle
237+ }
238+ return p.p.State()
239+}
240+
241+// Start begins playback
242+func (p *Player) Start() error {
243+ if p.p == nil {
244+ return ErrPlayerClosed
245+ }
246+ return p.p.Start()
247+}
248+
249+// Pause temporarily pauses playback
250+func (p *Player) Pause() error {
251+ if p.p == nil {
252+ return ErrPlayerClosed
253+ }
254+ return p.p.Pause()
255+}
256+
257+// Stop stops playback but allows restart
258+func (p *Player) Stop() error {
259+ if p.p == nil {
260+ return ErrPlayerClosed
261+ }
262+ return p.p.Stop()
263+}
264+
265+// Close permanently shuts down the player
266+func (p *Player) Close() error {
267+ if p.p == nil {
268+ return nil
269+ }
270+ return p.p.Close()
271+}
@@ -6,29 +6,39 @@ import (
6 "sync/atomic"6 "sync/atomic"
7 )7 )
8 8
9-var ErrInvalidPlayerState = errors.New("invalid player state transition")9+var (
10+ ErrInvalidPlayerState = errors.New("invalid player state transition")
11+ ErrPlayerClosed = errors.New("player is closed")
12+)
10 13
11-// playerConfig holds configuration for the player14+// PlayerConfig holds configuration for the player
12-type playerConfig struct {15+type PlayerConfig struct {
13 // Minimal for now, will be extended in future tasks16 // Minimal for now, will be extended in future tasks
14 }17 }
15 18
16-// playerCallbacks holds callbacks for player events19+// PlayerCallbacks holds callbacks for player events
17-type playerCallbacks struct {20+type PlayerCallbacks struct {
18 OnStateChange func(PlayerState)21 OnStateChange func(PlayerState)
19 }22 }
20 23
24+// playerConfig is the internal alias for PlayerConfig
25+type playerConfig = PlayerConfig
26+
27+// playerCallbacks is the internal alias for PlayerCallbacks
28+type playerCallbacks = PlayerCallbacks
29+
21 // player is the internal player shell with state machine30 // player is the internal player shell with state machine
22 type player struct {31 type player struct {
23 config playerConfig32 config playerConfig
24 callbacks playerCallbacks33 callbacks playerCallbacks
25 state atomic.Int3234 state atomic.Int32
26 terminal atomic.Bool35 terminal atomic.Bool
36+ paused atomic.Bool
27 mu sync.Mutex37 mu sync.Mutex
28 }38 }
29 39
30 // newPlayer creates a new player instance40 // newPlayer creates a new player instance
31-func newPlayer(config playerConfig, callbacks playerCallbacks) *player {41+func newPlayer(config PlayerConfig, callbacks PlayerCallbacks) *player {
32 p := &player{42 p := &player{
33 config: config,43 config: config,
34 callbacks: callbacks,44 callbacks: callbacks,
@@ -98,3 +108,164 @@ func isValidTransition(current, next PlayerState) bool {
98 return false108 return false
99 }109 }
100 }110 }
111+
112+// Start begins playback, transitioning from Idle or Stopped to Playing
113+func (p *player) Start() error {
114+ // Check if player is in terminal state
115+ current := p.State()
116+ if current == PlayerStateClosed || current == PlayerStateClosing {
117+ return ErrPlayerClosed
118+ }
119+
120+ // Only valid from Idle or Stopped
121+ if current != PlayerStateIdle && current != PlayerStateStopped {
122+ return ErrInvalidPlayerState
123+ }
124+
125+ // Ensure runtime is available (placeholder)
126+ if err := p.ensureRuntime(); err != nil {
127+ return err
128+ }
129+
130+ // Transition to Starting then Playing
131+ if err := p.transition(PlayerStateStarting); err != nil {
132+ return err
133+ }
134+
135+ // Clear paused flag
136+ p.setPaused(false)
137+
138+ // Transition to Playing
139+ return p.transition(PlayerStatePlaying)
140+}
141+
142+// Pause temporarily pauses playback
143+func (p *player) Pause() error {
144+ current := p.State()
145+ if current == PlayerStateClosed || current == PlayerStateClosing {
146+ return ErrPlayerClosed
147+ }
148+
149+ // Only valid from Playing
150+ if current != PlayerStatePlaying {
151+ return ErrInvalidPlayerState
152+ }
153+
154+ p.setPaused(true)
155+ return p.transition(PlayerStatePaused)
156+}
157+
158+// Stop stops playback but allows restart
159+func (p *player) Stop() error {
160+ current := p.State()
161+ if current == PlayerStateClosed || current == PlayerStateClosing {
162+ return ErrPlayerClosed
163+ }
164+
165+ // Valid from Playing, Paused, or Idle
166+ if current != PlayerStatePlaying && current != PlayerStatePaused && current != PlayerStateIdle {
167+ return ErrInvalidPlayerState
168+ }
169+
170+ // Clear paused flag
171+ p.setPaused(false)
172+
173+ // Deactivate stream (placeholder)
174+ p.deactivateStream()
175+
176+ return p.transition(PlayerStateStopped)
177+}
178+
179+// Close permanently shuts down the player
180+func (p *player) Close() error {
181+ current := p.State()
182+
183+ // Idempotent on already closed
184+ if current == PlayerStateClosed {
185+ return nil
186+ }
187+
188+ // Transition to Closing if not already there
189+ if current != PlayerStateClosing {
190+ if err := p.transition(PlayerStateClosing); err != nil {
191+ return err
192+ }
193+ }
194+
195+ // Teardown resources (placeholder)
196+ p.teardown()
197+
198+ // Transition to Closed
199+ return p.transition(PlayerStateClosed)
200+}
201+
202+// ensureRuntime ensures the runtime is available (placeholder)
203+func (p *player) ensureRuntime() error {
204+ // Minimal placeholder - will be implemented in future tasks
205+ return nil
206+}
207+
208+// setPaused sets the paused flag
209+func (p *player) setPaused(paused bool) {
210+ p.paused.Store(paused)
211+}
212+
213+// deactivateStream deactivates the stream (placeholder)
214+func (p *player) deactivateStream() {
215+ // Minimal placeholder - will be implemented in future tasks
216+}
217+
218+// teardown performs cleanup (placeholder)
219+func (p *player) teardown() {
220+ // Minimal placeholder - will be implemented in future tasks
221+}
222+
223+// Player is the exported wrapper for the internal player
224+type Player struct {
225+ p *player
226+}
227+
228+// NewPlayer creates a new exported Player instance
229+func NewPlayer(config playerConfig, callbacks playerCallbacks) *Player {
230+ return &Player{p: newPlayer(config, callbacks)}
231+}
232+
233+// State returns the current player state
234+func (p *Player) State() PlayerState {
235+ if p.p == nil {
236+ return PlayerStateIdle
237+ }
238+ return p.p.State()
239+}
240+
241+// Start begins playback
242+func (p *Player) Start() error {
243+ if p.p == nil {
244+ return ErrPlayerClosed
245+ }
246+ return p.p.Start()
247+}
248+
249+// Pause temporarily pauses playback
250+func (p *Player) Pause() error {
251+ if p.p == nil {
252+ return ErrPlayerClosed
253+ }
254+ return p.p.Pause()
255+}
256+
257+// Stop stops playback but allows restart
258+func (p *Player) Stop() error {
259+ if p.p == nil {
260+ return ErrPlayerClosed
261+ }
262+ return p.p.Stop()
263+}
264+
265+// Close permanently shuts down the player
266+func (p *Player) Close() error {
267+ if p.p == nil {
268+ return nil
269+ }
270+ return p.p.Close()
271+}
A internal/core/player_test.go +161 -0
@@ -0,0 +1,161 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
9+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
10+
11+ // Start from Stopped state should work
12+ p.setState(PlayerStateStopped)
13+ if err := p.Start(); err != nil {
14+ t.Fatalf("Start from Stopped failed: %v", err)
15+ }
16+
17+ // Should be in Playing state after Start
18+ if p.State() != PlayerStatePlaying {
19+ t.Fatalf("expected Playing state after Start, got %v", p.State())
20+ }
21+
22+ // Stop should transition to Stopped
23+ if err := p.Stop(); err != nil {
24+ t.Fatalf("Stop failed: %v", err)
25+ }
26+ if p.State() != PlayerStateStopped {
27+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
28+ }
29+
30+ // Start from Stopped should work again (restartable)
31+ if err := p.Start(); err != nil {
32+ t.Fatalf("Start from Stopped (restart) failed: %v", err)
33+ }
34+ if p.State() != PlayerStatePlaying {
35+ t.Fatalf("expected Playing state after restart, got %v", p.State())
36+ }
37+
38+ // Close should transition to Closed
39+ if err := p.Close(); err != nil {
40+ t.Fatalf("Close failed: %v", err)
41+ }
42+ if p.State() != PlayerStateClosed {
43+ t.Fatalf("expected Closed state after Close, got %v", p.State())
44+ }
45+
46+ // Start from Closed should fail (terminal)
47+ if err := p.Start(); err == nil {
48+ t.Fatal("expected error starting from Closed state, got nil")
49+ }
50+}
51+
52+func TestPlayerStartFromClosedReturnsError(t *testing.T) {
53+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
54+ p.setState(PlayerStateClosed)
55+
56+ err := p.Start()
57+ if err == nil {
58+ t.Fatal("expected error starting from Closed state, got nil")
59+ }
60+ if !errors.Is(err, ErrPlayerClosed) {
61+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
62+ }
63+}
64+
65+func TestPlayerStartFromClosingReturnsError(t *testing.T) {
66+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
67+ p.setState(PlayerStateClosing)
68+
69+ err := p.Start()
70+ if err == nil {
71+ t.Fatal("expected error starting from Closing state, got nil")
72+ }
73+ if !errors.Is(err, ErrPlayerClosed) {
74+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
75+ }
76+}
77+
78+func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
79+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
80+
81+ // Initial state should be Idle
82+ if p.State() != PlayerStateIdle {
83+ t.Fatalf("expected initial state Idle, got %v", p.State())
84+ }
85+
86+ // Start should transition to Playing
87+ if err := p.Start(); err != nil {
88+ t.Fatalf("Start failed: %v", err)
89+ }
90+
91+ if p.State() != PlayerStatePlaying {
92+ t.Fatalf("expected Playing state after Start, got %v", p.State())
93+ }
94+}
95+
96+func TestPlayerPauseTransitionsToPaused(t *testing.T) {
97+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
98+
99+ // Start first
100+ if err := p.Start(); err != nil {
101+ t.Fatalf("Start failed: %v", err)
102+ }
103+
104+ // Pause should transition to Paused
105+ if err := p.Pause(); err != nil {
106+ t.Fatalf("Pause failed: %v", err)
107+ }
108+
109+ if p.State() != PlayerStatePaused {
110+ t.Fatalf("expected Paused state after Pause, got %v", p.State())
111+ }
112+}
113+
114+func TestPlayerStopClearsPausedState(t *testing.T) {
115+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
116+
117+ // Start then pause
118+ if err := p.Start(); err != nil {
119+ t.Fatalf("Start failed: %v", err)
120+ }
121+ if err := p.Pause(); err != nil {
122+ t.Fatalf("Pause failed: %v", err)
123+ }
124+ if p.State() != PlayerStatePaused {
125+ t.Fatalf("expected Paused state, got %v", p.State())
126+ }
127+
128+ // Stop should transition to Stopped (clearing paused state)
129+ if err := p.Stop(); err != nil {
130+ t.Fatalf("Stop failed: %v", err)
131+ }
132+
133+ if p.State() != PlayerStateStopped {
134+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
135+ }
136+}
137+
138+func TestPlayerCloseIsIdempotent(t *testing.T) {
139+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
140+
141+ // Start first
142+ if err := p.Start(); err != nil {
143+ t.Fatalf("Start failed: %v", err)
144+ }
145+
146+ // Close should work
147+ if err := p.Close(); err != nil {
148+ t.Fatalf("first Close failed: %v", err)
149+ }
150+ if p.State() != PlayerStateClosed {
151+ t.Fatalf("expected Closed state, got %v", p.State())
152+ }
153+
154+ // Second close should also succeed (idempotent)
155+ if err := p.Close(); err != nil {
156+ t.Fatalf("second Close failed: %v", err)
157+ }
158+ if p.State() != PlayerStateClosed {
159+ t.Fatalf("expected Closed state after second close, got %v", p.State())
160+ }
161+}
@@ -0,0 +1,161 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
9+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
10+
11+ // Start from Stopped state should work
12+ p.setState(PlayerStateStopped)
13+ if err := p.Start(); err != nil {
14+ t.Fatalf("Start from Stopped failed: %v", err)
15+ }
16+
17+ // Should be in Playing state after Start
18+ if p.State() != PlayerStatePlaying {
19+ t.Fatalf("expected Playing state after Start, got %v", p.State())
20+ }
21+
22+ // Stop should transition to Stopped
23+ if err := p.Stop(); err != nil {
24+ t.Fatalf("Stop failed: %v", err)
25+ }
26+ if p.State() != PlayerStateStopped {
27+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
28+ }
29+
30+ // Start from Stopped should work again (restartable)
31+ if err := p.Start(); err != nil {
32+ t.Fatalf("Start from Stopped (restart) failed: %v", err)
33+ }
34+ if p.State() != PlayerStatePlaying {
35+ t.Fatalf("expected Playing state after restart, got %v", p.State())
36+ }
37+
38+ // Close should transition to Closed
39+ if err := p.Close(); err != nil {
40+ t.Fatalf("Close failed: %v", err)
41+ }
42+ if p.State() != PlayerStateClosed {
43+ t.Fatalf("expected Closed state after Close, got %v", p.State())
44+ }
45+
46+ // Start from Closed should fail (terminal)
47+ if err := p.Start(); err == nil {
48+ t.Fatal("expected error starting from Closed state, got nil")
49+ }
50+}
51+
52+func TestPlayerStartFromClosedReturnsError(t *testing.T) {
53+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
54+ p.setState(PlayerStateClosed)
55+
56+ err := p.Start()
57+ if err == nil {
58+ t.Fatal("expected error starting from Closed state, got nil")
59+ }
60+ if !errors.Is(err, ErrPlayerClosed) {
61+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
62+ }
63+}
64+
65+func TestPlayerStartFromClosingReturnsError(t *testing.T) {
66+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
67+ p.setState(PlayerStateClosing)
68+
69+ err := p.Start()
70+ if err == nil {
71+ t.Fatal("expected error starting from Closing state, got nil")
72+ }
73+ if !errors.Is(err, ErrPlayerClosed) {
74+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
75+ }
76+}
77+
78+func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
79+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
80+
81+ // Initial state should be Idle
82+ if p.State() != PlayerStateIdle {
83+ t.Fatalf("expected initial state Idle, got %v", p.State())
84+ }
85+
86+ // Start should transition to Playing
87+ if err := p.Start(); err != nil {
88+ t.Fatalf("Start failed: %v", err)
89+ }
90+
91+ if p.State() != PlayerStatePlaying {
92+ t.Fatalf("expected Playing state after Start, got %v", p.State())
93+ }
94+}
95+
96+func TestPlayerPauseTransitionsToPaused(t *testing.T) {
97+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
98+
99+ // Start first
100+ if err := p.Start(); err != nil {
101+ t.Fatalf("Start failed: %v", err)
102+ }
103+
104+ // Pause should transition to Paused
105+ if err := p.Pause(); err != nil {
106+ t.Fatalf("Pause failed: %v", err)
107+ }
108+
109+ if p.State() != PlayerStatePaused {
110+ t.Fatalf("expected Paused state after Pause, got %v", p.State())
111+ }
112+}
113+
114+func TestPlayerStopClearsPausedState(t *testing.T) {
115+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
116+
117+ // Start then pause
118+ if err := p.Start(); err != nil {
119+ t.Fatalf("Start failed: %v", err)
120+ }
121+ if err := p.Pause(); err != nil {
122+ t.Fatalf("Pause failed: %v", err)
123+ }
124+ if p.State() != PlayerStatePaused {
125+ t.Fatalf("expected Paused state, got %v", p.State())
126+ }
127+
128+ // Stop should transition to Stopped (clearing paused state)
129+ if err := p.Stop(); err != nil {
130+ t.Fatalf("Stop failed: %v", err)
131+ }
132+
133+ if p.State() != PlayerStateStopped {
134+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
135+ }
136+}
137+
138+func TestPlayerCloseIsIdempotent(t *testing.T) {
139+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
140+
141+ // Start first
142+ if err := p.Start(); err != nil {
143+ t.Fatalf("Start failed: %v", err)
144+ }
145+
146+ // Close should work
147+ if err := p.Close(); err != nil {
148+ t.Fatalf("first Close failed: %v", err)
149+ }
150+ if p.State() != PlayerStateClosed {
151+ t.Fatalf("expected Closed state, got %v", p.State())
152+ }
153+
154+ // Second close should also succeed (idempotent)
155+ if err := p.Close(); err != nil {
156+ t.Fatalf("second Close failed: %v", err)
157+ }
158+ if p.State() != PlayerStateClosed {
159+ t.Fatalf("expected Closed state after second close, got %v", p.State())
160+ }
161+}
M pipewire/player.go +65 -25
@@ -2,6 +2,8 @@ package pipewire
22
33 import (
44 "errors"
5+
6+ "github.com/bnema/purego-pipewire/internal/core"
57 )
68
79 // SampleFormat represents the audio sample format type.
@@ -26,22 +28,34 @@ const (
2628 type PlayerState int
2729
2830 const (
29- // Idle is the initial state before the player is started.
30- Idle PlayerState = iota
31- // Starting indicates the player is initializing.
32- Starting
33- // Playing indicates the player is actively outputting audio.
34- Playing
35- // Paused indicates the player is temporarily stopped.
36- Paused
37- // Stopped indicates the player has been stopped.
38- Stopped
39- // Closing indicates the player is shutting down.
40- Closing
41- // Closed indicates the player has been fully closed.
42- Closed
43- // Error indicates the player encountered an error.
44- Error
31+ // PlayerStateIdle is the initial state before the player is started.
32+ PlayerStateIdle PlayerState = iota
33+ // PlayerStateStarting indicates the player is initializing.
34+ PlayerStateStarting
35+ // PlayerStatePlaying indicates the player is actively outputting audio.
36+ PlayerStatePlaying
37+ // PlayerStatePaused indicates the player is temporarily stopped.
38+ PlayerStatePaused
39+ // PlayerStateStopped indicates the player has been stopped.
40+ PlayerStateStopped
41+ // PlayerStateClosing indicates the player is shutting down.
42+ PlayerStateClosing
43+ // PlayerStateClosed indicates the player has been fully closed.
44+ PlayerStateClosed
45+ // PlayerStateError indicates the player encountered an error.
46+ PlayerStateError
47+)
48+
49+// Legacy aliases for backwards compatibility (deprecated)
50+const (
51+ Idle = PlayerStateIdle
52+ Starting = PlayerStateStarting
53+ Playing = PlayerStatePlaying
54+ Paused = PlayerStatePaused
55+ Stopped = PlayerStateStopped
56+ Closing = PlayerStateClosing
57+ Closed = PlayerStateClosed
58+ Error = PlayerStateError
4559 )
4660
4761 // PlayerConfig holds the configuration for a new audio player.
@@ -82,32 +96,46 @@ type Player interface {
8296 // ErrInvalidPlayerConfig is returned when the player configuration is invalid.
8397 var ErrInvalidPlayerConfig = errors.New("invalid player config")
8498
85-// playerImpl is a placeholder implementation of the Player interface.
86-// Full implementation will be added in subsequent tasks.
99+// playerImpl wraps the internal core player
87100 type playerImpl struct {
88101 config PlayerConfig
89102 callbacks PlayerCallbacks
90- state PlayerState
103+ internal *core.Player
91104 }
92105
93106 func (p *playerImpl) Start() error {
94- return nil
107+ if p.internal == nil {
108+ return errors.New("player not initialized")
109+ }
110+ return p.internal.Start()
95111 }
96112
97113 func (p *playerImpl) Pause() error {
98- return nil
114+ if p.internal == nil {
115+ return errors.New("player not initialized")
116+ }
117+ return p.internal.Pause()
99118 }
100119
101120 func (p *playerImpl) Stop() error {
102- return nil
121+ if p.internal == nil {
122+ return errors.New("player not initialized")
123+ }
124+ return p.internal.Stop()
103125 }
104126
105127 func (p *playerImpl) Close() error {
106- return nil
128+ if p.internal == nil {
129+ return nil
130+ }
131+ return p.internal.Close()
107132 }
108133
109134 func (p *playerImpl) State() PlayerState {
110- return p.state
135+ if p.internal == nil {
136+ return PlayerStateIdle
137+ }
138+ return PlayerState(p.internal.State())
111139 }
112140
113141 // NewPlayer creates a new audio player with the given configuration and callbacks.
@@ -126,9 +154,21 @@ func NewPlayer(config PlayerConfig, callbacks PlayerCallbacks) (Player, error) {
126154 return nil, ErrInvalidPlayerConfig
127155 }
128156
157+ // Convert public callbacks to internal callbacks
158+ coreCallbacks := core.PlayerCallbacks{
159+ OnStateChange: func(state core.PlayerState) {
160+ if callbacks.OnStateChange != nil {
161+ callbacks.OnStateChange(PlayerState(state))
162+ }
163+ },
164+ }
165+
166+ // Create internal player
167+ internalPlayer := core.NewPlayer(core.PlayerConfig{}, coreCallbacks)
168+
129169 return &playerImpl{
130170 config: config,
131171 callbacks: callbacks,
132- state: Idle,
172+ internal: internalPlayer,
133173 }, nil
134174 }
@@ -2,6 +2,8 @@ package pipewire
2 2
3 import (3 import (
4 "errors"4 "errors"
5+
6+ "github.com/bnema/purego-pipewire/internal/core"
5 )7 )
6 8
7 // SampleFormat represents the audio sample format type.9 // SampleFormat represents the audio sample format type.
@@ -26,22 +28,34 @@ const (
26 type PlayerState int28 type PlayerState int
27 29
28 const (30 const (
29- // Idle is the initial state before the player is started.31+ // PlayerStateIdle is the initial state before the player is started.
30- Idle PlayerState = iota32+ PlayerStateIdle PlayerState = iota
31- // Starting indicates the player is initializing.33+ // PlayerStateStarting indicates the player is initializing.
32- Starting34+ PlayerStateStarting
33- // Playing indicates the player is actively outputting audio.35+ // PlayerStatePlaying indicates the player is actively outputting audio.
34- Playing36+ PlayerStatePlaying
35- // Paused indicates the player is temporarily stopped.37+ // PlayerStatePaused indicates the player is temporarily stopped.
36- Paused38+ PlayerStatePaused
37- // Stopped indicates the player has been stopped.39+ // PlayerStateStopped indicates the player has been stopped.
38- Stopped40+ PlayerStateStopped
39- // Closing indicates the player is shutting down.41+ // PlayerStateClosing indicates the player is shutting down.
40- Closing42+ PlayerStateClosing
41- // Closed indicates the player has been fully closed.43+ // PlayerStateClosed indicates the player has been fully closed.
42- Closed44+ PlayerStateClosed
43- // Error indicates the player encountered an error.45+ // PlayerStateError indicates the player encountered an error.
44- Error46+ PlayerStateError
47+)
48+
49+// Legacy aliases for backwards compatibility (deprecated)
50+const (
51+ Idle = PlayerStateIdle
52+ Starting = PlayerStateStarting
53+ Playing = PlayerStatePlaying
54+ Paused = PlayerStatePaused
55+ Stopped = PlayerStateStopped
56+ Closing = PlayerStateClosing
57+ Closed = PlayerStateClosed
58+ Error = PlayerStateError
45 )59 )
46 60
47 // PlayerConfig holds the configuration for a new audio player.61 // PlayerConfig holds the configuration for a new audio player.
@@ -82,32 +96,46 @@ type Player interface {
82 // ErrInvalidPlayerConfig is returned when the player configuration is invalid.96 // ErrInvalidPlayerConfig is returned when the player configuration is invalid.
83 var ErrInvalidPlayerConfig = errors.New("invalid player config")97 var ErrInvalidPlayerConfig = errors.New("invalid player config")
84 98
85-// playerImpl is a placeholder implementation of the Player interface.99+// playerImpl wraps the internal core player
86-// Full implementation will be added in subsequent tasks.
87 type playerImpl struct {100 type playerImpl struct {
88 config PlayerConfig101 config PlayerConfig
89 callbacks PlayerCallbacks102 callbacks PlayerCallbacks
90- state PlayerState103+ internal *core.Player
91 }104 }
92 105
93 func (p *playerImpl) Start() error {106 func (p *playerImpl) Start() error {
94- return nil107+ if p.internal == nil {
108+ return errors.New("player not initialized")
109+ }
110+ return p.internal.Start()
95 }111 }
96 112
97 func (p *playerImpl) Pause() error {113 func (p *playerImpl) Pause() error {
98- return nil114+ if p.internal == nil {
115+ return errors.New("player not initialized")
116+ }
117+ return p.internal.Pause()
99 }118 }
100 119
101 func (p *playerImpl) Stop() error {120 func (p *playerImpl) Stop() error {
102- return nil121+ if p.internal == nil {
122+ return errors.New("player not initialized")
123+ }
124+ return p.internal.Stop()
103 }125 }
104 126
105 func (p *playerImpl) Close() error {127 func (p *playerImpl) Close() error {
106- return nil128+ if p.internal == nil {
129+ return nil
130+ }
131+ return p.internal.Close()
107 }132 }
108 133
109 func (p *playerImpl) State() PlayerState {134 func (p *playerImpl) State() PlayerState {
110- return p.state135+ if p.internal == nil {
136+ return PlayerStateIdle
137+ }
138+ return PlayerState(p.internal.State())
111 }139 }
112 140
113 // NewPlayer creates a new audio player with the given configuration and callbacks.141 // NewPlayer creates a new audio player with the given configuration and callbacks.
@@ -126,9 +154,21 @@ func NewPlayer(config PlayerConfig, callbacks PlayerCallbacks) (Player, error) {
126 return nil, ErrInvalidPlayerConfig154 return nil, ErrInvalidPlayerConfig
127 }155 }
128 156
157+ // Convert public callbacks to internal callbacks
158+ coreCallbacks := core.PlayerCallbacks{
159+ OnStateChange: func(state core.PlayerState) {
160+ if callbacks.OnStateChange != nil {
161+ callbacks.OnStateChange(PlayerState(state))
162+ }
163+ },
164+ }
165+
166+ // Create internal player
167+ internalPlayer := core.NewPlayer(core.PlayerConfig{}, coreCallbacks)
168+
129 return &playerImpl{169 return &playerImpl{
130 config: config,170 config: config,
131 callbacks: callbacks,171 callbacks: callbacks,
132- state: Idle,172+ internal: internalPlayer,
133 }, nil173 }, nil
134 }174 }
M pipewire/player_test.go +94 -2
@@ -42,7 +42,99 @@ func TestPlayerStateReflectsInitialState(t *testing.T) {
4242 if err != nil {
4343 t.Fatalf("NewPlayer returned error: %v", err)
4444 }
45- if player.State() != Idle {
46- t.Fatalf("expected initial state Idle, got %v", player.State())
45+ if player.State() != PlayerStateIdle {
46+ t.Fatalf("expected initial state PlayerStateIdle, got %v", player.State())
47+ }
48+}
49+
50+func TestPlayerLifecycleStartStopClose(t *testing.T) {
51+ config := PlayerConfig{
52+ SampleRate: 44100,
53+ Channels: 2,
54+ FramesPerBuffer: 256,
55+ SampleFormat: SampleFormatF32,
56+ }
57+ player, err := NewPlayer(config, PlayerCallbacks{})
58+ if err != nil {
59+ t.Fatalf("NewPlayer returned error: %v", err)
60+ }
61+
62+ // Start should transition to Playing
63+ if err := player.Start(); err != nil {
64+ t.Fatalf("Start failed: %v", err)
65+ }
66+ if player.State() != PlayerStatePlaying {
67+ t.Fatalf("expected Playing state after Start, got %v", player.State())
68+ }
69+
70+ // Pause should transition to Paused
71+ if err := player.Pause(); err != nil {
72+ t.Fatalf("Pause failed: %v", err)
73+ }
74+ if player.State() != PlayerStatePaused {
75+ t.Fatalf("expected Paused state after Pause, got %v", player.State())
76+ }
77+
78+ // Stop should transition to Stopped
79+ if err := player.Stop(); err != nil {
80+ t.Fatalf("Stop failed: %v", err)
81+ }
82+ if player.State() != PlayerStateStopped {
83+ t.Fatalf("expected Stopped state after Stop, got %v", player.State())
84+ }
85+
86+ // Restart should work from Stopped
87+ if err := player.Start(); err != nil {
88+ t.Fatalf("Start from Stopped failed: %v", err)
89+ }
90+ if player.State() != PlayerStatePlaying {
91+ t.Fatalf("expected Playing state after restart, got %v", player.State())
92+ }
93+
94+ // Close should transition to Closed
95+ if err := player.Close(); err != nil {
96+ t.Fatalf("Close failed: %v", err)
97+ }
98+ if player.State() != PlayerStateClosed {
99+ t.Fatalf("expected Closed state after Close, got %v", player.State())
100+ }
101+
102+ // Start from Closed should fail
103+ if err := player.Start(); err == nil {
104+ t.Fatal("expected error starting from Closed state, got nil")
105+ }
106+}
107+
108+func TestPlayerCloseIsIdempotent(t *testing.T) {
109+ config := PlayerConfig{
110+ SampleRate: 44100,
111+ Channels: 2,
112+ FramesPerBuffer: 256,
113+ SampleFormat: SampleFormatF32,
114+ }
115+ player, err := NewPlayer(config, PlayerCallbacks{})
116+ if err != nil {
117+ t.Fatalf("NewPlayer returned error: %v", err)
118+ }
119+
120+ // Start first
121+ if err := player.Start(); err != nil {
122+ t.Fatalf("Start failed: %v", err)
123+ }
124+
125+ // First close should work
126+ if err := player.Close(); err != nil {
127+ t.Fatalf("first Close failed: %v", err)
128+ }
129+ if player.State() != PlayerStateClosed {
130+ t.Fatalf("expected Closed state, got %v", player.State())
131+ }
132+
133+ // Second close should succeed (idempotent)
134+ if err := player.Close(); err != nil {
135+ t.Fatalf("second Close failed: %v", err)
136+ }
137+ if player.State() != PlayerStateClosed {
138+ t.Fatalf("expected Closed state after second close, got %v", player.State())
47139 }
48140 }
@@ -42,7 +42,99 @@ func TestPlayerStateReflectsInitialState(t *testing.T) {
42 if err != nil {42 if err != nil {
43 t.Fatalf("NewPlayer returned error: %v", err)43 t.Fatalf("NewPlayer returned error: %v", err)
44 }44 }
45- if player.State() != Idle {45+ if player.State() != PlayerStateIdle {
46- t.Fatalf("expected initial state Idle, got %v", player.State())46+ t.Fatalf("expected initial state PlayerStateIdle, got %v", player.State())
47+ }
48+}
49+
50+func TestPlayerLifecycleStartStopClose(t *testing.T) {
51+ config := PlayerConfig{
52+ SampleRate: 44100,
53+ Channels: 2,
54+ FramesPerBuffer: 256,
55+ SampleFormat: SampleFormatF32,
56+ }
57+ player, err := NewPlayer(config, PlayerCallbacks{})
58+ if err != nil {
59+ t.Fatalf("NewPlayer returned error: %v", err)
60+ }
61+
62+ // Start should transition to Playing
63+ if err := player.Start(); err != nil {
64+ t.Fatalf("Start failed: %v", err)
65+ }
66+ if player.State() != PlayerStatePlaying {
67+ t.Fatalf("expected Playing state after Start, got %v", player.State())
68+ }
69+
70+ // Pause should transition to Paused
71+ if err := player.Pause(); err != nil {
72+ t.Fatalf("Pause failed: %v", err)
73+ }
74+ if player.State() != PlayerStatePaused {
75+ t.Fatalf("expected Paused state after Pause, got %v", player.State())
76+ }
77+
78+ // Stop should transition to Stopped
79+ if err := player.Stop(); err != nil {
80+ t.Fatalf("Stop failed: %v", err)
81+ }
82+ if player.State() != PlayerStateStopped {
83+ t.Fatalf("expected Stopped state after Stop, got %v", player.State())
84+ }
85+
86+ // Restart should work from Stopped
87+ if err := player.Start(); err != nil {
88+ t.Fatalf("Start from Stopped failed: %v", err)
89+ }
90+ if player.State() != PlayerStatePlaying {
91+ t.Fatalf("expected Playing state after restart, got %v", player.State())
92+ }
93+
94+ // Close should transition to Closed
95+ if err := player.Close(); err != nil {
96+ t.Fatalf("Close failed: %v", err)
97+ }
98+ if player.State() != PlayerStateClosed {
99+ t.Fatalf("expected Closed state after Close, got %v", player.State())
100+ }
101+
102+ // Start from Closed should fail
103+ if err := player.Start(); err == nil {
104+ t.Fatal("expected error starting from Closed state, got nil")
105+ }
106+}
107+
108+func TestPlayerCloseIsIdempotent(t *testing.T) {
109+ config := PlayerConfig{
110+ SampleRate: 44100,
111+ Channels: 2,
112+ FramesPerBuffer: 256,
113+ SampleFormat: SampleFormatF32,
114+ }
115+ player, err := NewPlayer(config, PlayerCallbacks{})
116+ if err != nil {
117+ t.Fatalf("NewPlayer returned error: %v", err)
118+ }
119+
120+ // Start first
121+ if err := player.Start(); err != nil {
122+ t.Fatalf("Start failed: %v", err)
123+ }
124+
125+ // First close should work
126+ if err := player.Close(); err != nil {
127+ t.Fatalf("first Close failed: %v", err)
128+ }
129+ if player.State() != PlayerStateClosed {
130+ t.Fatalf("expected Closed state, got %v", player.State())
131+ }
132+
133+ // Second close should succeed (idempotent)
134+ if err := player.Close(); err != nil {
135+ t.Fatalf("second Close failed: %v", err)
136+ }
137+ if player.State() != PlayerStateClosed {
138+ t.Fatalf("expected Closed state after second close, got %v", player.State())
47 }139 }
48 }140 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 8

↑↓ moveenter openesc close