From 4f731833c06b282b8b81dbdddda56c667fe22006 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 12 Mar 2025 16:47:48 -0700 Subject: [PATCH 01/24] very WIP for DLNA casting --- backend/app.go | 106 +++++++------ backend/player/dlna/dlnaplayer.go | 243 ++++++++++++++++++++++++++++++ go.mod | 4 + go.sum | 6 + ui/controller/controller.go | 97 ++++++------ ui/controller/visualizations.go | 8 +- 6 files changed, 363 insertions(+), 101 deletions(-) create mode 100644 backend/player/dlna/dlnaplayer.go diff --git a/backend/app.go b/backend/app.go index d93309f..dbf1c99 100644 --- a/backend/app.go +++ b/backend/app.go @@ -11,16 +11,18 @@ 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" @@ -43,7 +45,7 @@ type App struct { ServerManager *ServerManager ImageManager *ImageManager PlaybackManager *PlaybackManager - LocalPlayer *mpv.Player + LocalPlayer player.BasePlayer UpdateChecker UpdateChecker MPRISHandler *MPRISHandler WinSMTC *SMTC @@ -273,7 +275,11 @@ 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 + // a.LocalPlayer = p + devices, _ := device.SearchMediaRenderers(context.Background(), 10, services.AVTransport) + if len(devices) > 0 { + a.LocalPlayer, _ = dlna.NewDLNAPlayer(devices[0]) + } return nil } @@ -281,55 +287,57 @@ 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 - } - - desiredDevice := a.Config.LocalPlayback.AudioDeviceName - var desiredDeviceAvailable bool - for _, dev := range devs { - if dev.Name == desiredDevice { - desiredDeviceAvailable = true - break + /* + devs, err := a.LocalPlayer.ListAudioDevices() + if err != nil { + return err } - } - 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 - } + 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) - a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - Mode: mode, - PreventClipping: a.Config.ReplayGain.PreventClipping, - PreampGain: a.Config.ReplayGain.PreampGainDB, - }) - a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + 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 + } - eq := &mpv.ISO15BandEqualizer{ - EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, - Disabled: !a.Config.LocalPlayback.EqualizerEnabled, - } - copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) - a.LocalPlayer.SetEqualizer(eq) + 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) + */ return nil } @@ -450,7 +458,7 @@ func (a *App) Shutdown() { a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() - a.LocalPlayer.Destroy() + //a.LocalPlayer.Destroy() } func (a *App) SavePlayQueueIfEnabled() { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go new file mode 100644 index 0000000..23b6bb9 --- /dev/null +++ b/backend/player/dlna/dlnaplayer.go @@ -0,0 +1,243 @@ +package dlna + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "sync" + "sync/atomic" + + "github.com/dweymouth/supersonic/backend/player" + "github.com/supersonic-app/go-upnpcast/device" + "github.com/supersonic-app/go-upnpcast/services/avtransport" +) + +const ( + stopped = 0 + playing = 1 + paused = 2 +) + +var unimplemented = errors.New("unimplemented") + +type DLNAPlayer struct { + player.BasePlayerCallbackImpl + + avTransport *avtransport.Client + + state int // stopped, playing, paused + seeking bool +} + +func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { + avt, err := device.AVTransportClient() + if err != nil { + return nil, err + } + return &DLNAPlayer{avTransport: avt}, nil +} + +func (d *DLNAPlayer) SetVolume(vol int) error { + return unimplemented +} + +func (d *DLNAPlayer) GetVolume() int { + return 0 +} + +func (d *DLNAPlayer) PlayFile(urlstr string) error { + ensureSetupProxies() + + proxyURLLock.Lock() + dlnaProxyCurrent.url = urlstr + proxyURLLock.Unlock() + + media := avtransport.MediaItem{ + URL: "http://" + localIP + ":8080/current", + Title: "Supersonic media item", + } + log.Printf("URL %s", media.URL) + + err := d.avTransport.SetAVTransportMedia(context.Background(), &media) + if err != nil { + return err + } + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + d.state = playing + d.InvokeOnPlaying() + return nil +} + +func (d *DLNAPlayer) SetNextFile(url string) error { + var media *avtransport.MediaItem + if url != "" { + ensureSetupProxies() + + proxyURLLock.Lock() + dlnaProxyCurrent.url = url + proxyURLLock.Unlock() + + media = &avtransport.MediaItem{ + URL: "http://" + localIP + ":8080/next", + } + } + return d.avTransport.SetNextAVTransportMedia(context.Background(), media) +} + +func (d *DLNAPlayer) Continue() error { + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + d.state = playing + d.InvokeOnPlaying() + return nil +} + +func (d *DLNAPlayer) Pause() error { + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + d.state = paused + d.InvokeOnPaused() + return nil +} + +func (d *DLNAPlayer) Stop() error { + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + d.state = stopped + d.InvokeOnStopped() + return nil +} + +func (d *DLNAPlayer) SeekSeconds(secs float64) error { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } + d.seeking = false + d.InvokeOnSeek() + return nil +} + +func (d *DLNAPlayer) IsSeeking() bool { + return d.seeking +} + +func (d *DLNAPlayer) GetStatus() player.Status { + state := player.Stopped + if d.state == playing { + state = player.Playing + } else if d.state == paused { + state = player.Paused + } + + // TODO - the rest + + return player.Status{ + State: state, + } +} + +func getLocalIP() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + + return "", fmt.Errorf("no suitable interface found") +} + +var ( + localIP string + proxyURLLock sync.Mutex + dlnaProxyCurrent proxy + dlnaProxyNext proxy + proxyActive atomic.Bool +) + +func ensureSetupProxies() { + if proxyActive.Swap(true) { + return // already active + } + + localIP, _ = getLocalIP() + log.Println(localIP) + + mux := http.NewServeMux() + mux.HandleFunc("/current", dlnaProxyCurrent.handleRequest) + mux.HandleFunc("/next", dlnaProxyNext.handleRequest) + go http.ListenAndServe(":8080", mux) +} + +type proxy struct { + url string +} + +func (p *proxy) handleRequest(w http.ResponseWriter, r *http.Request) { + // Create a new request to the target server + proxyURLLock.Lock() + url := p.url + proxyURLLock.Unlock() + log.Println("Got request for " + url) + proxyReq, err := http.NewRequest(r.Method, url, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Copy headers from the original request to the new request + proxyReq.Header = r.Header + + // Create an HTTP client and send the request + client := &http.Client{} + resp, err := client.Do(proxyReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy headers from the response to the writer + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Set the status code + w.WriteHeader(resp.StatusCode) + + // Copy the response body to the writer + _, err = io.Copy(w, resp.Body) + if err != nil { + fmt.Fprintln(os.Stderr, "Error copying response body:", err) + } +} diff --git a/go.mod b/go.mod index 672b93e..9e487dc 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/go-mpv v0.1.0 github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d + github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce github.com/zalando/go-keyring v0.2.6 golang.org/x/net v0.25.0 golang.org/x/sys v0.30.0 @@ -39,8 +40,10 @@ require ( github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -53,4 +56,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) +replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde diff --git a/go.sum b/go.sum index ac2322e..e8a2382 100644 --- a/go.sum +++ b/go.sum @@ -57,10 +57,14 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= @@ -97,6 +101,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0 github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= +github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce h1:6R8/JRwRVrR9t2YPTgikS39lRf3gPp/MPcbx+lIf3rA= +github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce/go.mod h1:Wscg4vEzF9x6i4ltL2Qkvx4VhzXfkBBl8uoG0YSAJMU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 246f61b..1ef9529 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -16,8 +16,6 @@ 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" @@ -270,53 +268,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")}} - } + /* + 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() + 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() + */ } func (c *Controller) doModalClosed() { diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index b63cdf6..1e043fb 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -71,13 +71,13 @@ func (c *Controller) stopVisualizationAnim() { if c.visualizationAnim != nil { c.visualizationAnim.Stop() c.visualizationAnim = nil - c.App.LocalPlayer.SetPeaksEnabled(false) + // c.App.LocalPlayer.SetPeaksEnabled(false) } } func (c *Controller) startVisualizationAnim() { if c.visualizationAnim == nil { - c.App.LocalPlayer.SetPeaksEnabled(true) + //c.App.LocalPlayer.SetPeaksEnabled(true) c.visualizationAnim = fyne.NewAnimation( time.Duration(math.MaxInt64), /*until stopped*/ c.tickVisualizations) @@ -86,8 +86,8 @@ func (c *Controller) startVisualizationAnim() { } func (c *Controller) tickVisualizations(_ float32) { - lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks() + //lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks() if c.visualizationData.peakMeter != nil { - c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) + //c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) } } From 8bd1c8183d20a6d72caeb1a0e888cf20aa54672f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 14 Mar 2025 16:35:42 -0700 Subject: [PATCH 02/24] more work for preparing for casting --- backend/app.go | 117 +++++++++++++++++--------------- backend/playbackengine.go | 74 +++++++++++++++----- backend/playbackmanager.go | 64 +++++++++++++++-- backend/player/player.go | 46 ++++++------- ui/controller/controller.go | 98 +++++++++++++------------- ui/controller/visualizations.go | 13 ++-- 6 files changed, 261 insertions(+), 151 deletions(-) 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() + } } }) } From 1ad45a33db96042c6e9bc6e2480a8831b25ac07d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 14 Mar 2025 18:51:14 -0700 Subject: [PATCH 03/24] more progress - player switching working --- backend/playbackengine.go | 1 + res/bundled.go | 5 +++++ res/bundled_gen.sh | 1 + res/icons/publicdomain/cast.svg | 4 ++++ ui/bottompanel.go | 6 ++++++ ui/controller/controller.go | 20 ++++++++++++++++++++ ui/theme/theme.go | 1 + ui/widgets/auxcontrols.go | 22 ++++++++++++++++++++-- 8 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 res/icons/publicdomain/cast.svg diff --git a/backend/playbackengine.go b/backend/playbackengine.go index dd7f6a1..397515b 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -157,6 +157,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { p.SeekSeconds(p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } + p.invokeNoArgCallbacks(p.onPlayerChange) } func (p *playbackEngine) PlayTrackAt(idx int) error { diff --git a/res/bundled.go b/res/bundled.go index dfb3f95..590f9a0 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -20,6 +20,11 @@ var ResPlaybuttonPng = &fyne.StaticResource{ StaticContent: []byte( "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x80\x00\x00\x00\x80\b\x03\x00\x00\x00\xf4\xe0\x91\xf9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\v\xfca\x05\x00\x00\x00 cHRM\x00\x00z&\x00\x00\x80\x84\x00\x00\xfa\x00\x00\x00\x80\xe8\x00\x00u0\x00\x00\xea`\x00\x00:\x98\x00\x00\x17p\x9c\xbaQ<\x00\x00\x01\xcePLTE\x00\x00\x00\"\xd7)!\xd3(#\xe2+!\xd6( \xce'\"\xd9)!\xd5(\"\xd6)\x1b\xae!!\xd2(\"\xd8)\x1b\xb0!\"\xd8)!\xd5(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(\"\xdb)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)\"\xd6)\"\xd6)\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd7)!\xd6(#\xde*!\xd5(!\xd6(!\xd6(!\xd6( \xcd'!\xd3(!\xd6(!\xd6(!\xd2(!\xd5(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd6)!\xd6(!\xd5(!\xd6(\"\xd6)!\xd6(!\xd6(!\xd6(\"\xd6)!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd7)!\xd6(\"\xd7)!\xd6(!\xd5(\"\xd6)!\xd6(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd5(!\xd6(!\xd4(!\xd6(!\xd5(!\xd6(!\xd6(!\xd5(\"\xd7)\"\xd7)!\xd6(!\xd6(\"\xd7)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd7)\"\xd6) \xd6'(\xd8/)\xd80\x1f\xd6&>\xdcD\x97\xec\x9b\xc3\xf4\xc5\xc6\xf4Ǐ\xea\x93?\xdcE\xc5\xf4\xc7\xff\xff\xff\xfe\xfe\xfe\xdc\xf8\xddz\xe6~-\xd94\x97\xec\x9a\xfa\xfe\xfa\xba\xf2\xbcQ\xdfW\xec\xfb\xec\x90\xeb\x944\xda:9\xdb?\xe0\xf9\xe1\xfc\xfe\xfc\xcf\xf6\xd0f\xe3k$\xd7+<\xdbB\xe4\xfa\xe5\xf2\xfc\xf2\xa7\xef\xa9D\xddI\xbf\xf3\xc1&\xd7-\x98원\xea\x93(2|%\x00\x00\x00qtRNS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x04\x141S\x7f\x93\xbe\xcf\xdd\x01\x1cEq\xa6\xcb\xe7\xf8\xfe\v3f\xa9\xd9\xfe\x05'e\xb4\xee\xfd\x01\x11L\xa0\xe4\x01\x16\xc7\xf6\x15o\xd0\xfb\x14\xca\nM\xbf\xfc\x01)\xa1\xf5\ro(\xa4\xfb\x04Q\xd7\x0f\x80\xf0\"\xfc*\xbdCC\xdd\xd6\"\x0e\x03\xfd)\rp\tN\x15\xc9p\xfc\x17\xce\x12Ɵ\n3\xed\xa8\xd8\x03\xa60\x92\x9c\x8e\xa7 \x00\x00\x00\x01bKGD\x7fH\xbfq\xe5\x00\x00\x00\atIME\a\xe7\x01\x13\x01%2\x99\xbf\xbb\v\x00\x00\x04[IDATx\xda\xe5\x9bgC\x13A\x10\x86wÑP\x83\xb4H\rx\x82tA\x9a\"\x1d%H\x93Б\xa2\"Ho\xb7\x89\x82\xf5,\xd8{\x03\xfd\xb7\xe6\x12\x90\x00Iؽ\xdd\xcb|\xf0\xfd\xbe\xf7>\x99\x9d\xdcm\x99A\x88Q8ޚp*1)9%զ\x90=)\xb6Ԕ\xe4\xa4\xc4S\t\xd6x\xcc\xfa<6\xf3\xd3ִ\xf4\x8c̬l{\x0e9\xa6\x1c{vVfFz\x9a\xf5\xb4A\x10\x18瞑\xcf\xe6\xd9\xf3I\b\xe5\xdb\xf3\xce\xcagr\xb1x\x06|\xae\xa0\xb0\xa8\xb8\x84P\xa8\xa4\xb8\xa8\xb0\xe0\x9cP\x04\\Zv\xbe\xbc\xa2\xc4Ec\xaf\xc9URQ~\xbe\xacT\x14\x83\xe9B\xa5\\UMk\xbe\xaf\xea*\xb9\xf2\x82I\x84}DM\xed\xc5K\xac\xf6\x9a.]\xac\xad\x89\xe0E\xc0R\x9d|Y\x97\xbd\x17\xe1\xb2\\'qM\x04\xaeohl\xd2k\xaf\xa9\xa9\xb1\xa1^?\x01nnim\xa3μ\xc0r\xb5\xb5\xb64\xebD\xc0W\xae\x1e\xbc\xec\xf4K\xb1]\xbd\xa2\x87\x00G\xb6;:\xf8\xed5u8\xda#\x99\x11\xf0\xb5\xce.\xce\xe8\xfb\xcdCW\xe75F\x02\xdc\xdd\xd3+\xcc\xdfC\xd0\xdb\xd3\xcdB\x80\xa5\xeb\x8e>q\xf6\x9a\xfa\x1c\xd7\xe9\xff\x90\xd8\xec\xec\x17k\xaf\xa9\xdfi\xa6$\xc0x\xa0K@\xf6\x1f\x95\xd25@\xf7\x91\xc4xpH\xbc\xbd\xa6\xa1A\x1a\x02\xe3\xfc\xe9\b\x8c\xf4\xa7!0\xd6\xffd\x02lq\x0e\x1b\xe9OȰ\xd3\x12\x8a\x00\x8f\xf4\x1b\x90\xff\xfeR\xfaGB\x00H\xa3\x0ec\xed59F\xa5\xa0\xfec=7\x8c\a\xb8\xd13\x16\x84\x00\xe3\xce^\xe3\xfd\t\xe9\xed\f\x92\x88\xd2\xf8\xb0\xc0\xefOp\xb9\x86\xc7\x03\x86@\x9ap\x84\xc5\xdfC\xe0\x98\b@\x80\xa3&\x05\xad?NV\xc7d\xd4\xf1I\xb08m\xe1\xf2'\xc4\xe6\xb4\x1c\x9b\x80\xa9V\x83\xdf\x00\xfeRZ\xa7\x8eLB\xb4\xf9f[\xf8\xfc\ti\xbbi\x8e>\x1c\x80[\x8da\xca@\x9f\\\x8d\xb7\x0e\x85 &V\xe6\xda\x7f\xb0\xabI\x8e\x8d\xf1\x030U\xde\x0e\xaf?!\xb7+\xfd\xb6\x8dX\x9a\xbe\x13n\x80;\xd3~\x8bT\xd3\xccl\xb8\xfd\t\x99\x9d1\x1d\x04@\x0e{\x00ݢ\x8d\x81A\x00\xea6\xed\xc2\xdd(\x80\x9d\xdd\xff\x1d\x00z\n\x80\x93\x90\xe9o\b\xfe\"\x02\x7f\x15\x83\x7f\x8c\xc0?\xc7\xe0\v\x12\xf0%\x19\xf8\xa2\x14|Y\x0e\xbe1\x01ߚ\x81oN\xc1\xb7\xe7\xe0\a\x14\xe0G4\xe0\x87T\xe0\xc7t\xf0\a\x95\xe0G\xb5\xe0\x87\xd5\xcc\xc7\xf5\xbfwv\xfe\b=\xaeg\xbe\xb0\xd8\xdd\x15{a\x01\x7fe\x03~i\x05~m\a\x7fq\t~u\v\x7fy\r~}\x0f_\xc0\x00^\xc2\x01_\xc4\x02_\xc6\x03^\xc8\x04_\xca\x05_\xcc\x06^\xce\a_\xd0\b_\xd2\t_\xd4\n^\xd6\v_\xd8\f_\xda\r_\xdc\x0e_\xde\x0f\xdf\xe0\x00\xdf\xe2\x81\xc0\x9b\\\x10|\x9b\x0f|\xa3\x13\x02o\xf5B\xf0\xcdn\b\xbc\xdd\x0f\xc17<\"\xf0\x96O/\x02lӫw\"`\xdb~}\f\xa0\x8d\xcf>\x04\xd8\xd6o\x1f\x03h\xf3\xfb>\x84\xe0\xf6\xff\xbfN\xd2\xc9+\xf1{\x02!\x00\x00\x00%tEXtdate:create\x002023-01-19T01:37:03+00:00/\x9b0\x17\x00\x00\x00%tEXtdate:modify\x002023-01-19T01:36:39+00:00\x9b\xfb\xbb8\x00\x00\x00\x00IEND\xaeB`\x82"), } +var ResCastSvg = &fyne.StaticResource{ + StaticName: "cast.svg", + StaticContent: []byte( + "\n\n \n"), +} var ResDiscSvg = &fyne.StaticResource{ StaticName: "disc.svg", StaticContent: []byte( diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh index a963c50..67c52fa 100755 --- a/res/bundled_gen.sh +++ b/res/bundled_gen.sh @@ -3,6 +3,7 @@ fyne bundle -package res -prefix Res appicon-256.png > bundled.go fyne bundle -append -prefix Res icons/coreui/playlist-add-next.svg >> bundled.go fyne bundle -append -prefix Res icons/freepik/playbutton.png >> bundled.go +fyne bundle -append -prefix Res icons/publicdomain/cast.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/disc.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/headphones.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go diff --git a/res/icons/publicdomain/cast.svg b/res/icons/publicdomain/cast.svg new file mode 100644 index 0000000..0a32d9c --- /dev/null +++ b/res/icons/publicdomain/cast.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 80da01b..cdccac4 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -3,6 +3,7 @@ package ui import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/util" @@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.OnVolumeChange(func(vol int) { fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) }) }) + pm.OnPlayerChange(func() { + _, local := pm.CurrentPlayer().(*mpv.Player) + fyne.Do(func() { bp.AuxControls.SetIsRemotePlayer(!local) }) + }) bp.AuxControls.VolumeControl.OnSetVolume = func(v int) { pm.SetVolume(v) } @@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.SetAutoplay(autoplay) } bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue) + bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu) bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index d08dfde..c9fff4b 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -151,6 +151,26 @@ func (m *Controller) HaveModal() bool { return m.haveModal } +func (m *Controller) ShowCastMenu() { + devices := m.App.PlaybackManager.RemotePlayers() + menu := fyne.NewMenu("") + menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Local player"), func() { + m.App.PlaybackManager.SetRemotePlayer(nil) + })) + for _, d := range devices { + _d := d + menu.Items = append(menu.Items, fyne.NewMenuItem(d.Name, func() { + m.App.PlaybackManager.SetRemotePlayer(&_d) + })) + } + pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) + canvasSize := m.MainWindow.Canvas().Size() + pop.ShowAtPosition(fyne.NewPos( + canvasSize.Width-pop.MinSize().Width-10, + canvasSize.Height-pop.MinSize().Height-100, + )) +} + func (m *Controller) ShowPopUpPlayQueue() { m.popUpQueueMutex.Lock() if m.popUpQueue == nil { diff --git a/ui/theme/theme.go b/ui/theme/theme.go index a9a1bcc..5df2935 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -39,6 +39,7 @@ var ( AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg) + CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg) RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index ce17cd5..94b4ff4 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -25,6 +25,7 @@ type AuxControls struct { VolumeControl *VolumeControl autoplay *IconButton loop *IconButton + cast *IconButton showQueue *IconButton container *fyne.Container @@ -35,10 +36,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial VolumeControl: NewVolumeControl(initialVolume), autoplay: NewIconButton(myTheme.AutoplayIcon, nil), loop: NewIconButton(myTheme.RepeatIcon, nil), + cast: NewIconButton(myTheme.CastIcon, nil), showQueue: NewIconButton(myTheme.PlayQueueIcon, nil), } + a.loop.IconSize = IconButtonSizeSmaller a.loop.SetToolTip(lang.L("Repeat")) + a.SetLoopMode(initialLoopMode) + + a.cast.IconSize = IconButtonSizeSmaller + a.cast.SetToolTip(lang.L("Cast to device")) + a.autoplay.Highlighted = initialAutoplay //a.autoplay.IconSize = IconButtonSizeSmaller a.autoplay.SetToolTip(lang.L("Autoplay")) @@ -48,9 +56,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial a.OnChangeAutoplay(a.autoplay.Highlighted) } } - a.SetLoopMode(initialLoopMode) + a.showQueue.IconSize = IconButtonSizeSmaller a.showQueue.SetToolTip(lang.L("Show play queue")) + a.container = container.NewHBox( layout.NewSpacer(), container.NewVBox( @@ -58,7 +67,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial a.VolumeControl, container.New( layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5), - layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)), + layout.NewSpacer(), a.autoplay, a.loop, a.cast, a.showQueue, util.NewHSpace(5)), layout.NewSpacer(), ), ) @@ -88,6 +97,11 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { } } +func (a *AuxControls) SetIsRemotePlayer(isRemote bool) { + a.cast.Highlighted = isRemote + a.cast.Refresh() +} + func (a *AuxControls) SetAutoplay(autoplay bool) { if autoplay == a.autoplay.Highlighted { return @@ -100,6 +114,10 @@ func (a *AuxControls) OnShowPlayQueue(f func()) { a.showQueue.OnTapped = f } +func (a *AuxControls) OnShowCastMenu(f func()) { + a.cast.OnTapped = f +} + type volumeSlider struct { widget.Slider From 5a37b1e39fdd54809426ee8495b4164fb49f81f8 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 15 Mar 2025 14:12:05 -0700 Subject: [PATCH 04/24] gapless DLNA working --- backend/app.go | 4 +- backend/playbackengine.go | 46 ++++++---- backend/player/dlna/dlnaplayer.go | 148 +++++++++++++++++++++--------- backend/player/mpv/player.go | 5 +- backend/player/player.go | 4 +- ui/browsing/nowplayingpage.go | 4 +- 6 files changed, 143 insertions(+), 68 deletions(-) diff --git a/backend/app.go b/backend/app.go index f8e126c..d2fe784 100644 --- a/backend/app.go +++ b/backend/app.go @@ -404,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }) a.PlaybackManager.OnSeek(func() { dur := a.PlaybackManager.NowPlaying().Metadata().Duration - smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) + smtc.UpdatePosition(int(a.PlaybackManager.PlayerStatus().TimePos*1000), dur*1000) }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) @@ -465,7 +465,7 @@ func (a *App) Shutdown() { a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() - //a.LocalPlayer.Destroy() + a.LocalPlayer.Destroy() } func (a *App) SavePlayQueueIfEnabled() { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 397515b..ecd7719 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -54,9 +54,13 @@ type playbackEngine struct { loopMode LoopMode // flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks - wasStopped bool // true iff player was stopped before handleOnTrackChange invocation - 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 + wasStopped bool // true iff player was stopped before handleOnTrackChange invocation + alreadyScrobbled bool // true iff the previously-playing track was already scrobbled + + // if >= 0, track number that was requested by PlayTrackAt + // onTrackChange callback should set nowPlayingIdx to this, + // and reset this to -1 + pendingTrackChangeNum int pendingPlayerChange bool pendingPlayerChangeTimePos float64 @@ -136,7 +140,7 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause := false - stat := p.player.GetStatus() + stat := p.PlayerStatus() switch stat.State { case player.Stopped: // nothing @@ -149,6 +153,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause = true } + oldVol := p.player.GetVolume() p.player = pl p.registerPlayerCallbacks(pl) @@ -158,6 +163,12 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) + vol := pl.GetVolume() + if oldVol != vol { + for _, cb := range p.onVolumeChange { + cb(vol) + } + } } func (p *playbackEngine) PlayTrackAt(idx int) error { @@ -167,11 +178,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { // scrobble current track if needed p.checkScrobble() p.alreadyScrobbled = true - p.noIncrementNextTrackChange = true + p.pendingTrackChangeNum = idx err := p.setTrack(idx, false) - if err == nil { - p.nowPlayingIdx = idx - } return err } @@ -222,14 +230,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer { } func (p *playbackEngine) SeekNext() error { - if p.CurrentPlayer().GetStatus().State == player.Stopped { + if p.PlayerStatus().State == player.Stopped { return nil } return p.PlayTrackAt(p.nowPlayingIdx + 1) } func (p *playbackEngine) SeekBackOrPrevious() error { - if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 { + if p.nowPlayingIdx == 0 || p.PlayerStatus().TimePos > 3 { return p.player.SeekSeconds(0) } return p.PlayTrackAt(p.nowPlayingIdx - 1) @@ -237,7 +245,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error { func (p *playbackEngine) SeekFwdBackN(n int) error { idx := p.nowPlayingIdx - if n < 0 && p.player.GetStatus().TimePos > 3 { + if n < 0 && p.PlayerStatus().TimePos > 3 { n += 1 // first seek back is just seek to beginning of current } if n == 0 || (idx == 0 && n < 0) { @@ -511,21 +519,23 @@ func (p *playbackEngine) handleOnTrackChange() { p.checkScrobble() } - if p.player.GetStatus().State == player.Playing { + if p.PlayerStatus().State == player.Playing { p.playTimeStopwatch.Start() } - if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) { + if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) { p.nowPlayingIdx++ if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { p.nowPlayingIdx = 0 // wrapped around } + } else if p.pendingTrackChangeNum >= 0 { + p.nowPlayingIdx = p.pendingTrackChangeNum + p.pendingTrackChangeNum = -1 } nowPlaying := p.playQueue[p.nowPlayingIdx] _, isRadio := nowPlaying.(*mediaprovider.RadioStation) p.isRadio = isRadio // reset flags - p.noIncrementNextTrackChange = false p.wasStopped = false p.alreadyScrobbled = false @@ -594,9 +604,11 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() { func (p *playbackEngine) setTrack(idx int, next bool) error { if urlP, ok := p.player.(player.URLPlayer); ok { url := "" + var meta mediaprovider.MediaItemMetadata if idx >= 0 { var err error item := p.playQueue[idx] + meta = item.Metadata() if tr, ok := item.(*mediaprovider.Track); ok { url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile) } else { @@ -607,9 +619,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { } } if next { - return urlP.SetNextFile(url) + return urlP.SetNextFile(url, meta) } - return urlP.PlayFile(url) + return urlP.PlayFile(url, meta) } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track if idx >= 0 { @@ -744,7 +756,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if p.callbacksDisabled { return } - s := p.player.GetStatus() + s := p.PlayerStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 23b6bb9..b013a29 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -2,6 +2,8 @@ package dlna import ( "context" + "crypto/md5" + "encoding/base64" "errors" "fmt" "io" @@ -9,12 +11,16 @@ import ( "net" "net/http" "os" + "strings" "sync" "sync/atomic" + "time" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" + "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" ) const ( @@ -28,10 +34,25 @@ var unimplemented = errors.New("unimplemented") type DLNAPlayer struct { player.BasePlayerCallbackImpl - avTransport *avtransport.Client + avTransport *avtransport.Client + renderControl *renderingcontrol.Client state int // stopped, playing, paused seeking bool + + curTrackMeta mediaprovider.MediaItemMetadata + nextTrackMeta mediaprovider.MediaItemMetadata + + lastSeekSecs int + seekedAt time.Time + + proxyServer *http.Server + proxyActive atomic.Bool + localIP string + proxyPort int + + proxyURLLock sync.Mutex + proxyURLs map[string]string } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { @@ -39,29 +60,38 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { if err != nil { return nil, err } - return &DLNAPlayer{avTransport: avt}, nil + rc, err := device.RenderingControlClient() + if err != nil { + return nil, err + } + return &DLNAPlayer{ + avTransport: avt, + renderControl: rc, + proxyURLs: make(map[string]string), + }, nil } func (d *DLNAPlayer) SetVolume(vol int) error { - return unimplemented + return d.renderControl.SetVolume(context.Background(), vol) } func (d *DLNAPlayer) GetVolume() int { - return 0 + vol, _ := d.renderControl.GetVolume(context.Background()) + return vol } -func (d *DLNAPlayer) PlayFile(urlstr string) error { - ensureSetupProxies() +func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { + d.ensureSetupProxy() - proxyURLLock.Lock() - dlnaProxyCurrent.url = urlstr - proxyURLLock.Unlock() + log.Println("playing track " + meta.Name) + + d.curTrackMeta = meta + key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ - URL: "http://" + localIP + ":8080/current", - Title: "Supersonic media item", + URL: d.urlForItem(key), + Title: meta.Name, } - log.Printf("URL %s", media.URL) err := d.avTransport.SetAVTransportMedia(context.Background(), &media) if err != nil { @@ -71,26 +101,41 @@ func (d *DLNAPlayer) PlayFile(urlstr string) error { return err } d.state = playing + d.seekedAt = time.Now() + d.lastSeekSecs = 0 d.InvokeOnPlaying() + d.InvokeOnTrackChange() return nil } -func (d *DLNAPlayer) SetNextFile(url string) error { +func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem + d.nextTrackMeta = meta if url != "" { - ensureSetupProxies() - - proxyURLLock.Lock() - dlnaProxyCurrent.url = url - proxyURLLock.Unlock() + d.ensureSetupProxy() + key := d.addURLToProxy(url) media = &avtransport.MediaItem{ - URL: "http://" + localIP + ":8080/next", + URL: d.urlForItem(key), + Title: meta.Name, } } return d.avTransport.SetNextAVTransportMedia(context.Background(), media) } +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + d.proxyURLs[key] = url + d.proxyURLLock.Unlock() + return key +} + +func (d *DLNAPlayer) urlForItem(key string) string { + return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) +} + func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err @@ -125,6 +170,8 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return err } d.seeking = false + d.seekedAt = time.Now() + d.lastSeekSecs = int(secs) d.InvokeOnSeek() return nil } @@ -141,10 +188,12 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - // TODO - the rest + time := time.Now().Sub(d.seekedAt) + time.Duration(d.lastSeekSecs)*time.Second return player.Status{ - State: state, + State: state, + TimePos: time.Seconds(), + Duration: float64(d.curTrackMeta.Duration), } } @@ -175,38 +224,51 @@ func getLocalIP() (string, error) { return "", fmt.Errorf("no suitable interface found") } -var ( - localIP string - proxyURLLock sync.Mutex - dlnaProxyCurrent proxy - dlnaProxyNext proxy - proxyActive atomic.Bool -) - -func ensureSetupProxies() { - if proxyActive.Swap(true) { - return // already active +func (d *DLNAPlayer) ensureSetupProxy() error { + if d.proxyActive.Swap(true) { + return nil // already active } - localIP, _ = getLocalIP() - log.Println(localIP) + var err error + d.localIP, err = getLocalIP() + if err != nil { + return err + } - mux := http.NewServeMux() - mux.HandleFunc("/current", dlnaProxyCurrent.handleRequest) - mux.HandleFunc("/next", dlnaProxyNext.handleRequest) - go http.ListenAndServe(":8080", mux) + listener, err := net.Listen("tcp", ":0") + if err != nil { + return err + } + d.proxyPort = listener.Addr().(*net.TCPAddr).Port + + d.proxyServer = &http.Server{ + Handler: http.HandlerFunc(d.handleRequest), + } + + go d.proxyServer.Serve(listener) + return nil } type proxy struct { url string } -func (p *proxy) handleRequest(w http.ResponseWriter, r *http.Request) { +func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { + var url string + key := strings.TrimPrefix(r.URL.Path, "/") + d.proxyURLLock.Lock() + if u, ok := d.proxyURLs[key]; ok { + url = u + } + d.proxyURLLock.Unlock() + + if url == "" { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404")) + return + } + // Create a new request to the target server - proxyURLLock.Lock() - url := p.url - proxyURLLock.Unlock() - log.Println("Got request for " + url) proxyReq, err := http.NewRequest(r.Method, url, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index e97535b..4b2ab55 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -7,6 +7,7 @@ import ( "math" "strconv" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/supersonic-app/go-mpv" ) @@ -133,7 +134,7 @@ func (p *Player) Init(maxCacheMB int) error { } // Plays the specified file, clearing the previous play queue, if any. -func (p *Player) PlayFile(url string) error { +func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata) error { if !p.initialized { return ErrUnitialized } @@ -169,7 +170,7 @@ func (p *Player) Stop() error { return err } -func (p *Player) SetNextFile(url string) error { +func (p *Player) SetNextFile(url string, _ mediaprovider.MediaItemMetadata) error { if p.lenPlaylist > p.curPlaylistPos+1 { if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil { return err diff --git a/backend/player/player.go b/backend/player/player.go index ec65dcf..eb855cc 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -4,8 +4,8 @@ import "github.com/dweymouth/supersonic/backend/mediaprovider" type URLPlayer interface { BasePlayer - PlayFile(url string) error - SetNextFile(url string) error + PlayFile(url string, metadata mediaprovider.MediaItemMetadata) error + SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error } type TrackPlayer interface { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index fd331f0..ae539e5 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() { } switch a.tabs.SelectedIndex() { case 1: /*lyrics*/ - a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos + a.lastPlayPos = a.pm.PlayerStatus().TimePos a.updateLyrics() case 2: /*related*/ a.updateRelatedList() @@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) { func (a *NowPlayingPage) formatStatusLine() { curPlayer := a.pm.CurrentPlayer() - playerStats := curPlayer.GetStatus() + playerStats := a.pm.PlayerStatus() lastStatus := a.statusLabel.Text stopped := lang.L("Stopped") state := stopped From e5c8027a82c2938837f41bc72ed7b5455f192a27 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 15 Mar 2025 14:46:17 -0700 Subject: [PATCH 05/24] shutdown proxy server when switching back to local; debounce vol slider --- backend/playbackengine.go | 4 ++++ backend/player/dlna/dlnaplayer.go | 6 ++++++ backend/player/jukebox/jukeboxplayer.go | 2 ++ backend/player/player.go | 2 ++ ui/widgets/auxcontrols.go | 21 +++++++++++++++++++++ 5 files changed, 35 insertions(+) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index ecd7719..9ae9a5d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -9,6 +9,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/sharedutil" ) @@ -154,6 +155,9 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { } oldVol := p.player.GetVolume() + if _, isMPV := p.player.(*mpv.Player); !isMPV { + p.player.Destroy() + } p.player = pl p.registerPlayerCallbacks(pl) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index b013a29..b9410ac 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -197,6 +197,12 @@ func (d *DLNAPlayer) GetStatus() player.Status { } } +func (d *DLNAPlayer) Destroy() { + if d.proxyServer != nil { + go d.proxyServer.Shutdown(context.Background()) + } +} + func getLocalIP() (string, error) { interfaces, err := net.Interfaces() if err != nil { diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 3518beb..c3db780 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status { } } +func (j *JukeboxPlayer) Destroy() {} + func (j *JukeboxPlayer) startAndUpdateTime() error { beforeStart := time.Now() if err := j.provider.JukeboxStart(); err != nil { diff --git a/backend/player/player.go b/backend/player/player.go index eb855cc..9c941a5 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -27,6 +27,8 @@ type BasePlayer interface { GetStatus() Status + Destroy() + // Event API OnPaused(func()) OnStopped(func()) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index 94b4ff4..bb50172 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -2,6 +2,7 @@ package widgets import ( "math" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -163,6 +164,10 @@ type VolumeControl struct { muted bool lastVol int + setVolDebouncer func() + delaySetVolume bool + pendingVolume int + container *fyne.Container } @@ -178,6 +183,15 @@ func NewVolumeControl(initialVol int) *VolumeControl { v.slider.Orientation = widget.Horizontal v.slider.Value = float64(v.lastVol) v.slider.OnChanged = v.onChanged + + // for players that are slow to respond to volume changes + // (e.g. DLNA), delay responding to the SetVolume call + // to avoid hiccuping from callback "echoes" + v.setVolDebouncer = util.NewDebouncer(100*time.Millisecond, func() { + v.delaySetVolume = false + v.SetVolume(v.pendingVolume) + }) + v.container = container.NewHBox(container.NewCenter(v.icon), v.slider) return v } @@ -185,6 +199,11 @@ func NewVolumeControl(initialVol int) *VolumeControl { // Sets the volume that is displayed in the slider. // Does not invoke OnSetVolume callback. func (v *VolumeControl) SetVolume(vol int) { + if v.delaySetVolume { + v.pendingVolume = vol + return + } + if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) { return } @@ -195,6 +214,8 @@ func (v *VolumeControl) SetVolume(vol int) { func (v *VolumeControl) onChanged(volume float64) { vol := int(volume) + v.delaySetVolume = true + v.setVolDebouncer() v.lastVol = vol v.muted = false v.updateIconForVolume(vol) From 436df0f1996c28b5494547071960a05b07546d9c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 16 Mar 2025 08:54:18 -0700 Subject: [PATCH 06/24] reorganize; add stopwatch to dlna player for play time tracking --- backend/playbackengine.go | 2 + backend/player/dlna/dlnaplayer.go | 80 ++++++++++++------------------- backend/util/util.go | 29 +++++++++++ 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 9ae9a5d..3cd5854 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -163,6 +163,8 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { if needToUnpause { p.PlayTrackAt(p.nowPlayingIdx) + // TODO - find a better way to wait until the players are ready + time.Sleep(200 * time.Millisecond) p.SeekSeconds(p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index b9410ac..49279c4 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -18,6 +18,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/util" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" @@ -43,8 +44,8 @@ type DLNAPlayer struct { curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata - lastSeekSecs int - seekedAt time.Time + lastStartTime int + stopwatch util.Stopwatch proxyServer *http.Server proxyActive atomic.Bool @@ -101,8 +102,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return err } d.state = playing - d.seekedAt = time.Now() - d.lastSeekSecs = 0 + d.stopwatch.Reset() + d.stopwatch.Start() + d.lastStartTime = 0 d.InvokeOnPlaying() d.InvokeOnTrackChange() return nil @@ -123,24 +125,12 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat return d.avTransport.SetNextAVTransportMedia(context.Background(), media) } -func (d *DLNAPlayer) addURLToProxy(url string) string { - hash := md5.Sum([]byte(url)) - key := base64.StdEncoding.EncodeToString(hash[:]) - d.proxyURLLock.Lock() - d.proxyURLs[key] = url - d.proxyURLLock.Unlock() - return key -} - -func (d *DLNAPlayer) urlForItem(key string) string { - return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) -} - func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err } d.state = playing + d.stopwatch.Start() d.InvokeOnPlaying() return nil } @@ -149,6 +139,7 @@ func (d *DLNAPlayer) Pause() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.stopwatch.Stop() d.state = paused d.InvokeOnPaused() return nil @@ -158,6 +149,8 @@ func (d *DLNAPlayer) Stop() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.stopwatch.Reset() + d.lastStartTime = 0 d.state = stopped d.InvokeOnStopped() return nil @@ -170,8 +163,13 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return err } d.seeking = false - d.seekedAt = time.Now() - d.lastSeekSecs = int(secs) + + d.lastStartTime = int(secs) + d.stopwatch.Reset() + if d.state == playing { + d.stopwatch.Start() + } + d.InvokeOnSeek() return nil } @@ -188,7 +186,7 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - time := time.Now().Sub(d.seekedAt) + time.Duration(d.lastSeekSecs)*time.Second + time := time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() return player.Status{ State: state, @@ -203,40 +201,13 @@ func (d *DLNAPlayer) Destroy() { } } -func getLocalIP() (string, error) { - interfaces, err := net.Interfaces() - if err != nil { - return "", err - } - - for _, iface := range interfaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - - addrs, err := iface.Addrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipnet, ok := addr.(*net.IPNet) - if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String(), nil - } - } - } - - return "", fmt.Errorf("no suitable interface found") -} - func (d *DLNAPlayer) ensureSetupProxy() error { if d.proxyActive.Swap(true) { return nil // already active } var err error - d.localIP, err = getLocalIP() + d.localIP, err = util.GetLocalIP() if err != nil { return err } @@ -255,8 +226,17 @@ func (d *DLNAPlayer) ensureSetupProxy() error { return nil } -type proxy struct { - url string +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + d.proxyURLs[key] = url + d.proxyURLLock.Unlock() + return key +} + +func (d *DLNAPlayer) urlForItem(key string) string { + return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) } func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { diff --git a/backend/util/util.go b/backend/util/util.go index 9111db8..bf573c8 100644 --- a/backend/util/util.go +++ b/backend/util/util.go @@ -1,7 +1,9 @@ package util import ( + "fmt" "io" + "net" "os" ) @@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error { _, err = io.Copy(fout, fin) return err } + +func GetLocalIP() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + + return "", fmt.Errorf("no suitable interface found") +} From 417b1b262d5807378b606021768fc6229112bad5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 16 Mar 2025 10:21:27 -0700 Subject: [PATCH 07/24] add timer to fire track change event --- backend/player/dlna/dlnaplayer.go | 101 +++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 49279c4..5886af6 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,6 +41,7 @@ type DLNAPlayer struct { state int // stopped, playing, paused seeking bool + metaLock sync.Mutex curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata @@ -54,6 +55,10 @@ type DLNAPlayer struct { proxyURLLock sync.Mutex proxyURLs map[string]string + + timerActive atomic.Bool + timer *time.Timer + resetChan chan (time.Duration) } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { @@ -69,6 +74,7 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { avTransport: avt, renderControl: rc, proxyURLs: make(map[string]string), + resetChan: make(chan time.Duration), }, nil } @@ -86,7 +92,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat log.Println("playing track " + meta.Name) + d.metaLock.Lock() d.curTrackMeta = meta + d.metaLock.Unlock() key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ @@ -102,6 +110,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return err } d.state = playing + d.setTrackChangeTimer(time.Duration(meta.Duration) * time.Second) d.stopwatch.Reset() d.stopwatch.Start() d.lastStartTime = 0 @@ -112,7 +121,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem + d.metaLock.Lock() d.nextTrackMeta = meta + d.metaLock.Unlock() if url != "" { d.ensureSetupProxy() @@ -129,7 +140,11 @@ func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err } + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos() + d.metaLock.Unlock() d.state = playing + d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() d.InvokeOnPlaying() return nil @@ -139,6 +154,7 @@ func (d *DLNAPlayer) Pause() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.setTrackChangeTimer(0) d.stopwatch.Stop() d.state = paused d.InvokeOnPaused() @@ -149,6 +165,7 @@ func (d *DLNAPlayer) Stop() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.setTrackChangeTimer(0) d.stopwatch.Reset() d.lastStartTime = 0 d.state = stopped @@ -164,6 +181,10 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { } d.seeking = false + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + d.metaLock.Unlock() + d.setTrackChangeTimer(nextTrackChange) d.lastStartTime = int(secs) d.stopwatch.Reset() if d.state == playing { @@ -186,15 +207,17 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - time := time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() - return player.Status{ State: state, - TimePos: time.Seconds(), + TimePos: d.curPlayPos().Seconds(), Duration: float64(d.curTrackMeta.Duration), } } +func (d *DLNAPlayer) curPlayPos() time.Duration { + return time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() +} + func (d *DLNAPlayer) Destroy() { if d.proxyServer != nil { go d.proxyServer.Shutdown(context.Background()) @@ -235,6 +258,78 @@ func (d *DLNAPlayer) addURLToProxy(url string) string { return key } +func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { + if d.timerActive.Swap(true) { + // was active + log.Println("timer was active") + d.resetChan <- dur + log.Println("and reset") + return + } + if dur == 0 { + d.timerActive.Store(false) + return + } + log.Println("starting timer") + + d.timer = time.NewTimer(dur) + go func() { + for { + select { + case dur := <-d.resetChan: + if dur == 0 { + d.timerActive.Store(false) + if !d.timer.Stop() { + select { + case <-d.timer.C: + default: + } + } + d.timer = nil + return + } + // reset the timer + if !d.timer.Stop() { + select { + case <-d.timer.C: + default: + } + } + d.timer.Reset(dur) + case <-d.timer.C: + d.timerActive.Store(false) + d.timer = nil + d.handleOnTrackChange() + return + } + } + }() +} + +func (d *DLNAPlayer) handleOnTrackChange() { + stopping := false + d.metaLock.Lock() + if d.nextTrackMeta.ID == "" { + stopping = true + } + d.curTrackMeta = d.nextTrackMeta + d.nextTrackMeta = mediaprovider.MediaItemMetadata{} + nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second + d.metaLock.Unlock() + + if stopping { + d.lastStartTime = 0 + d.stopwatch.Reset() + d.InvokeOnStopped() + } else { + d.lastStartTime = 0 + d.stopwatch.Reset() + d.stopwatch.Start() + d.setTrackChangeTimer(nextTrackChange) + d.InvokeOnTrackChange() + } +} + func (d *DLNAPlayer) urlForItem(key string) string { return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) } From 7828614d913e1a2901b3fd5e64a01892ce9051ca Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 17 Mar 2025 08:53:40 -0700 Subject: [PATCH 08/24] add HTTP retries for DLNA player --- backend/app.go | 4 ++-- backend/playbackmanager.go | 4 ++-- backend/player/dlna/dlnaplayer.go | 7 +++++++ go.mod | 3 +++ go.sum | 4 ++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/app.go b/backend/app.go index d2fe784..6b233b8 100644 --- a/backend/app.go +++ b/backend/app.go @@ -153,16 +153,16 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // Periodically scan for remote players + go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 5 /*waitSec*/) 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.ScanRemotePlayers(a.bgrndCtx, 10 /*waitSec*/) } } }() diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index d66a580..60df166 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -96,8 +96,8 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } -func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context) { - devices, _ := device.SearchMediaRenderers(ctx, 10, services.AVTransport, services.RenderingControl) +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, waitSec int) { + devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) var discovered []remotePlayer for _, d := range devices { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 5886af6..aa51a0a 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -19,6 +19,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/util" + "github.com/hashicorp/go-retryablehttp" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" @@ -62,14 +63,20 @@ type DLNAPlayer struct { } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { + retry := retryablehttp.NewClient() + retry.RetryMax = 3 + cli := retry.StandardClient() + avt, err := device.AVTransportClient() if err != nil { return nil, err } + avt.HTTPClient = cli rc, err := device.RenderingControlClient() if err != nil { return nil, err } + rc.HTTPClient = cli return &DLNAPlayer{ avTransport: avt, renderControl: rc, diff --git a/go.mod b/go.mod index 9e487dc..93c831c 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,8 @@ require ( github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect github.com/h2non/filetype v1.1.3 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/koron/go-ssdp v0.0.4 // indirect @@ -57,4 +59,5 @@ require ( ) replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast + replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde diff --git a/go.sum b/go.sum index e8a2382..a28ff24 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= From 9ad40a1c2daeacda2d4bd8a2c5e21474f8a78cd7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 08:56:10 -0700 Subject: [PATCH 09/24] get rid of go.mod `replace` directive for local dev --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 93c831c..1fbe980 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast - replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde From ff455528d6c7507c4b82b618a529e38f5a2ab413 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 17:49:27 -0700 Subject: [PATCH 10/24] shorten retry time, clean up logging --- backend/playbackmanager.go | 8 ++++---- backend/player/dlna/dlnaplayer.go | 28 +++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 60df166..b4bece3 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -570,7 +570,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { if len(tr.ArtistIDs) > 0 { similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize) if err != nil { - log.Println("autoplay error: failed to get similar tracks: %v", err) + log.Printf("autoplay error: failed to get similar tracks: %v", err) } tracks = filterRecentlyPlayed(similar) } @@ -583,7 +583,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { } byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize) if err != nil { - log.Println("autoplay error: failed to get tracks by genre: %v", err) + log.Printf("autoplay error: failed to get tracks by genre: %v", err) } tracks = filterRecentlyPlayed(byGenre) if len(tracks) > 0 { @@ -598,7 +598,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { // fallback to random tracks random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize) if err != nil { - log.Println("autoplay error: failed to get random tracks: %v", err) + log.Printf("autoplay error: failed to get random tracks: %v", err) } tracks = filterRecentlyPlayed(random) } @@ -612,7 +612,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { func (p *PlaybackManager) runCmdQueue(ctx context.Context) { logIfErr := func(action string, err error) { if err != nil { - log.Println("Playback error (%s): %v", action, err) + log.Printf("Playback error (%s): %v", action, err) } } for { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index aa51a0a..cc32c5d 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -65,6 +65,8 @@ type DLNAPlayer struct { func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { retry := retryablehttp.NewClient() retry.RetryMax = 3 + retry.RetryWaitMin = 100 * time.Millisecond + retry.Logger = retryLogger{} cli := retry.StandardClient() avt, err := device.AVTransportClient() @@ -97,8 +99,6 @@ func (d *DLNAPlayer) GetVolume() int { func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { d.ensureSetupProxy() - log.Println("playing track " + meta.Name) - d.metaLock.Lock() d.curTrackMeta = meta d.metaLock.Unlock() @@ -268,16 +268,13 @@ func (d *DLNAPlayer) addURLToProxy(url string) string { func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { if d.timerActive.Swap(true) { // was active - log.Println("timer was active") d.resetChan <- dur - log.Println("and reset") return } if dur == 0 { d.timerActive.Store(false) return } - log.Println("starting timer") d.timer = time.NewTimer(dur) go func() { @@ -391,3 +388,24 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(os.Stderr, "Error copying response body:", err) } } + +type retryLogger struct{} + +func (retryLogger) Error(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Info(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Warn(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Debug(msg string, keysAndValues ...interface{}) { + // log only retries, not every request + if strings.Contains(msg, "retrying request") { + log.Println(msg, keysAndValues) + } +} From 6a7f06769db45799e1193569e861f79846411186 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 17:58:15 -0700 Subject: [PATCH 11/24] ensure casting playback stops when quitting app --- backend/app.go | 2 +- backend/playbackcommands.go | 16 ++++++++++++---- backend/playbackmanager.go | 7 +++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/app.go b/backend/app.go index 6b233b8..d2486bb 100644 --- a/backend/app.go +++ b/backend/app.go @@ -463,7 +463,7 @@ func (a *App) Shutdown() { a.WinSMTC.Shutdown() } a.PlaybackManager.DisableCallbacks() - a.PlaybackManager.Stop() // will trigger scrobble check + a.PlaybackManager.Shutdown() // will trigger scrobble check a.cancel() a.LocalPlayer.Destroy() } diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index cb06459..2d40a1b 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -31,10 +31,11 @@ const ( ) type playbackCommand struct { - Type playbackCommandType - Arg any - Arg2 any - Arg3 any + Type playbackCommandType + Arg any + Arg2 any + Arg3 any + OnDone func() } // playbackCommandQueue is a queue to accumulate player commands from the UI @@ -65,6 +66,13 @@ func (c *playbackCommandQueue) Stop() { playbackCommand{Type: cmdStop}) } +func (c *playbackCommandQueue) StopAndWait() { + done := make(chan struct{}) + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + playbackCommand{Type: cmdStop, OnDone: func() { close(done) }}) + <-done +} + func (c *playbackCommandQueue) Continue() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, playbackCommand{Type: cmdContinue}) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index b4bece3..bf24a9a 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -512,6 +512,10 @@ func (p *PlaybackManager) Stop() { p.cmdQueue.Stop() } +func (p *PlaybackManager) Shutdown() { + p.cmdQueue.StopAndWait() +} + func (p *PlaybackManager) Pause() { p.cmdQueue.Pause() } @@ -662,6 +666,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) { mpv.ForceRestartPlayback() } } + if c.OnDone != nil { + c.OnDone() + } } } } From d4c1b560d59f215e401bf3ee61886c8b95ac3c8e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 18:11:58 -0700 Subject: [PATCH 12/24] do fast scan for remote players on startup --- backend/app.go | 4 ++-- backend/playbackmanager.go | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app.go b/backend/app.go index d2486bb..55ca333 100644 --- a/backend/app.go +++ b/backend/app.go @@ -153,7 +153,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // Periodically scan for remote players - go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 5 /*waitSec*/) + go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/) go func() { t := time.NewTicker(5 * time.Minute) for { @@ -162,7 +162,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas t.Stop() return case <-t.C: - a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 10 /*waitSec*/) + a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false) } } }() diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index bf24a9a..4d27e5f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -96,7 +96,15 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } -func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, waitSec int) { +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) { + if fastScan { + p.scanRemotePlayers(ctx, 1 /*waitSec*/) + // continue to slow scan to detect players that take longer to respond + } + p.scanRemotePlayers(ctx, 10 /*waitSec*/) +} + +func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) var discovered []remotePlayer From e4c187b4df7abbddd9b8cba733e86a1e7d9c6770 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 19 Mar 2025 17:14:12 -0700 Subject: [PATCH 13/24] keep only 3 most recently used proxy URLs active --- backend/player/dlna/dlnaplayer.go | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index cc32c5d..3127f8d 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -33,6 +33,11 @@ const ( var unimplemented = errors.New("unimplemented") +type proxyMapEntry struct { + key string + url string +} + type DLNAPlayer struct { player.BasePlayerCallbackImpl @@ -54,8 +59,12 @@ type DLNAPlayer struct { localIP string proxyPort int + // keep in order of most recently accessed at the end + // that way the item in proxyURLs[0] can be kicked out + // when adding a new URL to the proxy, since + // only two will need to be active at any given time + proxyURLs [3]proxyMapEntry proxyURLLock sync.Mutex - proxyURLs map[string]string timerActive atomic.Bool timer *time.Timer @@ -82,7 +91,6 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { return &DLNAPlayer{ avTransport: avt, renderControl: rc, - proxyURLs: make(map[string]string), resetChan: make(chan time.Duration), }, nil } @@ -256,15 +264,6 @@ func (d *DLNAPlayer) ensureSetupProxy() error { return nil } -func (d *DLNAPlayer) addURLToProxy(url string) string { - hash := md5.Sum([]byte(url)) - key := base64.StdEncoding.EncodeToString(hash[:]) - d.proxyURLLock.Lock() - d.proxyURLs[key] = url - d.proxyURLLock.Unlock() - return key -} - func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { if d.timerActive.Swap(true) { // was active @@ -339,13 +338,8 @@ func (d *DLNAPlayer) urlForItem(key string) string { } func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { - var url string key := strings.TrimPrefix(r.URL.Path, "/") - d.proxyURLLock.Lock() - if u, ok := d.proxyURLs[key]; ok { - url = u - } - d.proxyURLLock.Unlock() + url, _ := d.lookupProxyURL(key) if url == "" { w.WriteHeader(http.StatusNotFound) @@ -389,6 +383,52 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { } } +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + defer d.proxyURLLock.Unlock() + d._updateProxyURL(key, url) + return key +} + +// lookupProxyURL finds a URL by key and updates its position to most recently used +func (d *DLNAPlayer) lookupProxyURL(key string) (string, bool) { + d.proxyURLLock.Lock() + defer d.proxyURLLock.Unlock() + + for i := 0; i < len(d.proxyURLs); i++ { + if d.proxyURLs[i].key == key { + url := d.proxyURLs[i].url + // Move accessed entry to the most recent position + d._updateProxyURL(key, url) + return url, true + } + } + + return "", false +} + +func (d *DLNAPlayer) _updateProxyURL(key, url string) { + // Check if the key already exists, and if so, move it to the most recently used position + for i := 0; i < len(d.proxyURLs); i++ { + if d.proxyURLs[i].key == key { + if i < len(d.proxyURLs)-1 { + // Shift elements to the left from found position to the end + copy(d.proxyURLs[i:], d.proxyURLs[i+1:]) + } + // Place updated entry at the last position + d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url} + return + } + } + + // Shift all elements left to make room for the new entry at the end + copy(d.proxyURLs[:], d.proxyURLs[1:]) + // Insert new element at the most recent position + d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url} +} + type retryLogger struct{} func (retryLogger) Error(msg string, keysAndValues ...interface{}) { From 379309c27f933d9582ec991c7771a6b4cd7267d2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Mar 2025 19:52:26 -0700 Subject: [PATCH 14/24] handle case where SetNextAVTransportURI fails --- backend/player/dlna/dlnaplayer.go | 45 ++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 3127f8d..5bbca30 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -66,6 +66,14 @@ type DLNAPlayer struct { proxyURLs [3]proxyMapEntry proxyURLLock sync.Mutex + // If SetNextAVTransport fails (e.g. because the device + // does not support the API/gapless), this flag is set + // true, and the next firing of the track change timer + // should clear it to false and use SetAVTransport + // to begin playing the item in nextTrackMeta. + failedToSetNext bool + unsetNextMediaItem *avtransport.MediaItem + timerActive atomic.Bool timer *time.Timer resetChan chan (time.Duration) @@ -117,11 +125,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } - err := d.avTransport.SetAVTransportMedia(context.Background(), &media) - if err != nil { - return err - } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.playAVTransportMedia(&media); err != nil { return err } d.state = playing @@ -134,6 +138,17 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return nil } +func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error { + err := d.avTransport.SetAVTransportMedia(context.Background(), media) + if err != nil { + return err + } + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + return nil +} + func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem d.metaLock.Lock() @@ -148,7 +163,14 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } } - return d.avTransport.SetNextAVTransportMedia(context.Background(), media) + err := d.avTransport.SetNextAVTransportMedia(context.Background(), media) + if err != nil { + d.metaLock.Lock() + d.failedToSetNext = true + d.unsetNextMediaItem = media + d.metaLock.Unlock() + } + return err } func (d *DLNAPlayer) Continue() error { @@ -325,6 +347,17 @@ func (d *DLNAPlayer) handleOnTrackChange() { d.stopwatch.Reset() d.InvokeOnStopped() } else { + d.metaLock.Lock() + if d.failedToSetNext { + d.failedToSetNext = false + media := d.unsetNextMediaItem + d.unsetNextMediaItem = nil + d.metaLock.Unlock() + d.playAVTransportMedia(media) + } else { + d.metaLock.Unlock() + } + d.lastStartTime = 0 d.stopwatch.Reset() d.stopwatch.Start() From 31c7692ec4d0607e315ef226f8fc611b63ae67b7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 23 Mar 2025 17:20:26 -0700 Subject: [PATCH 15/24] seeking while paused stays paused --- backend/player/dlna/dlnaplayer.go | 66 +++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 5bbca30..778cf37 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -59,6 +59,9 @@ type DLNAPlayer struct { localIP string proxyPort int + pendingSeek bool + pendingSeekSecs float64 + // keep in order of most recently accessed at the end // that way the item in proxyURLs[0] can be kicked out // when adding a new URL to the proxy, since @@ -174,6 +177,18 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) Continue() error { + if d.state == playing { + return nil + } + + if d.pendingSeek { + d.pendingSeek = false + err := d.avTransport.Seek(context.Background(), int(d.pendingSeekSecs)) + if err != nil { + return err + } + } + if err := d.avTransport.Play(context.Background()); err != nil { return err } @@ -188,6 +203,10 @@ func (d *DLNAPlayer) Continue() error { } func (d *DLNAPlayer) Pause() error { + if d.state != playing { + return nil + } + if err := d.avTransport.Pause(context.Background()); err != nil { return err } @@ -199,32 +218,47 @@ func (d *DLNAPlayer) Pause() error { } func (d *DLNAPlayer) Stop() error { - if err := d.avTransport.Pause(context.Background()); err != nil { - return err + switch d.state { + case stopped: + return nil + case playing: + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + fallthrough + case paused: + d.setTrackChangeTimer(0) + d.stopwatch.Reset() + d.lastStartTime = 0 + d.state = stopped + d.InvokeOnStopped() + return nil + default: + return errors.New("invalid player state") } - d.setTrackChangeTimer(0) - d.stopwatch.Reset() - d.lastStartTime = 0 - d.state = stopped - d.InvokeOnStopped() - return nil } func (d *DLNAPlayer) SeekSeconds(secs float64) error { - d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + if d.state == paused { + d.pendingSeek = true + d.pendingSeekSecs = secs + } else { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } d.seeking = false - return err } - d.seeking = false - d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second - d.metaLock.Unlock() - d.setTrackChangeTimer(nextTrackChange) d.lastStartTime = int(secs) d.stopwatch.Reset() + if d.state == playing { + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + d.metaLock.Unlock() + d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() } From 90ddfb87b8b9e527fcbfbb8ecbc08aca1d57ee51 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 23 Mar 2025 17:42:00 -0700 Subject: [PATCH 16/24] don't unpause when switching players while paused --- backend/playbackengine.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 3cd5854..6dbd9e8 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -142,16 +142,20 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause := false stat := p.PlayerStatus() + if p.pendingPlayerChange { + stat.State = player.Paused + } + switch stat.State { case player.Stopped: // nothing case player.Playing: p.Pause() + needToUnpause = true fallthrough case player.Paused: p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true - needToUnpause = true } oldVol := p.player.GetVolume() From 084aa6cdba8934d811f4a8242a9d14c2bfda0cd2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 24 Mar 2025 18:00:21 -0700 Subject: [PATCH 17/24] show current player in casting menu, show error toasts on failures --- backend/playbackengine.go | 10 +++++++-- backend/playbackmanager.go | 34 ++++++++++++++++++++++--------- backend/player/dlna/dlnaplayer.go | 8 ++++++++ ui/controller/controller.go | 29 +++++++++++++++++++------- 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 6dbd9e8..2ed3eb8 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -3,6 +3,7 @@ package backend import ( "context" "errors" + "fmt" "log" "math/rand" "time" @@ -14,6 +15,8 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) +// TODO: make thread-safe + var ( ReplayGainNone = player.ReplayGainNone.String() ReplayGainAlbum = player.ReplayGainAlbum.String() @@ -138,7 +141,7 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { }) } -func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { +func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { needToUnpause := false stat := p.PlayerStatus() @@ -150,7 +153,9 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { case player.Stopped: // nothing case player.Playing: - p.Pause() + if err := p.Pause(); err != nil { + return fmt.Errorf("failed to pause: %v", err) + } needToUnpause = true fallthrough case player.Paused: @@ -179,6 +184,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { cb(vol) } } + return nil } func (p *playbackEngine) PlayTrackAt(idx int) error { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 4d27e5f..057ea8a 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -26,17 +26,19 @@ type PlaybackManager struct { cmdQueue *playbackCommandQueue cfg *AppConfig - localPlayer player.BasePlayer - remotePlayersLock sync.Mutex - remotePlayers []remotePlayer + localPlayer player.BasePlayer + remotePlayersLock sync.Mutex + remotePlayers []RemotePlaybackDevice + currentRemotePlayer *RemotePlaybackDevice autoplay bool lastPlayTime float64 } -type remotePlayer struct { +type RemotePlaybackDevice struct { Name string + URL string Protocol string new func() (player.BasePlayer, error) } @@ -107,10 +109,11 @@ func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) - var discovered []remotePlayer + var discovered []RemotePlaybackDevice for _, d := range devices { - p := remotePlayer{ + p := RemotePlaybackDevice{ Name: d.FriendlyName, + URL: d.URL, Protocol: "DLNA", new: func() (player.BasePlayer, error) { return dlna.NewDLNAPlayer(d) @@ -124,16 +127,23 @@ func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { p.remotePlayersLock.Unlock() } -func (p *PlaybackManager) RemotePlayers() []remotePlayer { +func (p *PlaybackManager) RemotePlayers() []RemotePlaybackDevice { p.remotePlayersLock.Lock() players := p.remotePlayers p.remotePlayersLock.Unlock() return players } -func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { +func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice { + return p.currentRemotePlayer +} + +func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error { if rp == nil { - p.engine.SetPlayer(p.localPlayer) + if err := p.engine.SetPlayer(p.localPlayer); err != nil { + return err + } + p.currentRemotePlayer = nil return nil } @@ -141,7 +151,11 @@ func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { if err != nil { return err } - p.engine.SetPlayer(player) + if err := p.engine.SetPlayer(player); err != nil { + return err + } + + p.currentRemotePlayer = rp return nil } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 778cf37..cb28591 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -99,6 +99,14 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { return nil, err } rc.HTTPClient = cli + + // ping to test connectivity + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := avt.GetTransportInfo(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to %s", device.FriendlyName) + } + return &DLNAPlayer{ avTransport: avt, renderControl: rc, diff --git a/ui/controller/controller.go b/ui/controller/controller.go index c9fff4b..5bd0a44 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -152,16 +152,31 @@ func (m *Controller) HaveModal() bool { } func (m *Controller) ShowCastMenu() { + rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() - menu := fyne.NewMenu("") - menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Local player"), func() { - m.App.PlaybackManager.SetRemotePlayer(nil) - })) + local := fyne.NewMenuItem(lang.L("Local player"), func() { + go func() { + if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { + fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") }) + } + }() + }) + local.Icon = theme.ComputerIcon() + local.Checked = rp == nil + + menu := fyne.NewMenu("", local) for _, d := range devices { _d := d - menu.Items = append(menu.Items, fyne.NewMenuItem(d.Name, func() { - m.App.PlaybackManager.SetRemotePlayer(&_d) - })) + item := fyne.NewMenuItem(d.Name, func() { + go func() { + if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { + fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) }) + } + }() + }) + item.Icon = myTheme.CastIcon + item.Checked = rp != nil && _d.URL == rp.URL + menu.Items = append(menu.Items, item) } pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) canvasSize := m.MainWindow.Canvas().Size() From f9c9f290bd6bd82ded88452aac3c981b99685e9e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 15:57:34 -0700 Subject: [PATCH 18/24] seek to correct position when switching players --- backend/playbackengine.go | 21 ++++++++------- backend/player/dlna/dlnaplayer.go | 31 +++++++++++++++++----- backend/player/jukebox/jukeboxplayer.go | 2 +- backend/player/mpv/player.go | 35 ++++++++++++++++++------- backend/player/player.go | 4 +-- 5 files changed, 63 insertions(+), 30 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 2ed3eb8..13a08d1 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -171,10 +171,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.registerPlayerCallbacks(pl) if needToUnpause { - p.PlayTrackAt(p.nowPlayingIdx) - // TODO - find a better way to wait until the players are ready - time.Sleep(200 * time.Millisecond) - p.SeekSeconds(p.pendingPlayerChangeTimePos) + p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) @@ -188,6 +185,10 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { } func (p *playbackEngine) PlayTrackAt(idx int) error { + return p.playTrackAt(idx, 0) +} + +func (p *playbackEngine) playTrackAt(idx int, startTime float64) error { if idx < 0 || idx >= len(p.playQueue) { return errors.New("track index out of range") } @@ -195,7 +196,7 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { p.checkScrobble() p.alreadyScrobbled = true p.pendingTrackChangeNum = idx - err := p.setTrack(idx, false) + err := p.setTrack(idx, false, startTime) return err } @@ -475,7 +476,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { p.Stop() } else { p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player - p.setTrack(newNowPlaying, false) + p.setTrack(newNowPlaying, false, 0) } // setNextTrack and onSongChange callbacks will be handled // when we receive new track event from player @@ -617,7 +618,7 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() { } } -func (p *playbackEngine) setTrack(idx int, next bool) error { +func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { if urlP, ok := p.player.(player.URLPlayer); ok { url := "" var meta mediaprovider.MediaItemMetadata @@ -637,7 +638,7 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { if next { return urlP.SetNextFile(url, meta) } - return urlP.PlayFile(url, meta) + return urlP.PlayFile(url, meta, startTime) } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track if idx >= 0 { @@ -649,13 +650,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { if next { return trP.SetNextTrack(track) } - return trP.PlayTrack(track) + return trP.PlayTrack(track, startTime) } panic("Unsupported player type") } func (p *playbackEngine) setNextTrack(idx int) error { - return p.setTrack(idx, true) + return p.setTrack(idx, true, 0) } // call BEFORE updating p.nowPlayingIdx diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index cb28591..dd87e34 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -123,7 +123,7 @@ func (d *DLNAPlayer) GetVolume() int { return vol } -func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { +func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error { d.ensureSetupProxy() d.metaLock.Lock() @@ -139,13 +139,23 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat if err := d.playAVTransportMedia(&media); err != nil { return err } + if startTime > 0 { + // TODO: do something better than this!! + time.Sleep(2 * time.Second) + d.sendSeekCmd(startTime) + } d.state = playing - d.setTrackChangeTimer(time.Duration(meta.Duration) * time.Second) + remainingDur := meta.Duration - int(startTime) + d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second) d.stopwatch.Reset() d.stopwatch.Start() - d.lastStartTime = 0 + d.lastStartTime = int(startTime) d.InvokeOnPlaying() d.InvokeOnTrackChange() + if startTime > 0 { + d.InvokeOnSeek() + } + return nil } @@ -251,12 +261,9 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { d.pendingSeek = true d.pendingSeekSecs = secs } else { - d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { - d.seeking = false + if err := d.sendSeekCmd(secs); err != nil { return err } - d.seeking = false } d.lastStartTime = int(secs) @@ -274,6 +281,16 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return nil } +func (d *DLNAPlayer) sendSeekCmd(secs float64) error { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } + d.seeking = false + return nil +} + func (d *DLNAPlayer) IsSeeking() bool { return d.seeking } diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index c3db780..176f43d 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error { return nil } -func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error { +func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error { if err := j.provider.JukeboxSet(track.ID); err != nil { return err } diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 4b2ab55..9eb785b 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "strconv" + "sync" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" @@ -68,6 +69,9 @@ type Player struct { equalizer Equalizer peaksEnabled bool + fileLoadedLock sync.Mutex + fileLoadedSig *sync.Cond + bgCancel context.CancelFunc } @@ -80,10 +84,12 @@ func New() *Player { // Same as New, but sets the application name that mpv // reports to the system audio API. func NewWithClientName(c string) *Player { - return &Player{ + p := &Player{ vol: -1, // use 100 in Init clientName: c, } + p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock) + return p } // Initializes the Player and makes it ready for playback. @@ -134,18 +140,27 @@ func (p *Player) Init(maxCacheMB int) error { } // Plays the specified file, clearing the previous play queue, if any. -func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata) error { +func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTime float64) error { if !p.initialized { return ErrUnitialized } err := p.mpv.Command([]string{"loadfile", url, "replace"}) - if err == nil { - p.lenPlaylist = 1 - if p.status.State == player.Paused { - return p.Continue() - } + if err != nil { + return err + } + p.lenPlaylist = 1 + if p.status.State == player.Paused { + err = p.Continue() + } else { p.setState(player.Playing) } + if startTime > 0 { + p.fileLoadedLock.Lock() + p.fileLoadedSig.Wait() + p.fileLoadedLock.Unlock() + p.SeekSeconds(startTime) + } + return err } @@ -481,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) { } switch e.Event_Id { case mpv.EVENT_PLAYBACK_RESTART: - if p.seeking { - p.seeking = false - } + p.seeking = false case mpv.EVENT_SEEK: + p.seeking = false p.InvokeOnSeek() case mpv.EVENT_FILE_LOADED: p.curPlaylistPos, _ = p.getInt64Property("playlist-pos") @@ -494,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) { p.InvokeOnSeek() } p.InvokeOnTrackChange() + p.fileLoadedSig.Signal() case mpv.EVENT_IDLE: p.status.Duration = 0 p.status.TimePos = 0 diff --git a/backend/player/player.go b/backend/player/player.go index 9c941a5..201e549 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -4,13 +4,13 @@ import "github.com/dweymouth/supersonic/backend/mediaprovider" type URLPlayer interface { BasePlayer - PlayFile(url string, metadata mediaprovider.MediaItemMetadata) error + PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error } type TrackPlayer interface { BasePlayer - PlayTrack(track *mediaprovider.Track) error + PlayTrack(track *mediaprovider.Track, startTime float64) error SetNextTrack(track *mediaprovider.Track) error } From a0c97f0be1457654f7617c4096e00b545bc67b21 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 16:03:27 -0700 Subject: [PATCH 19/24] signal correct play pos to OS when switching back to MPV player --- backend/player/mpv/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 9eb785b..bef95fe 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -496,7 +496,7 @@ func (p *Player) eventHandler(ctx context.Context) { } switch e.Event_Id { case mpv.EVENT_PLAYBACK_RESTART: - p.seeking = false + fallthrough case mpv.EVENT_SEEK: p.seeking = false p.InvokeOnSeek() From 447519c413144b8b432b592ee92cc428887a823c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 16:15:42 -0700 Subject: [PATCH 20/24] disable cast button while player switch in progress --- backend/playbackengine.go | 4 ++++ ui/controller/controller.go | 4 +++- ui/widgets/auxcontrols.go | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 13a08d1..352ac25 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -142,6 +142,10 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { } func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { + // even if we don't successfully change players, + // make sure UI updates if needed (eg enabling cast button) + defer p.invokeNoArgCallbacks(p.onPlayerChange) + needToUnpause := false stat := p.PlayerStatus() diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 5bd0a44..a3d3186 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -151,10 +151,11 @@ func (m *Controller) HaveModal() bool { return m.haveModal } -func (m *Controller) ShowCastMenu() { +func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() local := fyne.NewMenuItem(lang.L("Local player"), func() { + onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") }) @@ -168,6 +169,7 @@ func (m *Controller) ShowCastMenu() { for _, d := range devices { _d := d item := fyne.NewMenuItem(d.Name, func() { + onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) }) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index bb50172..324bce1 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -98,7 +98,12 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { } } +func (a *AuxControls) DisableCastButton() { + a.cast.Disable() +} + func (a *AuxControls) SetIsRemotePlayer(isRemote bool) { + a.cast.Enable() a.cast.Highlighted = isRemote a.cast.Refresh() } @@ -115,8 +120,10 @@ func (a *AuxControls) OnShowPlayQueue(f func()) { a.showQueue.OnTapped = f } -func (a *AuxControls) OnShowCastMenu(f func()) { - a.cast.OnTapped = f +func (a *AuxControls) OnShowCastMenu(f func(func())) { + a.cast.OnTapped = func() { + f(a.DisableCastButton) + } } type volumeSlider struct { From 2701bb3f7f646f9d56e4ca268a2f32b09be5667f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 13:08:18 -0700 Subject: [PATCH 21/24] sync playback time when seeking and starting new tracks --- backend/player/dlna/dlnaplayer.go | 57 +++++++++++++++++++++++++++++-- go.mod | 10 +++--- go.sum | 20 +++++++---- res/translations/en.json | 1 + ui/controller/controller.go | 2 +- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index dd87e34..3f9c168 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,6 +41,8 @@ type proxyMapEntry struct { type DLNAPlayer struct { player.BasePlayerCallbackImpl + destroyed bool + avTransport *avtransport.Client renderControl *renderingcontrol.Client @@ -51,8 +53,13 @@ type DLNAPlayer struct { curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata + // if true, report playback time 00:00 + // pending time sync with player after beginning playback + pendingPlayStart bool + // start playback position in seconds of the last seek/time sync lastStartTime int - stopwatch util.Stopwatch + // how long the track has been playing since last time sync + stopwatch util.Stopwatch proxyServer *http.Server proxyActive atomic.Bool @@ -139,10 +146,22 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat if err := d.playAVTransportMedia(&media); err != nil { return err } + d.pendingPlayStart = true if startTime > 0 { // TODO: do something better than this!! time.Sleep(2 * time.Second) - d.sendSeekCmd(startTime) + if !d.destroyed { + d.sendSeekCmd(startTime) + } + d.pendingPlayStart = false + } else { + go func() { + time.Sleep(2 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + d.pendingPlayStart = false + }() } d.state = playing remainingDur := meta.Duration - int(startTime) @@ -278,6 +297,13 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { } d.InvokeOnSeek() + + go func() { + time.Sleep(4 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + }() return nil } @@ -303,9 +329,13 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } + var timePos float64 + if !d.pendingPlayStart { + timePos = d.curPlayPos().Seconds() + } return player.Status{ State: state, - TimePos: d.curPlayPos().Seconds(), + TimePos: timePos, Duration: float64(d.curTrackMeta.Duration), } } @@ -315,11 +345,25 @@ func (d *DLNAPlayer) curPlayPos() time.Duration { } func (d *DLNAPlayer) Destroy() { + d.destroyed = true if d.proxyServer != nil { go d.proxyServer.Shutdown(context.Background()) } } +func (d *DLNAPlayer) syncPlaybackTime() { + start := time.Now() + if pos, err := d.avTransport.GetPositionInfo(context.Background()); err == nil { + d.lastStartTime = int(pos.RelTime.Seconds() + (time.Since(start) / 2).Seconds()) + d.stopwatch.Reset() + if d.state == playing { + d.stopwatch.Start() + } + d.setTrackChangeTimer(time.Duration(d.curTrackMeta.Duration-d.lastStartTime) * time.Second) + d.InvokeOnSeek() + } +} + func (d *DLNAPlayer) ensureSetupProxy() error { if d.proxyActive.Swap(true) { return nil // already active @@ -422,6 +466,13 @@ func (d *DLNAPlayer) handleOnTrackChange() { d.stopwatch.Start() d.setTrackChangeTimer(nextTrackChange) d.InvokeOnTrackChange() + + go func() { + time.Sleep(5 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + }() } } diff --git a/go.mod b/go.mod index 1fbe980..91c3880 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dweymouth/supersonic -go 1.21 +go 1.21.9 require ( fyne.io/fyne/v2 v2.6.0-beta1 @@ -14,13 +14,14 @@ require ( github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 + github.com/hashicorp/go-retryablehttp v0.7.7 github.com/pelletier/go-toml/v2 v2.0.8 github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/go-mpv v0.1.0 github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d - github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce + github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 github.com/zalando/go-keyring v0.2.6 - golang.org/x/net v0.25.0 + golang.org/x/net v0.33.0 golang.org/x/sys v0.30.0 golang.org/x/text v0.22.0 ) @@ -42,10 +43,9 @@ require ( github.com/go-text/typesetting v0.2.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/koron/go-ssdp v0.0.4 // indirect + github.com/koron/go-ssdp v0.0.5 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index a28ff24..e4cd2e6 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde h1:vSJQkJW github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde/go.mod h1:ON11afuS9jVyN+nfEtITv3jX1H+JD+Z50gxCiEC1zLw= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= @@ -61,16 +63,22 @@ github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= -github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= +github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= @@ -105,8 +113,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0 github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= -github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce h1:6R8/JRwRVrR9t2YPTgikS39lRf3gPp/MPcbx+lIf3rA= -github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce/go.mod h1:Wscg4vEzF9x6i4ltL2Qkvx4VhzXfkBBl8uoG0YSAJMU= +github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 h1:aoUJKPFD/ZrNZjK6fl2Xhazwsttx42esKxhSSzEE3Bo= +github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5/go.mod h1:ibt19zDV5/vvF14jHJpTv3AOorq1EbmrMAubxnuvR5Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -123,8 +131,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/res/translations/en.json b/res/translations/en.json index 5c10897..d2cfee6 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -207,6 +207,7 @@ "Testing connection": "Testing connection", "The request timed out": "The request timed out", "Theme": "Theme", + "This computer": "This computer", "Time": "Time", "Title": "Title", "Title (A-Z)": "Title (A-Z)", diff --git a/ui/controller/controller.go b/ui/controller/controller.go index a3d3186..67dde41 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -154,7 +154,7 @@ func (m *Controller) HaveModal() bool { func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() - local := fyne.NewMenuItem(lang.L("Local player"), func() { + local := fyne.NewMenuItem(lang.L("This computer"), func() { onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { From 9001dc296f0667df6781942eafae829c9f80c2aa Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 13:51:50 -0700 Subject: [PATCH 22/24] keep hanging DLNA player from hanging Supersonic when quitting/switching players --- backend/playbackcommands.go | 6 ++ backend/playbackengine.go | 15 ++-- backend/playbackmanager.go | 1 + backend/player/dlna/dlnaplayer.go | 97 +++++++++++++++++++++---- backend/player/jukebox/jukeboxplayer.go | 2 +- backend/player/mpv/player.go | 2 +- backend/player/player.go | 6 +- 7 files changed, 102 insertions(+), 27 deletions(-) diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index 2d40a1b..2632fef 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -61,6 +61,12 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand { return c.nextChan } +func (c *playbackCommandQueue) Clear() { + c.mutex.Lock() + c.queue = nil + c.mutex.Unlock() +} + func (c *playbackCommandQueue) Stop() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, playbackCommand{Type: cmdStop}) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 352ac25..52b6e78 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -3,7 +3,6 @@ package backend import ( "context" "errors" - "fmt" "log" "math/rand" "time" @@ -157,15 +156,15 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { case player.Stopped: // nothing case player.Playing: - if err := p.Pause(); err != nil { - return fmt.Errorf("failed to pause: %v", err) - } needToUnpause = true fallthrough case player.Paused: p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true } + if err := p.player.Stop(true); err != nil { + log.Printf("failed to stop player: %v", err) + } oldVol := p.player.GetVolume() if _, isMPV := p.player.(*mpv.Player); !isMPV { @@ -293,7 +292,7 @@ func (p *playbackEngine) IsSeeking() bool { } func (p *playbackEngine) Stop() error { - return p.player.Stop() + return p.player.Stop(false) } func (p *playbackEngine) Pause() error { @@ -332,7 +331,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error { if insertQueueMode == Replace { - p.player.Stop() + p.player.Stop(false) p.nowPlayingIdx = -1 p.playQueue = nil } @@ -358,7 +357,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { if insertMode == Replace { - p.player.Stop() + p.player.Stop(false) p.nowPlayingIdx = -1 p.playQueue = nil } @@ -384,7 +383,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins // Stop playback and clear the play queue. func (p *playbackEngine) StopAndClearPlayQueue() { changed := len(p.playQueue) > 0 - p.player.Stop() + p.player.Stop(false) p.playQueue = nil p.nowPlayingIdx = -1 if changed { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 057ea8a..a6eb122 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -139,6 +139,7 @@ func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice { } func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error { + p.cmdQueue.Clear() if rp == nil { if err := p.engine.SetPlayer(p.localPlayer); err != nil { return err diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 3f9c168..939824f 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,7 +41,8 @@ type proxyMapEntry struct { type DLNAPlayer struct { player.BasePlayerCallbackImpl - destroyed bool + destroyed bool + cancelRequest context.CancelFunc avTransport *avtransport.Client renderControl *renderingcontrol.Client @@ -122,15 +123,31 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { } func (d *DLNAPlayer) SetVolume(vol int) error { - return d.renderControl.SetVolume(context.Background(), vol) + if d.destroyed { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + return d.renderControl.SetVolume(ctx, vol) } func (d *DLNAPlayer) GetVolume() int { - vol, _ := d.renderControl.GetVolume(context.Background()) + if d.destroyed { + return 0 + } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + vol, _ := d.renderControl.GetVolume(ctx) return vol } func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error { + if d.destroyed { + return nil + } + d.ensureSetupProxy() d.metaLock.Lock() @@ -179,17 +196,25 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error { - err := d.avTransport.SetAVTransportMedia(context.Background(), media) + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + + err := d.avTransport.SetAVTransportMedia(ctx, media) if err != nil { return err } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.avTransport.Play(ctx); err != nil { return err } return nil } func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { + if d.destroyed { + return nil + } + var media *avtransport.MediaItem d.metaLock.Lock() d.nextTrackMeta = meta @@ -203,7 +228,11 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } } - err := d.avTransport.SetNextAVTransportMedia(context.Background(), media) + + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + err := d.avTransport.SetNextAVTransportMedia(ctx, media) if err != nil { d.metaLock.Lock() d.failedToSetNext = true @@ -214,19 +243,23 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) Continue() error { - if d.state == playing { + if d.destroyed || d.state == playing { return nil } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + if d.pendingSeek { d.pendingSeek = false - err := d.avTransport.Seek(context.Background(), int(d.pendingSeekSecs)) + err := d.avTransport.Seek(ctx, int(d.pendingSeekSecs)) if err != nil { return err } } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.avTransport.Play(ctx); err != nil { return err } d.metaLock.Lock() @@ -240,11 +273,14 @@ func (d *DLNAPlayer) Continue() error { } func (d *DLNAPlayer) Pause() error { - if d.state != playing { + if d.destroyed || d.state != playing { return nil } - if err := d.avTransport.Pause(context.Background()); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + if err := d.avTransport.Pause(ctx); err != nil { return err } d.setTrackChangeTimer(0) @@ -254,12 +290,29 @@ func (d *DLNAPlayer) Pause() error { return nil } -func (d *DLNAPlayer) Stop() error { +func (d *DLNAPlayer) Stop(force bool) error { + if d.destroyed { + return nil + } + if force && d.cancelRequest != nil { + d.cancelRequest() + } + switch d.state { case stopped: return nil case playing: - if err := d.avTransport.Pause(context.Background()); err != nil { + var ctx context.Context + var cancel context.CancelFunc + if force { + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + d.cancelRequest = cancel + defer cancel() + + if err := d.avTransport.Pause(ctx); err != nil { return err } fallthrough @@ -276,6 +329,10 @@ func (d *DLNAPlayer) Stop() error { } func (d *DLNAPlayer) SeekSeconds(secs float64) error { + if d.destroyed { + return nil + } + if d.state == paused { d.pendingSeek = true d.pendingSeekSecs = secs @@ -309,7 +366,9 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { func (d *DLNAPlayer) sendSeekCmd(secs float64) error { d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := d.avTransport.Seek(ctx, int(secs)); err != nil { d.seeking = false return err } @@ -346,8 +405,16 @@ func (d *DLNAPlayer) curPlayPos() time.Duration { func (d *DLNAPlayer) Destroy() { d.destroyed = true + d.setTrackChangeTimer(0) + if d.cancelRequest != nil { + d.cancelRequest() + } + if d.proxyServer != nil { - go d.proxyServer.Shutdown(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + go d.proxyServer.Shutdown(ctx) + d.proxyServer = nil } } diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 176f43d..0dd63b8 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -68,7 +68,7 @@ func (j *JukeboxPlayer) Pause() error { return nil } -func (j *JukeboxPlayer) Stop() error { +func (j *JukeboxPlayer) Stop(_ bool) error { if j.state == stopped { return nil } diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index bef95fe..3c5965a 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -165,7 +165,7 @@ func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTi } // Stops playback and clears the play queue. -func (p *Player) Stop() error { +func (p *Player) Stop(_ bool) error { if !p.initialized { return ErrUnitialized } diff --git a/backend/player/player.go b/backend/player/player.go index 201e549..877fcea 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -1,6 +1,8 @@ package player -import "github.com/dweymouth/supersonic/backend/mediaprovider" +import ( + "github.com/dweymouth/supersonic/backend/mediaprovider" +) type URLPlayer interface { BasePlayer @@ -17,7 +19,7 @@ type TrackPlayer interface { type BasePlayer interface { Continue() error Pause() error - Stop() error + Stop(force bool) error SeekSeconds(secs float64) error IsSeeking() bool From 6e7480f48a4be0a20273295ba9f15e2d4ca792c9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 14:39:27 -0700 Subject: [PATCH 23/24] pass MIME type and seekable flag to DLNA players --- backend/mediaprovider/model.go | 2 ++ backend/playbackengine.go | 9 +++++++++ backend/player/dlna/dlnaplayer.go | 12 ++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index a39477d..bd7b4d7 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -183,6 +183,7 @@ const ( type MediaItemMetadata struct { Type MediaItemType + MIMEType string ID string Name string Artists []string @@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata { } return MediaItemMetadata{ Type: MediaItemTypeTrack, + MIMEType: t.ContentType, ID: t.ID, Name: t.Title, Artists: t.ArtistNames, diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 52b6e78..8ed8654 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -140,6 +140,14 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { }) } +func (p *playbackEngine) unregisterPlayerCallbacks(pl player.BasePlayer) { + pl.OnPaused(nil) + pl.OnPlaying(nil) + pl.OnStopped(nil) + pl.OnSeek(nil) + pl.OnTrackChange(nil) +} + func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { // even if we don't successfully change players, // make sure UI updates if needed (eg enabling cast button) @@ -162,6 +170,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true } + p.unregisterPlayerCallbacks(p.player) if err := p.player.Stop(true); err != nil { log.Printf("failed to stop player: %v", err) } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 939824f..0dcb38f 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -156,8 +156,10 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ - URL: d.urlForItem(key), - Title: meta.Name, + URL: d.urlForItem(key), + Title: meta.Name, + ContentType: meta.MIMEType, + Seekable: true, } if err := d.playAVTransportMedia(&media); err != nil { @@ -224,8 +226,10 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat key := d.addURLToProxy(url) media = &avtransport.MediaItem{ - URL: d.urlForItem(key), - Title: meta.Name, + URL: d.urlForItem(key), + ContentType: meta.MIMEType, + Title: meta.Name, + Seekable: true, } } From 28baf912f2dc48c415dba38501a78cbb3852bf76 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 5 Apr 2025 10:57:19 -0700 Subject: [PATCH 24/24] fix behavior of switching players while paused --- backend/app.go | 2 +- backend/mpmedia_mac.go | 6 ++--- backend/mpris.go | 12 ++++----- backend/playbackengine.go | 49 ++++++++++++++++++++++------------- backend/playbackmanager.go | 10 +++---- backend/savedplayqueue.go | 2 +- ui/browsing/nowplayingpage.go | 4 +-- ui/controller/controller.go | 9 ++++++- 8 files changed, 57 insertions(+), 37 deletions(-) diff --git a/backend/app.go b/backend/app.go index 55ca333..f76a58b 100644 --- a/backend/app.go +++ b/backend/app.go @@ -404,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }) a.PlaybackManager.OnSeek(func() { dur := a.PlaybackManager.NowPlaying().Metadata().Duration - smtc.UpdatePosition(int(a.PlaybackManager.PlayerStatus().TimePos*1000), dur*1000) + smtc.UpdatePosition(int(a.PlaybackManager.PlaybackStatus().TimePos*1000), dur*1000) }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index dff1fcc..e7616ee 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac }) mp.playbackManager.OnSeek(func() { - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) mp.playbackManager.OnPlaying(func() { C.set_os_playback_state_playing() - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) mp.playbackManager.OnPaused(func() { C.set_os_playback_state_paused() - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) return nil diff --git a/backend/mpris.go b/backend/mpris.go index 9d90da4..f2921a4 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { pm.OnSeek(func() { if m.connErr == nil { - pos := secondsToMicroseconds(pm.PlayerStatus().TimePos) + pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos) m.evt.Player.OnSeek(pos) } }) @@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error { } func (m *MPRISHandler) Pause() error { - if m.pm.PlayerStatus().State == player.Playing { + if m.pm.PlaybackStatus().State == player.Playing { m.pm.PlayPause() } return nil @@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error { } func (m *MPRISHandler) Play() error { - switch m.pm.PlayerStatus().State { + switch m.pm.PlaybackStatus().State { case player.Paused: m.pm.PlayPause() case player.Stopped: @@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error { } func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { - switch m.pm.PlayerStatus().State { + switch m.pm.PlaybackStatus().State { case player.Playing: return types.PlaybackStatusPlaying, nil case player.Paused: @@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { if m.curTrackPath != "" { trackObjPath = m.curTrackPath } - status := m.pm.PlayerStatus() + status := m.pm.PlaybackStatus() var meta mediaprovider.MediaItemMetadata // metadata that can come only from tracks @@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error { } func (m *MPRISHandler) Position() (int64, error) { - return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil + return int64(secondsToMicroseconds(m.pm.PlaybackStatus().TimePos)), nil } func (m *MPRISHandler) MinimumRate() (float64, error) { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 8ed8654..09e7192 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -40,6 +40,14 @@ const ( LoopOne ) +type PlaybackState = player.State + +type PlaybackStatus struct { + State PlaybackState + TimePos float64 + Duration float64 +} + type playbackEngine struct { ctx context.Context cancelPollPos context.CancelFunc @@ -65,8 +73,8 @@ type playbackEngine struct { // and reset this to -1 pendingTrackChangeNum int - pendingPlayerChange bool - pendingPlayerChangeTimePos float64 + pendingPlayerChange bool + pendingPlayerChangeStatus player.Status // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -155,7 +163,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { needToUnpause := false - stat := p.PlayerStatus() + stat := p.CurrentPlayer().GetStatus() if p.pendingPlayerChange { stat.State = player.Paused } @@ -165,9 +173,10 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { // nothing case player.Playing: needToUnpause = true + p.stopPollTimePos() fallthrough case player.Paused: - p.pendingPlayerChangeTimePos = stat.TimePos + p.pendingPlayerChangeStatus = stat p.pendingPlayerChange = true } p.unregisterPlayerCallbacks(p.player) @@ -183,7 +192,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.registerPlayerCallbacks(pl) if needToUnpause { - p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeTimePos) + p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos) p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) @@ -239,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode { return p.loopMode } -func (p *playbackEngine) PlayerStatus() player.Status { - return p.player.GetStatus() +func (p *playbackEngine) PlaybackStatus() PlaybackStatus { + stat := p.pendingPlayerChangeStatus + if !p.pendingPlayerChange { + stat = p.CurrentPlayer().GetStatus() + } + return PlaybackStatus{ + State: stat.State, + TimePos: stat.TimePos, + Duration: stat.Duration, + } } func (p *playbackEngine) SetVolume(vol int) error { @@ -259,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer { } func (p *playbackEngine) SeekNext() error { - if p.PlayerStatus().State == player.Stopped { + if p.PlaybackStatus().State == player.Stopped { return nil } return p.PlayTrackAt(p.nowPlayingIdx + 1) } func (p *playbackEngine) SeekBackOrPrevious() error { - if p.nowPlayingIdx == 0 || p.PlayerStatus().TimePos > 3 { + if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 { return p.player.SeekSeconds(0) } return p.PlayTrackAt(p.nowPlayingIdx - 1) @@ -274,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error { func (p *playbackEngine) SeekFwdBackN(n int) error { idx := p.nowPlayingIdx - if n < 0 && p.PlayerStatus().TimePos > 3 { + if n < 0 && p.PlaybackStatus().TimePos > 3 { n += 1 // first seek back is just seek to beginning of current } if n == 0 || (idx == 0 && n < 0) { @@ -310,15 +327,11 @@ 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 + return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos) } - if p.PlayerStatus().State == player.Stopped { + if p.PlaybackStatus().State == player.Stopped { return p.PlayTrackAt(0) } return p.player.Continue() @@ -548,7 +561,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.checkScrobble() } - if p.PlayerStatus().State == player.Playing { + if p.PlaybackStatus().State == player.Playing { p.playTimeStopwatch.Start() } if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) { @@ -785,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if p.callbacksDisabled { return } - s := p.PlayerStatus() + s := p.PlaybackStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index a6eb122..153760f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -85,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() { return } // workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment) - if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlayerStatus().State == player.Playing { + if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing { p.lastPlayTime = 0 go func() { time.Sleep(300 * time.Millisecond) @@ -476,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool { return p.autoplay } -func (p *PlaybackManager) PlayerStatus() player.Status { - return p.engine.PlayerStatus() +func (p *PlaybackManager) PlaybackStatus() PlaybackStatus { + return p.engine.PlaybackStatus() } func (p *PlaybackManager) SetVolume(vol int) { @@ -510,7 +510,7 @@ func (p *PlaybackManager) SeekSeconds(sec float64) { // Seek by given relative position in the current track by seconds. func (p *PlaybackManager) SeekBySeconds(sec float64) { - status := p.engine.PlayerStatus() + status := p.engine.PlaybackStatus() target := status.TimePos + sec if target < 0 { target = 0 @@ -548,7 +548,7 @@ func (p *PlaybackManager) Continue() { } func (p *PlaybackManager) PlayPause() { - switch p.engine.PlayerStatus().State { + switch p.engine.PlaybackStatus().State { case player.Playing: p.Pause() case player.Paused: diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index fd6e025..5efc0d8 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct { // If the provided CanSavePlayQueue server is non-nil, it will also save to the server. func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error { queue := pm.GetPlayQueue() - stats := pm.PlayerStatus() + stats := pm.PlaybackStatus() trackIdx := pm.NowPlayingIndex() trackIDs := make([]string, 0, len(queue)) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index ae539e5..bbb0a7f 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() { } switch a.tabs.SelectedIndex() { case 1: /*lyrics*/ - a.lastPlayPos = a.pm.PlayerStatus().TimePos + a.lastPlayPos = a.pm.PlaybackStatus().TimePos a.updateLyrics() case 2: /*related*/ a.updateRelatedList() @@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) { func (a *NowPlayingPage) formatStatusLine() { curPlayer := a.pm.CurrentPlayer() - playerStats := a.pm.PlayerStatus() + playerStats := a.pm.PlaybackStatus() lastStatus := a.statusLabel.Text stopped := lang.L("Stopped") state := stopped diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 67dde41..91bd54a 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -155,6 +155,9 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() local := fyne.NewMenuItem(lang.L("This computer"), func() { + if rp == nil { + return // no-op. already not casting + } onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { @@ -168,7 +171,11 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { menu := fyne.NewMenu("", local) for _, d := range devices { _d := d + isCurrent := rp != nil && _d.URL == rp.URL item := fyne.NewMenuItem(d.Name, func() { + if isCurrent { + return // no-op. + } onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { @@ -177,7 +184,7 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { }() }) item.Icon = myTheme.CastIcon - item.Checked = rp != nil && _d.URL == rp.URL + item.Checked = isCurrent menu.Items = append(menu.Items, item) } pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas())