feat: implement player lifecycle semantics
- commit
- 8096c50dfdf34794f015ae71aead42eef6cd48af
- author
- brice · · 2026-04-09 15:24 UTC
- parent
- 2f38a8b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +497 -33
M internal/core/player.go +177 -6
| @@ -6,29 +6,39 @@ import ( | |||
| 6 | 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 player | |
| 12 | - | type playerConfig struct { | |
| 14 | + | // PlayerConfig holds configuration for the player | |
| 15 | + | type PlayerConfig struct { | |
| 13 | 16 | // Minimal for now, will be extended in future tasks | |
| 14 | 17 | } | |
| 15 | 18 | ||
| 16 | - | // playerCallbacks holds callbacks for player events | |
| 17 | - | type playerCallbacks struct { | |
| 19 | + | // PlayerCallbacks holds callbacks for player events | |
| 20 | + | type PlayerCallbacks struct { | |
| 18 | 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 | 30 | // player is the internal player shell with state machine | |
| 22 | 31 | type player struct { | |
| 23 | 32 | config playerConfig | |
| 24 | 33 | callbacks playerCallbacks | |
| 25 | 34 | state atomic.Int32 | |
| 26 | 35 | terminal atomic.Bool | |
| 36 | + | paused atomic.Bool | |
| 27 | 37 | mu sync.Mutex | |
| 28 | 38 | } | |
| 29 | 39 | ||
| 30 | 40 | // newPlayer creates a new player instance | |
| 31 | - | func newPlayer(config playerConfig, callbacks playerCallbacks) *player { | |
| 41 | + | func newPlayer(config PlayerConfig, callbacks PlayerCallbacks) *player { | |
| 32 | 42 | p := &player{ | |
| 33 | 43 | config: config, | |
| 34 | 44 | callbacks: callbacks, | |
| @@ -98,3 +108,164 @@ func isValidTransition(current, next PlayerState) bool { | |||
| 98 | 108 | 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 | + | } | |
| @@ -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 player | 14 | + | // PlayerConfig holds configuration for the player |
| 12 | - | type playerConfig struct { | 15 | + | type PlayerConfig struct { |
| 13 | // Minimal for now, will be extended in future tasks | 16 | // Minimal for now, will be extended in future tasks | ||
| 14 | } | 17 | } | ||
| 15 | 18 | ||||
| 16 | - | // playerCallbacks holds callbacks for player events | 19 | + | // 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 machine | 30 | // player is the internal player shell with state machine | ||
| 22 | type player struct { | 31 | type player struct { | ||
| 23 | config playerConfig | 32 | config playerConfig | ||
| 24 | callbacks playerCallbacks | 33 | callbacks playerCallbacks | ||
| 25 | state atomic.Int32 | 34 | state atomic.Int32 | ||
| 26 | terminal atomic.Bool | 35 | terminal atomic.Bool | ||
| 36 | + | paused atomic.Bool | |||
| 27 | mu sync.Mutex | 37 | mu sync.Mutex | ||
| 28 | } | 38 | } | ||
| 29 | 39 | ||||
| 30 | // newPlayer creates a new player instance | 40 | // 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 false | 108 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/purego-pipewire/internal/core" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| 7 | 9 | // SampleFormat represents the audio sample format type. | |
| @@ -26,22 +28,34 @@ const ( | |||
| 26 | 28 | type PlayerState int | |
| 27 | 29 | ||
| 28 | 30 | 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 | |
| 45 | 59 | ) | |
| 46 | 60 | ||
| 47 | 61 | // PlayerConfig holds the configuration for a new audio player. | |
| @@ -82,32 +96,46 @@ type Player interface { | |||
| 82 | 96 | // ErrInvalidPlayerConfig is returned when the player configuration is invalid. | |
| 83 | 97 | var ErrInvalidPlayerConfig = errors.New("invalid player config") | |
| 84 | 98 | ||
| 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 | |
| 87 | 100 | type playerImpl struct { | |
| 88 | 101 | config PlayerConfig | |
| 89 | 102 | callbacks PlayerCallbacks | |
| 90 | - | state PlayerState | |
| 103 | + | internal *core.Player | |
| 91 | 104 | } | |
| 92 | 105 | ||
| 93 | 106 | 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() | |
| 95 | 111 | } | |
| 96 | 112 | ||
| 97 | 113 | 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() | |
| 99 | 118 | } | |
| 100 | 119 | ||
| 101 | 120 | 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() | |
| 103 | 125 | } | |
| 104 | 126 | ||
| 105 | 127 | func (p *playerImpl) Close() error { | |
| 106 | - | return nil | |
| 128 | + | if p.internal == nil { | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | return p.internal.Close() | |
| 107 | 132 | } | |
| 108 | 133 | ||
| 109 | 134 | func (p *playerImpl) State() PlayerState { | |
| 110 | - | return p.state | |
| 135 | + | if p.internal == nil { | |
| 136 | + | return PlayerStateIdle | |
| 137 | + | } | |
| 138 | + | return PlayerState(p.internal.State()) | |
| 111 | 139 | } | |
| 112 | 140 | ||
| 113 | 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 | 154 | 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 | 169 | return &playerImpl{ | |
| 130 | 170 | config: config, | |
| 131 | 171 | callbacks: callbacks, | |
| 132 | - | state: Idle, | |
| 172 | + | internal: internalPlayer, | |
| 133 | 173 | }, nil | |
| 134 | 174 | } | |
| @@ -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 int | 28 | 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 = iota | 32 | + | PlayerStateIdle PlayerState = iota |
| 31 | - | // Starting indicates the player is initializing. | 33 | + | // PlayerStateStarting indicates the player is initializing. |
| 32 | - | Starting | 34 | + | PlayerStateStarting |
| 33 | - | // Playing indicates the player is actively outputting audio. | 35 | + | // PlayerStatePlaying indicates the player is actively outputting audio. |
| 34 | - | Playing | 36 | + | PlayerStatePlaying |
| 35 | - | // Paused indicates the player is temporarily stopped. | 37 | + | // PlayerStatePaused indicates the player is temporarily stopped. |
| 36 | - | Paused | 38 | + | PlayerStatePaused |
| 37 | - | // Stopped indicates the player has been stopped. | 39 | + | // PlayerStateStopped indicates the player has been stopped. |
| 38 | - | Stopped | 40 | + | PlayerStateStopped |
| 39 | - | // Closing indicates the player is shutting down. | 41 | + | // PlayerStateClosing indicates the player is shutting down. |
| 40 | - | Closing | 42 | + | PlayerStateClosing |
| 41 | - | // Closed indicates the player has been fully closed. | 43 | + | // PlayerStateClosed indicates the player has been fully closed. |
| 42 | - | Closed | 44 | + | PlayerStateClosed |
| 43 | - | // Error indicates the player encountered an error. | 45 | + | // PlayerStateError indicates the player encountered an error. |
| 44 | - | 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 | |||
| 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 PlayerConfig | 101 | config PlayerConfig | ||
| 89 | callbacks PlayerCallbacks | 102 | callbacks PlayerCallbacks | ||
| 90 | - | state PlayerState | 103 | + | internal *core.Player |
| 91 | } | 104 | } | ||
| 92 | 105 | ||||
| 93 | func (p *playerImpl) Start() error { | 106 | 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() | |||
| 95 | } | 111 | } | ||
| 96 | 112 | ||||
| 97 | func (p *playerImpl) Pause() error { | 113 | 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() | |||
| 99 | } | 118 | } | ||
| 100 | 119 | ||||
| 101 | func (p *playerImpl) Stop() error { | 120 | 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() | |||
| 103 | } | 125 | } | ||
| 104 | 126 | ||||
| 105 | func (p *playerImpl) Close() error { | 127 | func (p *playerImpl) Close() error { | ||
| 106 | - | return nil | 128 | + | 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.state | 135 | + | 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, ErrInvalidPlayerConfig | 154 | 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 | }, nil | 173 | }, nil | ||
| 134 | } | 174 | } | ||
M pipewire/player_test.go +94 -2
| @@ -42,7 +42,99 @@ func TestPlayerStateReflectsInitialState(t *testing.T) { | |||
| 42 | 42 | if err != nil { | |
| 43 | 43 | t.Fatalf("NewPlayer returned error: %v", err) | |
| 44 | 44 | } | |
| 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()) | |
| 47 | 139 | } | |
| 48 | 140 | } | |
| @@ -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 | } | ||