diff --git a/backend/app.go b/backend/app.go index dbf1c99..f8e126c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -11,18 +11,16 @@ import ( "path/filepath" "reflect" "runtime" + "slices" "strings" "time" "github.com/dweymouth/supersonic/backend/ipc" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" - "github.com/dweymouth/supersonic/backend/player/dlna" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" "github.com/google/uuid" - "github.com/supersonic-app/go-upnpcast/device" - "github.com/supersonic-app/go-upnpcast/services" "github.com/20after4/configdir" "github.com/zalando/go-keyring" @@ -45,7 +43,7 @@ type App struct { ServerManager *ServerManager ImageManager *ImageManager PlaybackManager *PlaybackManager - LocalPlayer player.BasePlayer + LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler WinSMTC *SMTC @@ -154,6 +152,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout) } + // Periodically scan for remote players + go func() { + t := time.NewTicker(5 * time.Minute) + for { + a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx) + select { + case <-a.bgrndCtx.Done(): + t.Stop() + return + case <-t.C: + continue + } + } + }() + a.PlaybackManager.OnPlaying(func() { SetSystemSleepDisabled(true) }) @@ -275,11 +288,7 @@ func (a *App) initMPV() error { if err := p.Init(c.InMemoryCacheSizeMB); err != nil { return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) } - // a.LocalPlayer = p - devices, _ := device.SearchMediaRenderers(context.Background(), 10, services.AVTransport) - if len(devices) > 0 { - a.LocalPlayer, _ = dlna.NewDLNAPlayer(devices[0]) - } + a.LocalPlayer = p return nil } @@ -287,57 +296,55 @@ func (a *App) setupMPV() error { a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100) a.LocalPlayer.SetVolume(a.Config.LocalPlayback.Volume) - /* - devs, err := a.LocalPlayer.ListAudioDevices() - if err != nil { - return err - } + devs, err := a.LocalPlayer.ListAudioDevices() + if err != nil { + return err + } - desiredDevice := a.Config.LocalPlayback.AudioDeviceName - var desiredDeviceAvailable bool - for _, dev := range devs { - if dev.Name == desiredDevice { - desiredDeviceAvailable = true - break - } + desiredDevice := a.Config.LocalPlayback.AudioDeviceName + var desiredDeviceAvailable bool + for _, dev := range devs { + if dev.Name == desiredDevice { + desiredDeviceAvailable = true + break } - if !desiredDeviceAvailable { - // The audio device the user has configured is not available. - // Use the default (autoselect) device but leave the setting unchanged, - // in case the device is later available on a subsequent run of the app - // (e.g. a USB audio device that is currently unplugged) - desiredDevice = "auto" - } - a.LocalPlayer.SetAudioDevice(desiredDevice) + } + if !desiredDeviceAvailable { + // The audio device the user has configured is not available. + // Use the default (autoselect) device but leave the setting unchanged, + // in case the device is later available on a subsequent run of the app + // (e.g. a USB audio device that is currently unplugged) + desiredDevice = "auto" + } + a.LocalPlayer.SetAudioDevice(desiredDevice) - rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} - if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { - a.Config.ReplayGain.Mode = ReplayGainNone - } - mode := player.ReplayGainNone - switch a.Config.ReplayGain.Mode { - case ReplayGainAlbum: - mode = player.ReplayGainAlbum - case ReplayGainTrack: - mode = player.ReplayGainTrack - case ReplayGainAuto: - mode = player.ReplayGainTrack - } + rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} + if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { + a.Config.ReplayGain.Mode = ReplayGainNone + } + mode := player.ReplayGainNone + switch a.Config.ReplayGain.Mode { + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAuto: + mode = player.ReplayGainTrack + } - a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - Mode: mode, - PreventClipping: a.Config.ReplayGain.PreventClipping, - PreampGain: a.Config.ReplayGain.PreampGainDB, - }) - a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ + Mode: mode, + PreventClipping: a.Config.ReplayGain.PreventClipping, + PreampGain: a.Config.ReplayGain.PreampGainDB, + }) + a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) - eq := &mpv.ISO15BandEqualizer{ - EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, - Disabled: !a.Config.LocalPlayback.EqualizerEnabled, - } - copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) - a.LocalPlayer.SetEqualizer(eq) - */ + eq := &mpv.ISO15BandEqualizer{ + EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, + Disabled: !a.Config.LocalPlayback.EqualizerEnabled, + } + copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) + a.LocalPlayer.SetEqualizer(eq) return nil } diff --git a/backend/playbackengine.go b/backend/playbackengine.go index d7a644c..dd7f6a1 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -58,6 +58,9 @@ type playbackEngine struct { noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremented on the next onTrackChange alreadyScrobbled bool // true iff the previously-playing track was already scrobbled + pendingPlayerChange bool + pendingPlayerChangeTimePos float64 + // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track scrobbleCfg *ScrobbleConfig @@ -102,23 +105,8 @@ func NewPlaybackEngine( case "One": pm.loopMode = LoopOne } - p.OnTrackChange(pm.handleOnTrackChange) - p.OnSeek(func() { - pm.doUpdateTimePos(true) - pm.invokeNoArgCallbacks(pm.onSeek) - }) - p.OnStopped(pm.handleOnStopped) - p.OnPaused(func() { - pm.playTimeStopwatch.Stop() - pm.stopPollTimePos() - pm.invokeNoArgCallbacks(pm.onPaused) - }) - p.OnPlaying(func() { - pm.playTimeStopwatch.Start() - pm.startPollTimePos() - pm.invokeNoArgCallbacks(pm.onPlaying) - }) + pm.registerPlayerCallbacks(p) s.OnLogout(func() { pm.StopAndClearPlayQueue() }) @@ -126,6 +114,51 @@ func NewPlaybackEngine( return pm } +func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { + pl.OnTrackChange(p.handleOnTrackChange) + pl.OnSeek(func() { + p.doUpdateTimePos(true) + p.invokeNoArgCallbacks(p.onSeek) + }) + pl.OnStopped(p.handleOnStopped) + pl.OnPaused(func() { + p.playTimeStopwatch.Stop() + p.stopPollTimePos() + p.invokeNoArgCallbacks(p.onPaused) + }) + pl.OnPlaying(func() { + p.playTimeStopwatch.Start() + p.startPollTimePos() + p.invokeNoArgCallbacks(p.onPlaying) + }) +} + +func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { + needToUnpause := false + + stat := p.player.GetStatus() + switch stat.State { + case player.Stopped: + // nothing + case player.Playing: + p.Pause() + fallthrough + case player.Paused: + p.pendingPlayerChangeTimePos = stat.TimePos + p.pendingPlayerChange = true + needToUnpause = true + } + + p.player = pl + p.registerPlayerCallbacks(pl) + + if needToUnpause { + p.PlayTrackAt(p.nowPlayingIdx) + p.SeekSeconds(p.pendingPlayerChangeTimePos) + p.pendingPlayerChange = false + } +} + func (p *playbackEngine) PlayTrackAt(idx int) error { if idx < 0 || idx >= len(p.playQueue) { return errors.New("track index out of range") @@ -238,6 +271,15 @@ func (p *playbackEngine) Pause() error { } func (p *playbackEngine) Continue() error { + if p.pendingPlayerChange { + err := p.PlayTrackAt(p.nowPlayingIdx) + if p.pendingPlayerChangeTimePos != 0 { + p.SeekSeconds(p.pendingPlayerChangeTimePos) + } + p.pendingPlayerChange = false + return err + } + if p.PlayerStatus().State == player.Stopped { return p.PlayTrackAt(0) } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 95debbb..d66a580 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -7,12 +7,16 @@ import ( "math/rand" "runtime" "slices" + "sync" "time" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/dlna" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" + "github.com/supersonic-app/go-upnpcast/device" + "github.com/supersonic-app/go-upnpcast/services" ) // A high-level MediaProvider-aware playback engine, serves as an @@ -22,11 +26,21 @@ type PlaybackManager struct { cmdQueue *playbackCommandQueue cfg *AppConfig + localPlayer player.BasePlayer + remotePlayersLock sync.Mutex + remotePlayers []remotePlayer + autoplay bool lastPlayTime float64 } +type remotePlayer struct { + Name string + Protocol string + new func() (player.BasePlayer, error) +} + func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -39,10 +53,11 @@ func NewPlaybackManager( e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) q := NewCommandQueue() pm := &PlaybackManager{ - engine: e, - cmdQueue: q, - cfg: appCfg, - autoplay: playbackCfg.Autoplay, + engine: e, + cmdQueue: q, + cfg: appCfg, + autoplay: playbackCfg.Autoplay, + localPlayer: p, } pm.addOnTrackChangeHook() go pm.runCmdQueue(ctx) @@ -81,6 +96,47 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context) { + devices, _ := device.SearchMediaRenderers(ctx, 10, services.AVTransport, services.RenderingControl) + + var discovered []remotePlayer + for _, d := range devices { + p := remotePlayer{ + Name: d.FriendlyName, + Protocol: "DLNA", + new: func() (player.BasePlayer, error) { + return dlna.NewDLNAPlayer(d) + }, + } + discovered = append(discovered, p) + } + + p.remotePlayersLock.Lock() + p.remotePlayers = discovered + p.remotePlayersLock.Unlock() +} + +func (p *PlaybackManager) RemotePlayers() []remotePlayer { + p.remotePlayersLock.Lock() + players := p.remotePlayers + p.remotePlayersLock.Unlock() + return players +} + +func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { + if rp == nil { + p.engine.SetPlayer(p.localPlayer) + return nil + } + + player, err := rp.new() + if err != nil { + return err + } + p.engine.SetPlayer(player) + return nil +} + func (p *PlaybackManager) CurrentPlayer() player.BasePlayer { return p.engine.CurrentPlayer() } diff --git a/backend/player/player.go b/backend/player/player.go index 778c941..ec65dcf 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -84,66 +84,66 @@ func (r ReplayGainMode) String() string { } type BasePlayerCallbackImpl struct { - onPaused []func() - onStopped []func() - onPlaying []func() - onSeek []func() - onTrackChange []func() + onPaused func() + onStopped func() + onPlaying func() + onSeek func() + onTrackChange func() } -// Registers a callback which is invoked when the player transitions to the Paused state. +// Sets a callback which is invoked when the player transitions to the Paused state. func (p *BasePlayerCallbackImpl) OnPaused(cb func()) { - p.onPaused = append(p.onPaused, cb) + p.onPaused = cb } -// Registers a callback which is invoked when the player transitions to the Stopped state. +// Sets a callback which is invoked when the player transitions to the Stopped state. func (p *BasePlayerCallbackImpl) OnStopped(cb func()) { - p.onStopped = append(p.onStopped, cb) + p.onStopped = cb } -// Registers a callback which is invoked when the player transitions to the Playing state. +// Sets a callback which is invoked when the player transitions to the Playing state. func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) { - p.onPlaying = append(p.onPlaying, cb) + p.onPlaying = cb } // Registers a callback which is invoked whenever a seek event occurs. func (p *BasePlayerCallbackImpl) OnSeek(cb func()) { - p.onSeek = append(p.onSeek, cb) + p.onSeek = cb } // Registers a callback which is invoked when the currently playing track changes, // or when playback begins at any time from the Stopped state. // Callback is invoked with the index of the currently playing track (zero-based). func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) { - p.onTrackChange = append(p.onTrackChange, cb) + p.onTrackChange = cb } func (p *BasePlayerCallbackImpl) InvokeOnPaused() { - for _, cb := range p.onPaused { - cb() + if p.onPaused != nil { + p.onPaused() } } func (p *BasePlayerCallbackImpl) InvokeOnPlaying() { - for _, cb := range p.onPlaying { - cb() + if p.onPlaying != nil { + p.onPlaying() } } func (p *BasePlayerCallbackImpl) InvokeOnStopped() { - for _, cb := range p.onStopped { - cb() + if p.onStopped != nil { + p.onStopped() } } func (p *BasePlayerCallbackImpl) InvokeOnSeek() { - for _, cb := range p.onSeek { - cb() + if p.onSeek != nil { + p.onSeek() } } func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() { - for _, cb := range p.onTrackChange { - cb() + if p.onTrackChange != nil { + p.onTrackChange() } } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 1ef9529..d08dfde 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -16,6 +16,8 @@ import ( fynetooltip "github.com/dweymouth/fyne-tooltip" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/dialogs" myTheme "github.com/dweymouth/supersonic/ui/theme" @@ -268,56 +270,56 @@ func (c *Controller) ShowAboutDialog() { } func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) { - /* - devs, err := c.App.LocalPlayer.ListAudioDevices() - if err != nil { - log.Printf("error listing audio devices: %v", err) - devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} - } - curPlayer := c.App.PlaybackManager.CurrentPlayer() - _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) - _, isEqualizerPlayer := curPlayer.(*mpv.Player) - _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) - isLocalPlayer := isEqualizerPlayer - bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + devs, err := c.App.LocalPlayer.ListAudioDevices() + if err != nil { + log.Printf("error listing audio devices: %v", err) + devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} + } + + curPlayer := c.App.PlaybackManager.CurrentPlayer() + _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) + _, isEqualizerPlayer := curPlayer.(*mpv.Player) + _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) + isLocalPlayer := isEqualizerPlayer + bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + + dlg := dialogs.NewSettingsDialog(c.App.Config, + devs, themeFiles, bands, + c.App.ServerManager.Server.ClientDecidesScrobble(), + isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, + c.MainWindow) + dlg.OnReplayGainSettingsChanged = func() { + c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) + } + dlg.OnAudioExclusiveSettingChanged = func() { + c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) + } + dlg.OnAudioDeviceSettingChanged = func() { + c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) + } + dlg.OnThemeSettingChanged = themeUpdateCallbk + dlg.OnEqualizerSettingsChanged = func() { + // currently we only have one equalizer type + eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) + eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled + eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp + copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) + c.App.LocalPlayer.SetEqualizer(eq) + } + dlg.OnPageNeedsRefresh = c.RefreshPageFunc + pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) + fynetooltip.AddPopUpToolTipLayer(pop) + dlg.OnDismiss = func() { + pop.Hide() + fynetooltip.DestroyPopUpToolTipLayer(pop) + c.doModalClosed() + c.App.SaveConfigFile() + } + c.ClosePopUpOnEscape(pop) + c.haveModal = true + pop.Show() - dlg := dialogs.NewSettingsDialog(c.App.Config, - devs, themeFiles, bands, - c.App.ServerManager.Server.ClientDecidesScrobble(), - isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, - c.MainWindow) - dlg.OnReplayGainSettingsChanged = func() { - c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) - } - dlg.OnAudioExclusiveSettingChanged = func() { - c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) - } - dlg.OnAudioDeviceSettingChanged = func() { - c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) - } - dlg.OnThemeSettingChanged = themeUpdateCallbk - dlg.OnEqualizerSettingsChanged = func() { - // currently we only have one equalizer type - eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) - eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled - eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp - copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) - c.App.LocalPlayer.SetEqualizer(eq) - } - dlg.OnPageNeedsRefresh = c.RefreshPageFunc - pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) - fynetooltip.AddPopUpToolTipLayer(pop) - dlg.OnDismiss = func() { - pop.Hide() - fynetooltip.DestroyPopUpToolTipLayer(pop) - c.doModalClosed() - c.App.SaveConfigFile() - } - c.ClosePopUpOnEscape(pop) - c.haveModal = true - pop.Show() - */ } func (c *Controller) doModalClosed() { diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index 1e043fb..4fbfdae 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -7,6 +7,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/ui/shortcuts" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/visualizations" @@ -21,11 +22,13 @@ type visualizationData struct { } func (c *Controller) initVisualizations() { - c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim) - c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim) - c.App.LocalPlayer.OnPlaying(func() { - if c.peakMeter != nil { - c.startVisualizationAnim() + c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim) + c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim) + c.App.PlaybackManager.OnPlaying(func() { + if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok { + if c.peakMeter != nil { + c.startVisualizationAnim() + } } }) }