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