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\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/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)