Merge pull request #582 from dweymouth/feat/dlnacast
Add ability to cast playback to DLNA devices
This commit is contained in:
+17
-2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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)",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user