diff --git a/go.mod b/go.mod index d5b27bb..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 diff --git a/go.sum b/go.sum index f8a4da2..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= @@ -64,8 +66,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= @@ -99,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/app/app.go b/internal/app/app.go index 3f0517b..d46c182 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,7 +13,9 @@ 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" "github.com/llehouerou/waves/internal/playlist" "github.com/llehouerou/waves/internal/playlists" @@ -46,7 +48,9 @@ type Model struct { Popups PopupManager Input InputManager Layout LayoutManager - Playback PlaybackManager + PlaybackService playback.Service + playbackSub *playback.Subscription + mprisAdapter *mpris.Adapter Keys *keymap.Resolver LibraryScanCh <-chan library.ScanProgress LibraryScanJob *jobbar.Job @@ -106,7 +110,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 +143,13 @@ 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() + + // Initialize MPRIS adapter (optional - app works fine without D-Bus) + mprisAdapter, _ := mpris.New(svc) + return Model{ Navigation: NewNavigationManager(), Library: lib, @@ -148,7 +159,9 @@ 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, + mprisAdapter: mprisAdapter, 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..24c2825 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" @@ -31,64 +32,65 @@ func TestUpdate_WindowSizeMsg_ResizesComponents(t *testing.T) { } } -func TestUpdate_TrackFinishedMsg_AdvancesQueue(t *testing.T) { +func TestUpdate_ServiceTrackChangedMsg_UpdatesUI(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(1) // Move to second track - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } 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") } - if result.Playback.Queue().CurrentIndex() != 1 { - t.Errorf("CurrentIndex = %d, want 1", result.Playback.Queue().CurrentIndex()) + // 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.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") } - 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.Playback.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) { m := newIntegrationTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -116,7 +118,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") } @@ -170,7 +172,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") } @@ -214,11 +216,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(), + 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 3177624..d0f9853 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -34,12 +34,42 @@ 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. -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 { - <-m.Playback.FinishedChan() - return TrackFinishedMsg{} + select { + case e := <-m.playbackSub.StateChanged: + return ServiceStateChangedMsg{ + Previous: int(e.Previous), + Current: int(e.Current), + } + case e := <-m.playbackSub.TrackChanged: + return ServiceTrackChangedMsg{ + 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, + Path: e.Path, + Err: e.Err, + } + case <-m.playbackSub.Done: + return ServiceClosedMsg{} + } } } diff --git a/internal/app/handlers.go b/internal/app/handlers.go index a0fab47..d3a2a64 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -13,7 +13,10 @@ func (m *Model) handleQuitKeys(key string) handler.Result { if m.Keys.Resolve(key) != keymap.ActionQuit { return handler.NotHandled } - m.Playback.Stop() + _ = 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/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 cdadfec..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. @@ -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: @@ -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: @@ -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.PlaybackService.RepeatMode() // Determine next mode nextMode := m.nextRepeatMode(currentMode) @@ -70,48 +69,48 @@ func (m *Model) handleCycleRepeat() tea.Cmd { // Handle radio state transitions cmd := m.handleRadioTransition(currentMode, nextMode) - 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 d59bee2..8a05e40 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" @@ -126,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") } @@ -144,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") } @@ -162,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") } }) @@ -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)), + PlaybackService: svc, + playbackSub: svc.Subscribe(), + Keys: keymap.NewResolver(keymap.Bindings), + StateMgr: state.NewMock(), } } @@ -277,7 +280,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") } @@ -296,27 +299,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") } @@ -331,27 +334,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 49471eb..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,22 +242,25 @@ 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) // 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 m.PlaybackService.QueueCurrentIndex() != 2 { + t.Errorf("queue index = %d, want 2", m.PlaybackService.QueueCurrentIndex()) } - 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.PlaybackService.Player().(*player.Mock) + if mock.State() != player.Playing { + t.Errorf("player state = %v, want Playing", mock.State()) } }) } @@ -306,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")) @@ -321,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/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/layout.go b/internal/app/layout.go index 2366b2b..ab86ebf 100644 --- a/internal/app/layout.go +++ b/internal/app/layout.go @@ -23,8 +23,8 @@ func NotificationHeight(count int) int { func (m *Model) ContentHeight() int { height := m.Layout.Height() height -= headerbar.Height - if !m.Playback.IsStopped() { - height -= playerbar.Height(m.Playback.DisplayMode()) + if !m.PlaybackService.IsStopped() { + 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/messages.go b/internal/app/messages.go index 5d37c7f..ef9573f 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -81,10 +81,52 @@ type LibraryScanCompleteMsg struct { func (LibraryScanCompleteMsg) libraryScanMessage() {} -// TrackFinishedMsg is sent when the current track finishes playing. -type TrackFinishedMsg struct{} +// 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() {} + +// ServiceErrorMsg is sent when an error occurs in the playback service. +type ServiceErrorMsg struct { + Operation string + Path string + Err error +} + +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 (TrackFinishedMsg) playbackMessage() {} +func (ServicePositionChangedMsg) playbackMessage() {} // FocusTarget represents which UI component has focus. type FocusTarget int 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 04a2255..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,8 +43,10 @@ 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 !m.PlaybackService.IsStopped() { + if err := m.PlaybackService.Toggle(); err != nil { + m.Popups.ShowError(errmsg.Format(errmsg.OpPlaybackStart, err)) + } return nil } return m.StartQueuePlayback() @@ -52,23 +54,25 @@ 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 } - 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. 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 } @@ -79,59 +83,69 @@ 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 } 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 + } + + // 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. func (m *Model) TogglePlayerDisplayMode() { - if m.Playback.IsStopped() { + if m.PlaybackService.IsStopped() { 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/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 9d26f22..0e4195f 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" @@ -12,26 +13,24 @@ 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) - cmd := m.HandleSpaceAction() + _ = m.HandleSpaceAction() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } 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) { m := newPlaybackTestModel() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -46,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") } @@ -64,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") } @@ -86,14 +85,14 @@ 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) + m.PlaybackService.AddTracks(playback.Track{Path: "/music/song.mp3"}) + m.PlaybackService.QueueMoveTo(0) - cmd := m.StartQueuePlayback() + _ = m.StartQueuePlayback() - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -101,24 +100,22 @@ 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) { 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") } @@ -132,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") } @@ -164,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") @@ -182,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"}, ) - cmd := m.PlayTrackAtIndex(1) + _ = m.PlayTrackAtIndex(1) - mock, ok := m.Playback.Player().(*player.Mock) + mock, ok := m.PlaybackService.Player().(*player.Mock) if !ok { t.Fatal("expected mock player") } @@ -227,14 +224,12 @@ 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) { m := newPlaybackTestModel() - m.Playback.Queue().Add(playlist.Track{Path: "/track1.mp3"}) + m.PlaybackService.AddTracks(playback.Track{Path: "/track1.mp3"}) cmd := m.PlayTrackAtIndex(5) @@ -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(), + PlaybackService: svc, + playbackSub: svc.Subscribe(), + Layout: NewLayoutManager(queuepanel.New(queue)), + StateMgr: state.NewMock(), } } diff --git a/internal/app/queue.go b/internal/app/queue.go index 0b0ecb5..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,22 +21,26 @@ 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() 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 } @@ -125,16 +130,19 @@ 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() 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..2654f2b 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) @@ -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 d7821c1..0b36ee5 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" @@ -70,7 +71,15 @@ 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) + // 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) + 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) @@ -108,12 +117,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(cmds ...tea.Cmd) tea.Cmd { + allCmds := append([]tea.Cmd{m.WatchServiceEvents()}, cmds...) if downloadsRefreshCmd != nil { - return tea.Batch(cmd, downloadsRefreshCmd) + allCmds = append(allCmds, downloadsRefreshCmd) } - return cmd + return tea.Batch(allCmds...) } // Decide whether to transition to done based on current phase @@ -123,11 +133,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(LoadingTickCmd(), HideLoadingFirstLaunchCmd()) } // Init finished before show delay - never show loading screen m.loadingState = loadingDone - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds() case loadingShowing: // Check if minimum display time has elapsed minTime := 800 * time.Millisecond @@ -136,16 +146,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() + } + // Otherwise wait for HideLoadingMsg - still need to start service events + cmds := []tea.Cmd{m.WatchServiceEvents()} + if downloadsRefreshCmd != nil { + cmds = append(cmds, downloadsRefreshCmd) } - // Otherwise wait for HideLoadingMsg - return m, downloadsRefreshCmd + return m, tea.Batch(cmds...) case loadingDone: // Already done (shouldn't happen) - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds() } - return m, withDownloadsRefresh(m.WatchTrackFinished()) + return m, withCommonCmds() } // handleShowLoading transitions to showing state if still waiting. @@ -164,7 +178,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) @@ -183,7 +197,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 94e0c60..42e3b34 100644 --- a/internal/app/update_playback.go +++ b/internal/app/update_playback.go @@ -7,17 +7,42 @@ 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.PlaybackService.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: - 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 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: - if m.Playback.IsPlaying() { + if m.PlaybackService.IsPlaying() { cmds := []tea.Cmd{TickCmd()} if cmd := m.checkScrobbleThreshold(); cmd != nil { cmds = append(cmds, cmd) @@ -31,28 +56,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.Playback.Queue().HasNext() { - next := m.Playback.Queue().Next() - 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.Playback.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 { @@ -62,6 +65,71 @@ 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.PlaybackService.IsPlaying() { + cmds := []tea.Cmd{TickCmd(), m.WatchServiceEvents()} + + // Reset scrobble state when starting from stopped + if msg.Previous == int(playback.StateStopped) { + m.resetScrobbleState() + // Trigger radio fill if starting the last track (pre-fetch next tracks) + if cmd := m.triggerRadioFill(); 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() + + var cmds []tea.Cmd + cmds = append(cmds, m.WatchServiceEvents()) + + // Trigger radio fill if now on the last track (pre-fetch next tracks) + if cmd := m.triggerRadioFill(); cmd != nil { + cmds = append(cmds, cmd) + } + + // Start tick command if playing + if m.PlaybackService.IsPlaying() { + cmds = append(cmds, TickCmd()) + } + + 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. @@ -70,8 +138,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 45abf18..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.Playback.DisplayMode()) - 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/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) + } +} 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()) +} 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 +} diff --git a/internal/playback/events.go b/internal/playback/events.go new file mode 100644 index 0000000..b0de7b2 --- /dev/null +++ b/internal/playback/events.go @@ -0,0 +1,41 @@ +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 + PreviousIndex int + 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 +} + +// 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/events_test.go b/internal/playback/events_test.go new file mode 100644 index 0000000..89516e4 --- /dev/null +++ b/internal/playback/events_test.go @@ -0,0 +1,119 @@ +package playback + +import ( + "testing" + "time" +) + +const ( + testPathA = "/a.mp3" + testPathB = "/b.mp3" + testMusicPath = "/music/song.mp3" +) + +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: testPathA} + curr := &Track{Path: testPathB} + tc := TrackChange{ + Previous: prev, + Current: curr, + Index: 1, + } + if tc.Previous.Path != testPathA { + t.Errorf("Previous.Path = %q, want %s", tc.Previous.Path, testPathA) + } + 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) + } +} + +func TestQueueChange_Fields(t *testing.T) { + tracks := []Track{ + {Path: testPathA, Title: "Track A"}, + {Path: testPathB, 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 != 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) + } +} + +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: testMusicPath, + 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 != testMusicPath { + t.Errorf("Path = %q, want %s", track.Path, testMusicPath) + } + 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/service.go b/internal/playback/service.go new file mode 100644 index 0000000..d7562bc --- /dev/null +++ b/internal/playback/service.go @@ -0,0 +1,69 @@ +package playback + +import ( + "time" + + "github.com/llehouerou/waves/internal/player" +) + +// Service defines the playback service contract. +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 + Next() error + Previous() error + Seek(delta time.Duration) error + SeekTo(position time.Duration) error + + // 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 + ClearQueue() + + // State queries + State() State + IsPlaying() bool + IsStopped() bool + IsPaused() bool + Position() time.Duration + Duration() time.Duration + CurrentTrack() *Track + TrackInfo() *player.TrackInfo + Player() player.Interface // Direct player access (for UI rendering) + + // Queue queries + QueueTracks() []Track + QueueCurrentIndex() int + QueueLen() int + QueueIsEmpty() bool + QueueHasNext() bool + + // Queue history + Undo() bool + Redo() bool + + // Mode control + RepeatMode() RepeatMode + SetRepeatMode(mode RepeatMode) + CycleRepeatMode() RepeatMode + Shuffle() bool + SetShuffle(enabled bool) + ToggleShuffle() bool + + // Event subscription + Subscribe() *Subscription + + // Lifecycle + Close() error +} diff --git a/internal/playback/service_impl.go b/internal/playback/service_impl.go new file mode 100644 index 0000000..7ece8e5 --- /dev/null +++ b/internal/playback/service_impl.go @@ -0,0 +1,670 @@ +// internal/playback/service_impl.go +package playback + +import ( + "errors" + "sync" + "time" + + "github.com/llehouerou/waves/internal/player" + "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") + ErrInvalidIndex = errors.New("invalid queue index") +) + +// 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{}), + } + go s.watchTrackFinished() + 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 + } +} + +// 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() + 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 + } + track := TrackFromPlaylist(*t) + return &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() +} + +// 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() + defer s.mu.RUnlock() + return TracksFromPlaylist(s.queue.Tracks()) +} + +// 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...) + s.emitQueueChange() +} + +// 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...) + s.emitQueueChange() + 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() + s.emitQueueChange() +} + +// Undo reverts the last queue modification. +func (s *serviceImpl) Undo() bool { + s.mu.Lock() + defer s.mu.Unlock() + 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() + if s.queue.Redo() { + s.emitQueueChange() + return true + } + return false +} + +// 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 + } + track := TrackFromPlaylist(*t) + return &track +} + +// 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 + } + track := TrackFromPlaylist(*t) + return &track +} + +// 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 +} + +// 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) + s.emitError("play_next", nextTrack.Path, err) + } +} + +// 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() +} + +// 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, + PreviousIndex: prevIndex, + Index: currIndex, + } + s.subsMu.RLock() + for _, sub := range s.subs { + sub.sendTrack(e) + } + 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() +} + +// 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() + 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 +} + +// 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() + 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 +} + +// Toggle toggles between play and pause states. +func (s *serviceImpl) Toggle() error { + s.mu.Lock() + defer s.mu.Unlock() + + prevState := s.playerStateToState(s.player.State()) + + 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 + } + } + + currState := s.playerStateToState(s.player.State()) + s.emitStateChange(prevState, currState) + 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 + + 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 +} + +// 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 +} + +// 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. +// 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 +} + +// 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() +} + +// 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) +} + +// SetShuffle sets the shuffle state. +func (s *serviceImpl) SetShuffle(enabled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.queue.SetShuffle(enabled) + s.emitModeChange() +} + +// 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 new file mode 100644 index 0000000..cbd86bc --- /dev/null +++ b/internal/playback/service_impl_test.go @@ -0,0 +1,1154 @@ +// internal/playback/service_impl_test.go +package playback + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/llehouerou/waves/internal/player" + "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() + + 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: testSvcMusicPath, + Title: "Test Song", + }) + q.JumpTo(0) + svc := New(p, q) + + track := svc.CurrentTrack() + + if track == nil { + t.Fatal("CurrentTrack() returned nil") + } + 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) + } +} + +func TestService_Queue_ReturnsCopy(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add( + playlist.Track{Path: testSvcPathA}, + playlist.Track{Path: testSvcPathB}, + ) + svc := New(p, q) + + tracks := svc.QueueTracks() + + if len(tracks) != 2 { + t.Fatalf("len(Queue()) = %d, want 2", len(tracks)) + } + if tracks[0].Path != testSvcPathA { + t.Errorf("tracks[0].Path = %q, want /a.mp3", tracks[0].Path) + } + if tracks[1].Path != testSvcPathB { + 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: testSvcPathA}, playlist.Track{Path: testSvcPathB}) + svc := New(p, q) + + if svc.QueueCurrentIndex() != -1 { + t.Errorf("QueueIndex() = %d, want -1 (no current)", svc.QueueCurrentIndex()) + } + + q.JumpTo(1) + if svc.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) + } +} + +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) + } +} + +func TestService_Play_StartsPlayback(t *testing.T) { + p := player.NewMock() + q := playlist.NewQueue() + q.Add(playlist.Track{Path: testSvcMusicPath}) + 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] != testSvcMusicPath { + t.Errorf("PlayCalls() = %v, want [%s]", p.PlayCalls(), testSvcMusicPath) + } + + // 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: testSvcMusicPath}) + // 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: testSvcMusicPath}) + 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: testSvcMusicPath}) + 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: testSvcMusicPath}) + 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: testSvcMusicPath}) + 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: testSvcMusicPath}) + 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") + } +} + +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.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) + } + + // 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.QueueCurrentIndex() != 1 { + t.Errorf("QueueIndex() = %d, want 1", svc.QueueCurrentIndex()) + } + 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.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0", svc.QueueCurrentIndex()) + } + + // 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.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueCurrentIndex()) + } + + // 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.QueueCurrentIndex() != 2 { + t.Errorf("QueueIndex() = %d, want 2", svc.QueueCurrentIndex()) + } + + // 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.QueueCurrentIndex() != 0 { + t.Errorf("QueueIndex() = %d, want 0 (unchanged)", svc.QueueCurrentIndex()) + } +} + +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") + } +} + +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()) + } +} + +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.QueueTracks() + }() + + 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/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) + } + } +} diff --git a/internal/playback/subscription.go b/internal/playback/subscription.go new file mode 100644 index 0000000..01e8b91 --- /dev/null +++ b/internal/playback/subscription.go @@ -0,0 +1,100 @@ +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 + Error <-chan ErrorEvent + Done <-chan struct{} + + // Internal write channels + stateCh chan StateChange + trackCh chan TrackChange + positionCh chan PositionChange + queueCh chan QueueChange + modeCh chan ModeChange + errorCh chan ErrorEvent + 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), + errorCh: make(chan ErrorEvent, 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.Error = s.errorCh + 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). +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: + } +} + +// sendError sends an error event (non-blocking). +func (s *Subscription) sendError(e ErrorEvent) { + select { + case s.errorCh <- 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) + } +} diff --git a/internal/playback/track.go b/internal/playback/track.go new file mode 100644 index 0000000..a82dd9a --- /dev/null +++ b/internal/playback/track.go @@ -0,0 +1,63 @@ +package playback + +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. +type Track struct { + ID int64 + Path string + Title string + Artist string + Album string + 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 +} diff --git a/internal/player/mock.go b/internal/player/mock.go index 8f1d0dc..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,17 +117,59 @@ 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.mu.Lock() + defer m.mu.Unlock() + m.playErr = err +} -func (m *Mock) SetPlayError(err error) { m.playErr = err } +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) PlayCalls() []string { return m.playCalls } +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) SeekCalls() []time.Duration { return m.seekCalls } +func (m *Mock) SetTrackInfo(info *TrackInfo) { + m.mu.Lock() + defer m.mu.Unlock() + m.trackInfo = info +} -func (m *Mock) SetTrackInfo(info *TrackInfo) { m.trackInfo = info } +func (m *Mock) SetDuration(d time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + m.duration = d +} -func (m *Mock) SetDuration(d time.Duration) { m.duration = 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() { + select { + case m.finishedCh <- struct{}{}: + default: + } +} // Verify Mock implements Interface at compile time. var _ Interface = (*Mock)(nil)