diff --git a/backend/app.go b/backend/app.go
index d93309f..f76a58b 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -152,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 a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
+ go func() {
+ t := time.NewTicker(5 * time.Minute)
+ for {
+ select {
+ case <-a.bgrndCtx.Done():
+ t.Stop()
+ return
+ case <-t.C:
+ a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false)
+ }
+ }
+ }()
+
a.PlaybackManager.OnPlaying(func() {
SetSystemSleepDisabled(true)
})
@@ -389,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.PlaybackStatus().TimePos*1000), dur*1000)
})
a.PlaybackManager.OnPlaying(func() {
smtc.SetEnabled(true)
@@ -448,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/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/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/playbackcommands.go b/backend/playbackcommands.go
index cb06459..2632fef 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
@@ -60,11 +61,24 @@ 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})
}
+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/playbackengine.go b/backend/playbackengine.go
index d7a644c..09e7192 100644
--- a/backend/playbackengine.go
+++ b/backend/playbackengine.go
@@ -9,10 +9,13 @@ 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"
)
+// TODO: make thread-safe
+
var (
ReplayGainNone = player.ReplayGainNone.String()
ReplayGainAlbum = player.ReplayGainAlbum.String()
@@ -37,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
@@ -54,9 +65,16 @@ 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
+ pendingPlayerChangeStatus player.Status
// to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *mediaprovider.Track
@@ -102,23 +120,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,18 +129,95 @@ 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) 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)
+ defer p.invokeNoArgCallbacks(p.onPlayerChange)
+
+ needToUnpause := false
+
+ stat := p.CurrentPlayer().GetStatus()
+ if p.pendingPlayerChange {
+ stat.State = player.Paused
+ }
+
+ switch stat.State {
+ case player.Stopped:
+ // nothing
+ case player.Playing:
+ needToUnpause = true
+ p.stopPollTimePos()
+ fallthrough
+ case player.Paused:
+ p.pendingPlayerChangeStatus = stat
+ p.pendingPlayerChange = true
+ }
+ p.unregisterPlayerCallbacks(p.player)
+ 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 {
+ p.player.Destroy()
+ }
+ p.player = pl
+ p.registerPlayerCallbacks(pl)
+
+ if needToUnpause {
+ p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
+ p.pendingPlayerChange = false
+ }
+ p.invokeNoArgCallbacks(p.onPlayerChange)
+ vol := pl.GetVolume()
+ if oldVol != vol {
+ for _, cb := range p.onVolumeChange {
+ cb(vol)
+ }
+ }
+ return nil
+}
+
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")
}
// scrobble current track if needed
p.checkScrobble()
p.alreadyScrobbled = true
- p.noIncrementNextTrackChange = true
- err := p.setTrack(idx, false)
- if err == nil {
- p.nowPlayingIdx = idx
- }
+ p.pendingTrackChangeNum = idx
+ err := p.setTrack(idx, false, startTime)
return err
}
@@ -168,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 {
@@ -188,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
}
func (p *playbackEngine) SeekNext() error {
- if p.CurrentPlayer().GetStatus().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.player.GetStatus().TimePos > 3 {
+ if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 {
return p.player.SeekSeconds(0)
}
return p.PlayTrackAt(p.nowPlayingIdx - 1)
@@ -203,7 +291,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.PlaybackStatus().TimePos > 3 {
n += 1 // first seek back is just seek to beginning of current
}
if n == 0 || (idx == 0 && n < 0) {
@@ -230,7 +318,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 {
@@ -238,7 +326,12 @@ func (p *playbackEngine) Pause() error {
}
func (p *playbackEngine) Continue() error {
- if p.PlayerStatus().State == player.Stopped {
+ if p.pendingPlayerChange {
+ p.pendingPlayerChange = false
+ return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
+ }
+
+ if p.PlaybackStatus().State == player.Stopped {
return p.PlayTrackAt(0)
}
return p.player.Continue()
@@ -260,7 +353,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
}
@@ -286,7 +379,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
}
@@ -312,7 +405,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 {
@@ -408,7 +501,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
@@ -468,21 +561,23 @@ func (p *playbackEngine) handleOnTrackChange() {
p.checkScrobble()
}
- if p.player.GetStatus().State == player.Playing {
+ if p.PlaybackStatus().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
@@ -548,12 +643,14 @@ 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
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 {
@@ -564,9 +661,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, startTime)
} else if trP, ok := p.player.(player.TrackPlayer); ok {
var track *mediaprovider.Track
if idx >= 0 {
@@ -578,13 +675,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
@@ -701,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
if p.callbacksDisabled {
return
}
- s := p.player.GetStatus()
+ s := p.PlaybackStatus()
if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos
}
diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go
index 95debbb..153760f 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,23 @@ type PlaybackManager struct {
cmdQueue *playbackCommandQueue
cfg *AppConfig
+ localPlayer player.BasePlayer
+ remotePlayersLock sync.Mutex
+ remotePlayers []RemotePlaybackDevice
+ currentRemotePlayer *RemotePlaybackDevice
+
autoplay bool
lastPlayTime float64
}
+type RemotePlaybackDevice struct {
+ Name string
+ URL string
+ Protocol string
+ new func() (player.BasePlayer, error)
+}
+
func NewPlaybackManager(
ctx context.Context,
s *ServerManager,
@@ -39,10 +55,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)
@@ -68,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)
@@ -81,6 +98,68 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
})
}
+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 []RemotePlaybackDevice
+ for _, d := range devices {
+ p := RemotePlaybackDevice{
+ Name: d.FriendlyName,
+ URL: d.URL,
+ 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() []RemotePlaybackDevice {
+ p.remotePlayersLock.Lock()
+ players := p.remotePlayers
+ p.remotePlayersLock.Unlock()
+ return players
+}
+
+func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice {
+ return p.currentRemotePlayer
+}
+
+func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error {
+ p.cmdQueue.Clear()
+ if rp == nil {
+ if err := p.engine.SetPlayer(p.localPlayer); err != nil {
+ return err
+ }
+ p.currentRemotePlayer = nil
+ return nil
+ }
+
+ player, err := rp.new()
+ if err != nil {
+ return err
+ }
+ if err := p.engine.SetPlayer(player); err != nil {
+ return err
+ }
+
+ p.currentRemotePlayer = rp
+ return nil
+}
+
func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
return p.engine.CurrentPlayer()
}
@@ -397,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) {
@@ -431,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
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
p.cmdQueue.Stop()
}
+func (p *PlaybackManager) Shutdown() {
+ p.cmdQueue.StopAndWait()
+}
+
func (p *PlaybackManager) Pause() {
p.cmdQueue.Pause()
}
@@ -465,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:
@@ -514,7 +597,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)
}
@@ -527,7 +610,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 {
@@ -542,7 +625,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)
}
@@ -556,7 +639,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 {
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
mpv.ForceRestartPlayback()
}
}
+ if c.OnDone != nil {
+ c.OnDone()
+ }
}
}
}
diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go
new file mode 100644
index 0000000..0dcb38f
--- /dev/null
+++ b/backend/player/dlna/dlnaplayer.go
@@ -0,0 +1,665 @@
+package dlna
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "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"
+)
+
+const (
+ stopped = 0
+ playing = 1
+ paused = 2
+)
+
+var unimplemented = errors.New("unimplemented")
+
+type proxyMapEntry struct {
+ key string
+ url string
+}
+
+type DLNAPlayer struct {
+ player.BasePlayerCallbackImpl
+
+ destroyed bool
+ cancelRequest context.CancelFunc
+
+ avTransport *avtransport.Client
+ renderControl *renderingcontrol.Client
+
+ state int // stopped, playing, paused
+ seeking bool
+
+ metaLock sync.Mutex
+ 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
+ // how long the track has been playing since last time sync
+ stopwatch util.Stopwatch
+
+ proxyServer *http.Server
+ proxyActive atomic.Bool
+ 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
+ // only two will need to be active at any given time
+ 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)
+}
+
+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()
+ if err != nil {
+ return nil, err
+ }
+ avt.HTTPClient = cli
+ rc, err := device.RenderingControlClient()
+ if err != nil {
+ 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,
+ resetChan: make(chan time.Duration),
+ }, nil
+}
+
+func (d *DLNAPlayer) SetVolume(vol int) error {
+ 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 {
+ 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()
+ d.curTrackMeta = meta
+ d.metaLock.Unlock()
+ key := d.addURLToProxy(urlstr)
+
+ media := avtransport.MediaItem{
+ URL: d.urlForItem(key),
+ Title: meta.Name,
+ ContentType: meta.MIMEType,
+ Seekable: true,
+ }
+
+ 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)
+ 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)
+ d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second)
+ d.stopwatch.Reset()
+ d.stopwatch.Start()
+ d.lastStartTime = int(startTime)
+ d.InvokeOnPlaying()
+ d.InvokeOnTrackChange()
+ if startTime > 0 {
+ d.InvokeOnSeek()
+ }
+
+ return nil
+}
+
+func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error {
+ 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(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
+ d.metaLock.Unlock()
+ if url != "" {
+ d.ensureSetupProxy()
+
+ key := d.addURLToProxy(url)
+ media = &avtransport.MediaItem{
+ URL: d.urlForItem(key),
+ ContentType: meta.MIMEType,
+ Title: meta.Name,
+ Seekable: true,
+ }
+ }
+
+ 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
+ d.unsetNextMediaItem = media
+ d.metaLock.Unlock()
+ }
+ return err
+}
+
+func (d *DLNAPlayer) Continue() error {
+ 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(ctx, int(d.pendingSeekSecs))
+ if err != nil {
+ return err
+ }
+ }
+
+ if err := d.avTransport.Play(ctx); 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
+}
+
+func (d *DLNAPlayer) Pause() error {
+ if d.destroyed || d.state != playing {
+ return 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)
+ d.stopwatch.Stop()
+ d.state = paused
+ d.InvokeOnPaused()
+ return nil
+}
+
+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:
+ 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
+ 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")
+ }
+}
+
+func (d *DLNAPlayer) SeekSeconds(secs float64) error {
+ if d.destroyed {
+ return nil
+ }
+
+ if d.state == paused {
+ d.pendingSeek = true
+ d.pendingSeekSecs = secs
+ } else {
+ if err := d.sendSeekCmd(secs); err != nil {
+ return err
+ }
+ }
+
+ 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()
+ }
+
+ d.InvokeOnSeek()
+
+ go func() {
+ time.Sleep(4 * time.Second)
+ if !d.destroyed {
+ d.syncPlaybackTime()
+ }
+ }()
+ return nil
+}
+
+func (d *DLNAPlayer) sendSeekCmd(secs float64) error {
+ d.seeking = true
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if err := d.avTransport.Seek(ctx, int(secs)); err != nil {
+ d.seeking = false
+ return err
+ }
+ d.seeking = false
+ 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
+ }
+
+ var timePos float64
+ if !d.pendingPlayStart {
+ timePos = d.curPlayPos().Seconds()
+ }
+ return player.Status{
+ State: state,
+ TimePos: timePos,
+ 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() {
+ d.destroyed = true
+ d.setTrackChangeTimer(0)
+ if d.cancelRequest != nil {
+ d.cancelRequest()
+ }
+
+ if d.proxyServer != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ go d.proxyServer.Shutdown(ctx)
+ d.proxyServer = nil
+ }
+}
+
+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
+ }
+
+ var err error
+ d.localIP, err = util.GetLocalIP()
+ if err != nil {
+ return err
+ }
+
+ 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
+}
+
+func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) {
+ if d.timerActive.Swap(true) {
+ // was active
+ d.resetChan <- dur
+ return
+ }
+ if dur == 0 {
+ d.timerActive.Store(false)
+ return
+ }
+
+ 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.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()
+ d.setTrackChangeTimer(nextTrackChange)
+ d.InvokeOnTrackChange()
+
+ go func() {
+ time.Sleep(5 * time.Second)
+ if !d.destroyed {
+ d.syncPlaybackTime()
+ }
+ }()
+ }
+}
+
+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) {
+ key := strings.TrimPrefix(r.URL.Path, "/")
+ url, _ := d.lookupProxyURL(key)
+
+ if url == "" {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("404"))
+ return
+ }
+
+ // Create a new request to the target server
+ 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)
+ }
+}
+
+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{}) {
+ 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)
+ }
+}
diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go
index 3518beb..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
}
@@ -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
}
@@ -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/mpv/player.go b/backend/player/mpv/player.go
index e97535b..3c5965a 100644
--- a/backend/player/mpv/player.go
+++ b/backend/player/mpv/player.go
@@ -6,7 +6,9 @@ import (
"fmt"
"math"
"strconv"
+ "sync"
+ "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/supersonic-app/go-mpv"
)
@@ -67,6 +69,9 @@ type Player struct {
equalizer Equalizer
peaksEnabled bool
+ fileLoadedLock sync.Mutex
+ fileLoadedSig *sync.Cond
+
bgCancel context.CancelFunc
}
@@ -79,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.
@@ -133,23 +140,32 @@ 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, 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
}
// Stops playback and clears the play queue.
-func (p *Player) Stop() error {
+func (p *Player) Stop(_ bool) error {
if !p.initialized {
return ErrUnitialized
}
@@ -169,7 +185,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
@@ -480,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
- }
+ fallthrough
case mpv.EVENT_SEEK:
+ p.seeking = false
p.InvokeOnSeek()
case mpv.EVENT_FILE_LOADED:
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
@@ -493,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 778c941..877fcea 100644
--- a/backend/player/player.go
+++ b/backend/player/player.go
@@ -1,23 +1,25 @@
package player
-import "github.com/dweymouth/supersonic/backend/mediaprovider"
+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, 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
}
type BasePlayer interface {
Continue() error
Pause() error
- Stop() error
+ Stop(force bool) error
SeekSeconds(secs float64) error
IsSeeking() bool
@@ -27,6 +29,8 @@ type BasePlayer interface {
GetStatus() Status
+ Destroy()
+
// Event API
OnPaused(func())
OnStopped(func())
@@ -84,66 +88,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/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/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")
+}
diff --git a/go.mod b/go.mod
index 672b93e..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,12 +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-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
)
@@ -39,8 +41,11 @@ 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/hashicorp/go-cleanhttp v0.5.2 // 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.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 ac2322e..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=
@@ -57,12 +59,26 @@ 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/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.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=
@@ -97,6 +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-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=
@@ -113,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/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"),
+}
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/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/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/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go
index fd331f0..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.CurrentPlayer().GetStatus().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 := curPlayer.GetStatus()
+ 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 246f61b..91bd54a 100644
--- a/ui/controller/controller.go
+++ b/ui/controller/controller.go
@@ -151,6 +151,50 @@ func (m *Controller) HaveModal() bool {
return m.haveModal
}
+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 {
+ 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
+ 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 {
+ fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) })
+ }
+ }()
+ })
+ item.Icon = myTheme.CastIcon
+ item.Checked = isCurrent
+ menu.Items = append(menu.Items, item)
+ }
+ 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 {
@@ -270,6 +314,7 @@ 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)
@@ -282,6 +327,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
_, 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(),
@@ -317,6 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
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..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()
+ }
}
})
}
@@ -71,13 +74,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 +89,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)
}
}
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..324bce1 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"
@@ -25,6 +26,7 @@ type AuxControls struct {
VolumeControl *VolumeControl
autoplay *IconButton
loop *IconButton
+ cast *IconButton
showQueue *IconButton
container *fyne.Container
@@ -35,10 +37,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 +57,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 +68,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 +98,16 @@ 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()
+}
+
func (a *AuxControls) SetAutoplay(autoplay bool) {
if autoplay == a.autoplay.Highlighted {
return
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
a.showQueue.OnTapped = f
}
+func (a *AuxControls) OnShowCastMenu(f func(func())) {
+ a.cast.OnTapped = func() {
+ f(a.DisableCastButton)
+ }
+}
+
type volumeSlider struct {
widget.Slider
@@ -145,6 +171,10 @@ type VolumeControl struct {
muted bool
lastVol int
+ setVolDebouncer func()
+ delaySetVolume bool
+ pendingVolume int
+
container *fyne.Container
}
@@ -160,6 +190,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
}
@@ -167,6 +206,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
}
@@ -177,6 +221,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)