From 6691cb88a8da3cf34d47e4d7feb64096aff15e4b Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 17:40:16 +0400 Subject: [PATCH 01/26] feat(playback): add state and repeat mode types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/state.go | 56 +++++++++++++++++++++++++++++++++ internal/playback/state_test.go | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 internal/playback/state.go create mode 100644 internal/playback/state_test.go diff --git a/internal/playback/state.go b/internal/playback/state.go new file mode 100644 index 0000000..811d9f1 --- /dev/null +++ b/internal/playback/state.go @@ -0,0 +1,56 @@ +// internal/playback/state.go +package playback + +// State represents the playback state. +type State int + +const ( + StateStopped State = iota + StatePlaying + StatePaused +) + +// String returns the state name. +func (s State) String() string { + switch s { + case StateStopped: + return "Stopped" + case StatePlaying: + return "Playing" + case StatePaused: + return "Paused" + default: + return "Unknown" + } +} + +// IsActive returns true if playback is active (playing or paused). +func (s State) IsActive() bool { + return s == StatePlaying || s == StatePaused +} + +// RepeatMode defines the repeat behavior. +type RepeatMode int + +const ( + RepeatOff RepeatMode = iota + RepeatAll + RepeatOne + RepeatRadio +) + +// String returns the repeat mode name. +func (m RepeatMode) String() string { + switch m { + case RepeatOff: + return "Off" + case RepeatAll: + return "All" + case RepeatOne: + return "One" + case RepeatRadio: + return "Radio" + default: + return "Unknown" + } +} diff --git a/internal/playback/state_test.go b/internal/playback/state_test.go new file mode 100644 index 0000000..287f994 --- /dev/null +++ b/internal/playback/state_test.go @@ -0,0 +1,55 @@ +// internal/playback/state_test.go +package playback + +import "testing" + +func TestState_String(t *testing.T) { + tests := []struct { + state State + want string + }{ + {StateStopped, "Stopped"}, + {StatePlaying, "Playing"}, + {StatePaused, "Paused"}, + {State(99), "Unknown"}, + } + for _, tt := range tests { + if got := tt.state.String(); got != tt.want { + t.Errorf("%d.String() = %q, want %q", tt.state, got, tt.want) + } + } +} + +func TestState_IsActive(t *testing.T) { + tests := []struct { + state State + want bool + }{ + {StateStopped, false}, + {StatePlaying, true}, + {StatePaused, true}, + } + for _, tt := range tests { + if got := tt.state.IsActive(); got != tt.want { + t.Errorf("%v.IsActive() = %v, want %v", tt.state, got, tt.want) + } + } +} + +func TestRepeatMode_String(t *testing.T) { + tests := []struct { + mode RepeatMode + want string + }{ + {RepeatOff, "Off"}, + {RepeatAll, "All"}, + {RepeatOne, "One"}, + {RepeatRadio, "Radio"}, + {RepeatMode(99), "Unknown"}, + } + for _, tt := range tests { + if got := tt.mode.String(); got != tt.want { + t.Errorf("%d.String() = %q, want %q", tt.mode, got, tt.want) + } + } +} From ab79802cb9325409f5e232555773f0ab0f113901 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 17:45:17 +0400 Subject: [PATCH 02/26] feat(playback): add track and event types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Track type for representing queue tracks (independent of playlist.Track) and event types (StateChange, TrackChange, QueueChange, ModeChange, PositionChange) for the subscription system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/events.go | 33 +++++++++ internal/playback/events_test.go | 113 +++++++++++++++++++++++++++++++ internal/playback/track.go | 15 ++++ 3 files changed, 161 insertions(+) create mode 100644 internal/playback/events.go create mode 100644 internal/playback/events_test.go create mode 100644 internal/playback/track.go diff --git a/internal/playback/events.go b/internal/playback/events.go new file mode 100644 index 0000000..cdba28a --- /dev/null +++ b/internal/playback/events.go @@ -0,0 +1,33 @@ +package playback + +import "time" + +// StateChange is emitted when playback state changes. +type StateChange struct { + Previous State + Current State +} + +// TrackChange is emitted when the current track changes. +type TrackChange struct { + Previous *Track + Current *Track + Index int +} + +// QueueChange is emitted when the queue contents change. +type QueueChange struct { + Tracks []Track + Index int +} + +// ModeChange is emitted when repeat or shuffle mode changes. +type ModeChange struct { + RepeatMode RepeatMode + Shuffle bool +} + +// PositionChange is emitted when a seek occurs. +type PositionChange struct { + Position time.Duration +} diff --git a/internal/playback/events_test.go b/internal/playback/events_test.go new file mode 100644 index 0000000..e0de5fb --- /dev/null +++ b/internal/playback/events_test.go @@ -0,0 +1,113 @@ +package playback + +import ( + "testing" + "time" +) + +func TestStateChange_Fields(t *testing.T) { + sc := StateChange{ + Previous: StateStopped, + Current: StatePlaying, + } + if sc.Previous != StateStopped { + t.Errorf("Previous = %v, want Stopped", sc.Previous) + } + if sc.Current != StatePlaying { + t.Errorf("Current = %v, want Playing", sc.Current) + } +} + +func TestTrackChange_Fields(t *testing.T) { + prev := &Track{Path: "/a.mp3"} + curr := &Track{Path: "/b.mp3"} + tc := TrackChange{ + Previous: prev, + Current: curr, + Index: 1, + } + if tc.Previous.Path != "/a.mp3" { + t.Errorf("Previous.Path = %q, want /a.mp3", tc.Previous.Path) + } + if tc.Current.Path != "/b.mp3" { + t.Errorf("Current.Path = %q, want /b.mp3", tc.Current.Path) + } + if tc.Index != 1 { + t.Errorf("Index = %d, want 1", tc.Index) + } +} + +func TestQueueChange_Fields(t *testing.T) { + tracks := []Track{ + {Path: "/a.mp3", Title: "Track A"}, + {Path: "/b.mp3", Title: "Track B"}, + } + qc := QueueChange{ + Tracks: tracks, + Index: 1, + } + if len(qc.Tracks) != 2 { + t.Errorf("len(Tracks) = %d, want 2", len(qc.Tracks)) + } + if qc.Tracks[0].Path != "/a.mp3" { + t.Errorf("Tracks[0].Path = %q, want /a.mp3", qc.Tracks[0].Path) + } + if qc.Index != 1 { + t.Errorf("Index = %d, want 1", qc.Index) + } +} + +func TestModeChange_Fields(t *testing.T) { + mc := ModeChange{ + RepeatMode: RepeatAll, + Shuffle: true, + } + if mc.RepeatMode != RepeatAll { + t.Errorf("RepeatMode = %v, want RepeatAll", mc.RepeatMode) + } + if !mc.Shuffle { + t.Error("Shuffle = false, want true") + } +} + +func TestPositionChange_Fields(t *testing.T) { + pc := PositionChange{ + Position: 30 * time.Second, + } + if pc.Position != 30*time.Second { + t.Errorf("Position = %v, want 30s", pc.Position) + } +} + +func TestTrack_Fields(t *testing.T) { + track := Track{ + ID: 42, + Path: "/music/song.mp3", + Title: "My Song", + Artist: "Artist Name", + Album: "Album Name", + TrackNumber: 5, + Duration: 3*time.Minute + 30*time.Second, + } + if track.ID != 42 { + t.Errorf("ID = %d, want 42", track.ID) + } + if track.Path != "/music/song.mp3" { + t.Errorf("Path = %q, want /music/song.mp3", track.Path) + } + if track.Title != "My Song" { + t.Errorf("Title = %q, want My Song", track.Title) + } + if track.Artist != "Artist Name" { + t.Errorf("Artist = %q, want Artist Name", track.Artist) + } + if track.Album != "Album Name" { + t.Errorf("Album = %q, want Album Name", track.Album) + } + if track.TrackNumber != 5 { + t.Errorf("TrackNumber = %d, want 5", track.TrackNumber) + } + if track.Duration != 3*time.Minute+30*time.Second { + t.Errorf("Duration = %v, want 3m30s", track.Duration) + } +} diff --git a/internal/playback/track.go b/internal/playback/track.go new file mode 100644 index 0000000..be641ed --- /dev/null +++ b/internal/playback/track.go @@ -0,0 +1,15 @@ +package playback + +import "time" + +// Track represents a track in the queue. +// This is a copy of the data, not a reference to playlist.Track. +type Track struct { + ID int64 + Path string + Title string + Artist string + Album string + TrackNumber int + Duration time.Duration +} From ba7a5d12869cdb8a04a780401c825fe091acf60f Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 17:49:36 +0400 Subject: [PATCH 03/26] feat(playback): add Service interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service.go | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 internal/playback/service.go diff --git a/internal/playback/service.go b/internal/playback/service.go new file mode 100644 index 0000000..c025c7a --- /dev/null +++ b/internal/playback/service.go @@ -0,0 +1,45 @@ +package playback + +import "time" + +// Service defines the playback service contract. +type Service interface { + // Playback control + Play() error + Pause() error + Stop() error + Toggle() error + Next() error + Previous() error + Seek(delta time.Duration) error + SeekTo(position time.Duration) error + + // Queue navigation + JumpTo(index int) error + + // State queries + State() State + Position() time.Duration + Duration() time.Duration + CurrentTrack() *Track + Queue() []Track + QueueIndex() int + + // Mode control + RepeatMode() RepeatMode + SetRepeatMode(mode RepeatMode) + CycleRepeatMode() RepeatMode + Shuffle() bool + SetShuffle(enabled bool) + ToggleShuffle() bool + + // Event subscription + Subscribe() *Subscription + + // Lifecycle + Close() error +} + +// Subscription allows receiving playback events. +// TODO: Full implementation in Task 4. +type Subscription struct{} From 24e71be28f2117cd230bd1c23b9131c82f728773 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 17:54:51 +0400 Subject: [PATCH 04/26] feat(playback): add Subscription with channel management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Subscription type with buffered channels for event delivery. Subscribers receive events without blocking the service through non-blocking send patterns using select with default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service.go | 4 - internal/playback/subscription.go | 90 ++++++++++++++++++++++ internal/playback/subscription_test.go | 102 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 internal/playback/subscription.go create mode 100644 internal/playback/subscription_test.go diff --git a/internal/playback/service.go b/internal/playback/service.go index c025c7a..3669a1b 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -39,7 +39,3 @@ type Service interface { // Lifecycle Close() error } - -// Subscription allows receiving playback events. -// TODO: Full implementation in Task 4. -type Subscription struct{} diff --git a/internal/playback/subscription.go b/internal/playback/subscription.go new file mode 100644 index 0000000..24513a9 --- /dev/null +++ b/internal/playback/subscription.go @@ -0,0 +1,90 @@ +package playback + +import "time" + +const eventBufferSize = 16 + +// Subscription provides event channels for a subscriber. +type Subscription struct { + StateChanged <-chan StateChange + TrackChanged <-chan TrackChange + PositionChanged <-chan PositionChange + QueueChanged <-chan QueueChange + ModeChanged <-chan ModeChange + Done <-chan struct{} + + // Internal write channels + stateCh chan StateChange + trackCh chan TrackChange + positionCh chan PositionChange + queueCh chan QueueChange + modeCh chan ModeChange + doneCh chan struct{} +} + +// newSubscription creates a new subscription with buffered channels. +func newSubscription() *Subscription { + s := &Subscription{ + stateCh: make(chan StateChange, eventBufferSize), + trackCh: make(chan TrackChange, eventBufferSize), + positionCh: make(chan PositionChange, eventBufferSize), + queueCh: make(chan QueueChange, eventBufferSize), + modeCh: make(chan ModeChange, eventBufferSize), + doneCh: make(chan struct{}), + } + s.StateChanged = s.stateCh + s.TrackChanged = s.trackCh + s.PositionChanged = s.positionCh + s.QueueChanged = s.queueCh + s.ModeChanged = s.modeCh + s.Done = s.doneCh + return s +} + +// close signals subscribers to stop by closing doneCh. +func (s *Subscription) close() { + close(s.doneCh) +} + +// sendState sends a state change event (non-blocking). +func (s *Subscription) sendState(e StateChange) { + select { + case s.stateCh <- e: + default: + // Drop if buffer full + } +} + +// sendTrack sends a track change event (non-blocking). +func (s *Subscription) sendTrack(e TrackChange) { + select { + case s.trackCh <- e: + default: + } +} + +// sendPosition sends a position change event (non-blocking). +func (s *Subscription) sendPosition(pos time.Duration) { + select { + case s.positionCh <- PositionChange{Position: pos}: + default: + } +} + +// sendQueue sends a queue change event (non-blocking). +// +//nolint:unused // Will be used by service implementation +func (s *Subscription) sendQueue(e QueueChange) { + select { + case s.queueCh <- e: + default: + } +} + +// sendMode sends a mode change event (non-blocking). +func (s *Subscription) sendMode(e ModeChange) { + select { + case s.modeCh <- e: + default: + } +} diff --git a/internal/playback/subscription_test.go b/internal/playback/subscription_test.go new file mode 100644 index 0000000..96be69f --- /dev/null +++ b/internal/playback/subscription_test.go @@ -0,0 +1,102 @@ +package playback + +import ( + "testing" + "time" +) + +func TestNewSubscription_ChannelsReadable(t *testing.T) { + sub := newSubscription() + + // Send events + sub.sendState(StateChange{Previous: StateStopped, Current: StatePlaying}) + sub.sendTrack(TrackChange{Index: 1}) + sub.sendPosition(30 * time.Second) + sub.sendQueue(QueueChange{Index: 2, Tracks: []Track{{Path: "/test/queue.mp3"}}}) + sub.sendMode(ModeChange{RepeatMode: RepeatAll, Shuffle: true}) + + // Receive events + select { + case e := <-sub.StateChanged: + if e.Current != StatePlaying { + t.Errorf("StateChanged.Current = %v, want Playing", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged") + } + + select { + case e := <-sub.TrackChanged: + if e.Index != 1 { + t.Errorf("TrackChanged.Index = %d, want 1", e.Index) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged") + } + + select { + case e := <-sub.PositionChanged: + if e.Position != 30*time.Second { + t.Errorf("PositionChanged.Position = %v, want 30s", e.Position) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for PositionChanged") + } + + select { + case e := <-sub.QueueChanged: + if e.Index != 2 { + t.Errorf("QueueChanged.Index = %d, want 2", e.Index) + } + if len(e.Tracks) != 1 || e.Tracks[0].Path != "/test/queue.mp3" { + t.Errorf("QueueChanged.Tracks = %v, want [{Path: /test/queue.mp3}]", e.Tracks) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for QueueChanged") + } + + select { + case e := <-sub.ModeChanged: + if e.RepeatMode != RepeatAll { + t.Errorf("ModeChanged.RepeatMode = %v, want RepeatAll", e.RepeatMode) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged") + } +} + +func TestSubscription_Close_SignalsDone(t *testing.T) { + sub := newSubscription() + sub.close() + + select { + case <-sub.Done: + // Expected + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for Done") + } +} + +func TestSubscription_NonBlocking_DropsWhenFull(t *testing.T) { + sub := newSubscription() + + // Fill buffer + for range eventBufferSize + 5 { + sub.sendState(StateChange{}) + } + + // Should not block or panic - count what we got + count := 0 + for { + select { + case <-sub.StateChanged: + count++ + default: + goto done + } + } +done: + if count != eventBufferSize { + t.Errorf("received %d events, want %d (buffer size)", count, eventBufferSize) + } +} From 6cef9b41f7fbc331c062ebfb043bc576dc761d08 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:05:22 +0400 Subject: [PATCH 05/26] feat(playback): add service implementation with state queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement serviceImpl struct that wraps player.Interface and playlist.PlayingQueue, exposing them through the Service interface. State query methods are fully implemented; playback control methods are stubs for now. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/events_test.go | 14 +- internal/playback/service_impl.go | 192 ++++++++++++++++++++++ internal/playback/service_impl_test.go | 218 +++++++++++++++++++++++++ internal/player/mock.go | 2 + 4 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 internal/playback/service_impl.go create mode 100644 internal/playback/service_impl_test.go diff --git a/internal/playback/events_test.go b/internal/playback/events_test.go index e0de5fb..c2f5cb2 100644 --- a/internal/playback/events_test.go +++ b/internal/playback/events_test.go @@ -5,6 +5,8 @@ import ( "time" ) +const testPathA = "/a.mp3" + func TestStateChange_Fields(t *testing.T) { sc := StateChange{ Previous: StateStopped, @@ -19,15 +21,15 @@ func TestStateChange_Fields(t *testing.T) { } func TestTrackChange_Fields(t *testing.T) { - prev := &Track{Path: "/a.mp3"} + prev := &Track{Path: testPathA} curr := &Track{Path: "/b.mp3"} tc := TrackChange{ Previous: prev, Current: curr, Index: 1, } - if tc.Previous.Path != "/a.mp3" { - t.Errorf("Previous.Path = %q, want /a.mp3", tc.Previous.Path) + if tc.Previous.Path != testPathA { + t.Errorf("Previous.Path = %q, want %s", tc.Previous.Path, testPathA) } if tc.Current.Path != "/b.mp3" { t.Errorf("Current.Path = %q, want /b.mp3", tc.Current.Path) @@ -39,7 +41,7 @@ func TestTrackChange_Fields(t *testing.T) { func TestQueueChange_Fields(t *testing.T) { tracks := []Track{ - {Path: "/a.mp3", Title: "Track A"}, + {Path: testPathA, Title: "Track A"}, {Path: "/b.mp3", Title: "Track B"}, } qc := QueueChange{ @@ -49,8 +51,8 @@ func TestQueueChange_Fields(t *testing.T) { if len(qc.Tracks) != 2 { t.Errorf("len(Tracks) = %d, want 2", len(qc.Tracks)) } - if qc.Tracks[0].Path != "/a.mp3" { - t.Errorf("Tracks[0].Path = %q, want /a.mp3", qc.Tracks[0].Path) + if qc.Tracks[0].Path != testPathA { + t.Errorf("Tracks[0].Path = %q, want %s", qc.Tracks[0].Path, testPathA) } if qc.Index != 1 { t.Errorf("Index = %d, want 1", qc.Index) diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go new file mode 100644 index 0000000..14cda39 --- /dev/null +++ b/internal/playback/service_impl.go @@ -0,0 +1,192 @@ +// internal/playback/service_impl.go +package playback + +import ( + "sync" + "time" + + "github.com/llehouerou/waves/internal/player" + "github.com/llehouerou/waves/internal/playlist" +) + +// Verify serviceImpl implements Service at compile time. +var _ Service = (*serviceImpl)(nil) + +type serviceImpl struct { + mu sync.RWMutex + + player player.Interface + queue *playlist.PlayingQueue + + subs []*Subscription + subsMu sync.RWMutex + + done chan struct{} + closed bool +} + +// New creates a new playback service. +func New(p player.Interface, q *playlist.PlayingQueue) Service { + s := &serviceImpl{ + player: p, + queue: q, + done: make(chan struct{}), + } + return s +} + +// State returns the current playback state. +func (s *serviceImpl) State() State { + s.mu.RLock() + defer s.mu.RUnlock() + return s.playerStateToState(s.player.State()) +} + +func (s *serviceImpl) playerStateToState(ps player.State) State { + switch ps { + case player.Playing: + return StatePlaying + case player.Paused: + return StatePaused + case player.Stopped: + return StateStopped + default: + return StateStopped + } +} + +// Position returns the current playback position. +func (s *serviceImpl) Position() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.Position() +} + +// Duration returns the current track duration. +func (s *serviceImpl) Duration() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.Duration() +} + +// CurrentTrack returns the current track, or nil if none. +func (s *serviceImpl) CurrentTrack() *Track { + s.mu.RLock() + defer s.mu.RUnlock() + return s.currentTrackLocked() +} + +func (s *serviceImpl) currentTrackLocked() *Track { + t := s.queue.Current() + if t == nil { + return nil + } + return &Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } +} + +// Queue returns a copy of all tracks in the queue. +func (s *serviceImpl) Queue() []Track { + s.mu.RLock() + defer s.mu.RUnlock() + tracks := s.queue.Tracks() + result := make([]Track, len(tracks)) + for i, t := range tracks { + result[i] = Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } + } + return result +} + +// QueueIndex returns the current queue index (-1 if none). +func (s *serviceImpl) QueueIndex() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.queue.CurrentIndex() +} + +// RepeatMode returns the current repeat mode. +func (s *serviceImpl) RepeatMode() RepeatMode { + s.mu.RLock() + defer s.mu.RUnlock() + return RepeatMode(s.queue.RepeatMode()) +} + +// Shuffle returns whether shuffle is enabled. +func (s *serviceImpl) Shuffle() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.queue.Shuffle() +} + +// Subscribe creates a new event subscription. +func (s *serviceImpl) Subscribe() *Subscription { + s.subsMu.Lock() + defer s.subsMu.Unlock() + sub := newSubscription() + s.subs = append(s.subs, sub) + return sub +} + +// Close shuts down the service. +func (s *serviceImpl) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + close(s.done) + s.mu.Unlock() + + s.subsMu.Lock() + for _, sub := range s.subs { + sub.close() + } + s.subs = nil + s.subsMu.Unlock() + + return nil +} + +// Stub implementations for interface compliance (will be implemented in later tasks) + +func (s *serviceImpl) Play() error { return nil } + +func (s *serviceImpl) Pause() error { return nil } + +func (s *serviceImpl) Stop() error { return nil } + +func (s *serviceImpl) Toggle() error { return nil } + +func (s *serviceImpl) Next() error { return nil } + +func (s *serviceImpl) Previous() error { return nil } + +func (s *serviceImpl) Seek(_ time.Duration) error { return nil } + +func (s *serviceImpl) SeekTo(_ time.Duration) error { return nil } + +func (s *serviceImpl) JumpTo(_ int) error { return nil } + +func (s *serviceImpl) SetRepeatMode(_ RepeatMode) {} + +func (s *serviceImpl) CycleRepeatMode() RepeatMode { return RepeatOff } + +func (s *serviceImpl) SetShuffle(_ bool) {} + +func (s *serviceImpl) ToggleShuffle() bool { return false } diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go new file mode 100644 index 0000000..9629a59 --- /dev/null +++ b/internal/playback/service_impl_test.go @@ -0,0 +1,218 @@ +// internal/playback/service_impl_test.go +package playback + +import ( + "testing" + "time" + + "github.com/llehouerou/waves/internal/player" + "github.com/llehouerou/waves/internal/playlist" +) + +func TestNew_ReturnsService(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + + svc := New(p, q) + + if svc == nil { + t.Fatal("New() returned nil") + } +} + +func TestService_State_ReflectsPlayer(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + // Initially stopped + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } + + // Set to playing + p.SetState(player.Playing) + if svc.State() != StatePlaying { + t.Errorf("State() = %v, want Playing", svc.State()) + } + + // Set to paused + p.SetState(player.Paused) + if svc.State() != StatePaused { + t.Errorf("State() = %v, want Paused", svc.State()) + } +} + +func TestService_Position_ReflectsPlayer(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + p.SetPosition(30 * time.Second) + + if svc.Position() != 30*time.Second { + t.Errorf("Position() = %v, want 30s", svc.Position()) + } +} + +func TestService_Duration_ReflectsPlayer(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + p.SetDuration(3 * time.Minute) + + if svc.Duration() != 3*time.Minute { + t.Errorf("Duration() = %v, want 3m", svc.Duration()) + } +} + +func TestService_CurrentTrack_ReturnsNilWhenEmpty(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + if svc.CurrentTrack() != nil { + t.Error("CurrentTrack() should be nil for empty queue") + } +} + +func TestService_CurrentTrack_ReturnsCopy(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{ + ID: 1, + Path: "/music/song.mp3", + Title: "Test Song", + }) + q.JumpTo(0) + svc := New(p, q) + + track := svc.CurrentTrack() + + if track == nil { + t.Fatal("CurrentTrack() returned nil") + } + if track.Path != "/music/song.mp3" { + t.Errorf("Path = %q, want /music/song.mp3", track.Path) + } + if track.Title != "Test Song" { + t.Errorf("Title = %q, want Test Song", track.Title) + } +} + +func TestService_Queue_ReturnsCopy(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: "/a.mp3"}, + playlist.Track{Path: "/b.mp3"}, + ) + svc := New(p, q) + + tracks := svc.Queue() + + if len(tracks) != 2 { + t.Fatalf("len(Queue()) = %d, want 2", len(tracks)) + } + if tracks[0].Path != "/a.mp3" { + t.Errorf("tracks[0].Path = %q, want /a.mp3", tracks[0].Path) + } + if tracks[1].Path != "/b.mp3" { + t.Errorf("tracks[1].Path = %q, want /b.mp3", tracks[1].Path) + } +} + +func TestService_QueueIndex_ReflectsQueue(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/a.mp3"}, playlist.Track{Path: "/b.mp3"}) + svc := New(p, q) + + if svc.QueueIndex() != -1 { + t.Errorf("QueueIndex() = %d, want -1 (no current)", svc.QueueIndex()) + } + + q.JumpTo(1) + if svc.QueueIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + } +} + +func TestService_RepeatMode_ReflectsQueue(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + if svc.RepeatMode() != RepeatOff { + t.Errorf("RepeatMode() = %v, want Off", svc.RepeatMode()) + } + + q.SetRepeatMode(playlist.RepeatAll) + if svc.RepeatMode() != RepeatAll { + t.Errorf("RepeatMode() = %v, want All", svc.RepeatMode()) + } +} + +func TestService_Shuffle_ReflectsQueue(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + if svc.Shuffle() { + t.Error("Shuffle() = true, want false") + } + + q.SetShuffle(true) + if !svc.Shuffle() { + t.Error("Shuffle() = false, want true") + } +} + +func TestService_Subscribe_ReturnsSubscription(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + sub := svc.Subscribe() + + if sub == nil { + t.Fatal("Subscribe() returned nil") + } + if sub.StateChanged == nil { + t.Error("StateChanged channel is nil") + } +} + +func TestService_Close_SignalsSubscribers(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + err := svc.Close() + + if err != nil { + t.Errorf("Close() error = %v", err) + } + + select { + case <-sub.Done: + // Expected + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for Done") + } +} + +func TestService_Close_Idempotent(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + _ = svc.Close() + err := svc.Close() + + if err != nil { + t.Errorf("second Close() error = %v", err) + } +} diff --git a/internal/player/mock.go b/internal/player/mock.go index 8f1d0dc..ec000b9 100644 --- a/internal/player/mock.go +++ b/internal/player/mock.go @@ -95,5 +95,7 @@ func (m *Mock) SetTrackInfo(info *TrackInfo) { m.trackInfo = info } func (m *Mock) SetDuration(d time.Duration) { m.duration = d } +func (m *Mock) SetPosition(d time.Duration) { m.position = d } + // Verify Mock implements Interface at compile time. var _ Interface = (*Mock)(nil) From 307e1f7fad8d16b88550f13374d0d37c965afcff Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:20:08 +0400 Subject: [PATCH 06/26] feat(playback): implement Play, Pause, Stop, Toggle methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add playback control methods with proper state management and event emission: - Add ErrEmptyQueue and ErrNoCurrentTrack error types - Add emitStateChange helper for notifying subscribers - Implement Play() with queue/track validation - Implement Pause() with no-op when not playing - Implement Stop() with no-op when already stopped - Implement Toggle() for play/pause cycling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/events_test.go | 11 +- internal/playback/service_impl.go | 107 +++++++++- internal/playback/service_impl_test.go | 261 +++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 9 deletions(-) diff --git a/internal/playback/events_test.go b/internal/playback/events_test.go index c2f5cb2..ce1af5c 100644 --- a/internal/playback/events_test.go +++ b/internal/playback/events_test.go @@ -5,7 +5,10 @@ import ( "time" ) -const testPathA = "/a.mp3" +const ( + testPathA = "/a.mp3" + testMusicPath = "/music/song.mp3" +) func TestStateChange_Fields(t *testing.T) { sc := StateChange{ @@ -84,7 +87,7 @@ func TestPositionChange_Fields(t *testing.T) { func TestTrack_Fields(t *testing.T) { track := Track{ ID: 42, - Path: "/music/song.mp3", + Path: testMusicPath, Title: "My Song", Artist: "Artist Name", Album: "Album Name", @@ -94,8 +97,8 @@ func TestTrack_Fields(t *testing.T) { if track.ID != 42 { t.Errorf("ID = %d, want 42", track.ID) } - if track.Path != "/music/song.mp3" { - t.Errorf("Path = %q, want /music/song.mp3", track.Path) + if track.Path != testMusicPath { + t.Errorf("Path = %q, want %s", track.Path, testMusicPath) } if track.Title != "My Song" { t.Errorf("Title = %q, want My Song", track.Title) diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 14cda39..37394ec 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -2,6 +2,7 @@ package playback import ( + "errors" "sync" "time" @@ -9,6 +10,12 @@ import ( "github.com/llehouerou/waves/internal/playlist" ) +// Errors returned by playback service methods. +var ( + ErrEmptyQueue = errors.New("queue is empty") + ErrNoCurrentTrack = errors.New("no current track") +) + // Verify serviceImpl implements Service at compile time. var _ Service = (*serviceImpl)(nil) @@ -163,15 +170,105 @@ func (s *serviceImpl) Close() error { return nil } -// Stub implementations for interface compliance (will be implemented in later tasks) +// emitStateChange notifies all subscribers of a state change. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitStateChange(prev, curr State) { + if prev == curr { + return + } + e := StateChange{Previous: prev, Current: curr} + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendState(e) + } + s.subsMu.RUnlock() +} + +// Play starts playback of the current track in the queue. +func (s *serviceImpl) Play() error { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.queue.Tracks()) == 0 { + return ErrEmptyQueue + } + + track := s.queue.Current() + if track == nil { + return ErrNoCurrentTrack + } + + prevState := s.playerStateToState(s.player.State()) + if err := s.player.Play(track.Path); err != nil { + return err + } + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + return nil +} + +// Pause pauses playback if currently playing. +func (s *serviceImpl) Pause() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.player.State() != player.Playing { + return nil // no-op + } + + prevState := s.playerStateToState(s.player.State()) + s.player.Pause() + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + return nil +} + +// Stop stops playback. +func (s *serviceImpl) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.player.State() == player.Stopped { + return nil // no-op + } + + prevState := s.playerStateToState(s.player.State()) + s.player.Stop() + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + return nil +} -func (s *serviceImpl) Play() error { return nil } +// Toggle toggles between play and pause states. +func (s *serviceImpl) Toggle() error { + s.mu.Lock() + defer s.mu.Unlock() -func (s *serviceImpl) Pause() error { return nil } + prevState := s.playerStateToState(s.player.State()) -func (s *serviceImpl) Stop() error { return nil } + switch s.player.State() { + case player.Playing: + s.player.Pause() + case player.Paused: + s.player.Resume() + case player.Stopped: + // Play current track if available + if len(s.queue.Tracks()) == 0 { + return ErrEmptyQueue + } + track := s.queue.Current() + if track == nil { + return ErrNoCurrentTrack + } + if err := s.player.Play(track.Path); err != nil { + return err + } + } -func (s *serviceImpl) Toggle() error { return nil } + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + return nil +} func (s *serviceImpl) Next() error { return nil } diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index 9629a59..61e9588 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -2,6 +2,7 @@ package playback import ( + "errors" "testing" "time" @@ -216,3 +217,263 @@ func TestService_Close_Idempotent(t *testing.T) { t.Errorf("second Close() error = %v", err) } } + +func TestService_Play_StartsPlayback(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + err := svc.Play() + + if err != nil { + t.Fatalf("Play() error = %v", err) + } + if svc.State() != StatePlaying { + t.Errorf("State() = %v, want Playing", svc.State()) + } + if len(p.PlayCalls()) != 1 || p.PlayCalls()[0] != "/music/song.mp3" { + t.Errorf("PlayCalls() = %v, want [/music/song.mp3]", p.PlayCalls()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StateStopped { + t.Errorf("event.Previous = %v, want Stopped", e.Previous) + } + if e.Current != StatePlaying { + t.Errorf("event.Current = %v, want Playing", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Play_EmptyQueue_ReturnsError(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + + err := svc.Play() + + if !errors.Is(err, ErrEmptyQueue) { + t.Errorf("Play() error = %v, want ErrEmptyQueue", err) + } +} + +func TestService_Play_NoCurrentTrack_ReturnsError(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + // Don't call JumpTo, so current is nil + svc := New(p, q) + + err := svc.Play() + + if !errors.Is(err, ErrNoCurrentTrack) { + t.Errorf("Play() error = %v, want ErrNoCurrentTrack", err) + } +} + +func TestService_Pause_PausesPlayback(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first + _ = svc.Play() + // Drain the Play event + <-sub.StateChanged + + err := svc.Pause() + + if err != nil { + t.Fatalf("Pause() error = %v", err) + } + if svc.State() != StatePaused { + t.Errorf("State() = %v, want Paused", svc.State()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StatePlaying { + t.Errorf("event.Previous = %v, want Playing", e.Previous) + } + if e.Current != StatePaused { + t.Errorf("event.Current = %v, want Paused", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Pause_WhenStopped_NoOp(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + err := svc.Pause() + + if err != nil { + t.Fatalf("Pause() error = %v", err) + } + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } + + // Verify no StateChanged event was emitted + select { + case e := <-sub.StateChanged: + t.Errorf("unexpected StateChanged event: %+v", e) + case <-time.After(50 * time.Millisecond): + // Expected - no event + } +} + +func TestService_Stop_StopsPlayback(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first + _ = svc.Play() + // Drain the Play event + <-sub.StateChanged + + err := svc.Stop() + + if err != nil { + t.Fatalf("Stop() error = %v", err) + } + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StatePlaying { + t.Errorf("event.Previous = %v, want Playing", e.Previous) + } + if e.Current != StateStopped { + t.Errorf("event.Current = %v, want Stopped", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Toggle_PlaysWhenStopped(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + err := svc.Toggle() + + if err != nil { + t.Fatalf("Toggle() error = %v", err) + } + if svc.State() != StatePlaying { + t.Errorf("State() = %v, want Playing", svc.State()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StateStopped { + t.Errorf("event.Previous = %v, want Stopped", e.Previous) + } + if e.Current != StatePlaying { + t.Errorf("event.Current = %v, want Playing", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Toggle_PausesWhenPlaying(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first + _ = svc.Play() + // Drain the Play event + <-sub.StateChanged + + err := svc.Toggle() + + if err != nil { + t.Fatalf("Toggle() error = %v", err) + } + if svc.State() != StatePaused { + t.Errorf("State() = %v, want Paused", svc.State()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StatePlaying { + t.Errorf("event.Previous = %v, want Playing", e.Previous) + } + if e.Current != StatePaused { + t.Errorf("event.Current = %v, want Paused", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Toggle_ResumesWhenPaused(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing and pause + _ = svc.Play() + <-sub.StateChanged + _ = svc.Pause() + <-sub.StateChanged + + err := svc.Toggle() + + if err != nil { + t.Fatalf("Toggle() error = %v", err) + } + if svc.State() != StatePlaying { + t.Errorf("State() = %v, want Playing", svc.State()) + } + + // Verify StateChanged event was emitted + select { + case e := <-sub.StateChanged: + if e.Previous != StatePaused { + t.Errorf("event.Previous = %v, want Paused", e.Previous) + } + if e.Current != StatePlaying { + t.Errorf("event.Current = %v, want Playing", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} From af5c80e03355351f6df93f3a6d5c222f88428342 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:32:03 +0400 Subject: [PATCH 07/26] feat(playback): implement Next, Previous, JumpTo methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add navigation methods to the playback service: - Next(): advances to the next track, auto-plays if already playing - Previous(): goes back to previous track (no-op at start) - JumpTo(index): jumps to specific index with bounds checking - emitTrackChange(): helper to notify subscribers of track changes Includes comprehensive tests for all navigation scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/events_test.go | 9 +- internal/playback/service_impl.go | 114 ++++++++- internal/playback/service_impl_test.go | 318 +++++++++++++++++++++++-- 3 files changed, 417 insertions(+), 24 deletions(-) diff --git a/internal/playback/events_test.go b/internal/playback/events_test.go index ce1af5c..89516e4 100644 --- a/internal/playback/events_test.go +++ b/internal/playback/events_test.go @@ -7,6 +7,7 @@ import ( const ( testPathA = "/a.mp3" + testPathB = "/b.mp3" testMusicPath = "/music/song.mp3" ) @@ -25,7 +26,7 @@ func TestStateChange_Fields(t *testing.T) { func TestTrackChange_Fields(t *testing.T) { prev := &Track{Path: testPathA} - curr := &Track{Path: "/b.mp3"} + curr := &Track{Path: testPathB} tc := TrackChange{ Previous: prev, Current: curr, @@ -34,8 +35,8 @@ func TestTrackChange_Fields(t *testing.T) { if tc.Previous.Path != testPathA { t.Errorf("Previous.Path = %q, want %s", tc.Previous.Path, testPathA) } - if tc.Current.Path != "/b.mp3" { - t.Errorf("Current.Path = %q, want /b.mp3", tc.Current.Path) + if tc.Current.Path != testPathB { + t.Errorf("Current.Path = %q, want %s", tc.Current.Path, testPathB) } if tc.Index != 1 { t.Errorf("Index = %d, want 1", tc.Index) @@ -45,7 +46,7 @@ func TestTrackChange_Fields(t *testing.T) { func TestQueueChange_Fields(t *testing.T) { tracks := []Track{ {Path: testPathA, Title: "Track A"}, - {Path: "/b.mp3", Title: "Track B"}, + {Path: testPathB, Title: "Track B"}, } qc := QueueChange{ Tracks: tracks, diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 37394ec..6f67621 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -14,6 +14,7 @@ import ( var ( ErrEmptyQueue = errors.New("queue is empty") ErrNoCurrentTrack = errors.New("no current track") + ErrInvalidIndex = errors.New("invalid queue index") ) // Verify serviceImpl implements Service at compile time. @@ -184,6 +185,28 @@ func (s *serviceImpl) emitStateChange(prev, curr State) { s.subsMu.RUnlock() } +// emitTrackChange notifies all subscribers of a track change. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitTrackChange(prevTrack *Track, prevIndex int) { + curr := s.currentTrackLocked() + currIndex := s.queue.CurrentIndex() + + if prevIndex == currIndex { + return // Only emit if actually changed + } + + e := TrackChange{ + Previous: prevTrack, + Current: curr, + Index: currIndex, + } + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendTrack(e) + } + s.subsMu.RUnlock() +} + // Play starts playback of the current track in the queue. func (s *serviceImpl) Play() error { s.mu.Lock() @@ -270,15 +293,100 @@ func (s *serviceImpl) Toggle() error { return nil } -func (s *serviceImpl) Next() error { return nil } +// Next advances to the next track in the queue. +// If the player was active (playing or paused), it starts playing the new track. +// At end of queue (with repeat off), stops playback and emits StateChange. +func (s *serviceImpl) Next() error { + s.mu.Lock() + defer s.mu.Unlock() + + prevTrack := s.currentTrackLocked() + prevIndex := s.queue.CurrentIndex() + wasActive := s.player.State() == player.Playing || s.player.State() == player.Paused -func (s *serviceImpl) Previous() error { return nil } + nextTrack := s.queue.Next() + + if nextTrack == nil { + // At end of queue + if wasActive { + prevState := s.playerStateToState(s.player.State()) + s.player.Stop() + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + } + return nil + } + + s.emitTrackChange(prevTrack, prevIndex) + + if wasActive { + if err := s.player.Play(nextTrack.Path); err != nil { + return err + } + } + return nil +} + +// Previous goes back to the previous track in the queue. +// If already at the start (index 0 or less), does nothing. +// If the player was active, starts playing the new track. +func (s *serviceImpl) Previous() error { + s.mu.Lock() + defer s.mu.Unlock() + + currentIndex := s.queue.CurrentIndex() + if currentIndex <= 0 { + return nil // At start, no-op + } + + prevTrack := s.currentTrackLocked() + prevIndex := currentIndex + wasActive := s.player.State() == player.Playing || s.player.State() == player.Paused + + newTrack := s.queue.JumpTo(currentIndex - 1) + + s.emitTrackChange(prevTrack, prevIndex) + + if wasActive && newTrack != nil { + if err := s.player.Play(newTrack.Path); err != nil { + return err + } + } + return nil +} func (s *serviceImpl) Seek(_ time.Duration) error { return nil } func (s *serviceImpl) SeekTo(_ time.Duration) error { return nil } -func (s *serviceImpl) JumpTo(_ int) error { return nil } +// JumpTo jumps to the specified index in the queue. +// Returns ErrInvalidIndex if the index is out of bounds. +// If the player was active, starts playing the new track. +func (s *serviceImpl) JumpTo(index int) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Validate bounds + tracks := s.queue.Tracks() + if index < 0 || index >= len(tracks) { + return ErrInvalidIndex + } + + prevTrack := s.currentTrackLocked() + prevIndex := s.queue.CurrentIndex() + wasActive := s.player.State() == player.Playing || s.player.State() == player.Paused + + newTrack := s.queue.JumpTo(index) + + s.emitTrackChange(prevTrack, prevIndex) + + if wasActive && newTrack != nil { + if err := s.player.Play(newTrack.Path); err != nil { + return err + } + } + return nil +} func (s *serviceImpl) SetRepeatMode(_ RepeatMode) {} diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index 61e9588..c3b5484 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -10,6 +10,13 @@ import ( "github.com/llehouerou/waves/internal/playlist" ) +const ( + testSvcPathA = "/a.mp3" + testSvcPathB = "/b.mp3" + testSvcPathC = "/c.mp3" + testSvcMusicPath = "/music/song.mp3" +) + func TestNew_ReturnsService(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() @@ -83,7 +90,7 @@ func TestService_CurrentTrack_ReturnsCopy(t *testing.T) { q := playlist.NewQueue() q.Add(playlist.Track{ ID: 1, - Path: "/music/song.mp3", + Path: testSvcMusicPath, Title: "Test Song", }) q.JumpTo(0) @@ -94,8 +101,8 @@ func TestService_CurrentTrack_ReturnsCopy(t *testing.T) { if track == nil { t.Fatal("CurrentTrack() returned nil") } - if track.Path != "/music/song.mp3" { - t.Errorf("Path = %q, want /music/song.mp3", track.Path) + if track.Path != testSvcMusicPath { + t.Errorf("Path = %q, want %s", track.Path, testSvcMusicPath) } if track.Title != "Test Song" { t.Errorf("Title = %q, want Test Song", track.Title) @@ -106,8 +113,8 @@ func TestService_Queue_ReturnsCopy(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() q.Add( - playlist.Track{Path: "/a.mp3"}, - playlist.Track{Path: "/b.mp3"}, + playlist.Track{Path: testSvcPathA}, + playlist.Track{Path: testSvcPathB}, ) svc := New(p, q) @@ -116,10 +123,10 @@ func TestService_Queue_ReturnsCopy(t *testing.T) { if len(tracks) != 2 { t.Fatalf("len(Queue()) = %d, want 2", len(tracks)) } - if tracks[0].Path != "/a.mp3" { + if tracks[0].Path != testSvcPathA { t.Errorf("tracks[0].Path = %q, want /a.mp3", tracks[0].Path) } - if tracks[1].Path != "/b.mp3" { + if tracks[1].Path != testSvcPathB { t.Errorf("tracks[1].Path = %q, want /b.mp3", tracks[1].Path) } } @@ -127,7 +134,7 @@ func TestService_Queue_ReturnsCopy(t *testing.T) { func TestService_QueueIndex_ReflectsQueue(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/a.mp3"}, playlist.Track{Path: "/b.mp3"}) + q.Add(playlist.Track{Path: testSvcPathA}, playlist.Track{Path: testSvcPathB}) svc := New(p, q) if svc.QueueIndex() != -1 { @@ -221,7 +228,7 @@ func TestService_Close_Idempotent(t *testing.T) { func TestService_Play_StartsPlayback(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -234,8 +241,8 @@ func TestService_Play_StartsPlayback(t *testing.T) { if svc.State() != StatePlaying { t.Errorf("State() = %v, want Playing", svc.State()) } - if len(p.PlayCalls()) != 1 || p.PlayCalls()[0] != "/music/song.mp3" { - t.Errorf("PlayCalls() = %v, want [/music/song.mp3]", p.PlayCalls()) + if len(p.PlayCalls()) != 1 || p.PlayCalls()[0] != testSvcMusicPath { + t.Errorf("PlayCalls() = %v, want [%s]", p.PlayCalls(), testSvcMusicPath) } // Verify StateChanged event was emitted @@ -267,7 +274,7 @@ func TestService_Play_EmptyQueue_ReturnsError(t *testing.T) { func TestService_Play_NoCurrentTrack_ReturnsError(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) // Don't call JumpTo, so current is nil svc := New(p, q) @@ -281,7 +288,7 @@ func TestService_Play_NoCurrentTrack_ReturnsError(t *testing.T) { func TestService_Pause_PausesPlayback(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -341,7 +348,7 @@ func TestService_Pause_WhenStopped_NoOp(t *testing.T) { func TestService_Stop_StopsPlayback(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -377,7 +384,7 @@ func TestService_Stop_StopsPlayback(t *testing.T) { func TestService_Toggle_PlaysWhenStopped(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -408,7 +415,7 @@ func TestService_Toggle_PlaysWhenStopped(t *testing.T) { func TestService_Toggle_PausesWhenPlaying(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -444,7 +451,7 @@ func TestService_Toggle_PausesWhenPlaying(t *testing.T) { func TestService_Toggle_ResumesWhenPaused(t *testing.T) { p := player.NewMock() q := playlist.NewQueue() - q.Add(playlist.Track{Path: "/music/song.mp3"}) + q.Add(playlist.Track{Path: testSvcMusicPath}) q.JumpTo(0) svc := New(p, q) sub := svc.Subscribe() @@ -477,3 +484,280 @@ func TestService_Toggle_ResumesWhenPaused(t *testing.T) { t.Fatal("timeout waiting for StateChanged event") } } + +func TestService_Next_AdvancesToNextTrack(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: testSvcPathA, Title: "Song A"}, + playlist.Track{Path: testSvcPathB, Title: "Song B"}, + ) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first + _ = svc.Play() + <-sub.StateChanged + + err := svc.Next() + + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if svc.QueueIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + } + + // Verify TrackChanged event + select { + case e := <-sub.TrackChanged: + if e.Previous == nil || e.Previous.Path != testSvcPathA { + t.Errorf("event.Previous.Path = %v, want /a.mp3", e.Previous) + } + if e.Current == nil || e.Current.Path != testSvcPathB { + t.Errorf("event.Current.Path = %v, want /b.mp3", e.Current) + } + if e.Index != 1 { + t.Errorf("event.Index = %d, want 1", e.Index) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged event") + } + + // Verify player.Play was called with new track + calls := p.PlayCalls() + if len(calls) != 2 || calls[1] != testSvcPathB { + t.Errorf("PlayCalls() = %v, want [/a.mp3, /b.mp3]", calls) + } +} + +func TestService_Next_AtEnd_StopsPlayback(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: testSvcPathA}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing + _ = svc.Play() + <-sub.StateChanged + + err := svc.Next() + + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } + + // Verify StateChanged event (from Playing to Stopped) + select { + case e := <-sub.StateChanged: + if e.Previous != StatePlaying { + t.Errorf("event.Previous = %v, want Playing", e.Previous) + } + if e.Current != StateStopped { + t.Errorf("event.Current = %v, want Stopped", e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } +} + +func TestService_Next_WhenStopped_AdvancesWithoutPlaying(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: testSvcPathA}, + playlist.Track{Path: testSvcPathB}, + ) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Don't start playing - just call Next while stopped + err := svc.Next() + + if err != nil { + t.Fatalf("Next() error = %v", err) + } + if svc.QueueIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + } + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } + + // Verify TrackChanged event was emitted + select { + case e := <-sub.TrackChanged: + if e.Index != 1 { + t.Errorf("event.Index = %d, want 1", e.Index) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged event") + } + + // Verify player.Play was NOT called + if len(p.PlayCalls()) != 0 { + t.Errorf("PlayCalls() = %v, want empty", p.PlayCalls()) + } +} + +func TestService_Previous_GoesToPreviousTrack(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: testSvcPathA}, + playlist.Track{Path: testSvcPathB}, + ) + q.JumpTo(1) // Start at second track + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing + _ = svc.Play() + <-sub.StateChanged + + err := svc.Previous() + + if err != nil { + t.Fatalf("Previous() error = %v", err) + } + if svc.QueueIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0", svc.QueueIndex()) + } + + // Verify TrackChanged event + select { + case e := <-sub.TrackChanged: + if e.Previous == nil || e.Previous.Path != testSvcPathB { + t.Errorf("event.Previous.Path = %v, want /b.mp3", e.Previous) + } + if e.Current == nil || e.Current.Path != testSvcPathA { + t.Errorf("event.Current.Path = %v, want /a.mp3", e.Current) + } + if e.Index != 0 { + t.Errorf("event.Index = %d, want 0", e.Index) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged event") + } + + // Verify player.Play was called with new track + calls := p.PlayCalls() + if len(calls) != 2 || calls[1] != testSvcPathA { + t.Errorf("PlayCalls() = %v, want [/b.mp3, /a.mp3]", calls) + } +} + +func TestService_Previous_AtStart_StaysAtStart(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: testSvcPathA}) + q.JumpTo(0) // At start + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing + _ = svc.Play() + <-sub.StateChanged + + err := svc.Previous() + + if err != nil { + t.Fatalf("Previous() error = %v", err) + } + if svc.QueueIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueIndex()) + } + + // Verify no TrackChanged event (no-op) + select { + case e := <-sub.TrackChanged: + t.Errorf("unexpected TrackChanged event: %+v", e) + case <-time.After(50 * time.Millisecond): + // Expected - no event + } + + // Verify player.Play was NOT called again + if len(p.PlayCalls()) != 1 { + t.Errorf("PlayCalls() = %v, want single call", p.PlayCalls()) + } +} + +func TestService_JumpTo_ChangesIndex(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: testSvcPathA}, + playlist.Track{Path: testSvcPathB}, + playlist.Track{Path: testSvcPathC}, + ) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing + _ = svc.Play() + <-sub.StateChanged + + err := svc.JumpTo(2) + + if err != nil { + t.Fatalf("JumpTo() error = %v", err) + } + if svc.QueueIndex() != 2 { + t.Errorf("QueueIndex() = %d, want 2", svc.QueueIndex()) + } + + // Verify TrackChanged event + select { + case e := <-sub.TrackChanged: + if e.Previous == nil || e.Previous.Path != testSvcPathA { + t.Errorf("event.Previous.Path = %v, want /a.mp3", e.Previous) + } + if e.Current == nil || e.Current.Path != testSvcPathC { + t.Errorf("event.Current.Path = %v, want /c.mp3", e.Current) + } + if e.Index != 2 { + t.Errorf("event.Index = %d, want 2", e.Index) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged event") + } + + // Verify player.Play was called with new track + calls := p.PlayCalls() + if len(calls) != 2 || calls[1] != testSvcPathC { + t.Errorf("PlayCalls() = %v, want [/a.mp3, /c.mp3]", calls) + } +} + +func TestService_JumpTo_InvalidIndex_ReturnsError(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: testSvcPathA}) + q.JumpTo(0) + svc := New(p, q) + + // Test negative index + err := svc.JumpTo(-1) + if !errors.Is(err, ErrInvalidIndex) { + t.Errorf("JumpTo(-1) error = %v, want ErrInvalidIndex", err) + } + + // Test index too large + err = svc.JumpTo(5) + if !errors.Is(err, ErrInvalidIndex) { + t.Errorf("JumpTo(5) error = %v, want ErrInvalidIndex", err) + } + + // Verify queue index unchanged + if svc.QueueIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueIndex()) + } +} From 99bcecb3c8081ae0a3d8ac22dc36b1f7024f0aa8 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:37:51 +0400 Subject: [PATCH 08/26] feat(playback): implement Seek, SeekTo, and mode control methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add seek and mode control functionality to the playback service: - Seek(delta) adjusts position by delta and emits PositionChanged - SeekTo(position) calculates delta from current position and seeks - SetRepeatMode, CycleRepeatMode for repeat mode control - SetShuffle, ToggleShuffle for shuffle control - emitPositionChange and emitModeChange helper methods for events 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service_impl.go | 79 ++++++++- internal/playback/service_impl_test.go | 220 +++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 6 deletions(-) diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 6f67621..6d510cc 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -207,6 +207,31 @@ func (s *serviceImpl) emitTrackChange(prevTrack *Track, prevIndex int) { s.subsMu.RUnlock() } +// emitPositionChange notifies all subscribers of a position change. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitPositionChange() { + pos := s.player.Position() + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendPosition(pos) + } + s.subsMu.RUnlock() +} + +// emitModeChange notifies all subscribers of a mode change. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitModeChange() { + e := ModeChange{ + RepeatMode: RepeatMode(s.queue.RepeatMode()), + Shuffle: s.queue.Shuffle(), + } + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendMode(e) + } + s.subsMu.RUnlock() +} + // Play starts playback of the current track in the queue. func (s *serviceImpl) Play() error { s.mu.Lock() @@ -355,9 +380,25 @@ func (s *serviceImpl) Previous() error { return nil } -func (s *serviceImpl) Seek(_ time.Duration) error { return nil } +// Seek adjusts the playback position by the given delta. +func (s *serviceImpl) Seek(delta time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.player.Seek(delta) + s.emitPositionChange() + return nil +} -func (s *serviceImpl) SeekTo(_ time.Duration) error { return nil } +// SeekTo seeks to an absolute position. +func (s *serviceImpl) SeekTo(position time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + current := s.player.Position() + delta := position - current + s.player.Seek(delta) + s.emitPositionChange() + return nil +} // JumpTo jumps to the specified index in the queue. // Returns ErrInvalidIndex if the index is out of bounds. @@ -388,10 +429,36 @@ func (s *serviceImpl) JumpTo(index int) error { return nil } -func (s *serviceImpl) SetRepeatMode(_ RepeatMode) {} +// SetRepeatMode sets the repeat mode. +func (s *serviceImpl) SetRepeatMode(mode RepeatMode) { + s.mu.Lock() + defer s.mu.Unlock() + s.queue.SetRepeatMode(playlist.RepeatMode(mode)) + s.emitModeChange() +} -func (s *serviceImpl) CycleRepeatMode() RepeatMode { return RepeatOff } +// CycleRepeatMode cycles through repeat modes and returns the new mode. +func (s *serviceImpl) CycleRepeatMode() RepeatMode { + s.mu.Lock() + defer s.mu.Unlock() + newMode := s.queue.CycleRepeatMode() + s.emitModeChange() + return RepeatMode(newMode) +} -func (s *serviceImpl) SetShuffle(_ bool) {} +// SetShuffle sets the shuffle state. +func (s *serviceImpl) SetShuffle(enabled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.queue.SetShuffle(enabled) + s.emitModeChange() +} -func (s *serviceImpl) ToggleShuffle() bool { return false } +// ToggleShuffle toggles shuffle and returns the new state. +func (s *serviceImpl) ToggleShuffle() bool { + s.mu.Lock() + defer s.mu.Unlock() + newState := s.queue.ToggleShuffle() + s.emitModeChange() + return newState +} diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index c3b5484..41df998 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -761,3 +761,223 @@ func TestService_JumpTo_InvalidIndex_ReturnsError(t *testing.T) { t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueIndex()) } } + +func TestService_Seek_SeeksPlayer(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + delta := 10 * time.Second + err := svc.Seek(delta) + + if err != nil { + t.Fatalf("Seek() error = %v", err) + } + + // Verify player.Seek was called with the delta + calls := p.SeekCalls() + if len(calls) != 1 || calls[0] != delta { + t.Errorf("SeekCalls() = %v, want [%v]", calls, delta) + } + + // Verify PositionChanged event was emitted + select { + case e := <-sub.PositionChanged: + // Position should match what the mock player returns + if e.Position != p.Position() { + t.Errorf("event.Position = %v, want %v", e.Position, p.Position()) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for PositionChanged event") + } +} + +func TestService_SeekTo_SeeksToPosition(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + // Set current position to 30s + p.SetPosition(30 * time.Second) + + // Seek to 60s (delta should be 30s) + targetPosition := 60 * time.Second + err := svc.SeekTo(targetPosition) + + if err != nil { + t.Fatalf("SeekTo() error = %v", err) + } + + // Verify player.Seek was called with calculated delta (60s - 30s = 30s) + expectedDelta := 30 * time.Second + calls := p.SeekCalls() + if len(calls) != 1 || calls[0] != expectedDelta { + t.Errorf("SeekCalls() = %v, want [%v]", calls, expectedDelta) + } + + // Verify PositionChanged event was emitted + select { + case <-sub.PositionChanged: + // Event received + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for PositionChanged event") + } +} + +func TestService_SetRepeatMode_ChangesMode(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + svc.SetRepeatMode(RepeatAll) + + if svc.RepeatMode() != RepeatAll { + t.Errorf("RepeatMode() = %v, want RepeatAll", svc.RepeatMode()) + } + + // Verify ModeChanged event was emitted + select { + case e := <-sub.ModeChanged: + if e.RepeatMode != RepeatAll { + t.Errorf("event.RepeatMode = %v, want RepeatAll", e.RepeatMode) + } + if e.Shuffle != false { + t.Errorf("event.Shuffle = %v, want false", e.Shuffle) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged event") + } +} + +func TestService_CycleRepeatMode_CyclesThroughModes(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + // Verify initial mode is Off + if svc.RepeatMode() != RepeatOff { + t.Fatalf("initial RepeatMode() = %v, want RepeatOff", svc.RepeatMode()) + } + + // Cycle: Off -> All + mode := svc.CycleRepeatMode() + if mode != RepeatAll { + t.Errorf("CycleRepeatMode() = %v, want RepeatAll", mode) + } + <-sub.ModeChanged + + // Cycle: All -> One + mode = svc.CycleRepeatMode() + if mode != RepeatOne { + t.Errorf("CycleRepeatMode() = %v, want RepeatOne", mode) + } + <-sub.ModeChanged + + // Cycle: One -> Radio + mode = svc.CycleRepeatMode() + if mode != RepeatRadio { + t.Errorf("CycleRepeatMode() = %v, want RepeatRadio", mode) + } + <-sub.ModeChanged + + // Cycle: Radio -> Off + mode = svc.CycleRepeatMode() + if mode != RepeatOff { + t.Errorf("CycleRepeatMode() = %v, want RepeatOff", mode) + } + + // Verify final ModeChanged event + select { + case e := <-sub.ModeChanged: + if e.RepeatMode != RepeatOff { + t.Errorf("event.RepeatMode = %v, want RepeatOff", e.RepeatMode) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged event") + } +} + +func TestService_SetShuffle_ChangesShuffle(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + // Verify initial shuffle is off + if svc.Shuffle() { + t.Fatal("initial Shuffle() = true, want false") + } + + svc.SetShuffle(true) + + if !svc.Shuffle() { + t.Error("Shuffle() = false, want true") + } + + // Verify ModeChanged event was emitted + select { + case e := <-sub.ModeChanged: + if e.Shuffle != true { + t.Errorf("event.Shuffle = %v, want true", e.Shuffle) + } + if e.RepeatMode != RepeatOff { + t.Errorf("event.RepeatMode = %v, want RepeatOff", e.RepeatMode) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged event") + } +} + +func TestService_ToggleShuffle_TogglesAndReturnsNewState(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + svc := New(p, q) + sub := svc.Subscribe() + + // Verify initial shuffle is off + if svc.Shuffle() { + t.Fatal("initial Shuffle() = true, want false") + } + + // Toggle: off -> on + newState := svc.ToggleShuffle() + if !newState { + t.Error("ToggleShuffle() = false, want true") + } + if !svc.Shuffle() { + t.Error("Shuffle() = false, want true") + } + + // Verify ModeChanged event + select { + case e := <-sub.ModeChanged: + if e.Shuffle != true { + t.Errorf("event.Shuffle = %v, want true", e.Shuffle) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged event") + } + + // Toggle: on -> off + newState = svc.ToggleShuffle() + if newState { + t.Error("ToggleShuffle() = true, want false") + } + if svc.Shuffle() { + t.Error("Shuffle() = true, want false") + } + + // Verify ModeChanged event + select { + case e := <-sub.ModeChanged: + if e.Shuffle != false { + t.Errorf("event.Shuffle = %v, want false", e.Shuffle) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for ModeChanged event") + } +} From 13479ad67066ffaaf10593a3a96cb683d8fe72de Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:43:50 +0400 Subject: [PATCH 09/26] feat(playback): add track finished handling with auto-advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add goroutine to watch for track completion signals and automatically advance to the next track in the queue. When the queue ends, playback stops and a StateChanged event is emitted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service_impl.go | 37 ++++++++++ internal/playback/service_impl_test.go | 93 ++++++++++++++++++++++++++ internal/player/mock.go | 8 +++ 3 files changed, 138 insertions(+) diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 6d510cc..166bde2 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -40,6 +40,7 @@ func New(p player.Interface, q *playlist.PlayingQueue) Service { queue: q, done: make(chan struct{}), } + go s.watchTrackFinished() return s } @@ -171,6 +172,42 @@ func (s *serviceImpl) Close() error { return nil } +// watchTrackFinished listens for track finished signals and auto-advances. +func (s *serviceImpl) watchTrackFinished() { + for { + select { + case <-s.done: + return + case <-s.player.FinishedChan(): + s.handleTrackFinished() + } + } +} + +// handleTrackFinished advances to the next track when the current track ends. +func (s *serviceImpl) handleTrackFinished() { + s.mu.Lock() + defer s.mu.Unlock() + + prevTrack := s.currentTrackLocked() + prevIndex := s.queue.CurrentIndex() + + nextTrack := s.queue.Next() + if nextTrack == nil { + // End of queue + s.player.Stop() + s.emitStateChange(StatePlaying, StateStopped) + return + } + + s.emitTrackChange(prevTrack, prevIndex) + + if err := s.player.Play(nextTrack.Path); err != nil { + s.player.Stop() + s.emitStateChange(StatePlaying, StateStopped) + } +} + // emitStateChange notifies all subscribers of a state change. // Must be called while holding mu. Acquires subsMu internally. func (s *serviceImpl) emitStateChange(prev, curr State) { diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index 41df998..bdf33d4 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -981,3 +981,96 @@ func TestService_ToggleShuffle_TogglesAndReturnsNewState(t *testing.T) { t.Fatal("timeout waiting for ModeChanged event") } } + +func TestService_TrackFinished_AdvancesToNext(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: "/track1.mp3", Title: "Track 1"}, + playlist.Track{Path: "/track2.mp3", Title: "Track 2"}, + ) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first track + err := svc.Play() + if err != nil { + t.Fatalf("Play() error = %v", err) + } + + // Drain initial StateChanged event + select { + case <-sub.StateChanged: + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for initial StateChanged") + } + + // Simulate track finishing + p.SimulateFinished() + + // Expect TrackChanged event with Index=1 and Current.Path="/track2.mp3" + select { + case e := <-sub.TrackChanged: + if e.Index != 1 { + t.Errorf("event.Index = %d, want 1", e.Index) + } + if e.Current == nil || e.Current.Path != "/track2.mp3" { + t.Errorf("event.Current.Path = %v, want /track2.mp3", e.Current) + } + if e.Previous == nil || e.Previous.Path != "/track1.mp3" { + t.Errorf("event.Previous.Path = %v, want /track1.mp3", e.Previous) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for TrackChanged event") + } + + // Verify player.Play was called with new track + calls := p.PlayCalls() + if len(calls) != 2 || calls[1] != "/track2.mp3" { + t.Errorf("PlayCalls() = %v, want [/track1.mp3, /track2.mp3]", calls) + } +} + +func TestService_TrackFinished_AtEnd_Stops(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/track1.mp3", Title: "Track 1"}) + q.JumpTo(0) + svc := New(p, q) + sub := svc.Subscribe() + + // Start playing first track + err := svc.Play() + if err != nil { + t.Fatalf("Play() error = %v", err) + } + + // Drain initial StateChanged event + select { + case <-sub.StateChanged: + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for initial StateChanged") + } + + // Simulate track finishing (at end of queue) + p.SimulateFinished() + + // Expect StateChanged event with Current=StateStopped + select { + case e := <-sub.StateChanged: + if e.Previous != StatePlaying { + t.Errorf("event.Previous = %v, want Playing", e.Previous) + } + if e.Current != StateStopped { + t.Errorf("event.Current = %v, want Stopped", e.Current) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for StateChanged event") + } + + // Verify service state is stopped + if svc.State() != StateStopped { + t.Errorf("State() = %v, want Stopped", svc.State()) + } +} diff --git a/internal/player/mock.go b/internal/player/mock.go index ec000b9..4af482e 100644 --- a/internal/player/mock.go +++ b/internal/player/mock.go @@ -97,5 +97,13 @@ func (m *Mock) SetDuration(d time.Duration) { m.duration = d } func (m *Mock) SetPosition(d time.Duration) { m.position = d } +// SimulateFinished simulates a track finishing. +func (m *Mock) SimulateFinished() { + select { + case m.finishedCh <- struct{}{}: + default: + } +} + // Verify Mock implements Interface at compile time. var _ Interface = (*Mock)(nil) From e71a4e36cf263d81a9b7f79898f0203407be9b0c Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 18:49:48 +0400 Subject: [PATCH 10/26] test(playback): add concurrent access and multi-subscriber tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests to verify thread safety of PlaybackService: - TestService_ConcurrentAccess_NoRace: runs 300 concurrent operations (50 iterations x 6 goroutines) to verify no race conditions - TestService_MultipleSubscribers_AllReceiveEvents: verifies all subscribers receive state change events Also make player.Mock thread-safe with mutex protection to fix race conditions detected by the race detector in existing tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service_impl_test.go | 78 +++++++++++++++++++++ internal/player/mock.go | 96 ++++++++++++++++++++++---- 2 files changed, 159 insertions(+), 15 deletions(-) diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index bdf33d4..371ea7e 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -3,6 +3,7 @@ package playback import ( "errors" + "sync" "testing" "time" @@ -1074,3 +1075,80 @@ func TestService_TrackFinished_AtEnd_Stops(t *testing.T) { t.Errorf("State() = %v, want Stopped", svc.State()) } } + +func TestService_ConcurrentAccess_NoRace(_ *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: "/a.mp3"}, + playlist.Track{Path: "/b.mp3"}, + playlist.Track{Path: "/c.mp3"}, + ) + q.JumpTo(0) + svc := New(p, q) + defer svc.Close() + + var wg sync.WaitGroup + for range 50 { + wg.Add(6) + + go func() { + defer wg.Done() + _ = svc.Toggle() + }() + + go func() { + defer wg.Done() + _ = svc.State() + }() + + go func() { + defer wg.Done() + _ = svc.Position() + }() + + go func() { + defer wg.Done() + _ = svc.Queue() + }() + + go func() { + defer wg.Done() + _ = svc.CurrentTrack() + }() + + go func() { + defer wg.Done() + _ = svc.CycleRepeatMode() + }() + } + + wg.Wait() +} + +func TestService_MultipleSubscribers_AllReceiveEvents(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: "/song.mp3"}) + q.JumpTo(0) + svc := New(p, q) + defer svc.Close() + + sub1 := svc.Subscribe() + sub2 := svc.Subscribe() + sub3 := svc.Subscribe() + + _ = svc.Play() + + // All subscribers should receive the state change + for i, sub := range []*Subscription{sub1, sub2, sub3} { + select { + case e := <-sub.StateChanged: + if e.Current != StatePlaying { + t.Errorf("sub%d: Current = %v, want Playing", i+1, e.Current) + } + case <-time.After(100 * time.Millisecond): + t.Fatalf("sub%d: timeout waiting for StateChanged", i+1) + } + } +} diff --git a/internal/player/mock.go b/internal/player/mock.go index 4af482e..6baaa12 100644 --- a/internal/player/mock.go +++ b/internal/player/mock.go @@ -1,10 +1,14 @@ // internal/player/mock.go package player -import "time" +import ( + "sync" + "time" +) // Mock is a test double for Player. type Mock struct { + mu sync.Mutex state State position time.Duration duration time.Duration @@ -26,6 +30,8 @@ func NewMock() *Mock { } func (m *Mock) Play(path string) error { + m.mu.Lock() + defer m.mu.Unlock() m.playCalls = append(m.playCalls, path) if m.playErr != nil { return m.playErr @@ -34,40 +40,68 @@ func (m *Mock) Play(path string) error { return nil } -func (m *Mock) Stop() { m.state = Stopped } +func (m *Mock) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.state = Stopped +} func (m *Mock) Pause() { + m.mu.Lock() + defer m.mu.Unlock() if m.state == Playing { m.state = Paused } } func (m *Mock) Resume() { + m.mu.Lock() + defer m.mu.Unlock() if m.state == Paused { m.state = Playing } } func (m *Mock) Toggle() { + m.mu.Lock() + defer m.mu.Unlock() switch m.state { case Playing: - m.Pause() + m.state = Paused case Paused: - m.Resume() + m.state = Playing case Stopped: // Nothing to toggle when stopped } } -func (m *Mock) State() State { return m.state } +func (m *Mock) State() State { + m.mu.Lock() + defer m.mu.Unlock() + return m.state +} -func (m *Mock) TrackInfo() *TrackInfo { return m.trackInfo } +func (m *Mock) TrackInfo() *TrackInfo { + m.mu.Lock() + defer m.mu.Unlock() + return m.trackInfo +} -func (m *Mock) Position() time.Duration { return m.position } +func (m *Mock) Position() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + return m.position +} -func (m *Mock) Duration() time.Duration { return m.duration } +func (m *Mock) Duration() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + return m.duration +} func (m *Mock) Seek(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() m.seekCalls = append(m.seekCalls, d) } @@ -83,19 +117,51 @@ func (m *Mock) Done() <-chan struct{} { // Test helpers -func (m *Mock) SetState(s State) { m.state = s } +func (m *Mock) SetState(s State) { + m.mu.Lock() + defer m.mu.Unlock() + m.state = s +} -func (m *Mock) SetPlayError(err error) { m.playErr = err } +func (m *Mock) SetPlayError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.playErr = err +} -func (m *Mock) PlayCalls() []string { return m.playCalls } +func (m *Mock) PlayCalls() []string { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]string, len(m.playCalls)) + copy(result, m.playCalls) + return result +} -func (m *Mock) SeekCalls() []time.Duration { return m.seekCalls } +func (m *Mock) SeekCalls() []time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]time.Duration, len(m.seekCalls)) + copy(result, m.seekCalls) + return result +} -func (m *Mock) SetTrackInfo(info *TrackInfo) { m.trackInfo = info } +func (m *Mock) SetTrackInfo(info *TrackInfo) { + m.mu.Lock() + defer m.mu.Unlock() + m.trackInfo = info +} -func (m *Mock) SetDuration(d time.Duration) { m.duration = d } +func (m *Mock) SetDuration(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + m.duration = d +} -func (m *Mock) SetPosition(d time.Duration) { m.position = d } +func (m *Mock) SetPosition(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + m.position = d +} // SimulateFinished simulates a track finishing. func (m *Mock) SimulateFinished() { From e44f4a8b515f77b4f6c6c68794f81f69cd9b2194 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 19:19:00 +0400 Subject: [PATCH 11/26] feat(playback): integrate playback service with UI layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the playback service to handle playback control through the app's event-driven architecture: - Add PlaybackService field to app.Model initialized in New() - Add service event messages (ServiceStateChangedMsg, ServiceTrackChangedMsg, ServiceClosedMsg) for async notifications - Add WatchServiceEvents command to listen for service events - Refactor handlers to use PlaybackService methods: - HandleSpaceAction uses service.Toggle() - StartQueuePlayback uses service.Play() - PlayTrackAtIndex uses service.Play() - handleSeek uses service.Seek() - Stop actions use service.Stop() - Shuffle/repeat modes use service methods - Handle scrobble reset and radio fill in handleServiceStateChanged when transitioning from stopped to playing - Update all test helpers to include PlaybackService The service emits events which are received via WatchServiceEvents and converted to tea.Msg for the Bubble Tea update cycle. This enables external control (e.g., MPRIS) while keeping the UI reactive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/app.go | 11 ++++- internal/app/app_test.go | 13 +++--- internal/app/commands.go | 30 +++++++++++++ internal/app/handlers.go | 2 +- internal/app/handlers_playback.go | 9 ++-- internal/app/handlers_test.go | 13 +++--- internal/app/integration_test.go | 9 ++-- internal/app/keys.go | 2 +- internal/app/messages.go | 22 ++++++++++ internal/app/playback.go | 22 +++++++--- internal/app/playback_test.go | 29 ++++++------- internal/app/queue.go | 10 +++-- internal/app/update.go | 2 +- internal/app/update_playback.go | 71 +++++++++++++++++++++++++++++++ 14 files changed, 197 insertions(+), 48 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 3f0517b..c0b18d4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "github.com/llehouerou/waves/internal/lastfm" "github.com/llehouerou/waves/internal/library" "github.com/llehouerou/waves/internal/navigator" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" @@ -47,6 +48,8 @@ type Model struct { Input InputManager Layout LayoutManager Playback PlaybackManager + PlaybackService playback.Service + playbackSub *playback.Subscription Keys *keymap.Resolver LibraryScanCh <-chan library.ScanProgress LibraryScanJob *jobbar.Job @@ -106,7 +109,7 @@ func (m Model) Init() tea.Cmd { WatchStderr(), // Watch for stderr output from C libraries ) } - return tea.Batch(m.WatchTrackFinished(), WatchStderr()) + return tea.Batch(m.WatchServiceEvents(), WatchStderr()) } // New creates a new application model with deferred initialization. @@ -139,6 +142,10 @@ func New(cfg *config.Config, stateMgr *state.Manager) (Model, error) { downloadsView := dlview.New() downloadsView.SetConfigured(cfg.HasSlskdConfig()) + // Create playback service wrapping player and queue + svc := playback.New(p, queue) + sub := svc.Subscribe() + return Model{ Navigation: NewNavigationManager(), Library: lib, @@ -149,6 +156,8 @@ func New(cfg *config.Config, stateMgr *state.Manager) (Model, error) { Input: NewInputManager(), Layout: NewLayoutManager(queuepanel.New(queue)), Playback: NewPlaybackManager(p, queue), + PlaybackService: svc, + playbackSub: sub, Keys: keymap.NewResolver(keymap.Bindings), StateMgr: stateMgr, HasSlskdConfig: cfg.HasSlskdConfig(), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index b15dcfb..85e7bda 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/llehouerou/waves/internal/keymap" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/state" @@ -214,11 +215,13 @@ func TestUpdate_ErrorMsg_DismissedByAnyKey(t *testing.T) { func newIntegrationTestModel() Model { queue := playlist.NewQueue() p := player.NewMock() + svc := playback.New(p, queue) return Model{ - Navigation: NewNavigationManager(), - Playback: NewPlaybackManager(p, queue), - Layout: NewLayoutManager(queuepanel.New(queue)), - Keys: keymap.NewResolver(keymap.Bindings), - StateMgr: state.NewMock(), + Navigation: NewNavigationManager(), + Playback: NewPlaybackManager(p, queue), + PlaybackService: svc, + Layout: NewLayoutManager(queuepanel.New(queue)), + Keys: keymap.NewResolver(keymap.Bindings), + StateMgr: state.NewMock(), } } diff --git a/internal/app/commands.go b/internal/app/commands.go index 3177624..88341aa 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -36,6 +36,8 @@ func TrackSkipTimeoutCmd(version int) tea.Cmd { // WatchTrackFinished returns a command that waits for the player to finish naturally. // Returns TrackFinishedMsg only for natural track completion, not manual stops. +// +// Deprecated: The playback service now handles track finished internally. func (m Model) WatchTrackFinished() tea.Cmd { return func() tea.Msg { <-m.Playback.FinishedChan() @@ -43,6 +45,34 @@ func (m Model) WatchTrackFinished() tea.Cmd { } } +// WatchServiceEvents returns a command that waits for playback service events. +// It listens on all subscription channels and converts events to tea.Msg. +func (m Model) WatchServiceEvents() tea.Cmd { + if m.playbackSub == nil { + return nil + } + return func() tea.Msg { + select { + case e := <-m.playbackSub.StateChanged: + return ServiceStateChangedMsg{ + Previous: int(e.Previous), + Current: int(e.Current), + } + case e := <-m.playbackSub.TrackChanged: + prevIdx := -1 + if e.Previous != nil { + prevIdx = e.Index - 1 // Approximate previous index + } + return ServiceTrackChangedMsg{ + PreviousIndex: prevIdx, + CurrentIndex: e.Index, + } + case <-m.playbackSub.Done: + return ServiceClosedMsg{} + } + } +} + // LoadingTickCmd returns a command that sends LoadingTickMsg for animation. func LoadingTickCmd() tea.Cmd { return tea.Tick(150*time.Millisecond, func(_ time.Time) tea.Msg { diff --git a/internal/app/handlers.go b/internal/app/handlers.go index a0fab47..a880a54 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -13,7 +13,7 @@ func (m *Model) handleQuitKeys(key string) handler.Result { if m.Keys.Resolve(key) != keymap.ActionQuit { return handler.NotHandled } - m.Playback.Stop() + _ = m.PlaybackService.Stop() m.SaveQueueState() m.StateMgr.Close() return handler.Handled(tea.Quit) diff --git a/internal/app/handlers_playback.go b/internal/app/handlers_playback.go index cdadfec..2fa45cc 100644 --- a/internal/app/handlers_playback.go +++ b/internal/app/handlers_playback.go @@ -15,7 +15,7 @@ func (m *Model) handlePlaybackKeys(key string) handler.Result { case keymap.ActionPlayPause: return handler.Handled(m.HandleSpaceAction()) case keymap.ActionStop: - m.Playback.Stop() + _ = m.PlaybackService.Stop() m.ResizeComponents() return handler.HandledNoCmd case keymap.ActionNextTrack: @@ -50,7 +50,7 @@ func (m *Model) handlePlaybackKeys(key string) handler.Result { case keymap.ActionCycleRepeat: return handler.Handled(m.handleCycleRepeat()) case keymap.ActionToggleShuffle: - m.Playback.Queue().ToggleShuffle() + m.PlaybackService.ToggleShuffle() m.SaveQueueState() return handler.HandledNoCmd } @@ -61,8 +61,7 @@ func (m *Model) handlePlaybackKeys(key string) handler.Result { // Cycle: Off -> All -> One -> Radio -> Off // If Last.fm is not configured, Radio mode is skipped. func (m *Model) handleCycleRepeat() tea.Cmd { - queue := m.Playback.Queue() - currentMode := queue.RepeatMode() + currentMode := m.Playback.Queue().RepeatMode() // Determine next mode nextMode := m.nextRepeatMode(currentMode) @@ -70,7 +69,7 @@ func (m *Model) handleCycleRepeat() tea.Cmd { // Handle radio state transitions cmd := m.handleRadioTransition(currentMode, nextMode) - queue.SetRepeatMode(nextMode) + m.Playback.Queue().SetRepeatMode(nextMode) m.SaveQueueState() return cmd diff --git a/internal/app/handlers_test.go b/internal/app/handlers_test.go index d59bee2..acdcfc1 100644 --- a/internal/app/handlers_test.go +++ b/internal/app/handlers_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/llehouerou/waves/internal/keymap" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/state" @@ -218,12 +219,14 @@ func TestHandleNavigatorActionKeys(t *testing.T) { func newTestModel() *Model { queue := playlist.NewQueue() p := player.NewMock() + svc := playback.New(p, queue) return &Model{ - Navigation: NewNavigationManager(), - Layout: NewLayoutManager(queuepanel.New(queue)), - Playback: NewPlaybackManager(p, queue), - Keys: keymap.NewResolver(keymap.Bindings), - StateMgr: state.NewMock(), + Navigation: NewNavigationManager(), + Layout: NewLayoutManager(queuepanel.New(queue)), + Playback: NewPlaybackManager(p, queue), + PlaybackService: svc, + Keys: keymap.NewResolver(keymap.Bindings), + StateMgr: state.NewMock(), } } diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 49471eb..e71f0c9 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -251,13 +251,16 @@ func TestIntegration_QueuePanelInteraction(t *testing.T) { m.Navigation.SetFocus(FocusQueue) // Simulate JumpToTrack action (normally sent by queue panel) - m, cmd := updateModel(t, m, queuepanel.ActionMsg(queuepanel.JumpToTrack{Index: 2})) + m, _ = updateModel(t, m, queuepanel.ActionMsg(queuepanel.JumpToTrack{Index: 2})) if m.Playback.Queue().CurrentIndex() != 2 { t.Errorf("queue index = %d, want 2", m.Playback.Queue().CurrentIndex()) } - if cmd == nil { - t.Error("expected playback command") + // Note: PlayTrackAtIndex now uses PlaybackService.Play() which triggers + // async events instead of returning commands directly + mock, _ := m.Playback.Player().(*player.Mock) + if mock.State() != player.Playing { + t.Errorf("player state = %v, want Playing", mock.State()) } }) } diff --git a/internal/app/keys.go b/internal/app/keys.go index 2075ca3..c94ae39 100644 --- a/internal/app/keys.go +++ b/internal/app/keys.go @@ -177,5 +177,5 @@ func (m *Model) handleSeek(seconds int) { return } m.LastSeekTime = time.Now() - m.Playback.Seek(time.Duration(seconds) * time.Second) + _ = m.PlaybackService.Seek(time.Duration(seconds) * time.Second) } diff --git a/internal/app/messages.go b/internal/app/messages.go index 5d37c7f..ca54b88 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -82,10 +82,32 @@ type LibraryScanCompleteMsg struct { func (LibraryScanCompleteMsg) libraryScanMessage() {} // TrackFinishedMsg is sent when the current track finishes playing. +// +// Deprecated: Use ServiceTrackChangedMsg from the playback service instead. type TrackFinishedMsg struct{} func (TrackFinishedMsg) playbackMessage() {} +// ServiceStateChangedMsg is sent when the playback service state changes. +type ServiceStateChangedMsg struct { + Previous, Current int // playback.State values +} + +func (ServiceStateChangedMsg) playbackMessage() {} + +// ServiceTrackChangedMsg is sent when the current track changes. +type ServiceTrackChangedMsg struct { + PreviousIndex int + CurrentIndex int +} + +func (ServiceTrackChangedMsg) playbackMessage() {} + +// ServiceClosedMsg is sent when the playback service is closed. +type ServiceClosedMsg struct{} + +func (ServiceClosedMsg) playbackMessage() {} + // FocusTarget represents which UI component has focus. type FocusTarget int diff --git a/internal/app/playback.go b/internal/app/playback.go index 04a2255..fe1dd44 100644 --- a/internal/app/playback.go +++ b/internal/app/playback.go @@ -44,7 +44,9 @@ func (m *Model) PlayTrack(path string) tea.Cmd { // HandleSpaceAction handles the space key: toggle pause/resume or start playback. func (m *Model) HandleSpaceAction() tea.Cmd { if !m.Playback.IsStopped() { - m.Playback.Toggle() + if err := m.PlaybackService.Toggle(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) + } return nil } return m.StartQueuePlayback() @@ -55,12 +57,14 @@ func (m *Model) StartQueuePlayback() tea.Cmd { if m.Playback.Queue().IsEmpty() { return nil } - track := m.Playback.Queue().Current() - if track == nil { + m.Layout.QueuePanel().SyncCursor() + if err := m.PlaybackService.Play(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) return nil } - m.Layout.QueuePanel().SyncCursor() - return m.PlayTrack(track.Path) + m.SaveQueueState() + // Service emits events; handleServiceStateChanged starts TickCmd + return nil } // JumpToQueueIndex moves to a queue position with debouncing when playing. @@ -117,7 +121,13 @@ func (m *Model) PlayTrackAtIndex(index int) tea.Cmd { m.SaveQueueState() m.Layout.QueuePanel().SyncCursor() - return m.PlayTrack(track.Path) + + if err := m.PlaybackService.Play(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) + return nil + } + // Service emits events; handleServiceStateChanged starts TickCmd + return nil } // TogglePlayerDisplayMode cycles between compact and expanded player display. diff --git a/internal/app/playback_test.go b/internal/app/playback_test.go index 9d26f22..8dc7639 100644 --- a/internal/app/playback_test.go +++ b/internal/app/playback_test.go @@ -4,6 +4,7 @@ package app import ( "testing" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/state" @@ -15,7 +16,7 @@ func TestHandleSpaceAction_WhenStopped_StartsPlayback(t *testing.T) { m.Playback.Queue().Add(playlist.Track{Path: "/test.mp3"}) m.Playback.Queue().JumpTo(0) - cmd := m.HandleSpaceAction() + _ = m.HandleSpaceAction() mock, ok := m.Playback.Player().(*player.Mock) if !ok { @@ -24,9 +25,7 @@ func TestHandleSpaceAction_WhenStopped_StartsPlayback(t *testing.T) { if mock.State() != player.Playing { t.Error("expected player to be playing") } - if cmd == nil { - t.Error("expected tick command") - } + // Note: Tick commands are now started via service events, not returned directly } func TestHandleSpaceAction_WhenPlaying_Pauses(t *testing.T) { @@ -86,12 +85,12 @@ func TestStartQueuePlayback_WithEmptyQueue_ReturnsNil(t *testing.T) { } } -func TestStartQueuePlayback_WithTrack_PlaysAndReturnsTick(t *testing.T) { +func TestStartQueuePlayback_WithTrack_PlaysTrack(t *testing.T) { m := newPlaybackTestModel() m.Playback.Queue().Add(playlist.Track{Path: "/music/song.mp3"}) m.Playback.Queue().JumpTo(0) - cmd := m.StartQueuePlayback() + _ = m.StartQueuePlayback() mock, ok := m.Playback.Player().(*player.Mock) if !ok { @@ -101,9 +100,7 @@ func TestStartQueuePlayback_WithTrack_PlaysAndReturnsTick(t *testing.T) { if len(calls) != 1 || calls[0] != "/music/song.mp3" { t.Errorf("PlayCalls = %v, want [/music/song.mp3]", calls) } - if cmd == nil { - t.Error("expected tick command") - } + // Note: Tick commands are now started via service events, not returned directly } func TestJumpToQueueIndex_WhenStopped_DoesNotStartPlayback(t *testing.T) { @@ -217,7 +214,7 @@ func TestPlayTrackAtIndex_ValidIndex_PlaysTrack(t *testing.T) { playlist.Track{Path: "/track2.mp3"}, ) - cmd := m.PlayTrackAtIndex(1) + _ = m.PlayTrackAtIndex(1) mock, ok := m.Playback.Player().(*player.Mock) if !ok { @@ -227,9 +224,7 @@ func TestPlayTrackAtIndex_ValidIndex_PlaysTrack(t *testing.T) { if len(calls) != 1 || calls[0] != "/track2.mp3" { t.Errorf("PlayCalls = %v, want [/track2.mp3]", calls) } - if cmd == nil { - t.Error("expected tick command") - } + // Note: Tick commands are now started via service events, not returned directly } func TestPlayTrackAtIndex_InvalidIndex_ReturnsNil(t *testing.T) { @@ -255,9 +250,11 @@ func TestTogglePlayerDisplayMode_WhenStopped_DoesNothing(_ *testing.T) { func newPlaybackTestModel() *Model { queue := playlist.NewQueue() p := player.NewMock() + svc := playback.New(p, queue) return &Model{ - Playback: NewPlaybackManager(p, queue), - Layout: NewLayoutManager(queuepanel.New(queue)), - StateMgr: state.NewMock(), + Playback: NewPlaybackManager(p, queue), + PlaybackService: svc, + Layout: NewLayoutManager(queuepanel.New(queue)), + StateMgr: state.NewMock(), } } diff --git a/internal/app/queue.go b/internal/app/queue.go index 0b0ecb5..c1c9524 100644 --- a/internal/app/queue.go +++ b/internal/app/queue.go @@ -33,9 +33,10 @@ func (m *Model) HandleQueueAction(action QueueAction) tea.Cmd { m.Layout.QueuePanel().SyncCursor() if trackToPlay != nil { - return m.PlayTrack(trackToPlay.Path) + if err := m.PlaybackService.Play(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) + } } - return nil } @@ -132,9 +133,10 @@ func (m *Model) HandleContainerAndPlay() tea.Cmd { m.Layout.QueuePanel().SyncCursor() if trackToPlay != nil { - return m.PlayTrack(trackToPlay.Path) + if err := m.PlaybackService.Play(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) + } } - return nil } diff --git a/internal/app/update.go b/internal/app/update.go index ca54da2..29fbe3c 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -195,7 +195,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Check for audio server disconnection (ALSA errors indicate this) if isAudioDisconnectError(msg.Line) { m.Popups.ShowError("Audio server disconnected. Restart app to restore playback.") - m.Playback.Stop() + _ = m.PlaybackService.Stop() m.ResizeComponents() } else { m.Popups.ShowError("Audio: " + msg.Line) diff --git a/internal/app/update_playback.go b/internal/app/update_playback.go index 94e0c60..4f67a69 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -7,13 +7,35 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/llehouerou/waves/internal/lastfm" + "github.com/llehouerou/waves/internal/playback" ) +// resetScrobbleState resets scrobble tracking for a new track. +func (m *Model) resetScrobbleState() { + track := m.Playback.CurrentTrack() + if track == nil { + m.ScrobbleState = nil + return + } + m.ScrobbleState = &lastfm.ScrobbleState{ + TrackPath: track.Path, + StartedAt: time.Now(), + } + m.RadioFillTriggered = false +} + // handlePlaybackMsg routes playback-related messages. func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case TrackFinishedMsg: + // Deprecated: service now handles auto-advance internally return m.handleTrackFinished() + case ServiceStateChangedMsg: + return m.handleServiceStateChanged(msg) + case ServiceTrackChangedMsg: + return m.handleServiceTrackChanged(msg) + case ServiceClosedMsg: + return m, nil // Service closed, nothing to do case TrackSkipTimeoutMsg: return m.handleTrackSkipTimeout(msg) case TickMsg: @@ -62,6 +84,55 @@ func (m Model) handleTrackSkipTimeout(msg TrackSkipTimeoutMsg) (tea.Model, tea.C return m, nil } +// handleServiceStateChanged handles playback state changes from the service. +func (m Model) handleServiceStateChanged(msg ServiceStateChangedMsg) (tea.Model, tea.Cmd) { + // Update UI to reflect new state + m.ResizeComponents() + + // When starting playback (transitioning to playing), reset scrobble and check radio + if m.Playback.IsPlaying() { + cmds := []tea.Cmd{TickCmd(), m.WatchServiceEvents()} + + // Reset scrobble state when starting from stopped + if msg.Previous == int(playback.StateStopped) { + m.resetScrobbleState() + if cmd := m.checkRadioFillNearEnd(); cmd != nil { + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) + } + + return m, m.WatchServiceEvents() +} + +// handleServiceTrackChanged handles track changes from the service. +func (m Model) handleServiceTrackChanged(_ ServiceTrackChangedMsg) (tea.Model, tea.Cmd) { + // Update UI to reflect new track + m.SaveQueueState() + m.Layout.QueuePanel().SyncCursor() + m.ResizeComponents() + + // Reset scrobble state for new track + m.resetScrobbleState() + + // Check if we need to fill radio queue (when starting last track) + var cmds []tea.Cmd + cmds = append(cmds, m.WatchServiceEvents()) + + if cmd := m.checkRadioFillNearEnd(); cmd != nil { + cmds = append(cmds, cmd) + } + + // Start tick command if playing + if m.Playback.IsPlaying() { + cmds = append(cmds, TickCmd()) + } + + return m, tea.Batch(cmds...) +} + // checkScrobbleThreshold checks if the current track has been played long enough to scrobble. // Last.fm rules: scrobble after 50% of duration OR 4 minutes, whichever comes first. // Track must be at least 30 seconds long. From ff525940d74814adb9b397cd05368a5fdccc2b18 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 19:21:53 +0400 Subject: [PATCH 12/26] fix(playback): recreate service with restored queue during init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PlaybackService was created during app.New() with an empty queue, but the actual queue was restored from saved state during handleInitResult and only updated in PlaybackManager. This caused "queue is empty" errors when trying to play. Fix by recreating the PlaybackService with the restored queue and starting WatchServiceEvents after initialization completes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/update_loading.go | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/app/update_loading.go b/internal/app/update_loading.go index d7821c1..95486b4 100644 --- a/internal/app/update_loading.go +++ b/internal/app/update_loading.go @@ -9,6 +9,7 @@ import ( "github.com/llehouerou/waves/internal/errmsg" "github.com/llehouerou/waves/internal/library" "github.com/llehouerou/waves/internal/navigator" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" "github.com/llehouerou/waves/internal/ui/albumview" @@ -71,6 +72,10 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { } if queue, ok := msg.Queue.(*playlist.PlayingQueue); ok { m.Playback.SetQueue(queue) + // Recreate PlaybackService with the restored queue + // (the old service had an empty queue created during New()) + m.PlaybackService = playback.New(m.Playback.Player(), queue) + m.playbackSub = m.PlaybackService.Subscribe() } if queuePanel, ok := msg.QueuePanel.(queuepanel.Model); ok { m.Layout.SetQueuePanel(queuePanel) @@ -108,12 +113,13 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { } } - // Helper to batch downloads refresh with other commands - withDownloadsRefresh := func(cmd tea.Cmd) tea.Cmd { + // Helper to batch downloads refresh and service events with other commands + withCommonCmds := func(cmd tea.Cmd) tea.Cmd { + cmds := []tea.Cmd{cmd, m.WatchServiceEvents()} if downloadsRefreshCmd != nil { - return tea.Batch(cmd, downloadsRefreshCmd) + cmds = append(cmds, downloadsRefreshCmd) } - return cmd + return tea.Batch(cmds...) } // Decide whether to transition to done based on current phase @@ -123,11 +129,11 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { // First launch: show loading screen for 3 seconds m.loadingState = loadingShowing m.loadingShowTime = time.Now() - return m, withDownloadsRefresh(tea.Batch(LoadingTickCmd(), HideLoadingFirstLaunchCmd())) + return m, withCommonCmds(tea.Batch(LoadingTickCmd(), HideLoadingFirstLaunchCmd())) } // Init finished before show delay - never show loading screen m.loadingState = loadingDone - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds(m.WatchTrackFinished()) case loadingShowing: // Check if minimum display time has elapsed minTime := 800 * time.Millisecond @@ -136,16 +142,20 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { } if time.Since(m.loadingShowTime) >= minTime { m.loadingState = loadingDone - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds(m.WatchTrackFinished()) } - // Otherwise wait for HideLoadingMsg - return m, downloadsRefreshCmd + // Otherwise wait for HideLoadingMsg - still need to start service events + cmds := []tea.Cmd{m.WatchServiceEvents()} + if downloadsRefreshCmd != nil { + cmds = append(cmds, downloadsRefreshCmd) + } + return m, tea.Batch(cmds...) case loadingDone: // Already done (shouldn't happen) - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds(m.WatchTrackFinished()) } - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds(m.WatchTrackFinished()) } // handleShowLoading transitions to showing state if still waiting. From c82a7d9548c92b1ca8c4137bb3fb97aa20b9bbba Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 20:48:59 +0400 Subject: [PATCH 13/26] fix(playback): restore radio fill on track start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactoring to use PlaybackService bypassed PlayTrack which contained the triggerRadioFill() call. The handlers were only calling checkRadioFillNearEnd() which triggers near the end of a track. Fix by calling triggerRadioFill() in service event handlers: - handleServiceStateChanged: when starting from stopped - handleServiceTrackChanged: when track changes (auto-advance) This restores the pre-fetch behavior when starting the last track. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/update_playback.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/app/update_playback.go b/internal/app/update_playback.go index 4f67a69..7f63022 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -96,7 +96,8 @@ func (m Model) handleServiceStateChanged(msg ServiceStateChangedMsg) (tea.Model, // Reset scrobble state when starting from stopped if msg.Previous == int(playback.StateStopped) { m.resetScrobbleState() - if cmd := m.checkRadioFillNearEnd(); cmd != nil { + // Trigger radio fill if starting the last track (pre-fetch next tracks) + if cmd := m.triggerRadioFill(); cmd != nil { cmds = append(cmds, cmd) } } @@ -117,11 +118,11 @@ func (m Model) handleServiceTrackChanged(_ ServiceTrackChangedMsg) (tea.Model, t // Reset scrobble state for new track m.resetScrobbleState() - // Check if we need to fill radio queue (when starting last track) var cmds []tea.Cmd cmds = append(cmds, m.WatchServiceEvents()) - if cmd := m.checkRadioFillNearEnd(); cmd != nil { + // Trigger radio fill if now on the last track (pre-fetch next tracks) + if cmd := m.triggerRadioFill(); cmd != nil { cmds = append(cmds, cmd) } From ee5c170bc3be1bfb50cab3c4485f38063a3e926b Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 22:17:57 +0400 Subject: [PATCH 14/26] fix(playback): trigger radio fill on debounced track skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When skipping tracks with pgdown/pgup while playing, the queue moves immediately for UI feedback, then PlayTrackAtIndex is called after debounce. Since the queue was already moved, service.Play() doesn't emit TrackChange (index unchanged), so handleServiceTrackChanged never runs and radio fill isn't triggered. Fix by calling resetScrobbleState and triggerRadioFill directly in PlayTrackAtIndex to handle track changes from debounced navigation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/playback.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/app/playback.go b/internal/app/playback.go index fe1dd44..4bb5438 100644 --- a/internal/app/playback.go +++ b/internal/app/playback.go @@ -126,8 +126,12 @@ func (m *Model) PlayTrackAtIndex(index int) tea.Cmd { m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) return nil } - // Service emits events; handleServiceStateChanged starts TickCmd - return nil + + // Handle track change manually since service.Play() doesn't emit + // TrackChange when called after debounced queue navigation + // (the queue was already moved by AdvanceToNextTrack/GoToPreviousTrack) + m.resetScrobbleState() + return m.triggerRadioFill() } // TogglePlayerDisplayMode cycles between compact and expanded player display. From dcddd83d2478afa68bfe2a3e37d0a104f9dd2fe9 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 22:41:53 +0400 Subject: [PATCH 15/26] refactor(app): move DisplayMode to LayoutManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisplayMode is a UI layout concern, not playback state. - Add playerDisplayMode field to LayoutManager - Add PlayerDisplayMode() and SetPlayerDisplayMode() methods - Update playback.go, view.go, layout.go to use Layout methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/layout.go | 2 +- internal/app/layout_manager.go | 27 +++++++++++++++++++++------ internal/app/playback.go | 6 +++--- internal/app/view.go | 2 +- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/internal/app/layout.go b/internal/app/layout.go index 2366b2b..00b8fd1 100644 --- a/internal/app/layout.go +++ b/internal/app/layout.go @@ -24,7 +24,7 @@ func (m *Model) ContentHeight() int { height := m.Layout.Height() height -= headerbar.Height if !m.Playback.IsStopped() { - height -= playerbar.Height(m.Playback.DisplayMode()) + height -= playerbar.Height(m.Layout.PlayerDisplayMode()) } if activeCount := m.ActiveJobCount(); activeCount > 0 { height -= jobbar.Height(activeCount) diff --git a/internal/app/layout_manager.go b/internal/app/layout_manager.go index 96a0cc7..6401f78 100644 --- a/internal/app/layout_manager.go +++ b/internal/app/layout_manager.go @@ -2,6 +2,7 @@ package app import ( + "github.com/llehouerou/waves/internal/ui/playerbar" "github.com/llehouerou/waves/internal/ui/queuepanel" ) @@ -11,17 +12,19 @@ const NarrowThreshold = 120 // LayoutManager manages window dimensions, queue visibility, and the queue panel. type LayoutManager struct { - width int - height int - queueVisible bool - queuePanel queuepanel.Model + width int + height int + queueVisible bool + queuePanel queuepanel.Model + playerDisplayMode playerbar.DisplayMode } // NewLayoutManager creates a new LayoutManager with the given queue panel. func NewLayoutManager(queuePanel queuepanel.Model) LayoutManager { return LayoutManager{ - queueVisible: true, - queuePanel: queuePanel, + queueVisible: true, + queuePanel: queuePanel, + playerDisplayMode: playerbar.ModeExpanded, } } @@ -88,6 +91,18 @@ func (l *LayoutManager) SetQueuePanel(panel queuepanel.Model) { l.queuePanel = panel } +// --- Player Display Mode --- + +// PlayerDisplayMode returns the current player bar display mode. +func (l *LayoutManager) PlayerDisplayMode() playerbar.DisplayMode { + return l.playerDisplayMode +} + +// SetPlayerDisplayMode sets the player bar display mode. +func (l *LayoutManager) SetPlayerDisplayMode(mode playerbar.DisplayMode) { + l.playerDisplayMode = mode +} + // --- Layout Calculations --- // NavigatorWidth returns the available width for navigators. diff --git a/internal/app/playback.go b/internal/app/playback.go index 4bb5438..a19ac27 100644 --- a/internal/app/playback.go +++ b/internal/app/playback.go @@ -140,12 +140,12 @@ func (m *Model) TogglePlayerDisplayMode() { return } - if m.Playback.DisplayMode() == playerbar.ModeExpanded { - m.Playback.SetDisplayMode(playerbar.ModeCompact) + if m.Layout.PlayerDisplayMode() == playerbar.ModeExpanded { + m.Layout.SetPlayerDisplayMode(playerbar.ModeCompact) } else { minHeightForExpanded := playerbar.Height(playerbar.ModeExpanded) + 8 if m.Layout.Height() >= minHeightForExpanded { - m.Playback.SetDisplayMode(playerbar.ModeExpanded) + m.Layout.SetPlayerDisplayMode(playerbar.ModeExpanded) } } diff --git a/internal/app/view.go b/internal/app/view.go index 45abf18..b4630bb 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -221,7 +221,7 @@ func joinColumnsView(left, right string) string { // renderPlayerBar renders the player bar with radio state. func (m Model) renderPlayerBar() string { - state := playerbar.NewState(m.Playback.Player(), m.Playback.DisplayMode()) + state := playerbar.NewState(m.Playback.Player(), m.Layout.PlayerDisplayMode()) state.RadioEnabled = m.Playback.Queue().RepeatMode() == playlist.RepeatRadio return playerbar.Render(state, m.Layout.Width()) } From 543c3dbd2cb75ac8ef291f4cf2f2cc1e899349f2 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 22:44:49 +0400 Subject: [PATCH 16/26] feat(playback): add queue manipulation methods to service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add methods to PlaybackService for complete queue management: - AddTracks, ReplaceTracks, ClearQueue for manipulation - QueueLen, QueueIsEmpty, QueueHasNext for queries - Undo, Redo for history - Rename Queue() to QueueTracks(), QueueIndex() to QueueCurrentIndex() Add track conversion helpers between playback.Track and playlist.Track. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service.go | 18 +++++-- internal/playback/service_impl.go | 72 ++++++++++++++++++++++++-- internal/playback/service_impl_test.go | 36 ++++++------- internal/playback/track.go | 50 +++++++++++++++++- 4 files changed, 150 insertions(+), 26 deletions(-) diff --git a/internal/playback/service.go b/internal/playback/service.go index 3669a1b..3b2f47e 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -17,13 +17,25 @@ type Service interface { // Queue navigation JumpTo(index int) error - // State queries + // Queue manipulation + AddTracks(tracks ...Track) + ReplaceTracks(tracks ...Track) *Track // Returns track at index 0 or nil + ClearQueue() + + // Queue queries State() State Position() time.Duration Duration() time.Duration CurrentTrack() *Track - Queue() []Track - QueueIndex() int + QueueTracks() []Track + QueueCurrentIndex() int + QueueLen() int + QueueIsEmpty() bool + QueueHasNext() bool + + // Queue history + Undo() bool + Redo() bool // Mode control RepeatMode() RepeatMode diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 166bde2..89f2b60 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -101,8 +101,8 @@ func (s *serviceImpl) currentTrackLocked() *Track { } } -// Queue returns a copy of all tracks in the queue. -func (s *serviceImpl) Queue() []Track { +// QueueTracks returns a copy of all tracks in the queue. +func (s *serviceImpl) QueueTracks() []Track { s.mu.RLock() defer s.mu.RUnlock() tracks := s.queue.Tracks() @@ -121,13 +121,77 @@ func (s *serviceImpl) Queue() []Track { return result } -// QueueIndex returns the current queue index (-1 if none). -func (s *serviceImpl) QueueIndex() int { +// QueueCurrentIndex returns the current queue index (-1 if none). +func (s *serviceImpl) QueueCurrentIndex() int { s.mu.RLock() defer s.mu.RUnlock() return s.queue.CurrentIndex() } +// QueueLen returns the number of tracks in the queue. +func (s *serviceImpl) QueueLen() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.queue.Len() +} + +// QueueIsEmpty returns true if the queue is empty. +func (s *serviceImpl) QueueIsEmpty() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.queue.IsEmpty() +} + +// QueueHasNext returns true if there is a next track in the queue. +func (s *serviceImpl) QueueHasNext() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.queue.HasNext() +} + +// AddTracks adds tracks to the end of the queue. +func (s *serviceImpl) AddTracks(tracks ...Track) { + s.mu.Lock() + defer s.mu.Unlock() + playlistTracks := TracksToPlaylist(tracks) + s.queue.Add(playlistTracks...) +} + +// ReplaceTracks replaces all tracks in the queue. +// Returns the track at index 0 or nil if empty. +func (s *serviceImpl) ReplaceTracks(tracks ...Track) *Track { + s.mu.Lock() + defer s.mu.Unlock() + playlistTracks := TracksToPlaylist(tracks) + first := s.queue.Replace(playlistTracks...) + if first == nil { + return nil + } + result := TrackFromPlaylist(*first) + return &result +} + +// ClearQueue removes all tracks from the queue. +func (s *serviceImpl) ClearQueue() { + s.mu.Lock() + defer s.mu.Unlock() + s.queue.Clear() +} + +// Undo reverts the last queue modification. +func (s *serviceImpl) Undo() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.queue.Undo() +} + +// Redo reapplies the last undone queue modification. +func (s *serviceImpl) Redo() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.queue.Redo() +} + // RepeatMode returns the current repeat mode. func (s *serviceImpl) RepeatMode() RepeatMode { s.mu.RLock() diff --git a/internal/playback/service_impl_test.go b/internal/playback/service_impl_test.go index 371ea7e..cbd86bc 100644 --- a/internal/playback/service_impl_test.go +++ b/internal/playback/service_impl_test.go @@ -119,7 +119,7 @@ func TestService_Queue_ReturnsCopy(t *testing.T) { ) svc := New(p, q) - tracks := svc.Queue() + tracks := svc.QueueTracks() if len(tracks) != 2 { t.Fatalf("len(Queue()) = %d, want 2", len(tracks)) @@ -138,13 +138,13 @@ func TestService_QueueIndex_ReflectsQueue(t *testing.T) { q.Add(playlist.Track{Path: testSvcPathA}, playlist.Track{Path: testSvcPathB}) svc := New(p, q) - if svc.QueueIndex() != -1 { - t.Errorf("QueueIndex() = %d, want -1 (no current)", svc.QueueIndex()) + if svc.QueueCurrentIndex() != -1 { + t.Errorf("QueueIndex() = %d, want -1 (no current)", svc.QueueCurrentIndex()) } q.JumpTo(1) - if svc.QueueIndex() != 1 { - t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) } } @@ -506,8 +506,8 @@ func TestService_Next_AdvancesToNextTrack(t *testing.T) { if err != nil { t.Fatalf("Next() error = %v", err) } - if svc.QueueIndex() != 1 { - t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) } // Verify TrackChanged event @@ -585,8 +585,8 @@ func TestService_Next_WhenStopped_AdvancesWithoutPlaying(t *testing.T) { if err != nil { t.Fatalf("Next() error = %v", err) } - if svc.QueueIndex() != 1 { - t.Errorf("QueueIndex() = %d, want 1", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) } if svc.State() != StateStopped { t.Errorf("State() = %v, want Stopped", svc.State()) @@ -628,8 +628,8 @@ func TestService_Previous_GoesToPreviousTrack(t *testing.T) { if err != nil { t.Fatalf("Previous() error = %v", err) } - if svc.QueueIndex() != 0 { - t.Errorf("QueueIndex() = %d, want 0", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0", svc.QueueCurrentIndex()) } // Verify TrackChanged event @@ -672,8 +672,8 @@ func TestService_Previous_AtStart_StaysAtStart(t *testing.T) { if err != nil { t.Fatalf("Previous() error = %v", err) } - if svc.QueueIndex() != 0 { - t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueCurrentIndex()) } // Verify no TrackChanged event (no-op) @@ -711,8 +711,8 @@ func TestService_JumpTo_ChangesIndex(t *testing.T) { if err != nil { t.Fatalf("JumpTo() error = %v", err) } - if svc.QueueIndex() != 2 { - t.Errorf("QueueIndex() = %d, want 2", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 2 { + t.Errorf("QueueIndex() = %d, want 2", svc.QueueCurrentIndex()) } // Verify TrackChanged event @@ -758,8 +758,8 @@ func TestService_JumpTo_InvalidIndex_ReturnsError(t *testing.T) { } // Verify queue index unchanged - if svc.QueueIndex() != 0 { - t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueIndex()) + if svc.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueCurrentIndex()) } } @@ -1109,7 +1109,7 @@ func TestService_ConcurrentAccess_NoRace(_ *testing.T) { go func() { defer wg.Done() - _ = svc.Queue() + _ = svc.QueueTracks() }() go func() { diff --git a/internal/playback/track.go b/internal/playback/track.go index be641ed..a82dd9a 100644 --- a/internal/playback/track.go +++ b/internal/playback/track.go @@ -1,6 +1,10 @@ package playback -import "time" +import ( + "time" + + "github.com/llehouerou/waves/internal/playlist" +) // Track represents a track in the queue. // This is a copy of the data, not a reference to playlist.Track. @@ -13,3 +17,47 @@ type Track struct { TrackNumber int Duration time.Duration } + +// TrackFromPlaylist converts a playlist.Track to a playback.Track. +func TrackFromPlaylist(t playlist.Track) Track { + return Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } +} + +// ToPlaylist converts a playback.Track to a playlist.Track. +func (t Track) ToPlaylist() playlist.Track { + return playlist.Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } +} + +// TracksFromPlaylist converts a slice of playlist.Track to playback.Track. +func TracksFromPlaylist(tracks []playlist.Track) []Track { + result := make([]Track, len(tracks)) + for i, t := range tracks { + result[i] = TrackFromPlaylist(t) + } + return result +} + +// TracksToPlaylist converts a slice of playback.Track to playlist.Track. +func TracksToPlaylist(tracks []Track) []playlist.Track { + result := make([]playlist.Track, len(tracks)) + for i, t := range tracks { + result[i] = t.ToPlaylist() + } + return result +} From 549c41e705d519a051e5d7c516033fed68d4c7d4 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 22:46:20 +0400 Subject: [PATCH 17/26] feat(playback): add state helpers and TrackInfo to service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add convenience methods to PlaybackService: - IsPlaying(), IsStopped(), IsPaused() for state checks - TrackInfo() to access player's track metadata These allow callers to use the service directly without accessing the underlying player. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/playback/service.go | 14 ++++++++++++-- internal/playback/service_impl.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/playback/service.go b/internal/playback/service.go index 3b2f47e..2d84396 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -1,6 +1,10 @@ package playback -import "time" +import ( + "time" + + "github.com/llehouerou/waves/internal/player" +) // Service defines the playback service contract. type Service interface { @@ -22,11 +26,17 @@ type Service interface { ReplaceTracks(tracks ...Track) *Track // Returns track at index 0 or nil ClearQueue() - // Queue queries + // State queries State() State + IsPlaying() bool + IsStopped() bool + IsPaused() bool Position() time.Duration Duration() time.Duration CurrentTrack() *Track + TrackInfo() *player.TrackInfo + + // Queue queries QueueTracks() []Track QueueCurrentIndex() int QueueLen() int diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 89f2b60..7884016 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -64,6 +64,27 @@ func (s *serviceImpl) playerStateToState(ps player.State) State { } } +// IsPlaying returns true if currently playing. +func (s *serviceImpl) IsPlaying() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.State() == player.Playing +} + +// IsStopped returns true if currently stopped. +func (s *serviceImpl) IsStopped() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.State() == player.Stopped +} + +// IsPaused returns true if currently paused. +func (s *serviceImpl) IsPaused() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.State() == player.Paused +} + // Position returns the current playback position. func (s *serviceImpl) Position() time.Duration { s.mu.RLock() @@ -101,6 +122,13 @@ func (s *serviceImpl) currentTrackLocked() *Track { } } +// TrackInfo returns metadata about the currently playing track. +func (s *serviceImpl) TrackInfo() *player.TrackInfo { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player.TrackInfo() +} + // QueueTracks returns a copy of all tracks in the queue. func (s *serviceImpl) QueueTracks() []Track { s.mu.RLock() From 08accb57a3f6da65040010e6790ce8d4b46da40b Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 23:08:11 +0400 Subject: [PATCH 18/26] refactor(app): eliminate PlaybackManager, use PlaybackService only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete migration from PlaybackManager to PlaybackService as the single abstraction for all playback operations: - Add PlayPath, QueueAdvance, QueueMoveTo, Player methods to service - Migrate all app code to use PlaybackService instead of Playback - Update test files to use service methods - Remove PlaybackManager and PlaybackController interface - Clean up unused imports All queue and player operations now go through PlaybackService, eliminating the redundant manager layer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/app.go | 2 - internal/app/app_test.go | 29 +++--- internal/app/commands.go | 2 +- internal/app/handlers_export.go | 2 +- internal/app/handlers_playback.go | 42 ++++---- internal/app/handlers_queue.go | 4 +- internal/app/handlers_radio.go | 32 +++--- internal/app/handlers_test.go | 55 +++++----- internal/app/handlers_ui.go | 5 +- internal/app/integration_test.go | 84 +++++++-------- internal/app/interfaces.go | 46 --------- internal/app/layout.go | 2 +- internal/app/persistence.go | 8 +- internal/app/playback.go | 26 ++--- internal/app/playback_manager.go | 164 ------------------------------ internal/app/playback_test.go | 81 ++++++++------- internal/app/queue.go | 16 ++- internal/app/update.go | 2 +- internal/app/update_lastfm.go | 6 +- internal/app/update_loading.go | 3 +- internal/app/update_playback.go | 18 ++-- internal/app/view.go | 8 +- internal/playback/service.go | 8 +- internal/playback/service_impl.go | 65 ++++++++++++ 24 files changed, 284 insertions(+), 426 deletions(-) delete mode 100644 internal/app/playback_manager.go diff --git a/internal/app/app.go b/internal/app/app.go index c0b18d4..e355a7b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -47,7 +47,6 @@ type Model struct { Popups PopupManager Input InputManager Layout LayoutManager - Playback PlaybackManager PlaybackService playback.Service playbackSub *playback.Subscription Keys *keymap.Resolver @@ -155,7 +154,6 @@ func New(cfg *config.Config, stateMgr *state.Manager) (Model, error) { Popups: NewPopupManager(), Input: NewInputManager(), Layout: NewLayoutManager(queuepanel.New(queue)), - Playback: NewPlaybackManager(p, queue), PlaybackService: svc, playbackSub: sub, Keys: keymap.NewResolver(keymap.Bindings), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 85e7bda..9b3b77a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -34,13 +34,13 @@ func TestUpdate_WindowSizeMsg_ResizesComponents(t *testing.T) { func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.QueueMoveTo(0) - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -52,8 +52,8 @@ func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { t.Fatal("Update should return Model") } - if result.Playback.Queue().CurrentIndex() != 1 { - t.Errorf("CurrentIndex = %d, want 1", result.Playback.Queue().CurrentIndex()) + if result.PlaybackService.QueueCurrentIndex() != 1 { + t.Errorf("CurrentIndex = %d, want 1", result.PlaybackService.QueueCurrentIndex()) } if cmd == nil { t.Error("expected command for continued playback") @@ -62,10 +62,10 @@ func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { func TestUpdate_TrackFinishedMsg_StopsAtEndOfQueue(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/track1.mp3"}) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.AddTracks(playback.Track{Path: "/track1.mp3"}) + m.PlaybackService.QueueMoveTo(0) - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -77,7 +77,7 @@ func TestUpdate_TrackFinishedMsg_StopsAtEndOfQueue(t *testing.T) { t.Fatal("Update should return Model") } - resultMock, ok := result.Playback.Player().(*player.Mock) + resultMock, ok := result.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -89,7 +89,7 @@ func TestUpdate_TrackFinishedMsg_StopsAtEndOfQueue(t *testing.T) { func TestUpdate_KeyMsg_Quit(t *testing.T) { m := newIntegrationTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -117,7 +117,7 @@ func TestUpdate_KeyMsg_Quit(t *testing.T) { func TestUpdate_KeyMsg_TogglePause(t *testing.T) { m := newIntegrationTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -171,7 +171,7 @@ func TestUpdate_KeyMsg_TabSwitchesFocus(t *testing.T) { func TestUpdate_TickMsg_ContinuesWhenPlaying(t *testing.T) { m := newIntegrationTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -218,7 +218,6 @@ func newIntegrationTestModel() Model { svc := playback.New(p, queue) return Model{ Navigation: NewNavigationManager(), - Playback: NewPlaybackManager(p, queue), PlaybackService: svc, Layout: NewLayoutManager(queuepanel.New(queue)), Keys: keymap.NewResolver(keymap.Bindings), diff --git a/internal/app/commands.go b/internal/app/commands.go index 88341aa..2df8cba 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -40,7 +40,7 @@ func TrackSkipTimeoutCmd(version int) tea.Cmd { // Deprecated: The playback service now handles track finished internally. func (m Model) WatchTrackFinished() tea.Cmd { return func() tea.Msg { - <-m.Playback.FinishedChan() + <-m.PlaybackService.Player().FinishedChan() return TrackFinishedMsg{} } } diff --git a/internal/app/handlers_export.go b/internal/app/handlers_export.go index 2479a75..3e8a737 100644 --- a/internal/app/handlers_export.go +++ b/internal/app/handlers_export.go @@ -57,7 +57,7 @@ func (m *Model) collectExportTracks() (tracks []export.Track, albumName string) if m.Navigation.IsQueueFocused() { // Export from queue - queueTracks := m.Playback.Queue().Tracks() + queueTracks := m.PlaybackService.QueueTracks() for _, t := range queueTracks { trackIDs = append(trackIDs, t.ID) } diff --git a/internal/app/handlers_playback.go b/internal/app/handlers_playback.go index 2fa45cc..6795b17 100644 --- a/internal/app/handlers_playback.go +++ b/internal/app/handlers_playback.go @@ -6,7 +6,7 @@ import ( "github.com/llehouerou/waves/internal/app/handler" "github.com/llehouerou/waves/internal/keymap" - "github.com/llehouerou/waves/internal/playlist" + "github.com/llehouerou/waves/internal/playback" ) // handlePlaybackKeys handles space, s, pgup/pgdown, seek, R, S. @@ -23,13 +23,13 @@ func (m *Model) handlePlaybackKeys(key string) handler.Result { case keymap.ActionPrevTrack: return handler.Handled(m.GoToPreviousTrack()) case keymap.ActionFirstTrack: - if !m.Playback.Queue().IsEmpty() { + if !m.PlaybackService.QueueIsEmpty() { return handler.Handled(m.JumpToQueueIndex(0)) } return handler.HandledNoCmd case keymap.ActionLastTrack: - if !m.Playback.Queue().IsEmpty() { - return handler.Handled(m.JumpToQueueIndex(m.Playback.Queue().Len() - 1)) + if !m.PlaybackService.QueueIsEmpty() { + return handler.Handled(m.JumpToQueueIndex(m.PlaybackService.QueueLen() - 1)) } return handler.HandledNoCmd case keymap.ActionTogglePlayerDisplay: @@ -61,7 +61,7 @@ func (m *Model) handlePlaybackKeys(key string) handler.Result { // Cycle: Off -> All -> One -> Radio -> Off // If Last.fm is not configured, Radio mode is skipped. func (m *Model) handleCycleRepeat() tea.Cmd { - currentMode := m.Playback.Queue().RepeatMode() + currentMode := m.PlaybackService.RepeatMode() // Determine next mode nextMode := m.nextRepeatMode(currentMode) @@ -69,48 +69,48 @@ func (m *Model) handleCycleRepeat() tea.Cmd { // Handle radio state transitions cmd := m.handleRadioTransition(currentMode, nextMode) - m.Playback.Queue().SetRepeatMode(nextMode) + m.PlaybackService.SetRepeatMode(nextMode) m.SaveQueueState() return cmd } // nextRepeatMode returns the next repeat mode in the cycle. -func (m *Model) nextRepeatMode(current playlist.RepeatMode) playlist.RepeatMode { +func (m *Model) nextRepeatMode(current playback.RepeatMode) playback.RepeatMode { switch current { - case playlist.RepeatOff: - return playlist.RepeatAll - case playlist.RepeatAll: - return playlist.RepeatOne - case playlist.RepeatOne: + case playback.RepeatOff: + return playback.RepeatAll + case playback.RepeatAll: + return playback.RepeatOne + case playback.RepeatOne: // Only go to Radio mode if Last.fm is configured if m.isLastfmLinked() && m.Radio != nil { - return playlist.RepeatRadio + return playback.RepeatRadio } - return playlist.RepeatOff - case playlist.RepeatRadio: - return playlist.RepeatOff + return playback.RepeatOff + case playback.RepeatRadio: + return playback.RepeatOff default: - return playlist.RepeatOff + return playback.RepeatOff } } // handleRadioTransition handles enabling/disabling radio when transitioning modes. -func (m *Model) handleRadioTransition(from, to playlist.RepeatMode) tea.Cmd { +func (m *Model) handleRadioTransition(from, to playback.RepeatMode) tea.Cmd { if m.Radio == nil { return nil } // Leaving radio mode - if from == playlist.RepeatRadio && to != playlist.RepeatRadio { + if from == playback.RepeatRadio && to != playback.RepeatRadio { m.Radio.Disable() return nil } // Entering radio mode - if from != playlist.RepeatRadio && to == playlist.RepeatRadio { + if from != playback.RepeatRadio && to == playback.RepeatRadio { m.Radio.Enable() - if track := m.Playback.CurrentTrack(); track != nil { + if track := m.PlaybackService.CurrentTrack(); track != nil { m.Radio.SetSeed(track.Artist) } return func() tea.Msg { diff --git a/internal/app/handlers_queue.go b/internal/app/handlers_queue.go index 2e7b912..c5c1f20 100644 --- a/internal/app/handlers_queue.go +++ b/internal/app/handlers_queue.go @@ -10,13 +10,13 @@ import ( func (m *Model) handleQueueHistoryKeys(key string) handler.Result { switch m.Keys.Resolve(key) { //nolint:exhaustive // only handling history actions case keymap.ActionUndo: - if m.Playback.Queue().Undo() { + if m.PlaybackService.Undo() { m.SaveQueueState() m.Layout.QueuePanel().SyncCursor() } return handler.HandledNoCmd case keymap.ActionRedo: - if m.Playback.Queue().Redo() { + if m.PlaybackService.Redo() { m.SaveQueueState() m.Layout.QueuePanel().SyncCursor() } diff --git a/internal/app/handlers_radio.go b/internal/app/handlers_radio.go index 93036b0..a00b989 100644 --- a/internal/app/handlers_radio.go +++ b/internal/app/handlers_radio.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/llehouerou/waves/internal/errmsg" - "github.com/llehouerou/waves/internal/playlist" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/radio" ) @@ -67,10 +67,10 @@ func (m *Model) handleRadioFillResult(msg RadioFillResultMsg) { return } - // Convert to playlist tracks and add to queue - tracks := make([]playlist.Track, len(msg.Tracks)) + // Convert to playback tracks and add to queue + tracks := make([]playback.Track, len(msg.Tracks)) for i, t := range msg.Tracks { - tracks[i] = playlist.Track{ + tracks[i] = playback.Track{ ID: t.ID, Path: t.Path, Title: t.Title, @@ -80,7 +80,7 @@ func (m *Model) handleRadioFillResult(msg RadioFillResultMsg) { } } - m.Playback.Queue().Add(tracks...) + m.PlaybackService.AddTracks(tracks...) m.Layout.QueuePanel().SyncCursor() m.SaveQueueState() @@ -99,10 +99,8 @@ func (m *Model) handleRadioFillResult(msg RadioFillResultMsg) { // shouldFillRadio checks if radio should fill the queue. // Called when a track starts playing to pre-fetch more tracks. func (m *Model) shouldFillRadio() bool { - queue := m.Playback.Queue() - // Only active when in RepeatRadio mode - if queue.RepeatMode() != playlist.RepeatRadio { + if m.PlaybackService.RepeatMode() != playback.RepeatRadio { return false } @@ -110,21 +108,19 @@ func (m *Model) shouldFillRadio() bool { return false } - if queue.IsEmpty() { + if m.PlaybackService.QueueIsEmpty() { return false } // Fill when starting the last track (pre-fetch before it ends) - return queue.CurrentIndex() >= queue.Len()-1 + return m.PlaybackService.QueueCurrentIndex() >= m.PlaybackService.QueueLen()-1 } // shouldFillRadioNearEnd checks if radio should fill because track is near end with no next. // This handles the case where tracks were deleted/moved and current track became the last. func (m *Model) shouldFillRadioNearEnd() bool { - queue := m.Playback.Queue() - // Only active when in RepeatRadio mode - if queue.RepeatMode() != playlist.RepeatRadio { + if m.PlaybackService.RepeatMode() != playback.RepeatRadio { return false } @@ -138,13 +134,13 @@ func (m *Model) shouldFillRadioNearEnd() bool { } // Check if there's no next track - if queue.HasNext() { + if m.PlaybackService.QueueHasNext() { return false } // Check if we're within 15 seconds of the end - duration := m.Playback.Duration() - position := m.Playback.Position() + duration := m.PlaybackService.Duration() + position := m.PlaybackService.Position() // Need valid duration and position if duration <= 0 || position <= 0 { @@ -166,7 +162,7 @@ func (m *Model) triggerRadioFill() tea.Cmd { seed := m.Radio.CurrentSeed() if seed == "" { // Use current track's artist as seed - if track := m.Playback.CurrentTrack(); track != nil { + if track := m.PlaybackService.CurrentTrack(); track != nil { seed = track.Artist m.Radio.SetSeed(seed) } @@ -192,7 +188,7 @@ func (m *Model) checkRadioFillNearEnd() tea.Cmd { seed := m.Radio.CurrentSeed() if seed == "" { // Use current track's artist as seed - if track := m.Playback.CurrentTrack(); track != nil { + if track := m.PlaybackService.CurrentTrack(); track != nil { seed = track.Artist m.Radio.SetSeed(seed) } diff --git a/internal/app/handlers_test.go b/internal/app/handlers_test.go index acdcfc1..bb6c243 100644 --- a/internal/app/handlers_test.go +++ b/internal/app/handlers_test.go @@ -127,7 +127,7 @@ func TestHandleFocusKeys(t *testing.T) { func TestHandlePlaybackKeys(t *testing.T) { t.Run("space toggles play/pause", func(t *testing.T) { m := newTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -145,7 +145,7 @@ func TestHandlePlaybackKeys(t *testing.T) { t.Run("s stops player", func(t *testing.T) { m := newTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -163,28 +163,28 @@ func TestHandlePlaybackKeys(t *testing.T) { t.Run("R cycles repeat mode", func(t *testing.T) { m := newTestModel() - initialMode := m.Playback.Queue().RepeatMode() + initialMode := m.PlaybackService.RepeatMode() result := m.handlePlaybackKeys("R") if !result.Handled { t.Error("expected 'R' to be handled") } - if m.Playback.Queue().RepeatMode() == initialMode { + if m.PlaybackService.RepeatMode() == initialMode { t.Error("expected repeat mode to change") } }) t.Run("S toggles shuffle", func(t *testing.T) { m := newTestModel() - initialShuffle := m.Playback.Queue().Shuffle() + initialShuffle := m.PlaybackService.Shuffle() result := m.handlePlaybackKeys("S") if !result.Handled { t.Error("expected 'S' to be handled") } - if m.Playback.Queue().Shuffle() == initialShuffle { + if m.PlaybackService.Shuffle() == initialShuffle { t.Error("expected shuffle to toggle") } }) @@ -223,7 +223,6 @@ func newTestModel() *Model { return &Model{ Navigation: NewNavigationManager(), Layout: NewLayoutManager(queuepanel.New(queue)), - Playback: NewPlaybackManager(p, queue), PlaybackService: svc, Keys: keymap.NewResolver(keymap.Bindings), StateMgr: state.NewMock(), @@ -280,7 +279,7 @@ func TestPlaybackStateTransitions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := newTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -299,27 +298,27 @@ func TestPlaybackStateTransitions(t *testing.T) { func TestHandlePlaybackKeys_Home(t *testing.T) { m := newTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/1.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/2.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/3.mp3"}) - m.Playback.Queue().JumpTo(2) // Start at last track + m.PlaybackService.AddTracks(playback.Track{Path: "/1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/2.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/3.mp3"}) + m.PlaybackService.QueueMoveTo(2) // Start at last track result := m.handlePlaybackKeys("home") if !result.Handled { t.Error("expected 'home' to be handled") } - if m.Playback.Queue().CurrentIndex() != 0 { - t.Errorf("CurrentIndex = %d, want 0", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 0 { + t.Errorf("CurrentIndex = %d, want 0", m.PlaybackService.QueueCurrentIndex()) } } func TestHandlePlaybackKeys_HomeReturnsCmd_WhenPlaying(t *testing.T) { m := newTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/1.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/2.mp3"}) - m.Playback.Queue().JumpTo(1) - mock, ok := m.Playback.Player().(*player.Mock) + m.PlaybackService.AddTracks(playback.Track{Path: "/1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/2.mp3"}) + m.PlaybackService.QueueMoveTo(1) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -334,27 +333,27 @@ func TestHandlePlaybackKeys_HomeReturnsCmd_WhenPlaying(t *testing.T) { func TestHandlePlaybackKeys_End(t *testing.T) { m := newTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/1.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/2.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/3.mp3"}) - m.Playback.Queue().JumpTo(0) // Start at first track + m.PlaybackService.AddTracks(playback.Track{Path: "/1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/2.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/3.mp3"}) + m.PlaybackService.QueueMoveTo(0) // Start at first track result := m.handlePlaybackKeys("end") if !result.Handled { t.Error("expected 'end' to be handled") } - if m.Playback.Queue().CurrentIndex() != 2 { - t.Errorf("CurrentIndex = %d, want 2", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 2 { + t.Errorf("CurrentIndex = %d, want 2", m.PlaybackService.QueueCurrentIndex()) } } func TestHandlePlaybackKeys_EndReturnsCmd_WhenPlaying(t *testing.T) { m := newTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/1.mp3"}) - m.Playback.Queue().Add(playlist.Track{Path: "/2.mp3"}) - m.Playback.Queue().JumpTo(0) - mock, ok := m.Playback.Player().(*player.Mock) + m.PlaybackService.AddTracks(playback.Track{Path: "/1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/2.mp3"}) + m.PlaybackService.QueueMoveTo(0) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } diff --git a/internal/app/handlers_ui.go b/internal/app/handlers_ui.go index 322b267..969eed4 100644 --- a/internal/app/handlers_ui.go +++ b/internal/app/handlers_ui.go @@ -14,6 +14,7 @@ import ( "github.com/llehouerou/waves/internal/musicbrainz" "github.com/llehouerou/waves/internal/navigator" "github.com/llehouerou/waves/internal/navigator/sourceutil" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" "github.com/llehouerou/waves/internal/retag" @@ -194,9 +195,9 @@ func (m Model) handleAlbumViewQueueAction(act albumview.QueueAlbum) (tea.Model, } if act.Replace { - m.Playback.Queue().Clear() + m.PlaybackService.ClearQueue() } - m.Playback.Queue().Add(tracks...) + m.PlaybackService.AddTracks(playback.TracksFromPlaylist(tracks)...) m.SaveQueueState() if act.Replace { diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index e71f0c9..f2bac5f 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -6,8 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" - "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/ui/queuepanel" ) @@ -35,56 +35,56 @@ func updateModel(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { func TestIntegration_QueuePlaybackFlow(t *testing.T) { t.Run("add tracks then play from beginning", func(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/a.mp3", Artist: "A", Title: "Track A"}, - playlist.Track{Path: "/b.mp3", Artist: "B", Title: "Track B"}, - playlist.Track{Path: "/c.mp3", Artist: "C", Title: "Track C"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/a.mp3", Artist: "A", Title: "Track A"}, + playback.Track{Path: "/b.mp3", Artist: "B", Title: "Track B"}, + playback.Track{Path: "/c.mp3", Artist: "C", Title: "Track C"}, ) // Press Home to go to first track m, _ = updateModel(t, m, keyMsg("home")) - if m.Playback.Queue().CurrentIndex() != 0 { - t.Errorf("after home: index = %d, want 0", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 0 { + t.Errorf("after home: index = %d, want 0", m.PlaybackService.QueueCurrentIndex()) } // Press End to go to last track m, _ = updateModel(t, m, keyMsg("end")) - if m.Playback.Queue().CurrentIndex() != 2 { - t.Errorf("after end: index = %d, want 2", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 2 { + t.Errorf("after end: index = %d, want 2", m.PlaybackService.QueueCurrentIndex()) } }) t.Run("skip through tracks with pgdown", func(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/1.mp3"}, - playlist.Track{Path: "/2.mp3"}, - playlist.Track{Path: "/3.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/1.mp3"}, + playback.Track{Path: "/2.mp3"}, + playback.Track{Path: "/3.mp3"}, ) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.QueueMoveTo(0) - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Playing) // Skip to next track m, _ = updateModel(t, m, keyMsg("pgdown")) - if m.Playback.Queue().CurrentIndex() != 1 { - t.Errorf("after pgdown: index = %d, want 1", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 1 { + t.Errorf("after pgdown: index = %d, want 1", m.PlaybackService.QueueCurrentIndex()) } }) t.Run("cannot skip past beginning with pgup", func(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/1.mp3"}) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.AddTracks(playback.Track{Path: "/1.mp3"}) + m.PlaybackService.QueueMoveTo(0) - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Playing) // Try to skip before first track m, _ = updateModel(t, m, keyMsg("pgup")) - if m.Playback.Queue().CurrentIndex() != 0 { - t.Errorf("after pgup at start: index = %d, want 0", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 0 { + t.Errorf("after pgup at start: index = %d, want 0", m.PlaybackService.QueueCurrentIndex()) } }) } @@ -144,40 +144,40 @@ func TestIntegration_FocusCycling(t *testing.T) { func TestIntegration_RepeatModes(t *testing.T) { t.Run("cycle through repeat modes with R", func(t *testing.T) { m := newIntegrationTestModel() - initial := m.Playback.Queue().RepeatMode() + initial := m.PlaybackService.RepeatMode() // First R cycles to next mode m, _ = updateModel(t, m, keyMsg("R")) - mode1 := m.Playback.Queue().RepeatMode() + mode1 := m.PlaybackService.RepeatMode() if mode1 == initial { t.Error("repeat mode should change after first R") } // Second R cycles again m, _ = updateModel(t, m, keyMsg("R")) - mode2 := m.Playback.Queue().RepeatMode() + mode2 := m.PlaybackService.RepeatMode() if mode2 == mode1 { t.Error("repeat mode should change after second R") } // Third R should cycle back to initial m, _ = updateModel(t, m, keyMsg("R")) - if m.Playback.Queue().RepeatMode() != initial { - t.Errorf("repeat mode = %v, want %v (back to initial)", m.Playback.Queue().RepeatMode(), initial) + if m.PlaybackService.RepeatMode() != initial { + t.Errorf("repeat mode = %v, want %v (back to initial)", m.PlaybackService.RepeatMode(), initial) } }) t.Run("toggle shuffle with S", func(t *testing.T) { m := newIntegrationTestModel() - initial := m.Playback.Queue().Shuffle() + initial := m.PlaybackService.Shuffle() m, _ = updateModel(t, m, keyMsg("S")) - if m.Playback.Queue().Shuffle() == initial { + if m.PlaybackService.Shuffle() == initial { t.Error("shuffle should toggle") } m, _ = updateModel(t, m, keyMsg("S")) - if m.Playback.Queue().Shuffle() != initial { + if m.PlaybackService.Shuffle() != initial { t.Error("shuffle should toggle back") } }) @@ -188,7 +188,7 @@ func TestIntegration_RepeatModes(t *testing.T) { func TestIntegration_SpaceKey(t *testing.T) { t.Run("space toggles play/pause immediately", func(t *testing.T) { m := newIntegrationTestModel() - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Playing) // Press space - should immediately toggle play/pause @@ -202,7 +202,7 @@ func TestIntegration_SpaceKey(t *testing.T) { t.Run("space resumes paused playback", func(t *testing.T) { m := newIntegrationTestModel() - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Paused) // Press space @@ -224,7 +224,7 @@ func TestIntegration_StopBehavior(t *testing.T) { for _, initialState := range states { t.Run(initialState.String(), func(t *testing.T) { m := newIntegrationTestModel() - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(initialState) m, _ = updateModel(t, m, keyMsg("s")) @@ -242,10 +242,10 @@ func TestIntegration_StopBehavior(t *testing.T) { func TestIntegration_QueuePanelInteraction(t *testing.T) { t.Run("jump to track from queue panel", func(t *testing.T) { m := newIntegrationTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/a.mp3"}, - playlist.Track{Path: "/b.mp3"}, - playlist.Track{Path: "/c.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/a.mp3"}, + playback.Track{Path: "/b.mp3"}, + playback.Track{Path: "/c.mp3"}, ) m.Layout.ShowQueue() m.Navigation.SetFocus(FocusQueue) @@ -253,12 +253,12 @@ func TestIntegration_QueuePanelInteraction(t *testing.T) { // Simulate JumpToTrack action (normally sent by queue panel) m, _ = updateModel(t, m, queuepanel.ActionMsg(queuepanel.JumpToTrack{Index: 2})) - if m.Playback.Queue().CurrentIndex() != 2 { - t.Errorf("queue index = %d, want 2", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 2 { + t.Errorf("queue index = %d, want 2", m.PlaybackService.QueueCurrentIndex()) } // Note: PlayTrackAtIndex now uses PlaybackService.Play() which triggers // async events instead of returning commands directly - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) if mock.State() != player.Playing { t.Errorf("player state = %v, want Playing", mock.State()) } @@ -309,7 +309,7 @@ func TestIntegration_ErrorHandling(t *testing.T) { func TestIntegration_QuitBehavior(t *testing.T) { t.Run("q stops player and closes state", func(t *testing.T) { m := newIntegrationTestModel() - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Playing) _, cmd := m.Update(keyMsg("q")) @@ -324,7 +324,7 @@ func TestIntegration_QuitBehavior(t *testing.T) { t.Run("ctrl+c stops player and closes state", func(t *testing.T) { m := newIntegrationTestModel() - mock, _ := m.Playback.Player().(*player.Mock) + mock, _ := m.PlaybackService.Player().(*player.Mock) mock.SetState(player.Playing) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) diff --git a/internal/app/interfaces.go b/internal/app/interfaces.go index 8eb944b..32e0fb5 100644 --- a/internal/app/interfaces.go +++ b/internal/app/interfaces.go @@ -2,18 +2,13 @@ package app import ( - "time" - tea "github.com/charmbracelet/bubbletea" "github.com/llehouerou/waves/internal/library" "github.com/llehouerou/waves/internal/navigator" - "github.com/llehouerou/waves/internal/player" - "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" "github.com/llehouerou/waves/internal/search" "github.com/llehouerou/waves/internal/ui/librarysources" - "github.com/llehouerou/waves/internal/ui/playerbar" "github.com/llehouerou/waves/internal/ui/queuepanel" ) @@ -22,7 +17,6 @@ var ( _ PopupController = (*PopupManager)(nil) _ InputController = (*InputManager)(nil) _ LayoutController = (*LayoutManager)(nil) - _ PlaybackController = (*PlaybackManager)(nil) _ NavigationController = (*NavigationManager)(nil) ) @@ -113,46 +107,6 @@ type LayoutController interface { ResizeQueuePanel(height int) } -// PlaybackController manages audio playback, queue, and display mode. -type PlaybackController interface { - // Player access - Player() player.Interface - SetPlayer(p player.Interface) - - // Queue access - Queue() *playlist.PlayingQueue - SetQueue(q *playlist.PlayingQueue) - - // Player state - State() player.State - IsPlaying() bool - IsPaused() bool - IsStopped() bool - - // Playback controls - Play(path string) error - Pause() - Resume() - Toggle() - Stop() - Seek(delta time.Duration) - - // Position and duration - Position() time.Duration - Duration() time.Duration - - // Current track - CurrentTrack() *playlist.Track - - // Display mode - DisplayMode() playerbar.DisplayMode - SetDisplayMode(mode playerbar.DisplayMode) - ToggleDisplayMode() - - // Finished channel for track completion - FinishedChan() <-chan struct{} -} - // NavigationController manages view modes, focus state, and navigators. type NavigationController interface { // View mode diff --git a/internal/app/layout.go b/internal/app/layout.go index 00b8fd1..ab86ebf 100644 --- a/internal/app/layout.go +++ b/internal/app/layout.go @@ -23,7 +23,7 @@ func NotificationHeight(count int) int { func (m *Model) ContentHeight() int { height := m.Layout.Height() height -= headerbar.Height - if !m.Playback.IsStopped() { + if !m.PlaybackService.IsStopped() { height -= playerbar.Height(m.Layout.PlayerDisplayMode()) } if activeCount := m.ActiveJobCount(); activeCount > 0 { diff --git a/internal/app/persistence.go b/internal/app/persistence.go index 2bc82b7..1bd02bf 100644 --- a/internal/app/persistence.go +++ b/internal/app/persistence.go @@ -32,7 +32,7 @@ func (m *Model) SaveNavigationState() { // SaveQueueState persists the current queue state. func (m *Model) SaveQueueState() { - tracks := m.Playback.Queue().Tracks() + tracks := m.PlaybackService.QueueTracks() queueTracks := make([]state.QueueTrack, len(tracks)) for i, t := range tracks { queueTracks[i] = state.QueueTrack{ @@ -45,9 +45,9 @@ func (m *Model) SaveQueueState() { } } _ = m.StateMgr.SaveQueue(state.QueueState{ - CurrentIndex: m.Playback.Queue().CurrentIndex(), - RepeatMode: int(m.Playback.Queue().RepeatMode()), - Shuffle: m.Playback.Queue().Shuffle(), + CurrentIndex: m.PlaybackService.QueueCurrentIndex(), + RepeatMode: int(m.PlaybackService.RepeatMode()), + Shuffle: m.PlaybackService.Shuffle(), Tracks: queueTracks, }) } diff --git a/internal/app/playback.go b/internal/app/playback.go index a19ac27..5a192c7 100644 --- a/internal/app/playback.go +++ b/internal/app/playback.go @@ -15,7 +15,7 @@ import ( // Returns commands for tick and radio fill (if on last track). // Always calls ResizeComponents to ensure proper layout. func (m *Model) PlayTrack(path string) tea.Cmd { - if err := m.Playback.Play(path); err != nil { + if err := m.PlaybackService.PlayPath(path); err != nil { m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) m.ResizeComponents() m.Layout.QueuePanel().SyncCursor() @@ -43,7 +43,7 @@ func (m *Model) PlayTrack(path string) tea.Cmd { // HandleSpaceAction handles the space key: toggle pause/resume or start playback. func (m *Model) HandleSpaceAction() tea.Cmd { - if !m.Playback.IsStopped() { + if !m.PlaybackService.IsStopped() { if err := m.PlaybackService.Toggle(); err != nil { m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) } @@ -54,7 +54,7 @@ func (m *Model) HandleSpaceAction() tea.Cmd { // StartQueuePlayback starts playback from the current queue position. func (m *Model) StartQueuePlayback() tea.Cmd { - if m.Playback.Queue().IsEmpty() { + if m.PlaybackService.QueueIsEmpty() { return nil } m.Layout.QueuePanel().SyncCursor() @@ -69,10 +69,10 @@ func (m *Model) StartQueuePlayback() tea.Cmd { // JumpToQueueIndex moves to a queue position with debouncing when playing. func (m *Model) JumpToQueueIndex(index int) tea.Cmd { - m.Playback.Queue().JumpTo(index) + m.PlaybackService.QueueMoveTo(index) m.Layout.QueuePanel().SyncCursor() - if m.Playback.IsStopped() { + if m.PlaybackService.IsStopped() { m.SaveQueueState() return nil } @@ -83,38 +83,38 @@ func (m *Model) JumpToQueueIndex(index int) tea.Cmd { // AdvanceToNextTrack advances to the next track respecting shuffle/repeat modes. func (m *Model) AdvanceToNextTrack() tea.Cmd { - if m.Playback.Queue().IsEmpty() { + if m.PlaybackService.QueueIsEmpty() { return nil } - nextTrack := m.Playback.Queue().Next() + nextTrack := m.PlaybackService.QueueAdvance() if nextTrack == nil { return nil } m.Layout.QueuePanel().SyncCursor() - if m.Playback.IsStopped() { + if m.PlaybackService.IsStopped() { m.SaveQueueState() return nil } m.TrackSkipVersion++ - m.PendingTrackIdx = m.Playback.Queue().CurrentIndex() + m.PendingTrackIdx = m.PlaybackService.QueueCurrentIndex() return TrackSkipTimeoutCmd(m.TrackSkipVersion) } // GoToPreviousTrack moves to the previous track (always linear, ignores shuffle). func (m *Model) GoToPreviousTrack() tea.Cmd { - if m.Playback.Queue().CurrentIndex() <= 0 { + if m.PlaybackService.QueueCurrentIndex() <= 0 { return nil } - return m.JumpToQueueIndex(m.Playback.Queue().CurrentIndex() - 1) + return m.JumpToQueueIndex(m.PlaybackService.QueueCurrentIndex() - 1) } // PlayTrackAtIndex plays the track at the given queue index. func (m *Model) PlayTrackAtIndex(index int) tea.Cmd { - track := m.Playback.Queue().JumpTo(index) + track := m.PlaybackService.QueueMoveTo(index) if track == nil { return nil } @@ -136,7 +136,7 @@ func (m *Model) PlayTrackAtIndex(index int) tea.Cmd { // TogglePlayerDisplayMode cycles between compact and expanded player display. func (m *Model) TogglePlayerDisplayMode() { - if m.Playback.IsStopped() { + if m.PlaybackService.IsStopped() { return } diff --git a/internal/app/playback_manager.go b/internal/app/playback_manager.go deleted file mode 100644 index 583f17c..0000000 --- a/internal/app/playback_manager.go +++ /dev/null @@ -1,164 +0,0 @@ -// internal/app/playback_manager.go -package app - -import ( - "time" - - "github.com/llehouerou/waves/internal/player" - "github.com/llehouerou/waves/internal/playlist" - "github.com/llehouerou/waves/internal/ui/playerbar" -) - -// PlaybackManager manages audio playback, the queue, and display mode. -type PlaybackManager struct { - player player.Interface - queue *playlist.PlayingQueue - displayMode playerbar.DisplayMode -} - -// NewPlaybackManager creates a new PlaybackManager. -func NewPlaybackManager(p player.Interface, q *playlist.PlayingQueue) PlaybackManager { - return PlaybackManager{ - player: p, - queue: q, - displayMode: playerbar.ModeExpanded, - } -} - -// --- Player Access --- - -// Player returns the player interface for direct access. -func (p *PlaybackManager) Player() player.Interface { - return p.player -} - -// SetPlayer replaces the player implementation. -func (p *PlaybackManager) SetPlayer(pl player.Interface) { - p.player = pl -} - -// --- Queue Access --- - -// Queue returns the playing queue for direct access. -func (p *PlaybackManager) Queue() *playlist.PlayingQueue { - return p.queue -} - -// SetQueue replaces the queue. -func (p *PlaybackManager) SetQueue(q *playlist.PlayingQueue) { - p.queue = q -} - -// --- Player State --- - -// State returns the current player state. -func (p *PlaybackManager) State() player.State { - return p.player.State() -} - -// IsPlaying returns true if currently playing. -func (p *PlaybackManager) IsPlaying() bool { - return p.player.State() == player.Playing -} - -// IsPaused returns true if currently paused. -func (p *PlaybackManager) IsPaused() bool { - return p.player.State() == player.Paused -} - -// IsStopped returns true if currently stopped. -func (p *PlaybackManager) IsStopped() bool { - return p.player.State() == player.Stopped -} - -// --- Player Controls --- - -// Play starts playback of a track by path. -func (p *PlaybackManager) Play(path string) error { - return p.player.Play(path) -} - -// Pause pauses playback. -func (p *PlaybackManager) Pause() { - p.player.Pause() -} - -// Resume resumes playback. -func (p *PlaybackManager) Resume() { - p.player.Resume() -} - -// Toggle toggles between play and pause. -func (p *PlaybackManager) Toggle() { - p.player.Toggle() -} - -// Stop stops playback. -func (p *PlaybackManager) Stop() { - p.player.Stop() -} - -// Seek seeks by the given duration. -func (p *PlaybackManager) Seek(delta time.Duration) { - p.player.Seek(delta) -} - -// --- Position and Duration --- - -// Position returns the current playback position. -func (p *PlaybackManager) Position() time.Duration { - return p.player.Position() -} - -// Duration returns the total duration of the current track. -func (p *PlaybackManager) Duration() time.Duration { - return p.player.Duration() -} - -// --- Current Track --- - -// CurrentTrack returns the currently playing track, or nil if none. -func (p *PlaybackManager) CurrentTrack() *playlist.Track { - return p.queue.Current() -} - -// --- Display Mode --- - -// DisplayMode returns the current player bar display mode. -func (p *PlaybackManager) DisplayMode() playerbar.DisplayMode { - return p.displayMode -} - -// SetDisplayMode sets the player bar display mode. -func (p *PlaybackManager) SetDisplayMode(mode playerbar.DisplayMode) { - p.displayMode = mode -} - -// ToggleDisplayMode cycles between compact and expanded display. -func (p *PlaybackManager) ToggleDisplayMode() { - if p.displayMode == playerbar.ModeExpanded { - p.displayMode = playerbar.ModeCompact - } else { - p.displayMode = playerbar.ModeExpanded - } -} - -// --- Finished Channel --- - -// FinishedChan returns the channel that signals natural track completion. -func (p *PlaybackManager) FinishedChan() <-chan struct{} { - return p.player.FinishedChan() -} - -// Done returns the channel that is closed when the track ends (naturally or stopped). -func (p *PlaybackManager) Done() <-chan struct{} { - return p.player.Done() -} - -// --- View Rendering --- - -// RenderPlayerBar renders the player bar with the given width. -func (p *PlaybackManager) RenderPlayerBar(width int) string { - state := playerbar.NewState(p.player, p.displayMode) - return playerbar.Render(state, width) -} diff --git a/internal/app/playback_test.go b/internal/app/playback_test.go index 8dc7639..842acd1 100644 --- a/internal/app/playback_test.go +++ b/internal/app/playback_test.go @@ -13,12 +13,12 @@ import ( func TestHandleSpaceAction_WhenStopped_StartsPlayback(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/test.mp3"}) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.AddTracks(playback.Track{Path: "/test.mp3"}) + m.PlaybackService.QueueMoveTo(0) _ = m.HandleSpaceAction() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -30,7 +30,7 @@ func TestHandleSpaceAction_WhenStopped_StartsPlayback(t *testing.T) { func TestHandleSpaceAction_WhenPlaying_Pauses(t *testing.T) { m := newPlaybackTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -45,7 +45,7 @@ func TestHandleSpaceAction_WhenPlaying_Pauses(t *testing.T) { func TestHandleSpaceAction_WhenPaused_Resumes(t *testing.T) { m := newPlaybackTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -63,7 +63,7 @@ func TestHandleSpaceAction_WhenStoppedAndEmptyQueue_DoesNothing(t *testing.T) { cmd := m.HandleSpaceAction() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -87,12 +87,12 @@ func TestStartQueuePlayback_WithEmptyQueue_ReturnsNil(t *testing.T) { func TestStartQueuePlayback_WithTrack_PlaysTrack(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/music/song.mp3"}) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.AddTracks(playback.Track{Path: "/music/song.mp3"}) + m.PlaybackService.QueueMoveTo(0) _ = m.StartQueuePlayback() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -105,17 +105,17 @@ func TestStartQueuePlayback_WithTrack_PlaysTrack(t *testing.T) { func TestJumpToQueueIndex_WhenStopped_DoesNotStartPlayback(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) cmd := m.JumpToQueueIndex(1) - if m.Playback.Queue().CurrentIndex() != 1 { - t.Errorf("CurrentIndex = %d, want 1", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 1 { + t.Errorf("CurrentIndex = %d, want 1", m.PlaybackService.QueueCurrentIndex()) } - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -129,11 +129,11 @@ func TestJumpToQueueIndex_WhenStopped_DoesNotStartPlayback(t *testing.T) { func TestJumpToQueueIndex_WhenPlaying_ReturnsTimeoutCmd(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -161,16 +161,16 @@ func TestAdvanceToNextTrack_EmptyQueue_ReturnsNil(t *testing.T) { func TestAdvanceToNextTrack_WhenStopped_AdvancesWithoutPlaying(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.QueueMoveTo(0) cmd := m.AdvanceToNextTrack() - if m.Playback.Queue().CurrentIndex() != 1 { - t.Errorf("CurrentIndex = %d, want 1", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 1 { + t.Errorf("CurrentIndex = %d, want 1", m.PlaybackService.QueueCurrentIndex()) } if cmd != nil { t.Error("expected nil command when stopped") @@ -179,44 +179,44 @@ func TestAdvanceToNextTrack_WhenStopped_AdvancesWithoutPlaying(t *testing.T) { func TestGoToPreviousTrack_AtStart_ReturnsNil(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/track1.mp3"}) - m.Playback.Queue().JumpTo(0) + m.PlaybackService.AddTracks(playback.Track{Path: "/track1.mp3"}) + m.PlaybackService.QueueMoveTo(0) cmd := m.GoToPreviousTrack() if cmd != nil { t.Error("expected nil at start of queue") } - if m.Playback.Queue().CurrentIndex() != 0 { - t.Errorf("CurrentIndex = %d, want 0", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 0 { + t.Errorf("CurrentIndex = %d, want 0", m.PlaybackService.QueueCurrentIndex()) } } func TestGoToPreviousTrack_NotAtStart_MovesPrevious(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) - m.Playback.Queue().JumpTo(1) + m.PlaybackService.QueueMoveTo(1) m.GoToPreviousTrack() - if m.Playback.Queue().CurrentIndex() != 0 { - t.Errorf("CurrentIndex = %d, want 0", m.Playback.Queue().CurrentIndex()) + if m.PlaybackService.QueueCurrentIndex() != 0 { + t.Errorf("CurrentIndex = %d, want 0", m.PlaybackService.QueueCurrentIndex()) } } func TestPlayTrackAtIndex_ValidIndex_PlaysTrack(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add( - playlist.Track{Path: "/track1.mp3"}, - playlist.Track{Path: "/track2.mp3"}, + m.PlaybackService.AddTracks( + playback.Track{Path: "/track1.mp3"}, + playback.Track{Path: "/track2.mp3"}, ) _ = m.PlayTrackAtIndex(1) - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -229,7 +229,7 @@ func TestPlayTrackAtIndex_ValidIndex_PlaysTrack(t *testing.T) { func TestPlayTrackAtIndex_InvalidIndex_ReturnsNil(t *testing.T) { m := newPlaybackTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/track1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/track1.mp3"}) cmd := m.PlayTrackAtIndex(5) @@ -252,7 +252,6 @@ func newPlaybackTestModel() *Model { p := player.NewMock() svc := playback.New(p, queue) return &Model{ - Playback: NewPlaybackManager(p, queue), PlaybackService: svc, Layout: NewLayoutManager(queuepanel.New(queue)), StateMgr: state.NewMock(), diff --git a/internal/app/queue.go b/internal/app/queue.go index c1c9524..a3b6093 100644 --- a/internal/app/queue.go +++ b/internal/app/queue.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/llehouerou/waves/internal/errmsg" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" ) @@ -20,13 +21,16 @@ func (m *Model) HandleQueueAction(action QueueAction) tea.Cmd { return nil } - var trackToPlay *playlist.Track + // Convert to playback tracks + pbTracks := playback.TracksFromPlaylist(tracks) + + var trackToPlay *playback.Track switch action { case QueueAdd: - m.Playback.Queue().Add(tracks...) + m.PlaybackService.AddTracks(pbTracks...) case QueueReplace: - trackToPlay = m.Playback.Queue().Replace(tracks...) + trackToPlay = m.PlaybackService.ReplaceTracks(pbTracks...) } m.SaveQueueState() @@ -126,8 +130,10 @@ func (m *Model) HandleContainerAndPlay() tea.Cmd { return nil } - m.Playback.Queue().Replace(tracks...) - trackToPlay := m.Playback.Queue().JumpTo(selectedIdx) + // Convert to playback tracks + pbTracks := playback.TracksFromPlaylist(tracks) + m.PlaybackService.ReplaceTracks(pbTracks...) + trackToPlay := m.PlaybackService.QueueMoveTo(selectedIdx) m.SaveQueueState() m.Layout.QueuePanel().SyncCursor() diff --git a/internal/app/update.go b/internal/app/update.go index 29fbe3c..2654f2b 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -444,7 +444,7 @@ func (m Model) handleRadioMsgCategory(msg RadioMessage) (tea.Model, tea.Cmd) { case RadioFillResultMsg: m.handleRadioFillResult(msg) // If tracks were added and queue was empty, start playback - if len(msg.Tracks) > 0 && m.Playback.IsStopped() { + if len(msg.Tracks) > 0 && m.PlaybackService.IsStopped() { cmd := m.StartQueuePlayback() return m, cmd } diff --git a/internal/app/update_lastfm.go b/internal/app/update_lastfm.go index 93fae85..8734533 100644 --- a/internal/app/update_lastfm.go +++ b/internal/app/update_lastfm.go @@ -205,12 +205,12 @@ func (m *Model) handleLastfmAuthAction(msg lastfmauth.ActionMsg) (Model, tea.Cmd // buildScrobbleTrack creates a ScrobbleTrack from the current playing track. func (m *Model) buildScrobbleTrack() *lastfm.ScrobbleTrack { - current := m.Playback.CurrentTrack() + current := m.PlaybackService.CurrentTrack() if current == nil { return nil } - info := m.Playback.Player().TrackInfo() + info := m.PlaybackService.TrackInfo() if info == nil { return nil } @@ -219,7 +219,7 @@ func (m *Model) buildScrobbleTrack() *lastfm.ScrobbleTrack { Artist: info.Artist, Track: info.Title, Album: info.Album, - Duration: m.Playback.Duration(), + Duration: m.PlaybackService.Duration(), } if m.ScrobbleState != nil { diff --git a/internal/app/update_loading.go b/internal/app/update_loading.go index 95486b4..4d0cdd3 100644 --- a/internal/app/update_loading.go +++ b/internal/app/update_loading.go @@ -71,10 +71,9 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { m.Navigation.SetPlaylistNav(plsNav) } if queue, ok := msg.Queue.(*playlist.PlayingQueue); ok { - m.Playback.SetQueue(queue) // Recreate PlaybackService with the restored queue // (the old service had an empty queue created during New()) - m.PlaybackService = playback.New(m.Playback.Player(), queue) + m.PlaybackService = playback.New(m.PlaybackService.Player(), queue) m.playbackSub = m.PlaybackService.Subscribe() } if queuePanel, ok := msg.QueuePanel.(queuepanel.Model); ok { diff --git a/internal/app/update_playback.go b/internal/app/update_playback.go index 7f63022..65379d3 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -12,7 +12,7 @@ import ( // resetScrobbleState resets scrobble tracking for a new track. func (m *Model) resetScrobbleState() { - track := m.Playback.CurrentTrack() + track := m.PlaybackService.CurrentTrack() if track == nil { m.ScrobbleState = nil return @@ -39,7 +39,7 @@ func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { case TrackSkipTimeoutMsg: return m.handleTrackSkipTimeout(msg) case TickMsg: - if m.Playback.IsPlaying() { + if m.PlaybackService.IsPlaying() { cmds := []tea.Cmd{TickCmd()} if cmd := m.checkScrobbleThreshold(); cmd != nil { cmds = append(cmds, cmd) @@ -55,8 +55,8 @@ func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { // handleTrackFinished advances to the next track or stops playback. func (m Model) handleTrackFinished() (tea.Model, tea.Cmd) { - if m.Playback.Queue().HasNext() { - next := m.Playback.Queue().Next() + if m.PlaybackService.QueueHasNext() { + next := m.PlaybackService.QueueAdvance() m.SaveQueueState() m.Layout.QueuePanel().SyncCursor() cmd := m.PlayTrack(next.Path) @@ -70,7 +70,7 @@ func (m Model) handleTrackFinished() (tea.Model, tea.Cmd) { // No next track - stop playback // Note: Radio fill is triggered when the last track STARTS (in PlayTrack), // so by now the queue should already have new tracks if radio mode is active. - m.Playback.Stop() + _ = m.PlaybackService.Stop() m.ResizeComponents() return m, m.WatchTrackFinished() } @@ -90,7 +90,7 @@ func (m Model) handleServiceStateChanged(msg ServiceStateChangedMsg) (tea.Model, m.ResizeComponents() // When starting playback (transitioning to playing), reset scrobble and check radio - if m.Playback.IsPlaying() { + if m.PlaybackService.IsPlaying() { cmds := []tea.Cmd{TickCmd(), m.WatchServiceEvents()} // Reset scrobble state when starting from stopped @@ -127,7 +127,7 @@ func (m Model) handleServiceTrackChanged(_ ServiceTrackChangedMsg) (tea.Model, t } // Start tick command if playing - if m.Playback.IsPlaying() { + if m.PlaybackService.IsPlaying() { cmds = append(cmds, TickCmd()) } @@ -142,8 +142,8 @@ func (m *Model) checkScrobbleThreshold() tea.Cmd { return nil } - position := m.Playback.Position() - duration := m.Playback.Duration() + position := m.PlaybackService.Position() + duration := m.PlaybackService.Duration() // Track must be at least 30 seconds if duration < 30*time.Second { diff --git a/internal/app/view.go b/internal/app/view.go index b4630bb..d5f6a40 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/llehouerou/waves/internal/playlist" + "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/ui" "github.com/llehouerou/waves/internal/ui/headerbar" "github.com/llehouerou/waves/internal/ui/jobbar" @@ -69,7 +69,7 @@ func (m Model) View() string { view = header + "\n" + view // Add player bar if playing - if !m.Playback.IsStopped() { + if !m.PlaybackService.IsStopped() { view += "\n" + m.renderPlayerBar() } @@ -221,8 +221,8 @@ func joinColumnsView(left, right string) string { // renderPlayerBar renders the player bar with radio state. func (m Model) renderPlayerBar() string { - state := playerbar.NewState(m.Playback.Player(), m.Layout.PlayerDisplayMode()) - state.RadioEnabled = m.Playback.Queue().RepeatMode() == playlist.RepeatRadio + state := playerbar.NewState(m.PlaybackService.Player(), m.Layout.PlayerDisplayMode()) + state.RadioEnabled = m.PlaybackService.RepeatMode() == playback.RepeatRadio return playerbar.Render(state, m.Layout.Width()) } diff --git a/internal/playback/service.go b/internal/playback/service.go index 2d84396..d7562bc 100644 --- a/internal/playback/service.go +++ b/internal/playback/service.go @@ -10,6 +10,7 @@ import ( type Service interface { // Playback control Play() error + PlayPath(path string) error // Play a track directly from a file path Pause() error Stop() error Toggle() error @@ -18,9 +19,13 @@ type Service interface { Seek(delta time.Duration) error SeekTo(position time.Duration) error - // Queue navigation + // Queue navigation (starts playback if active) JumpTo(index int) error + // Queue position control (without playback) + QueueAdvance() *Track // Advance queue position (respects modes), returns track + QueueMoveTo(index int) *Track // Move queue position to index, returns track + // Queue manipulation AddTracks(tracks ...Track) ReplaceTracks(tracks ...Track) *Track // Returns track at index 0 or nil @@ -35,6 +40,7 @@ type Service interface { Duration() time.Duration CurrentTrack() *Track TrackInfo() *player.TrackInfo + Player() player.Interface // Direct player access (for UI rendering) // Queue queries QueueTracks() []Track diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 7884016..b3db9e8 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -129,6 +129,14 @@ func (s *serviceImpl) TrackInfo() *player.TrackInfo { return s.player.TrackInfo() } +// Player returns the underlying player interface. +// This is used for UI rendering (e.g., playerbar.NewState). +func (s *serviceImpl) Player() player.Interface { + s.mu.RLock() + defer s.mu.RUnlock() + return s.player +} + // QueueTracks returns a copy of all tracks in the queue. func (s *serviceImpl) QueueTracks() []Track { s.mu.RLock() @@ -220,6 +228,48 @@ func (s *serviceImpl) Redo() bool { return s.queue.Redo() } +// QueueAdvance advances the queue position (respecting repeat/shuffle modes) +// without starting playback. Returns the track at the new position, or nil. +func (s *serviceImpl) QueueAdvance() *Track { + s.mu.Lock() + defer s.mu.Unlock() + + t := s.queue.Next() + if t == nil { + return nil + } + return &Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } +} + +// QueueMoveTo moves the queue position to the specified index +// without starting playback. Returns the track at that position, or nil. +func (s *serviceImpl) QueueMoveTo(index int) *Track { + s.mu.Lock() + defer s.mu.Unlock() + + t := s.queue.JumpTo(index) + if t == nil { + return nil + } + return &Track{ + ID: t.ID, + Path: t.Path, + Title: t.Title, + Artist: t.Artist, + Album: t.Album, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + } +} + // RepeatMode returns the current repeat mode. func (s *serviceImpl) RepeatMode() RepeatMode { s.mu.RLock() @@ -384,6 +434,21 @@ func (s *serviceImpl) Play() error { return nil } +// PlayPath plays a track directly from a file path. +// This bypasses the queue and plays the specified file. +func (s *serviceImpl) PlayPath(path string) error { + s.mu.Lock() + defer s.mu.Unlock() + + prevState := s.playerStateToState(s.player.State()) + if err := s.player.Play(path); err != nil { + return err + } + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + return nil +} + // Pause pauses playback if currently playing. func (s *serviceImpl) Pause() error { s.mu.Lock() From 58a3d98c676b36bf151a88b1d4663339d302b224 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 23:25:40 +0400 Subject: [PATCH 19/26] fix(playback): address code review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fix: - Remove deprecated WatchTrackFinished and TrackFinishedMsg - Service now handles track finished internally via watchTrackFinished goroutine - App uses ServiceTrackChangedMsg for UI updates Important fixes: - Close old service before recreating with restored queue - Implement QueueChange events for AddTracks, ReplaceTracks, ClearQueue - Add ErrorEvent type and error handling for playback failures - App shows error popup when service encounters playback errors Test fixes: - Add playbackSub to test model helpers - Update tests to use service messages instead of TrackFinishedMsg 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/app_test.go | 28 +++++++++++---------- internal/app/commands.go | 17 +++++-------- internal/app/handlers_test.go | 1 + internal/app/messages.go | 16 ++++++------ internal/app/playback_test.go | 1 + internal/app/update_loading.go | 24 ++++++++++-------- internal/app/update_playback.go | 42 +++++++++++++------------------ internal/playback/events.go | 7 ++++++ internal/playback/service_impl.go | 37 +++++++++++++++++++++++++++ internal/playback/subscription.go | 14 +++++++++-- 10 files changed, 118 insertions(+), 69 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 9b3b77a..24c2825 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -32,13 +32,13 @@ func TestUpdate_WindowSizeMsg_ResizesComponents(t *testing.T) { } } -func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { +func TestUpdate_ServiceTrackChangedMsg_UpdatesUI(t *testing.T) { m := newIntegrationTestModel() m.PlaybackService.AddTracks( playback.Track{Path: "/track1.mp3"}, playback.Track{Path: "/track2.mp3"}, ) - m.PlaybackService.QueueMoveTo(0) + m.PlaybackService.QueueMoveTo(1) // Move to second track mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { @@ -46,21 +46,23 @@ func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { } mock.SetState(player.Playing) - newModel, cmd := m.Update(TrackFinishedMsg{}) + // Simulate service track change event + newModel, cmd := m.Update(ServiceTrackChangedMsg{PreviousIndex: 0, CurrentIndex: 1}) result, ok := newModel.(Model) if !ok { t.Fatal("Update should return Model") } + // UI should be updated and command returned for continued event watching if result.PlaybackService.QueueCurrentIndex() != 1 { t.Errorf("CurrentIndex = %d, want 1", result.PlaybackService.QueueCurrentIndex()) } if cmd == nil { - t.Error("expected command for continued playback") + t.Error("expected command for continued event watching") } } -func TestUpdate_TrackFinishedMsg_StopsAtEndOfQueue(t *testing.T) { +func TestUpdate_ServiceStateChangedMsg_UpdatesUI(t *testing.T) { m := newIntegrationTestModel() m.PlaybackService.AddTracks(playback.Track{Path: "/track1.mp3"}) m.PlaybackService.QueueMoveTo(0) @@ -69,21 +71,20 @@ func TestUpdate_TrackFinishedMsg_StopsAtEndOfQueue(t *testing.T) { if !ok { t.Fatal("expected mock player") } - mock.SetState(player.Playing) + mock.SetState(player.Stopped) - newModel, _ := m.Update(TrackFinishedMsg{}) + // Simulate service state change event (playing -> stopped) + newModel, cmd := m.Update(ServiceStateChangedMsg{Previous: 1, Current: 0}) // Playing -> Stopped result, ok := newModel.(Model) if !ok { t.Fatal("Update should return Model") } - resultMock, ok := result.PlaybackService.Player().(*player.Mock) - if !ok { - t.Fatal("expected mock player") - } - if resultMock.State() != player.Stopped { - t.Errorf("player state = %v, want Stopped", resultMock.State()) + // Should return command for continued event watching + if cmd == nil { + t.Error("expected command for continued event watching") } + _ = result // UI updates happen internally } func TestUpdate_KeyMsg_Quit(t *testing.T) { @@ -219,6 +220,7 @@ func newIntegrationTestModel() Model { return Model{ Navigation: NewNavigationManager(), PlaybackService: svc, + playbackSub: svc.Subscribe(), Layout: NewLayoutManager(queuepanel.New(queue)), Keys: keymap.NewResolver(keymap.Bindings), StateMgr: state.NewMock(), diff --git a/internal/app/commands.go b/internal/app/commands.go index 2df8cba..8deb050 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -34,17 +34,6 @@ func TrackSkipTimeoutCmd(version int) tea.Cmd { }) } -// WatchTrackFinished returns a command that waits for the player to finish naturally. -// Returns TrackFinishedMsg only for natural track completion, not manual stops. -// -// Deprecated: The playback service now handles track finished internally. -func (m Model) WatchTrackFinished() tea.Cmd { - return func() tea.Msg { - <-m.PlaybackService.Player().FinishedChan() - return TrackFinishedMsg{} - } -} - // WatchServiceEvents returns a command that waits for playback service events. // It listens on all subscription channels and converts events to tea.Msg. func (m Model) WatchServiceEvents() tea.Cmd { @@ -67,6 +56,12 @@ func (m Model) WatchServiceEvents() tea.Cmd { PreviousIndex: prevIdx, CurrentIndex: e.Index, } + case e := <-m.playbackSub.Error: + return ServiceErrorMsg{ + Operation: e.Operation, + Path: e.Path, + Err: e.Err, + } case <-m.playbackSub.Done: return ServiceClosedMsg{} } diff --git a/internal/app/handlers_test.go b/internal/app/handlers_test.go index bb6c243..8a05e40 100644 --- a/internal/app/handlers_test.go +++ b/internal/app/handlers_test.go @@ -224,6 +224,7 @@ func newTestModel() *Model { Navigation: NewNavigationManager(), Layout: NewLayoutManager(queuepanel.New(queue)), PlaybackService: svc, + playbackSub: svc.Subscribe(), Keys: keymap.NewResolver(keymap.Bindings), StateMgr: state.NewMock(), } diff --git a/internal/app/messages.go b/internal/app/messages.go index ca54b88..de936cd 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -81,13 +81,6 @@ type LibraryScanCompleteMsg struct { func (LibraryScanCompleteMsg) libraryScanMessage() {} -// TrackFinishedMsg is sent when the current track finishes playing. -// -// Deprecated: Use ServiceTrackChangedMsg from the playback service instead. -type TrackFinishedMsg struct{} - -func (TrackFinishedMsg) playbackMessage() {} - // ServiceStateChangedMsg is sent when the playback service state changes. type ServiceStateChangedMsg struct { Previous, Current int // playback.State values @@ -108,6 +101,15 @@ type ServiceClosedMsg struct{} func (ServiceClosedMsg) playbackMessage() {} +// ServiceErrorMsg is sent when an error occurs in the playback service. +type ServiceErrorMsg struct { + Operation string + Path string + Err error +} + +func (ServiceErrorMsg) playbackMessage() {} + // FocusTarget represents which UI component has focus. type FocusTarget int diff --git a/internal/app/playback_test.go b/internal/app/playback_test.go index 842acd1..0e4195f 100644 --- a/internal/app/playback_test.go +++ b/internal/app/playback_test.go @@ -253,6 +253,7 @@ func newPlaybackTestModel() *Model { svc := playback.New(p, queue) return &Model{ PlaybackService: svc, + playbackSub: svc.Subscribe(), Layout: NewLayoutManager(queuepanel.New(queue)), StateMgr: state.NewMock(), } diff --git a/internal/app/update_loading.go b/internal/app/update_loading.go index 4d0cdd3..b134099 100644 --- a/internal/app/update_loading.go +++ b/internal/app/update_loading.go @@ -71,6 +71,8 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { m.Navigation.SetPlaylistNav(plsNav) } if queue, ok := msg.Queue.(*playlist.PlayingQueue); ok { + // Close the old service to stop its goroutines and clean up subscriptions + _ = m.PlaybackService.Close() // Recreate PlaybackService with the restored queue // (the old service had an empty queue created during New()) m.PlaybackService = playback.New(m.PlaybackService.Player(), queue) @@ -113,12 +115,12 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { } // Helper to batch downloads refresh and service events with other commands - withCommonCmds := func(cmd tea.Cmd) tea.Cmd { - cmds := []tea.Cmd{cmd, m.WatchServiceEvents()} + withCommonCmds := func(cmds ...tea.Cmd) tea.Cmd { + allCmds := append([]tea.Cmd{m.WatchServiceEvents()}, cmds...) if downloadsRefreshCmd != nil { - cmds = append(cmds, downloadsRefreshCmd) + allCmds = append(allCmds, downloadsRefreshCmd) } - return tea.Batch(cmds...) + return tea.Batch(allCmds...) } // Decide whether to transition to done based on current phase @@ -128,11 +130,11 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { // First launch: show loading screen for 3 seconds m.loadingState = loadingShowing m.loadingShowTime = time.Now() - return m, withCommonCmds(tea.Batch(LoadingTickCmd(), HideLoadingFirstLaunchCmd())) + return m, withCommonCmds(LoadingTickCmd(), HideLoadingFirstLaunchCmd()) } // Init finished before show delay - never show loading screen m.loadingState = loadingDone - return m, withCommonCmds(m.WatchTrackFinished()) + return m, withCommonCmds() case loadingShowing: // Check if minimum display time has elapsed minTime := 800 * time.Millisecond @@ -141,7 +143,7 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { } if time.Since(m.loadingShowTime) >= minTime { m.loadingState = loadingDone - return m, withCommonCmds(m.WatchTrackFinished()) + return m, withCommonCmds() } // Otherwise wait for HideLoadingMsg - still need to start service events cmds := []tea.Cmd{m.WatchServiceEvents()} @@ -151,10 +153,10 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case loadingDone: // Already done (shouldn't happen) - return m, withCommonCmds(m.WatchTrackFinished()) + return m, withCommonCmds() } - return m, withCommonCmds(m.WatchTrackFinished()) + return m, withCommonCmds() } // handleShowLoading transitions to showing state if still waiting. @@ -173,7 +175,7 @@ func (m Model) handleShowLoading() (tea.Model, tea.Cmd) { } // Init finished during the delay - go straight to done m.loadingState = loadingDone - return m, m.WatchTrackFinished() + return m, m.WatchServiceEvents() } // Show the loading screen (init still running) @@ -192,7 +194,7 @@ func (m Model) handleHideLoading() (tea.Model, tea.Cmd) { if m.loadingInitDone { m.loadingState = loadingDone - return m, m.WatchTrackFinished() + return m, m.WatchServiceEvents() } // Init not done yet - keep showing, wait for InitResult diff --git a/internal/app/update_playback.go b/internal/app/update_playback.go index 65379d3..84fb44a 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -27,13 +27,12 @@ func (m *Model) resetScrobbleState() { // handlePlaybackMsg routes playback-related messages. func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case TrackFinishedMsg: - // Deprecated: service now handles auto-advance internally - return m.handleTrackFinished() case ServiceStateChangedMsg: return m.handleServiceStateChanged(msg) case ServiceTrackChangedMsg: return m.handleServiceTrackChanged(msg) + case ServiceErrorMsg: + return m.handleServiceError(msg) case ServiceClosedMsg: return m, nil // Service closed, nothing to do case TrackSkipTimeoutMsg: @@ -53,28 +52,6 @@ func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { return m, nil } -// handleTrackFinished advances to the next track or stops playback. -func (m Model) handleTrackFinished() (tea.Model, tea.Cmd) { - if m.PlaybackService.QueueHasNext() { - next := m.PlaybackService.QueueAdvance() - m.SaveQueueState() - m.Layout.QueuePanel().SyncCursor() - cmd := m.PlayTrack(next.Path) - if cmd != nil { - // PlayTrack already handles radio fill when starting last track - return m, tea.Batch(cmd, m.WatchTrackFinished()) - } - return m, m.WatchTrackFinished() - } - - // No next track - stop playback - // Note: Radio fill is triggered when the last track STARTS (in PlayTrack), - // so by now the queue should already have new tracks if radio mode is active. - _ = m.PlaybackService.Stop() - m.ResizeComponents() - return m, m.WatchTrackFinished() -} - // handleTrackSkipTimeout handles the debounced track skip after rapid key presses. func (m Model) handleTrackSkipTimeout(msg TrackSkipTimeoutMsg) (tea.Model, tea.Cmd) { if msg.Version == m.TrackSkipVersion { @@ -134,6 +111,21 @@ func (m Model) handleServiceTrackChanged(_ ServiceTrackChangedMsg) (tea.Model, t return m, tea.Batch(cmds...) } +// handleServiceError handles errors from the playback service. +func (m Model) handleServiceError(msg ServiceErrorMsg) (tea.Model, tea.Cmd) { + // Display error to user + errMsg := "Playback error" + if msg.Path != "" { + errMsg = "Failed to play: " + msg.Path + } + if msg.Err != nil { + errMsg += ": " + msg.Err.Error() + } + m.Popups.ShowError(errMsg) + + return m, m.WatchServiceEvents() +} + // checkScrobbleThreshold checks if the current track has been played long enough to scrobble. // Last.fm rules: scrobble after 50% of duration OR 4 minutes, whichever comes first. // Track must be at least 30 seconds long. diff --git a/internal/playback/events.go b/internal/playback/events.go index cdba28a..7d08854 100644 --- a/internal/playback/events.go +++ b/internal/playback/events.go @@ -31,3 +31,10 @@ type ModeChange struct { type PositionChange struct { Position time.Duration } + +// ErrorEvent is emitted when an error occurs during playback. +type ErrorEvent struct { + Operation string // e.g., "play", "seek" + Path string // track path if applicable + Err error +} diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index b3db9e8..16b83b0 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -191,6 +191,7 @@ func (s *serviceImpl) AddTracks(tracks ...Track) { defer s.mu.Unlock() playlistTracks := TracksToPlaylist(tracks) s.queue.Add(playlistTracks...) + s.emitQueueChange() } // ReplaceTracks replaces all tracks in the queue. @@ -200,6 +201,7 @@ func (s *serviceImpl) ReplaceTracks(tracks ...Track) *Track { defer s.mu.Unlock() playlistTracks := TracksToPlaylist(tracks) first := s.queue.Replace(playlistTracks...) + s.emitQueueChange() if first == nil { return nil } @@ -212,6 +214,7 @@ func (s *serviceImpl) ClearQueue() { s.mu.Lock() defer s.mu.Unlock() s.queue.Clear() + s.emitQueueChange() } // Undo reverts the last queue modification. @@ -347,6 +350,7 @@ func (s *serviceImpl) handleTrackFinished() { if err := s.player.Play(nextTrack.Path); err != nil { s.player.Stop() s.emitStateChange(StatePlaying, StateStopped) + s.emitError("play_next", nextTrack.Path, err) } } @@ -411,6 +415,39 @@ func (s *serviceImpl) emitModeChange() { s.subsMu.RUnlock() } +// emitQueueChange notifies all subscribers of a queue content change. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitQueueChange() { + tracks := make([]Track, 0, len(s.queue.Tracks())) + for _, t := range s.queue.Tracks() { + tracks = append(tracks, TrackFromPlaylist(t)) + } + e := QueueChange{ + Tracks: tracks, + Index: s.queue.CurrentIndex(), + } + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendQueue(e) + } + s.subsMu.RUnlock() +} + +// emitError notifies all subscribers of an error. +// Must be called while holding mu. Acquires subsMu internally. +func (s *serviceImpl) emitError(operation, path string, err error) { + e := ErrorEvent{ + Operation: operation, + Path: path, + Err: err, + } + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendError(e) + } + s.subsMu.RUnlock() +} + // Play starts playback of the current track in the queue. func (s *serviceImpl) Play() error { s.mu.Lock() diff --git a/internal/playback/subscription.go b/internal/playback/subscription.go index 24513a9..01e8b91 100644 --- a/internal/playback/subscription.go +++ b/internal/playback/subscription.go @@ -11,6 +11,7 @@ type Subscription struct { PositionChanged <-chan PositionChange QueueChanged <-chan QueueChange ModeChanged <-chan ModeChange + Error <-chan ErrorEvent Done <-chan struct{} // Internal write channels @@ -19,6 +20,7 @@ type Subscription struct { positionCh chan PositionChange queueCh chan QueueChange modeCh chan ModeChange + errorCh chan ErrorEvent doneCh chan struct{} } @@ -30,6 +32,7 @@ func newSubscription() *Subscription { positionCh: make(chan PositionChange, eventBufferSize), queueCh: make(chan QueueChange, eventBufferSize), modeCh: make(chan ModeChange, eventBufferSize), + errorCh: make(chan ErrorEvent, eventBufferSize), doneCh: make(chan struct{}), } s.StateChanged = s.stateCh @@ -37,6 +40,7 @@ func newSubscription() *Subscription { s.PositionChanged = s.positionCh s.QueueChanged = s.queueCh s.ModeChanged = s.modeCh + s.Error = s.errorCh s.Done = s.doneCh return s } @@ -72,8 +76,6 @@ func (s *Subscription) sendPosition(pos time.Duration) { } // sendQueue sends a queue change event (non-blocking). -// -//nolint:unused // Will be used by service implementation func (s *Subscription) sendQueue(e QueueChange) { select { case s.queueCh <- e: @@ -88,3 +90,11 @@ func (s *Subscription) sendMode(e ModeChange) { default: } } + +// sendError sends an error event (non-blocking). +func (s *Subscription) sendError(e ErrorEvent) { + select { + case s.errorCh <- e: + default: + } +} From 35b2e034b3ec6e7ff6e3b55e711ac6e7e522ac0d Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Sun, 28 Dec 2025 23:35:39 +0400 Subject: [PATCH 20/26] fix(playback): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreviousIndex to TrackChange event for accurate tracking - Emit QueueChange events on Undo/Redo operations - Handle all subscription channels (QueueChanged, ModeChanged, PositionChanged) to prevent buffer fill-up - Add nolint comment for intentionally ignored Stop() error on shutdown - Use TrackFromPlaylist helper consistently to reduce duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/commands.go | 15 ++++--- internal/app/handlers.go | 2 +- internal/app/messages.go | 18 +++++++++ internal/app/update_playback.go | 4 ++ internal/playback/events.go | 7 ++-- internal/playback/service_impl.go | 67 ++++++++++--------------------- 6 files changed, 58 insertions(+), 55 deletions(-) diff --git a/internal/app/commands.go b/internal/app/commands.go index 8deb050..d0f9853 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -48,14 +48,19 @@ func (m Model) WatchServiceEvents() tea.Cmd { Current: int(e.Current), } case e := <-m.playbackSub.TrackChanged: - prevIdx := -1 - if e.Previous != nil { - prevIdx = e.Index - 1 // Approximate previous index - } return ServiceTrackChangedMsg{ - PreviousIndex: prevIdx, + PreviousIndex: e.PreviousIndex, CurrentIndex: e.Index, } + case <-m.playbackSub.QueueChanged: + // Drain queue change events; UI updates synchronously on queue operations + return ServiceQueueChangedMsg{} + case <-m.playbackSub.ModeChanged: + // Drain mode change events; UI updates synchronously on mode operations + return ServiceModeChangedMsg{} + case <-m.playbackSub.PositionChanged: + // Drain position events; position updates come from TickMsg + return ServicePositionChangedMsg{} case e := <-m.playbackSub.Error: return ServiceErrorMsg{ Operation: e.Operation, diff --git a/internal/app/handlers.go b/internal/app/handlers.go index a880a54..4f3135e 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -13,7 +13,7 @@ func (m *Model) handleQuitKeys(key string) handler.Result { if m.Keys.Resolve(key) != keymap.ActionQuit { return handler.NotHandled } - _ = m.PlaybackService.Stop() + _ = m.PlaybackService.Stop() //nolint:errcheck // Ignore errors during shutdown; app is exiting m.SaveQueueState() m.StateMgr.Close() return handler.Handled(tea.Quit) diff --git a/internal/app/messages.go b/internal/app/messages.go index de936cd..ef9573f 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -110,6 +110,24 @@ type ServiceErrorMsg struct { func (ServiceErrorMsg) playbackMessage() {} +// ServiceQueueChangedMsg is sent when the queue contents change. +// Currently used to drain the subscription channel; may be used for future features. +type ServiceQueueChangedMsg struct{} + +func (ServiceQueueChangedMsg) playbackMessage() {} + +// ServiceModeChangedMsg is sent when repeat/shuffle mode changes. +// Currently used to drain the subscription channel; may be used for future features. +type ServiceModeChangedMsg struct{} + +func (ServiceModeChangedMsg) playbackMessage() {} + +// ServicePositionChangedMsg is sent when a seek operation occurs. +// Currently used to drain the subscription channel; position updates come from TickMsg. +type ServicePositionChangedMsg struct{} + +func (ServicePositionChangedMsg) playbackMessage() {} + // FocusTarget represents which UI component has focus. type FocusTarget int diff --git a/internal/app/update_playback.go b/internal/app/update_playback.go index 84fb44a..42e3b34 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -35,6 +35,10 @@ func (m Model) handlePlaybackMsg(msg PlaybackMessage) (tea.Model, tea.Cmd) { return m.handleServiceError(msg) case ServiceClosedMsg: return m, nil // Service closed, nothing to do + case ServiceQueueChangedMsg, ServiceModeChangedMsg, ServicePositionChangedMsg: + // These are drained from the subscription channel but handled synchronously in UI. + // Just re-issue the watch command to continue listening. + return m, m.WatchServiceEvents() case TrackSkipTimeoutMsg: return m.handleTrackSkipTimeout(msg) case TickMsg: diff --git a/internal/playback/events.go b/internal/playback/events.go index 7d08854..b0de7b2 100644 --- a/internal/playback/events.go +++ b/internal/playback/events.go @@ -10,9 +10,10 @@ type StateChange struct { // TrackChange is emitted when the current track changes. type TrackChange struct { - Previous *Track - Current *Track - Index int + Previous *Track + Current *Track + PreviousIndex int + Index int } // QueueChange is emitted when the queue contents change. diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go index 16b83b0..7ece8e5 100644 --- a/internal/playback/service_impl.go +++ b/internal/playback/service_impl.go @@ -111,15 +111,8 @@ func (s *serviceImpl) currentTrackLocked() *Track { if t == nil { return nil } - return &Track{ - ID: t.ID, - Path: t.Path, - Title: t.Title, - Artist: t.Artist, - Album: t.Album, - TrackNumber: t.TrackNumber, - Duration: t.Duration, - } + track := TrackFromPlaylist(*t) + return &track } // TrackInfo returns metadata about the currently playing track. @@ -141,20 +134,7 @@ func (s *serviceImpl) Player() player.Interface { func (s *serviceImpl) QueueTracks() []Track { s.mu.RLock() defer s.mu.RUnlock() - tracks := s.queue.Tracks() - result := make([]Track, len(tracks)) - for i, t := range tracks { - result[i] = Track{ - ID: t.ID, - Path: t.Path, - Title: t.Title, - Artist: t.Artist, - Album: t.Album, - TrackNumber: t.TrackNumber, - Duration: t.Duration, - } - } - return result + return TracksFromPlaylist(s.queue.Tracks()) } // QueueCurrentIndex returns the current queue index (-1 if none). @@ -221,14 +201,22 @@ func (s *serviceImpl) ClearQueue() { func (s *serviceImpl) Undo() bool { s.mu.Lock() defer s.mu.Unlock() - return s.queue.Undo() + if s.queue.Undo() { + s.emitQueueChange() + return true + } + return false } // Redo reapplies the last undone queue modification. func (s *serviceImpl) Redo() bool { s.mu.Lock() defer s.mu.Unlock() - return s.queue.Redo() + if s.queue.Redo() { + s.emitQueueChange() + return true + } + return false } // QueueAdvance advances the queue position (respecting repeat/shuffle modes) @@ -241,15 +229,8 @@ func (s *serviceImpl) QueueAdvance() *Track { if t == nil { return nil } - return &Track{ - ID: t.ID, - Path: t.Path, - Title: t.Title, - Artist: t.Artist, - Album: t.Album, - TrackNumber: t.TrackNumber, - Duration: t.Duration, - } + track := TrackFromPlaylist(*t) + return &track } // QueueMoveTo moves the queue position to the specified index @@ -262,15 +243,8 @@ func (s *serviceImpl) QueueMoveTo(index int) *Track { if t == nil { return nil } - return &Track{ - ID: t.ID, - Path: t.Path, - Title: t.Title, - Artist: t.Artist, - Album: t.Album, - TrackNumber: t.TrackNumber, - Duration: t.Duration, - } + track := TrackFromPlaylist(*t) + return &track } // RepeatMode returns the current repeat mode. @@ -379,9 +353,10 @@ func (s *serviceImpl) emitTrackChange(prevTrack *Track, prevIndex int) { } e := TrackChange{ - Previous: prevTrack, - Current: curr, - Index: currIndex, + Previous: prevTrack, + Current: curr, + PreviousIndex: prevIndex, + Index: currIndex, } s.subsMu.RLock() for _, sub := range s.subs { From 256506529ab84706622669691e62ff23b6d88192 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:11:42 +0400 Subject: [PATCH 21/26] chore(deps): add go-mpris-server for MPRIS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index f8a4da2..37c65f4 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,6 @@ github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmY github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/llehouerou/go-mp3 v1.1.0 h1:JvT7B/wwGAurvC9Qse9pXv685ty/kJJQjPh/7FF89qo= -github.com/llehouerou/go-mp3 v1.1.0/go.mod h1:/Rl7E/VQpWTQDTJgr69iYVSkS1BZEh4X/ABV1XvIpHA= github.com/llehouerou/go-mp3 v1.1.1 h1:xPILLNusFWbd6DuxlOk0AhUDsuZrKgGDDFLbQH0Aq6g= github.com/llehouerou/go-mp3 v1.1.1/go.mod h1:/Rl7E/VQpWTQDTJgr69iYVSkS1BZEh4X/ABV1XvIpHA= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= From e2c15193f645d1c9283de9dbb0da4cdc33aa0878 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:13:30 +0400 Subject: [PATCH 22/26] feat(mpris): add no-op stub for non-Linux platforms --- internal/mpris/stub.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 internal/mpris/stub.go diff --git a/internal/mpris/stub.go b/internal/mpris/stub.go new file mode 100644 index 0000000..2fa395e --- /dev/null +++ b/internal/mpris/stub.go @@ -0,0 +1,21 @@ +//go:build !linux + +package mpris + +import "github.com/llehouerou/waves/internal/playback" + +// Adapter is a no-op on non-Linux platforms. +type Adapter struct{} + +// New returns a no-op adapter on non-Linux platforms. +func New(_ playback.Service) (*Adapter, error) { + return &Adapter{}, nil +} + +// Resubscribe is a no-op on non-Linux platforms. +func (a *Adapter) Resubscribe(_ playback.Service) {} + +// Close is a no-op on non-Linux platforms. +func (a *Adapter) Close() error { + return nil +} From cb6bf84bc8d522f5dc533d3477bf12ccdd5df2ee Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:16:32 +0400 Subject: [PATCH 23/26] feat(mpris): add album art discovery helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/mpris/cover.go | 29 ++++++++++++++++++ internal/mpris/cover_test.go | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 internal/mpris/cover.go create mode 100644 internal/mpris/cover_test.go diff --git a/internal/mpris/cover.go b/internal/mpris/cover.go new file mode 100644 index 0000000..e5f9a07 --- /dev/null +++ b/internal/mpris/cover.go @@ -0,0 +1,29 @@ +//go:build linux + +package mpris + +import ( + "os" + "path/filepath" +) + +// coverNames lists common album art filenames in priority order. +var coverNames = []string{ + "cover.jpg", "cover.png", "cover.jpeg", + "folder.jpg", "folder.png", "folder.jpeg", + "album.jpg", "album.png", "album.jpeg", + "front.jpg", "front.png", "front.jpeg", +} + +// findAlbumArt looks for album art in the same directory as the track. +// Returns the path to the art file, or empty string if not found. +func findAlbumArt(trackPath string) string { + dir := filepath.Dir(trackPath) + for _, name := range coverNames { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} diff --git a/internal/mpris/cover_test.go b/internal/mpris/cover_test.go new file mode 100644 index 0000000..ce7dba4 --- /dev/null +++ b/internal/mpris/cover_test.go @@ -0,0 +1,58 @@ +//go:build linux + +package mpris + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindAlbumArt(t *testing.T) { + // Create temp directory with a cover file + dir := t.TempDir() + coverPath := filepath.Join(dir, "cover.jpg") + if err := os.WriteFile(coverPath, []byte("fake"), 0o600); err != nil { + t.Fatal(err) + } + + trackPath := filepath.Join(dir, "track.mp3") + + got := findAlbumArt(trackPath) + if got != coverPath { + t.Errorf("findAlbumArt() = %q, want %q", got, coverPath) + } +} + +func TestFindAlbumArt_NotFound(t *testing.T) { + dir := t.TempDir() + trackPath := filepath.Join(dir, "track.mp3") + + got := findAlbumArt(trackPath) + if got != "" { + t.Errorf("findAlbumArt() = %q, want empty string", got) + } +} + +func TestFindAlbumArt_Priority(t *testing.T) { + dir := t.TempDir() + + // Create folder.jpg (lower priority) + folderPath := filepath.Join(dir, "folder.jpg") + if err := os.WriteFile(folderPath, []byte("fake"), 0o600); err != nil { + t.Fatal(err) + } + + // Create cover.jpg (higher priority) + coverPath := filepath.Join(dir, "cover.jpg") + if err := os.WriteFile(coverPath, []byte("fake"), 0o600); err != nil { + t.Fatal(err) + } + + trackPath := filepath.Join(dir, "track.mp3") + + got := findAlbumArt(trackPath) + if got != coverPath { + t.Errorf("findAlbumArt() = %q, want %q (higher priority)", got, coverPath) + } +} From 174890a607a236789195d343d7043742d3301251 Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:25:11 +0400 Subject: [PATCH 24/26] feat(mpris): implement MPRIS adapter for Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MPRIS adapter that bridges PlaybackService to D-Bus, enabling media key support and integration with desktop media controls (playerctl, etc). The adapter implements go-mpris-server interfaces: - OrgMprisMediaPlayer2Adapter for root properties - OrgMprisMediaPlayer2PlayerAdapter for playback control - OrgMprisMediaPlayer2PlayerAdapterLoopStatus for repeat modes - OrgMprisMediaPlayer2PlayerAdapterShuffle for shuffle mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- go.mod | 2 + go.sum | 4 + internal/mpris/mpris.go | 272 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 internal/mpris/mpris.go diff --git a/go.mod b/go.mod index d5b27bb..2338ca1 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect @@ -53,6 +54,7 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/quarckster/go-mpris-server v1.0.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect diff --git a/go.sum b/go.sum index 37c65f4..d506470 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -97,6 +99,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quarckster/go-mpris-server v1.0.3 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA= +github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/internal/mpris/mpris.go b/internal/mpris/mpris.go new file mode 100644 index 0000000..a0032b8 --- /dev/null +++ b/internal/mpris/mpris.go @@ -0,0 +1,272 @@ +//go:build linux + +package mpris + +import ( + "fmt" + "hash/fnv" + "time" + + "github.com/godbus/dbus/v5" + "github.com/quarckster/go-mpris-server/pkg/server" + "github.com/quarckster/go-mpris-server/pkg/types" + + "github.com/llehouerou/waves/internal/playback" +) + +// Adapter connects PlaybackService to MPRIS over D-Bus. +type Adapter struct { + service playback.Service + server *server.Server + sub *playback.Subscription + done chan struct{} +} + +// New creates and starts a new MPRIS adapter. +func New(service playback.Service) (*Adapter, error) { + a := &Adapter{ + service: service, + done: make(chan struct{}), + } + + // Create adapters that delegate to the service + rootAdapter := &rootAdapter{} + playerAdapter := &playerAdapter{service: service} + + a.server = server.NewServer("waves", rootAdapter, playerAdapter) + a.sub = service.Subscribe() + + // Start the server in background + go func() { + _ = a.server.Listen() + }() + + return a, nil +} + +// Resubscribe updates the adapter to use a new PlaybackService instance. +// Call this when PlaybackService is recreated (e.g., after queue restore). +func (a *Adapter) Resubscribe(service playback.Service) { + a.service = service + a.sub = service.Subscribe() + // Update the player adapter's service reference + if pa, ok := a.server.PlayerAdapter.(*playerAdapter); ok { + pa.service = service + } +} + +// Close stops the adapter and releases D-Bus resources. +func (a *Adapter) Close() error { + close(a.done) + return a.server.Stop() +} + +// rootAdapter implements OrgMprisMediaPlayer2Adapter. +type rootAdapter struct{} + +func (r *rootAdapter) Raise() error { + return nil // Not supported +} + +func (r *rootAdapter) Quit() error { + return nil // Not supported - app manages its own lifecycle +} + +func (r *rootAdapter) CanQuit() (bool, error) { + return false, nil +} + +func (r *rootAdapter) CanRaise() (bool, error) { + return false, nil +} + +func (r *rootAdapter) HasTrackList() (bool, error) { + return false, nil // Track list interface not implemented +} + +func (r *rootAdapter) Identity() (string, error) { + return "Waves", nil +} + +//nolint:revive // Method name required by interface. +func (r *rootAdapter) SupportedUriSchemes() ([]string, error) { + return []string{"file"}, nil +} + +func (r *rootAdapter) SupportedMimeTypes() ([]string, error) { + return []string{"audio/mpeg", "audio/flac", "audio/mp3"}, nil +} + +// playerAdapter implements OrgMprisMediaPlayer2PlayerAdapter and optional interfaces. +type playerAdapter struct { + service playback.Service +} + +func (p *playerAdapter) Next() error { + return p.service.Next() +} + +func (p *playerAdapter) Previous() error { + return p.service.Previous() +} + +func (p *playerAdapter) Pause() error { + return p.service.Pause() +} + +func (p *playerAdapter) PlayPause() error { + return p.service.Toggle() +} + +func (p *playerAdapter) Stop() error { + return p.service.Stop() +} + +func (p *playerAdapter) Play() error { + if p.service.IsStopped() { + return p.service.Play() + } + return p.service.Toggle() +} + +func (p *playerAdapter) Seek(offset types.Microseconds) error { + return p.service.Seek(time.Duration(offset) * time.Microsecond) +} + +func (p *playerAdapter) SetPosition(_ string, position types.Microseconds) error { + return p.service.SeekTo(time.Duration(position) * time.Microsecond) +} + +//nolint:revive // Method name required by interface. +func (p *playerAdapter) OpenUri(_ string) error { + return nil // Not supported +} + +func (p *playerAdapter) PlaybackStatus() (types.PlaybackStatus, error) { + switch p.service.State() { + case playback.StatePlaying: + return types.PlaybackStatusPlaying, nil + case playback.StatePaused: + return types.PlaybackStatusPaused, nil + case playback.StateStopped: + return types.PlaybackStatusStopped, nil + } + return types.PlaybackStatusStopped, nil +} + +func (p *playerAdapter) Rate() (float64, error) { + return 1.0, nil +} + +func (p *playerAdapter) SetRate(_ float64) error { + return nil // Not supported +} + +func (p *playerAdapter) Metadata() (types.Metadata, error) { + track := p.service.CurrentTrack() + if track == nil { + return types.Metadata{}, nil + } + + meta := types.Metadata{ + TrackId: dbus.ObjectPath(formatTrackID(track.Path)), + Length: types.Microseconds(track.Duration.Microseconds()), + Title: track.Title, + Artist: []string{track.Artist}, + Album: track.Album, + TrackNumber: track.TrackNumber, + } + + if artPath := findAlbumArt(track.Path); artPath != "" { + meta.ArtUrl = "file://" + artPath + } + + return meta, nil +} + +func (p *playerAdapter) Volume() (float64, error) { + return 1.0, nil // Volume control not exposed via service +} + +func (p *playerAdapter) SetVolume(_ float64) error { + return nil // Not supported +} + +func (p *playerAdapter) Position() (int64, error) { + return p.service.Position().Microseconds(), nil +} + +func (p *playerAdapter) MinimumRate() (float64, error) { + return 1.0, nil +} + +func (p *playerAdapter) MaximumRate() (float64, error) { + return 1.0, nil +} + +func (p *playerAdapter) CanGoNext() (bool, error) { + return p.service.QueueHasNext(), nil +} + +func (p *playerAdapter) CanGoPrevious() (bool, error) { + return p.service.QueueCurrentIndex() > 0, nil +} + +func (p *playerAdapter) CanPlay() (bool, error) { + return !p.service.QueueIsEmpty(), nil +} + +func (p *playerAdapter) CanPause() (bool, error) { + return true, nil +} + +func (p *playerAdapter) CanSeek() (bool, error) { + return true, nil +} + +func (p *playerAdapter) CanControl() (bool, error) { + return true, nil +} + +// LoopStatus implements OrgMprisMediaPlayer2PlayerAdapterLoopStatus. +func (p *playerAdapter) LoopStatus() (types.LoopStatus, error) { + switch p.service.RepeatMode() { + case playback.RepeatOne: + return types.LoopStatusTrack, nil + case playback.RepeatAll: + return types.LoopStatusPlaylist, nil + case playback.RepeatOff, playback.RepeatRadio: + return types.LoopStatusNone, nil + } + return types.LoopStatusNone, nil +} + +// SetLoopStatus implements OrgMprisMediaPlayer2PlayerAdapterLoopStatus. +func (p *playerAdapter) SetLoopStatus(status types.LoopStatus) error { + switch status { + case types.LoopStatusNone: + p.service.SetRepeatMode(playback.RepeatOff) + case types.LoopStatusTrack: + p.service.SetRepeatMode(playback.RepeatOne) + case types.LoopStatusPlaylist: + p.service.SetRepeatMode(playback.RepeatAll) + } + return nil +} + +// Shuffle implements OrgMprisMediaPlayer2PlayerAdapterShuffle. +func (p *playerAdapter) Shuffle() (bool, error) { + return p.service.Shuffle(), nil +} + +// SetShuffle implements OrgMprisMediaPlayer2PlayerAdapterShuffle. +func (p *playerAdapter) SetShuffle(shuffle bool) error { + p.service.SetShuffle(shuffle) + return nil +} + +func formatTrackID(path string) string { + h := fnv.New64a() + h.Write([]byte(path)) + return fmt.Sprintf("/org/mpris/MediaPlayer2/Track/%x", h.Sum64()) +} From f68f852f2ff8b9429df25f63a933b4e99443a5ec Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:28:45 +0400 Subject: [PATCH 25/26] feat(mpris): integrate MPRIS adapter into app lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/app.go | 6 ++++++ internal/app/handlers.go | 3 +++ internal/app/update_loading.go | 3 +++ 3 files changed, 12 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index e355a7b..d46c182 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "github.com/llehouerou/waves/internal/keymap" "github.com/llehouerou/waves/internal/lastfm" "github.com/llehouerou/waves/internal/library" + "github.com/llehouerou/waves/internal/mpris" "github.com/llehouerou/waves/internal/navigator" "github.com/llehouerou/waves/internal/playback" "github.com/llehouerou/waves/internal/player" @@ -49,6 +50,7 @@ type Model struct { Layout LayoutManager PlaybackService playback.Service playbackSub *playback.Subscription + mprisAdapter *mpris.Adapter Keys *keymap.Resolver LibraryScanCh <-chan library.ScanProgress LibraryScanJob *jobbar.Job @@ -145,6 +147,9 @@ func New(cfg *config.Config, stateMgr *state.Manager) (Model, error) { svc := playback.New(p, queue) sub := svc.Subscribe() + // Initialize MPRIS adapter (optional - app works fine without D-Bus) + mprisAdapter, _ := mpris.New(svc) + return Model{ Navigation: NewNavigationManager(), Library: lib, @@ -156,6 +161,7 @@ func New(cfg *config.Config, stateMgr *state.Manager) (Model, error) { Layout: NewLayoutManager(queuepanel.New(queue)), PlaybackService: svc, playbackSub: sub, + mprisAdapter: mprisAdapter, Keys: keymap.NewResolver(keymap.Bindings), StateMgr: stateMgr, HasSlskdConfig: cfg.HasSlskdConfig(), diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 4f3135e..d3a2a64 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -14,6 +14,9 @@ func (m *Model) handleQuitKeys(key string) handler.Result { return handler.NotHandled } _ = m.PlaybackService.Stop() //nolint:errcheck // Ignore errors during shutdown; app is exiting + if m.mprisAdapter != nil { + _ = m.mprisAdapter.Close() + } m.SaveQueueState() m.StateMgr.Close() return handler.Handled(tea.Quit) diff --git a/internal/app/update_loading.go b/internal/app/update_loading.go index b134099..0b36ee5 100644 --- a/internal/app/update_loading.go +++ b/internal/app/update_loading.go @@ -77,6 +77,9 @@ func (m Model) handleInitResult(msg InitResult) (tea.Model, tea.Cmd) { // (the old service had an empty queue created during New()) m.PlaybackService = playback.New(m.PlaybackService.Player(), queue) m.playbackSub = m.PlaybackService.Subscribe() + if m.mprisAdapter != nil { + m.mprisAdapter.Resubscribe(m.PlaybackService) + } } if queuePanel, ok := msg.QueuePanel.(queuepanel.Model); ok { m.Layout.SetQueuePanel(queuePanel) From b82258348105429f1e81f8bb8694387541345f2c Mon Sep 17 00:00:00 2001 From: Laurent Le Houerou Date: Mon, 29 Dec 2025 14:35:42 +0400 Subject: [PATCH 26/26] chore(deps): tidy module dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 2338ca1..99fc63a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 + github.com/godbus/dbus/v5 v5.1.0 github.com/gopxl/beep/v2 v2.1.1 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.0 @@ -21,6 +22,7 @@ require ( github.com/llehouerou/go-mp3 v1.1.1 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-runewidth v0.0.19 + github.com/quarckster/go-mpris-server v1.0.3 github.com/rivo/uniseg v0.4.7 github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 modernc.org/sqlite v1.40.1 @@ -38,7 +40,6 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect @@ -54,7 +55,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/quarckster/go-mpris-server v1.0.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect