Merge pull request #582 from dweymouth/feat/dlnacast

Add ability to cast playback to DLNA devices
This commit is contained in:
Drew Weymouth
2025-04-05 11:01:10 -07:00
committed by GitHub
25 changed files with 1199 additions and 132 deletions
+17 -2
View File
@@ -152,6 +152,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout) 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() { a.PlaybackManager.OnPlaying(func() {
SetSystemSleepDisabled(true) SetSystemSleepDisabled(true)
}) })
@@ -389,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
}) })
a.PlaybackManager.OnSeek(func() { a.PlaybackManager.OnSeek(func() {
dur := a.PlaybackManager.NowPlaying().Metadata().Duration 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() { a.PlaybackManager.OnPlaying(func() {
smtc.SetEnabled(true) smtc.SetEnabled(true)
@@ -448,7 +463,7 @@ func (a *App) Shutdown() {
a.WinSMTC.Shutdown() a.WinSMTC.Shutdown()
} }
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Stop() // will trigger scrobble check a.PlaybackManager.Shutdown() // will trigger scrobble check
a.cancel() a.cancel()
a.LocalPlayer.Destroy() a.LocalPlayer.Destroy()
} }
+2
View File
@@ -183,6 +183,7 @@ const (
type MediaItemMetadata struct { type MediaItemMetadata struct {
Type MediaItemType Type MediaItemType
MIMEType string
ID string ID string
Name string Name string
Artists []string Artists []string
@@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata {
} }
return MediaItemMetadata{ return MediaItemMetadata{
Type: MediaItemTypeTrack, Type: MediaItemTypeTrack,
MIMEType: t.ContentType,
ID: t.ID, ID: t.ID,
Name: t.Title, Name: t.Title,
Artists: t.ArtistNames, Artists: t.ArtistNames,
+3 -3
View File
@@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
}) })
mp.playbackManager.OnSeek(func() { 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() { mp.playbackManager.OnPlaying(func() {
C.set_os_playback_state_playing() 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() { mp.playbackManager.OnPaused(func() {
C.set_os_playback_state_paused() 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 return nil
+6 -6
View File
@@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
pm.OnSeek(func() { pm.OnSeek(func() {
if m.connErr == nil { if m.connErr == nil {
pos := secondsToMicroseconds(pm.PlayerStatus().TimePos) pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos)
m.evt.Player.OnSeek(pos) m.evt.Player.OnSeek(pos)
} }
}) })
@@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error {
} }
func (m *MPRISHandler) Pause() error { func (m *MPRISHandler) Pause() error {
if m.pm.PlayerStatus().State == player.Playing { if m.pm.PlaybackStatus().State == player.Playing {
m.pm.PlayPause() m.pm.PlayPause()
} }
return nil return nil
@@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error {
} }
func (m *MPRISHandler) Play() error { func (m *MPRISHandler) Play() error {
switch m.pm.PlayerStatus().State { switch m.pm.PlaybackStatus().State {
case player.Paused: case player.Paused:
m.pm.PlayPause() m.pm.PlayPause()
case player.Stopped: case player.Stopped:
@@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error {
} }
func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
switch m.pm.PlayerStatus().State { switch m.pm.PlaybackStatus().State {
case player.Playing: case player.Playing:
return types.PlaybackStatusPlaying, nil return types.PlaybackStatusPlaying, nil
case player.Paused: case player.Paused:
@@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
if m.curTrackPath != "" { if m.curTrackPath != "" {
trackObjPath = m.curTrackPath trackObjPath = m.curTrackPath
} }
status := m.pm.PlayerStatus() status := m.pm.PlaybackStatus()
var meta mediaprovider.MediaItemMetadata var meta mediaprovider.MediaItemMetadata
// metadata that can come only from tracks // metadata that can come only from tracks
@@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error {
} }
func (m *MPRISHandler) Position() (int64, 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) { func (m *MPRISHandler) MinimumRate() (float64, error) {
+18 -4
View File
@@ -31,10 +31,11 @@ const (
) )
type playbackCommand struct { type playbackCommand struct {
Type playbackCommandType Type playbackCommandType
Arg any Arg any
Arg2 any Arg2 any
Arg3 any Arg3 any
OnDone func()
} }
// playbackCommandQueue is a queue to accumulate player commands from the UI // playbackCommandQueue is a queue to accumulate player commands from the UI
@@ -60,11 +61,24 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand {
return c.nextChan return c.nextChan
} }
func (c *playbackCommandQueue) Clear() {
c.mutex.Lock()
c.queue = nil
c.mutex.Unlock()
}
func (c *playbackCommandQueue) Stop() { func (c *playbackCommandQueue) Stop() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: 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() { func (c *playbackCommandQueue) Continue() {
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
playbackCommand{Type: cmdContinue}) playbackCommand{Type: cmdContinue})
+141 -44
View File
@@ -9,10 +9,13 @@ import (
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/backend/util"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
) )
// TODO: make thread-safe
var ( var (
ReplayGainNone = player.ReplayGainNone.String() ReplayGainNone = player.ReplayGainNone.String()
ReplayGainAlbum = player.ReplayGainAlbum.String() ReplayGainAlbum = player.ReplayGainAlbum.String()
@@ -37,6 +40,14 @@ const (
LoopOne LoopOne
) )
type PlaybackState = player.State
type PlaybackStatus struct {
State PlaybackState
TimePos float64
Duration float64
}
type playbackEngine struct { type playbackEngine struct {
ctx context.Context ctx context.Context
cancelPollPos context.CancelFunc cancelPollPos context.CancelFunc
@@ -54,9 +65,16 @@ type playbackEngine struct {
loopMode LoopMode loopMode LoopMode
// flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks // flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation 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
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 // to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *mediaprovider.Track lastScrobbled *mediaprovider.Track
@@ -102,23 +120,8 @@ func NewPlaybackEngine(
case "One": case "One":
pm.loopMode = LoopOne 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() { s.OnLogout(func() {
pm.StopAndClearPlayQueue() pm.StopAndClearPlayQueue()
}) })
@@ -126,18 +129,95 @@ func NewPlaybackEngine(
return pm 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 { 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) { if idx < 0 || idx >= len(p.playQueue) {
return errors.New("track index out of range") return errors.New("track index out of range")
} }
// scrobble current track if needed // scrobble current track if needed
p.checkScrobble() p.checkScrobble()
p.alreadyScrobbled = true p.alreadyScrobbled = true
p.noIncrementNextTrackChange = true p.pendingTrackChangeNum = idx
err := p.setTrack(idx, false) err := p.setTrack(idx, false, startTime)
if err == nil {
p.nowPlayingIdx = idx
}
return err return err
} }
@@ -168,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode {
return p.loopMode return p.loopMode
} }
func (p *playbackEngine) PlayerStatus() player.Status { func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
return p.player.GetStatus() 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 { func (p *playbackEngine) SetVolume(vol int) error {
@@ -188,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
} }
func (p *playbackEngine) SeekNext() error { func (p *playbackEngine) SeekNext() error {
if p.CurrentPlayer().GetStatus().State == player.Stopped { if p.PlaybackStatus().State == player.Stopped {
return nil return nil
} }
return p.PlayTrackAt(p.nowPlayingIdx + 1) return p.PlayTrackAt(p.nowPlayingIdx + 1)
} }
func (p *playbackEngine) SeekBackOrPrevious() error { 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.player.SeekSeconds(0)
} }
return p.PlayTrackAt(p.nowPlayingIdx - 1) return p.PlayTrackAt(p.nowPlayingIdx - 1)
@@ -203,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
func (p *playbackEngine) SeekFwdBackN(n int) error { func (p *playbackEngine) SeekFwdBackN(n int) error {
idx := p.nowPlayingIdx 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 n += 1 // first seek back is just seek to beginning of current
} }
if n == 0 || (idx == 0 && n < 0) { if n == 0 || (idx == 0 && n < 0) {
@@ -230,7 +318,7 @@ func (p *playbackEngine) IsSeeking() bool {
} }
func (p *playbackEngine) Stop() error { func (p *playbackEngine) Stop() error {
return p.player.Stop() return p.player.Stop(false)
} }
func (p *playbackEngine) Pause() error { func (p *playbackEngine) Pause() error {
@@ -238,7 +326,12 @@ func (p *playbackEngine) Pause() error {
} }
func (p *playbackEngine) Continue() 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.PlayTrackAt(0)
} }
return p.player.Continue() 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 { func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error {
if insertQueueMode == Replace { if insertQueueMode == Replace {
p.player.Stop() p.player.Stop(false)
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil p.playQueue = nil
} }
@@ -286,7 +379,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
if insertMode == Replace { if insertMode == Replace {
p.player.Stop() p.player.Stop(false)
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
p.playQueue = nil p.playQueue = nil
} }
@@ -312,7 +405,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
// Stop playback and clear the play queue. // Stop playback and clear the play queue.
func (p *playbackEngine) StopAndClearPlayQueue() { func (p *playbackEngine) StopAndClearPlayQueue() {
changed := len(p.playQueue) > 0 changed := len(p.playQueue) > 0
p.player.Stop() p.player.Stop(false)
p.playQueue = nil p.playQueue = nil
p.nowPlayingIdx = -1 p.nowPlayingIdx = -1
if changed { if changed {
@@ -408,7 +501,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
p.Stop() p.Stop()
} else { } else {
p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player 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 // setNextTrack and onSongChange callbacks will be handled
// when we receive new track event from player // when we receive new track event from player
@@ -468,21 +561,23 @@ func (p *playbackEngine) handleOnTrackChange() {
p.checkScrobble() p.checkScrobble()
} }
if p.player.GetStatus().State == player.Playing { if p.PlaybackStatus().State == player.Playing {
p.playTimeStopwatch.Start() p.playTimeStopwatch.Start()
} }
if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) { if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) {
p.nowPlayingIdx++ p.nowPlayingIdx++
if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) {
p.nowPlayingIdx = 0 // wrapped around p.nowPlayingIdx = 0 // wrapped around
} }
} else if p.pendingTrackChangeNum >= 0 {
p.nowPlayingIdx = p.pendingTrackChangeNum
p.pendingTrackChangeNum = -1
} }
nowPlaying := p.playQueue[p.nowPlayingIdx] nowPlaying := p.playQueue[p.nowPlayingIdx]
_, isRadio := nowPlaying.(*mediaprovider.RadioStation) _, isRadio := nowPlaying.(*mediaprovider.RadioStation)
p.isRadio = isRadio p.isRadio = isRadio
// reset flags // reset flags
p.noIncrementNextTrackChange = false
p.wasStopped = false p.wasStopped = false
p.alreadyScrobbled = 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 { if urlP, ok := p.player.(player.URLPlayer); ok {
url := "" url := ""
var meta mediaprovider.MediaItemMetadata
if idx >= 0 { if idx >= 0 {
var err error var err error
item := p.playQueue[idx] item := p.playQueue[idx]
meta = item.Metadata()
if tr, ok := item.(*mediaprovider.Track); ok { if tr, ok := item.(*mediaprovider.Track); ok {
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile) url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
} else { } else {
@@ -564,9 +661,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
} }
} }
if next { 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 { } else if trP, ok := p.player.(player.TrackPlayer); ok {
var track *mediaprovider.Track var track *mediaprovider.Track
if idx >= 0 { if idx >= 0 {
@@ -578,13 +675,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
if next { if next {
return trP.SetNextTrack(track) return trP.SetNextTrack(track)
} }
return trP.PlayTrack(track) return trP.PlayTrack(track, startTime)
} }
panic("Unsupported player type") panic("Unsupported player type")
} }
func (p *playbackEngine) setNextTrack(idx int) error { func (p *playbackEngine) setNextTrack(idx int) error {
return p.setTrack(idx, true) return p.setTrack(idx, true, 0)
} }
// call BEFORE updating p.nowPlayingIdx // call BEFORE updating p.nowPlayingIdx
@@ -701,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
if p.callbacksDisabled { if p.callbacksDisabled {
return return
} }
s := p.player.GetStatus() s := p.PlaybackStatus()
if s.TimePos > p.latestTrackPosition { if s.TimePos > p.latestTrackPosition {
p.latestTrackPosition = s.TimePos p.latestTrackPosition = s.TimePos
} }
+99 -13
View File
@@ -7,12 +7,16 @@ import (
"math/rand" "math/rand"
"runtime" "runtime"
"slices" "slices"
"sync"
"time" "time"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/dlna"
"github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/sharedutil" "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 // A high-level MediaProvider-aware playback engine, serves as an
@@ -22,11 +26,23 @@ type PlaybackManager struct {
cmdQueue *playbackCommandQueue cmdQueue *playbackCommandQueue
cfg *AppConfig cfg *AppConfig
localPlayer player.BasePlayer
remotePlayersLock sync.Mutex
remotePlayers []RemotePlaybackDevice
currentRemotePlayer *RemotePlaybackDevice
autoplay bool autoplay bool
lastPlayTime float64 lastPlayTime float64
} }
type RemotePlaybackDevice struct {
Name string
URL string
Protocol string
new func() (player.BasePlayer, error)
}
func NewPlaybackManager( func NewPlaybackManager(
ctx context.Context, ctx context.Context,
s *ServerManager, s *ServerManager,
@@ -39,10 +55,11 @@ func NewPlaybackManager(
e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg)
q := NewCommandQueue() q := NewCommandQueue()
pm := &PlaybackManager{ pm := &PlaybackManager{
engine: e, engine: e,
cmdQueue: q, cmdQueue: q,
cfg: appCfg, cfg: appCfg,
autoplay: playbackCfg.Autoplay, autoplay: playbackCfg.Autoplay,
localPlayer: p,
} }
pm.addOnTrackChangeHook() pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx) go pm.runCmdQueue(ctx)
@@ -68,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
return return
} }
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment) // 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 p.lastPlayTime = 0
go func() { go func() {
time.Sleep(300 * time.Millisecond) 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 { func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
return p.engine.CurrentPlayer() return p.engine.CurrentPlayer()
} }
@@ -397,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool {
return p.autoplay return p.autoplay
} }
func (p *PlaybackManager) PlayerStatus() player.Status { func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
return p.engine.PlayerStatus() return p.engine.PlaybackStatus()
} }
func (p *PlaybackManager) SetVolume(vol int) { 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. // Seek by given relative position in the current track by seconds.
func (p *PlaybackManager) SeekBySeconds(sec float64) { func (p *PlaybackManager) SeekBySeconds(sec float64) {
status := p.engine.PlayerStatus() status := p.engine.PlaybackStatus()
target := status.TimePos + sec target := status.TimePos + sec
if target < 0 { if target < 0 {
target = 0 target = 0
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
p.cmdQueue.Stop() p.cmdQueue.Stop()
} }
func (p *PlaybackManager) Shutdown() {
p.cmdQueue.StopAndWait()
}
func (p *PlaybackManager) Pause() { func (p *PlaybackManager) Pause() {
p.cmdQueue.Pause() p.cmdQueue.Pause()
} }
@@ -465,7 +548,7 @@ func (p *PlaybackManager) Continue() {
} }
func (p *PlaybackManager) PlayPause() { func (p *PlaybackManager) PlayPause() {
switch p.engine.PlayerStatus().State { switch p.engine.PlaybackStatus().State {
case player.Playing: case player.Playing:
p.Pause() p.Pause()
case player.Paused: case player.Paused:
@@ -514,7 +597,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
if len(tr.ArtistIDs) > 0 { if len(tr.ArtistIDs) > 0 {
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize) similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(similar)
} }
@@ -527,7 +610,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
} }
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize) byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(byGenre)
if len(tracks) > 0 { if len(tracks) > 0 {
@@ -542,7 +625,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
// fallback to random tracks // fallback to random tracks
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize) random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
if err != nil { 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) tracks = filterRecentlyPlayed(random)
} }
@@ -556,7 +639,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
func (p *PlaybackManager) runCmdQueue(ctx context.Context) { func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
logIfErr := func(action string, err error) { logIfErr := func(action string, err error) {
if err != nil { if err != nil {
log.Println("Playback error (%s): %v", action, err) log.Printf("Playback error (%s): %v", action, err)
} }
} }
for { for {
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
mpv.ForceRestartPlayback() mpv.ForceRestartPlayback()
} }
} }
if c.OnDone != nil {
c.OnDone()
}
} }
} }
} }
+665
View File
@@ -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)
}
}
+4 -2
View File
@@ -68,7 +68,7 @@ func (j *JukeboxPlayer) Pause() error {
return nil return nil
} }
func (j *JukeboxPlayer) Stop() error { func (j *JukeboxPlayer) Stop(_ bool) error {
if j.state == stopped { if j.state == stopped {
return nil return nil
} }
@@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error {
return nil 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 { if err := j.provider.JukeboxSet(track.ID); err != nil {
return err return err
} }
@@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status {
} }
} }
func (j *JukeboxPlayer) Destroy() {}
func (j *JukeboxPlayer) startAndUpdateTime() error { func (j *JukeboxPlayer) startAndUpdateTime() error {
beforeStart := time.Now() beforeStart := time.Now()
if err := j.provider.JukeboxStart(); err != nil { if err := j.provider.JukeboxStart(); err != nil {
+28 -12
View File
@@ -6,7 +6,9 @@ import (
"fmt" "fmt"
"math" "math"
"strconv" "strconv"
"sync"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/supersonic-app/go-mpv" "github.com/supersonic-app/go-mpv"
) )
@@ -67,6 +69,9 @@ type Player struct {
equalizer Equalizer equalizer Equalizer
peaksEnabled bool peaksEnabled bool
fileLoadedLock sync.Mutex
fileLoadedSig *sync.Cond
bgCancel context.CancelFunc bgCancel context.CancelFunc
} }
@@ -79,10 +84,12 @@ func New() *Player {
// Same as New, but sets the application name that mpv // Same as New, but sets the application name that mpv
// reports to the system audio API. // reports to the system audio API.
func NewWithClientName(c string) *Player { func NewWithClientName(c string) *Player {
return &Player{ p := &Player{
vol: -1, // use 100 in Init vol: -1, // use 100 in Init
clientName: c, clientName: c,
} }
p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock)
return p
} }
// Initializes the Player and makes it ready for playback. // 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. // 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 { if !p.initialized {
return ErrUnitialized return ErrUnitialized
} }
err := p.mpv.Command([]string{"loadfile", url, "replace"}) err := p.mpv.Command([]string{"loadfile", url, "replace"})
if err == nil { if err != nil {
p.lenPlaylist = 1 return err
if p.status.State == player.Paused { }
return p.Continue() p.lenPlaylist = 1
} if p.status.State == player.Paused {
err = p.Continue()
} else {
p.setState(player.Playing) p.setState(player.Playing)
} }
if startTime > 0 {
p.fileLoadedLock.Lock()
p.fileLoadedSig.Wait()
p.fileLoadedLock.Unlock()
p.SeekSeconds(startTime)
}
return err return err
} }
// Stops playback and clears the play queue. // Stops playback and clears the play queue.
func (p *Player) Stop() error { func (p *Player) Stop(_ bool) error {
if !p.initialized { if !p.initialized {
return ErrUnitialized return ErrUnitialized
} }
@@ -169,7 +185,7 @@ func (p *Player) Stop() error {
return err 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 p.lenPlaylist > p.curPlaylistPos+1 {
if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil { if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil {
return err return err
@@ -480,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) {
} }
switch e.Event_Id { switch e.Event_Id {
case mpv.EVENT_PLAYBACK_RESTART: case mpv.EVENT_PLAYBACK_RESTART:
if p.seeking { fallthrough
p.seeking = false
}
case mpv.EVENT_SEEK: case mpv.EVENT_SEEK:
p.seeking = false
p.InvokeOnSeek() p.InvokeOnSeek()
case mpv.EVENT_FILE_LOADED: case mpv.EVENT_FILE_LOADED:
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos") p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
@@ -493,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) {
p.InvokeOnSeek() p.InvokeOnSeek()
} }
p.InvokeOnTrackChange() p.InvokeOnTrackChange()
p.fileLoadedSig.Signal()
case mpv.EVENT_IDLE: case mpv.EVENT_IDLE:
p.status.Duration = 0 p.status.Duration = 0
p.status.TimePos = 0 p.status.TimePos = 0
+32 -28
View File
@@ -1,23 +1,25 @@
package player package player
import "github.com/dweymouth/supersonic/backend/mediaprovider" import (
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
type URLPlayer interface { type URLPlayer interface {
BasePlayer BasePlayer
PlayFile(url string) error PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error
SetNextFile(url string) error SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error
} }
type TrackPlayer interface { type TrackPlayer interface {
BasePlayer BasePlayer
PlayTrack(track *mediaprovider.Track) error PlayTrack(track *mediaprovider.Track, startTime float64) error
SetNextTrack(track *mediaprovider.Track) error SetNextTrack(track *mediaprovider.Track) error
} }
type BasePlayer interface { type BasePlayer interface {
Continue() error Continue() error
Pause() error Pause() error
Stop() error Stop(force bool) error
SeekSeconds(secs float64) error SeekSeconds(secs float64) error
IsSeeking() bool IsSeeking() bool
@@ -27,6 +29,8 @@ type BasePlayer interface {
GetStatus() Status GetStatus() Status
Destroy()
// Event API // Event API
OnPaused(func()) OnPaused(func())
OnStopped(func()) OnStopped(func())
@@ -84,66 +88,66 @@ func (r ReplayGainMode) String() string {
} }
type BasePlayerCallbackImpl struct { type BasePlayerCallbackImpl struct {
onPaused []func() onPaused func()
onStopped []func() onStopped func()
onPlaying []func() onPlaying func()
onSeek []func() onSeek func()
onTrackChange []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()) { 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()) { 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()) { 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. // Registers a callback which is invoked whenever a seek event occurs.
func (p *BasePlayerCallbackImpl) OnSeek(cb func()) { 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, // Registers a callback which is invoked when the currently playing track changes,
// or when playback begins at any time from the Stopped state. // or when playback begins at any time from the Stopped state.
// Callback is invoked with the index of the currently playing track (zero-based). // Callback is invoked with the index of the currently playing track (zero-based).
func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) { func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) {
p.onTrackChange = append(p.onTrackChange, cb) p.onTrackChange = cb
} }
func (p *BasePlayerCallbackImpl) InvokeOnPaused() { func (p *BasePlayerCallbackImpl) InvokeOnPaused() {
for _, cb := range p.onPaused { if p.onPaused != nil {
cb() p.onPaused()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnPlaying() { func (p *BasePlayerCallbackImpl) InvokeOnPlaying() {
for _, cb := range p.onPlaying { if p.onPlaying != nil {
cb() p.onPlaying()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnStopped() { func (p *BasePlayerCallbackImpl) InvokeOnStopped() {
for _, cb := range p.onStopped { if p.onStopped != nil {
cb() p.onStopped()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnSeek() { func (p *BasePlayerCallbackImpl) InvokeOnSeek() {
for _, cb := range p.onSeek { if p.onSeek != nil {
cb() p.onSeek()
} }
} }
func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() { func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() {
for _, cb := range p.onTrackChange { if p.onTrackChange != nil {
cb() p.onTrackChange()
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct {
// If the provided CanSavePlayQueue server is non-nil, it will also save to the server. // 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 { func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
queue := pm.GetPlayQueue() queue := pm.GetPlayQueue()
stats := pm.PlayerStatus() stats := pm.PlaybackStatus()
trackIdx := pm.NowPlayingIndex() trackIdx := pm.NowPlayingIndex()
trackIDs := make([]string, 0, len(queue)) trackIDs := make([]string, 0, len(queue))
+29
View File
@@ -1,7 +1,9 @@
package util package util
import ( import (
"fmt"
"io" "io"
"net"
"os" "os"
) )
@@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error {
_, err = io.Copy(fout, fin) _, err = io.Copy(fout, fin)
return err 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")
}
+7 -2
View File
@@ -1,6 +1,6 @@
module github.com/dweymouth/supersonic module github.com/dweymouth/supersonic
go 1.21 go 1.21.9
require ( require (
fyne.io/fyne/v2 v2.6.0-beta1 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/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
github.com/godbus/dbus/v5 v5.1.0 github.com/godbus/dbus/v5 v5.1.0
github.com/google/uuid v1.3.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/pelletier/go-toml/v2 v2.0.8
github.com/quarckster/go-mpris-server v1.0.3 github.com/quarckster/go-mpris-server v1.0.3
github.com/supersonic-app/go-mpv v0.1.0 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-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 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/sys v0.30.0
golang.org/x/text v0.22.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-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // 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/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
+20 -2
View File
@@ -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/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 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs=
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= 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 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= 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/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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= 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 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 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= 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-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 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-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.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 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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-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.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.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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+5
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -3,6 +3,7 @@
fyne bundle -package res -prefix Res appicon-256.png > bundled.go 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/coreui/playlist-add-next.svg >> bundled.go
fyne bundle -append -prefix Res icons/freepik/playbutton.png >> 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/disc.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/headphones.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 fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M1,21 L1,19 C2.1045695,19 3,19.8954305 3,21 L1,21 Z M7,21 L5,21 C5,18.790861 3.209139,17 1,17 L1,15 C4.3137085,15 7,17.6862915 7,21 Z M11,21 L9,21 C9,16.581722 5.418278,13 1,13 L1,11 C6.5228475,11 11,15.4771525 11,21 Z M3,9 L1,9 L1,5 C1,3.8954305 1.8954305,3 3,3 L21,3 C22.1045695,3 23,3.8954305 23,5 L23,19 C23,20.1045695 22.1045695,21 21,21 L13,21 L13,19 L21,19 L21,5 L3,5 L3,9 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

+1
View File
@@ -207,6 +207,7 @@
"Testing connection": "Testing connection", "Testing connection": "Testing connection",
"The request timed out": "The request timed out", "The request timed out": "The request timed out",
"Theme": "Theme", "Theme": "Theme",
"This computer": "This computer",
"Time": "Time", "Time": "Time",
"Title": "Title", "Title": "Title",
"Title (A-Z)": "Title (A-Z)", "Title (A-Z)": "Title (A-Z)",
+6
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
@@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.OnVolumeChange(func(vol int) { pm.OnVolumeChange(func(vol int) {
fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) }) 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) { bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
pm.SetVolume(v) pm.SetVolume(v)
} }
@@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.SetAutoplay(autoplay) pm.SetAutoplay(autoplay)
} }
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue) bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu)
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
+2 -2
View File
@@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() {
} }
switch a.tabs.SelectedIndex() { switch a.tabs.SelectedIndex() {
case 1: /*lyrics*/ case 1: /*lyrics*/
a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos a.lastPlayPos = a.pm.PlaybackStatus().TimePos
a.updateLyrics() a.updateLyrics()
case 2: /*related*/ case 2: /*related*/
a.updateRelatedList() a.updateRelatedList()
@@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) {
func (a *NowPlayingPage) formatStatusLine() { func (a *NowPlayingPage) formatStatusLine() {
curPlayer := a.pm.CurrentPlayer() curPlayer := a.pm.CurrentPlayer()
playerStats := curPlayer.GetStatus() playerStats := a.pm.PlaybackStatus()
lastStatus := a.statusLabel.Text lastStatus := a.statusLabel.Text
stopped := lang.L("Stopped") stopped := lang.L("Stopped")
state := stopped state := stopped
+47
View File
@@ -151,6 +151,50 @@ func (m *Controller) HaveModal() bool {
return m.haveModal 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() { func (m *Controller) ShowPopUpPlayQueue() {
m.popUpQueueMutex.Lock() m.popUpQueueMutex.Lock()
if m.popUpQueue == nil { if m.popUpQueue == nil {
@@ -270,6 +314,7 @@ func (c *Controller) ShowAboutDialog() {
} }
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) { func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
devs, err := c.App.LocalPlayer.ListAudioDevices() devs, err := c.App.LocalPlayer.ListAudioDevices()
if err != nil { if err != nil {
log.Printf("error listing audio devices: %v", err) 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) _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
isLocalPlayer := isEqualizerPlayer isLocalPlayer := isEqualizerPlayer
bands := c.App.LocalPlayer.Equalizer().BandFrequencies() bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
dlg := dialogs.NewSettingsDialog(c.App.Config, dlg := dialogs.NewSettingsDialog(c.App.Config,
devs, themeFiles, bands, devs, themeFiles, bands,
c.App.ServerManager.Server.ClientDecidesScrobble(), c.App.ServerManager.Server.ClientDecidesScrobble(),
@@ -317,6 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
c.ClosePopUpOnEscape(pop) c.ClosePopUpOnEscape(pop)
c.haveModal = true c.haveModal = true
pop.Show() pop.Show()
} }
func (c *Controller) doModalClosed() { func (c *Controller) doModalClosed() {
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/lang"
"github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
"github.com/dweymouth/supersonic/ui/shortcuts" "github.com/dweymouth/supersonic/ui/shortcuts"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/visualizations" "github.com/dweymouth/supersonic/ui/visualizations"
@@ -21,11 +22,13 @@ type visualizationData struct {
} }
func (c *Controller) initVisualizations() { func (c *Controller) initVisualizations() {
c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim) c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim) c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim)
c.App.LocalPlayer.OnPlaying(func() { c.App.PlaybackManager.OnPlaying(func() {
if c.peakMeter != nil { if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok {
c.startVisualizationAnim() if c.peakMeter != nil {
c.startVisualizationAnim()
}
} }
}) })
} }
@@ -71,13 +74,13 @@ func (c *Controller) stopVisualizationAnim() {
if c.visualizationAnim != nil { if c.visualizationAnim != nil {
c.visualizationAnim.Stop() c.visualizationAnim.Stop()
c.visualizationAnim = nil c.visualizationAnim = nil
c.App.LocalPlayer.SetPeaksEnabled(false) // c.App.LocalPlayer.SetPeaksEnabled(false)
} }
} }
func (c *Controller) startVisualizationAnim() { func (c *Controller) startVisualizationAnim() {
if c.visualizationAnim == nil { if c.visualizationAnim == nil {
c.App.LocalPlayer.SetPeaksEnabled(true) //c.App.LocalPlayer.SetPeaksEnabled(true)
c.visualizationAnim = fyne.NewAnimation( c.visualizationAnim = fyne.NewAnimation(
time.Duration(math.MaxInt64), /*until stopped*/ time.Duration(math.MaxInt64), /*until stopped*/
c.tickVisualizations) c.tickVisualizations)
@@ -86,8 +89,8 @@ func (c *Controller) startVisualizationAnim() {
} }
func (c *Controller) tickVisualizations(_ float32) { 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 { if c.visualizationData.peakMeter != nil {
c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) //c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
} }
} }
+1
View File
@@ -39,6 +39,7 @@ var (
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg) AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg)
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg) FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
+48 -2
View File
@@ -2,6 +2,7 @@ package widgets
import ( import (
"math" "math"
"time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
@@ -25,6 +26,7 @@ type AuxControls struct {
VolumeControl *VolumeControl VolumeControl *VolumeControl
autoplay *IconButton autoplay *IconButton
loop *IconButton loop *IconButton
cast *IconButton
showQueue *IconButton showQueue *IconButton
container *fyne.Container container *fyne.Container
@@ -35,10 +37,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
VolumeControl: NewVolumeControl(initialVolume), VolumeControl: NewVolumeControl(initialVolume),
autoplay: NewIconButton(myTheme.AutoplayIcon, nil), autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
loop: NewIconButton(myTheme.RepeatIcon, nil), loop: NewIconButton(myTheme.RepeatIcon, nil),
cast: NewIconButton(myTheme.CastIcon, nil),
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil), showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
} }
a.loop.IconSize = IconButtonSizeSmaller a.loop.IconSize = IconButtonSizeSmaller
a.loop.SetToolTip(lang.L("Repeat")) 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.Highlighted = initialAutoplay
//a.autoplay.IconSize = IconButtonSizeSmaller //a.autoplay.IconSize = IconButtonSizeSmaller
a.autoplay.SetToolTip(lang.L("Autoplay")) a.autoplay.SetToolTip(lang.L("Autoplay"))
@@ -48,9 +57,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.OnChangeAutoplay(a.autoplay.Highlighted) a.OnChangeAutoplay(a.autoplay.Highlighted)
} }
} }
a.SetLoopMode(initialLoopMode)
a.showQueue.IconSize = IconButtonSizeSmaller a.showQueue.IconSize = IconButtonSizeSmaller
a.showQueue.SetToolTip(lang.L("Show play queue")) a.showQueue.SetToolTip(lang.L("Show play queue"))
a.container = container.NewHBox( a.container = container.NewHBox(
layout.NewSpacer(), layout.NewSpacer(),
container.NewVBox( container.NewVBox(
@@ -58,7 +68,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
a.VolumeControl, a.VolumeControl,
container.New( container.New(
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5), 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(), 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) { func (a *AuxControls) SetAutoplay(autoplay bool) {
if autoplay == a.autoplay.Highlighted { if autoplay == a.autoplay.Highlighted {
return return
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
a.showQueue.OnTapped = f a.showQueue.OnTapped = f
} }
func (a *AuxControls) OnShowCastMenu(f func(func())) {
a.cast.OnTapped = func() {
f(a.DisableCastButton)
}
}
type volumeSlider struct { type volumeSlider struct {
widget.Slider widget.Slider
@@ -145,6 +171,10 @@ type VolumeControl struct {
muted bool muted bool
lastVol int lastVol int
setVolDebouncer func()
delaySetVolume bool
pendingVolume int
container *fyne.Container container *fyne.Container
} }
@@ -160,6 +190,15 @@ func NewVolumeControl(initialVol int) *VolumeControl {
v.slider.Orientation = widget.Horizontal v.slider.Orientation = widget.Horizontal
v.slider.Value = float64(v.lastVol) v.slider.Value = float64(v.lastVol)
v.slider.OnChanged = v.onChanged 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) v.container = container.NewHBox(container.NewCenter(v.icon), v.slider)
return v return v
} }
@@ -167,6 +206,11 @@ func NewVolumeControl(initialVol int) *VolumeControl {
// Sets the volume that is displayed in the slider. // Sets the volume that is displayed in the slider.
// Does not invoke OnSetVolume callback. // Does not invoke OnSetVolume callback.
func (v *VolumeControl) SetVolume(vol int) { func (v *VolumeControl) SetVolume(vol int) {
if v.delaySetVolume {
v.pendingVolume = vol
return
}
if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) { if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) {
return return
} }
@@ -177,6 +221,8 @@ func (v *VolumeControl) SetVolume(vol int) {
func (v *VolumeControl) onChanged(volume float64) { func (v *VolumeControl) onChanged(volume float64) {
vol := int(volume) vol := int(volume)
v.delaySetVolume = true
v.setVolDebouncer()
v.lastVol = vol v.lastVol = vol
v.muted = false v.muted = false
v.updateIconForVolume(vol) v.updateIconForVolume(vol)