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)
|
||||
}
|
||||
|
||||
// Periodically scan for remote players
|
||||
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-a.bgrndCtx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
a.PlaybackManager.OnPlaying(func() {
|
||||
SetSystemSleepDisabled(true)
|
||||
})
|
||||
@@ -389,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) {
|
||||
})
|
||||
a.PlaybackManager.OnSeek(func() {
|
||||
dur := a.PlaybackManager.NowPlaying().Metadata().Duration
|
||||
smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000)
|
||||
smtc.UpdatePosition(int(a.PlaybackManager.PlaybackStatus().TimePos*1000), dur*1000)
|
||||
})
|
||||
a.PlaybackManager.OnPlaying(func() {
|
||||
smtc.SetEnabled(true)
|
||||
@@ -448,7 +463,7 @@ func (a *App) Shutdown() {
|
||||
a.WinSMTC.Shutdown()
|
||||
}
|
||||
a.PlaybackManager.DisableCallbacks()
|
||||
a.PlaybackManager.Stop() // will trigger scrobble check
|
||||
a.PlaybackManager.Shutdown() // will trigger scrobble check
|
||||
a.cancel()
|
||||
a.LocalPlayer.Destroy()
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ const (
|
||||
|
||||
type MediaItemMetadata struct {
|
||||
Type MediaItemType
|
||||
MIMEType string
|
||||
ID string
|
||||
Name string
|
||||
Artists []string
|
||||
@@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata {
|
||||
}
|
||||
return MediaItemMetadata{
|
||||
Type: MediaItemTypeTrack,
|
||||
MIMEType: t.ContentType,
|
||||
ID: t.ID,
|
||||
Name: t.Title,
|
||||
Artists: t.ArtistNames,
|
||||
|
||||
@@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac
|
||||
})
|
||||
|
||||
mp.playbackManager.OnSeek(func() {
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
mp.playbackManager.OnPlaying(func() {
|
||||
C.set_os_playback_state_playing()
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
mp.playbackManager.OnPaused(func() {
|
||||
C.set_os_playback_state_paused()
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos))
|
||||
C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos))
|
||||
})
|
||||
|
||||
return nil
|
||||
|
||||
+6
-6
@@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler {
|
||||
|
||||
pm.OnSeek(func() {
|
||||
if m.connErr == nil {
|
||||
pos := secondsToMicroseconds(pm.PlayerStatus().TimePos)
|
||||
pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos)
|
||||
m.evt.Player.OnSeek(pos)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Pause() error {
|
||||
if m.pm.PlayerStatus().State == player.Playing {
|
||||
if m.pm.PlaybackStatus().State == player.Playing {
|
||||
m.pm.PlayPause()
|
||||
}
|
||||
return nil
|
||||
@@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Play() error {
|
||||
switch m.pm.PlayerStatus().State {
|
||||
switch m.pm.PlaybackStatus().State {
|
||||
case player.Paused:
|
||||
m.pm.PlayPause()
|
||||
case player.Stopped:
|
||||
@@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
|
||||
switch m.pm.PlayerStatus().State {
|
||||
switch m.pm.PlaybackStatus().State {
|
||||
case player.Playing:
|
||||
return types.PlaybackStatusPlaying, nil
|
||||
case player.Paused:
|
||||
@@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) {
|
||||
if m.curTrackPath != "" {
|
||||
trackObjPath = m.curTrackPath
|
||||
}
|
||||
status := m.pm.PlayerStatus()
|
||||
status := m.pm.PlaybackStatus()
|
||||
|
||||
var meta mediaprovider.MediaItemMetadata
|
||||
// metadata that can come only from tracks
|
||||
@@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) Position() (int64, error) {
|
||||
return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil
|
||||
return int64(secondsToMicroseconds(m.pm.PlaybackStatus().TimePos)), nil
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) MinimumRate() (float64, error) {
|
||||
|
||||
@@ -35,6 +35,7 @@ type playbackCommand struct {
|
||||
Arg any
|
||||
Arg2 any
|
||||
Arg3 any
|
||||
OnDone func()
|
||||
}
|
||||
|
||||
// playbackCommandQueue is a queue to accumulate player commands from the UI
|
||||
@@ -60,11 +61,24 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand {
|
||||
return c.nextChan
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Clear() {
|
||||
c.mutex.Lock()
|
||||
c.queue = nil
|
||||
c.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Stop() {
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdStop})
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) StopAndWait() {
|
||||
done := make(chan struct{})
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdStop, OnDone: func() { close(done) }})
|
||||
<-done
|
||||
}
|
||||
|
||||
func (c *playbackCommandQueue) Continue() {
|
||||
c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop},
|
||||
playbackCommand{Type: cmdContinue})
|
||||
|
||||
+139
-42
@@ -9,10 +9,13 @@ import (
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/backend/util"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
// TODO: make thread-safe
|
||||
|
||||
var (
|
||||
ReplayGainNone = player.ReplayGainNone.String()
|
||||
ReplayGainAlbum = player.ReplayGainAlbum.String()
|
||||
@@ -37,6 +40,14 @@ const (
|
||||
LoopOne
|
||||
)
|
||||
|
||||
type PlaybackState = player.State
|
||||
|
||||
type PlaybackStatus struct {
|
||||
State PlaybackState
|
||||
TimePos float64
|
||||
Duration float64
|
||||
}
|
||||
|
||||
type playbackEngine struct {
|
||||
ctx context.Context
|
||||
cancelPollPos context.CancelFunc
|
||||
@@ -55,9 +66,16 @@ type playbackEngine struct {
|
||||
|
||||
// flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks
|
||||
wasStopped bool // true iff player was stopped before handleOnTrackChange invocation
|
||||
noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremented on the next onTrackChange
|
||||
alreadyScrobbled bool // true iff the previously-playing track was already scrobbled
|
||||
|
||||
// if >= 0, track number that was requested by PlayTrackAt
|
||||
// onTrackChange callback should set nowPlayingIdx to this,
|
||||
// and reset this to -1
|
||||
pendingTrackChangeNum int
|
||||
|
||||
pendingPlayerChange bool
|
||||
pendingPlayerChangeStatus player.Status
|
||||
|
||||
// to pass to onSongChange listeners; clear once listeners have been called
|
||||
lastScrobbled *mediaprovider.Track
|
||||
scrobbleCfg *ScrobbleConfig
|
||||
@@ -102,23 +120,8 @@ func NewPlaybackEngine(
|
||||
case "One":
|
||||
pm.loopMode = LoopOne
|
||||
}
|
||||
p.OnTrackChange(pm.handleOnTrackChange)
|
||||
p.OnSeek(func() {
|
||||
pm.doUpdateTimePos(true)
|
||||
pm.invokeNoArgCallbacks(pm.onSeek)
|
||||
})
|
||||
p.OnStopped(pm.handleOnStopped)
|
||||
p.OnPaused(func() {
|
||||
pm.playTimeStopwatch.Stop()
|
||||
pm.stopPollTimePos()
|
||||
pm.invokeNoArgCallbacks(pm.onPaused)
|
||||
})
|
||||
p.OnPlaying(func() {
|
||||
pm.playTimeStopwatch.Start()
|
||||
pm.startPollTimePos()
|
||||
pm.invokeNoArgCallbacks(pm.onPlaying)
|
||||
})
|
||||
|
||||
pm.registerPlayerCallbacks(p)
|
||||
s.OnLogout(func() {
|
||||
pm.StopAndClearPlayQueue()
|
||||
})
|
||||
@@ -126,18 +129,95 @@ func NewPlaybackEngine(
|
||||
return pm
|
||||
}
|
||||
|
||||
func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) {
|
||||
pl.OnTrackChange(p.handleOnTrackChange)
|
||||
pl.OnSeek(func() {
|
||||
p.doUpdateTimePos(true)
|
||||
p.invokeNoArgCallbacks(p.onSeek)
|
||||
})
|
||||
pl.OnStopped(p.handleOnStopped)
|
||||
pl.OnPaused(func() {
|
||||
p.playTimeStopwatch.Stop()
|
||||
p.stopPollTimePos()
|
||||
p.invokeNoArgCallbacks(p.onPaused)
|
||||
})
|
||||
pl.OnPlaying(func() {
|
||||
p.playTimeStopwatch.Start()
|
||||
p.startPollTimePos()
|
||||
p.invokeNoArgCallbacks(p.onPlaying)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playbackEngine) unregisterPlayerCallbacks(pl player.BasePlayer) {
|
||||
pl.OnPaused(nil)
|
||||
pl.OnPlaying(nil)
|
||||
pl.OnStopped(nil)
|
||||
pl.OnSeek(nil)
|
||||
pl.OnTrackChange(nil)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error {
|
||||
// even if we don't successfully change players,
|
||||
// make sure UI updates if needed (eg enabling cast button)
|
||||
defer p.invokeNoArgCallbacks(p.onPlayerChange)
|
||||
|
||||
needToUnpause := false
|
||||
|
||||
stat := p.CurrentPlayer().GetStatus()
|
||||
if p.pendingPlayerChange {
|
||||
stat.State = player.Paused
|
||||
}
|
||||
|
||||
switch stat.State {
|
||||
case player.Stopped:
|
||||
// nothing
|
||||
case player.Playing:
|
||||
needToUnpause = true
|
||||
p.stopPollTimePos()
|
||||
fallthrough
|
||||
case player.Paused:
|
||||
p.pendingPlayerChangeStatus = stat
|
||||
p.pendingPlayerChange = true
|
||||
}
|
||||
p.unregisterPlayerCallbacks(p.player)
|
||||
if err := p.player.Stop(true); err != nil {
|
||||
log.Printf("failed to stop player: %v", err)
|
||||
}
|
||||
|
||||
oldVol := p.player.GetVolume()
|
||||
if _, isMPV := p.player.(*mpv.Player); !isMPV {
|
||||
p.player.Destroy()
|
||||
}
|
||||
p.player = pl
|
||||
p.registerPlayerCallbacks(pl)
|
||||
|
||||
if needToUnpause {
|
||||
p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
|
||||
p.pendingPlayerChange = false
|
||||
}
|
||||
p.invokeNoArgCallbacks(p.onPlayerChange)
|
||||
vol := pl.GetVolume()
|
||||
if oldVol != vol {
|
||||
for _, cb := range p.onVolumeChange {
|
||||
cb(vol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playbackEngine) PlayTrackAt(idx int) error {
|
||||
return p.playTrackAt(idx, 0)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) playTrackAt(idx int, startTime float64) error {
|
||||
if idx < 0 || idx >= len(p.playQueue) {
|
||||
return errors.New("track index out of range")
|
||||
}
|
||||
// scrobble current track if needed
|
||||
p.checkScrobble()
|
||||
p.alreadyScrobbled = true
|
||||
p.noIncrementNextTrackChange = true
|
||||
err := p.setTrack(idx, false)
|
||||
if err == nil {
|
||||
p.nowPlayingIdx = idx
|
||||
}
|
||||
p.pendingTrackChangeNum = idx
|
||||
err := p.setTrack(idx, false, startTime)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -168,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode {
|
||||
return p.loopMode
|
||||
}
|
||||
|
||||
func (p *playbackEngine) PlayerStatus() player.Status {
|
||||
return p.player.GetStatus()
|
||||
func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
|
||||
stat := p.pendingPlayerChangeStatus
|
||||
if !p.pendingPlayerChange {
|
||||
stat = p.CurrentPlayer().GetStatus()
|
||||
}
|
||||
return PlaybackStatus{
|
||||
State: stat.State,
|
||||
TimePos: stat.TimePos,
|
||||
Duration: stat.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SetVolume(vol int) error {
|
||||
@@ -188,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SeekNext() error {
|
||||
if p.CurrentPlayer().GetStatus().State == player.Stopped {
|
||||
if p.PlaybackStatus().State == player.Stopped {
|
||||
return nil
|
||||
}
|
||||
return p.PlayTrackAt(p.nowPlayingIdx + 1)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) SeekBackOrPrevious() error {
|
||||
if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 {
|
||||
if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 {
|
||||
return p.player.SeekSeconds(0)
|
||||
}
|
||||
return p.PlayTrackAt(p.nowPlayingIdx - 1)
|
||||
@@ -203,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error {
|
||||
|
||||
func (p *playbackEngine) SeekFwdBackN(n int) error {
|
||||
idx := p.nowPlayingIdx
|
||||
if n < 0 && p.player.GetStatus().TimePos > 3 {
|
||||
if n < 0 && p.PlaybackStatus().TimePos > 3 {
|
||||
n += 1 // first seek back is just seek to beginning of current
|
||||
}
|
||||
if n == 0 || (idx == 0 && n < 0) {
|
||||
@@ -230,7 +318,7 @@ func (p *playbackEngine) IsSeeking() bool {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Stop() error {
|
||||
return p.player.Stop()
|
||||
return p.player.Stop(false)
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Pause() error {
|
||||
@@ -238,7 +326,12 @@ func (p *playbackEngine) Pause() error {
|
||||
}
|
||||
|
||||
func (p *playbackEngine) Continue() error {
|
||||
if p.PlayerStatus().State == player.Stopped {
|
||||
if p.pendingPlayerChange {
|
||||
p.pendingPlayerChange = false
|
||||
return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
|
||||
}
|
||||
|
||||
if p.PlaybackStatus().State == player.Stopped {
|
||||
return p.PlayTrackAt(0)
|
||||
}
|
||||
return p.player.Continue()
|
||||
@@ -260,7 +353,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo
|
||||
|
||||
func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error {
|
||||
if insertQueueMode == Replace {
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.nowPlayingIdx = -1
|
||||
p.playQueue = nil
|
||||
}
|
||||
@@ -286,7 +379,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu
|
||||
|
||||
func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) {
|
||||
if insertMode == Replace {
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.nowPlayingIdx = -1
|
||||
p.playQueue = nil
|
||||
}
|
||||
@@ -312,7 +405,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins
|
||||
// Stop playback and clear the play queue.
|
||||
func (p *playbackEngine) StopAndClearPlayQueue() {
|
||||
changed := len(p.playQueue) > 0
|
||||
p.player.Stop()
|
||||
p.player.Stop(false)
|
||||
p.playQueue = nil
|
||||
p.nowPlayingIdx = -1
|
||||
if changed {
|
||||
@@ -408,7 +501,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) {
|
||||
p.Stop()
|
||||
} else {
|
||||
p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player
|
||||
p.setTrack(newNowPlaying, false)
|
||||
p.setTrack(newNowPlaying, false, 0)
|
||||
}
|
||||
// setNextTrack and onSongChange callbacks will be handled
|
||||
// when we receive new track event from player
|
||||
@@ -468,21 +561,23 @@ func (p *playbackEngine) handleOnTrackChange() {
|
||||
p.checkScrobble()
|
||||
}
|
||||
|
||||
if p.player.GetStatus().State == player.Playing {
|
||||
if p.PlaybackStatus().State == player.Playing {
|
||||
p.playTimeStopwatch.Start()
|
||||
}
|
||||
if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) {
|
||||
if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) {
|
||||
p.nowPlayingIdx++
|
||||
if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) {
|
||||
p.nowPlayingIdx = 0 // wrapped around
|
||||
}
|
||||
} else if p.pendingTrackChangeNum >= 0 {
|
||||
p.nowPlayingIdx = p.pendingTrackChangeNum
|
||||
p.pendingTrackChangeNum = -1
|
||||
}
|
||||
nowPlaying := p.playQueue[p.nowPlayingIdx]
|
||||
_, isRadio := nowPlaying.(*mediaprovider.RadioStation)
|
||||
p.isRadio = isRadio
|
||||
|
||||
// reset flags
|
||||
p.noIncrementNextTrackChange = false
|
||||
p.wasStopped = false
|
||||
p.alreadyScrobbled = false
|
||||
|
||||
@@ -548,12 +643,14 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error {
|
||||
if urlP, ok := p.player.(player.URLPlayer); ok {
|
||||
url := ""
|
||||
var meta mediaprovider.MediaItemMetadata
|
||||
if idx >= 0 {
|
||||
var err error
|
||||
item := p.playQueue[idx]
|
||||
meta = item.Metadata()
|
||||
if tr, ok := item.(*mediaprovider.Track); ok {
|
||||
url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile)
|
||||
} else {
|
||||
@@ -564,9 +661,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
}
|
||||
}
|
||||
if next {
|
||||
return urlP.SetNextFile(url)
|
||||
return urlP.SetNextFile(url, meta)
|
||||
}
|
||||
return urlP.PlayFile(url)
|
||||
return urlP.PlayFile(url, meta, startTime)
|
||||
} else if trP, ok := p.player.(player.TrackPlayer); ok {
|
||||
var track *mediaprovider.Track
|
||||
if idx >= 0 {
|
||||
@@ -578,13 +675,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error {
|
||||
if next {
|
||||
return trP.SetNextTrack(track)
|
||||
}
|
||||
return trP.PlayTrack(track)
|
||||
return trP.PlayTrack(track, startTime)
|
||||
}
|
||||
panic("Unsupported player type")
|
||||
}
|
||||
|
||||
func (p *playbackEngine) setNextTrack(idx int) error {
|
||||
return p.setTrack(idx, true)
|
||||
return p.setTrack(idx, true, 0)
|
||||
}
|
||||
|
||||
// call BEFORE updating p.nowPlayingIdx
|
||||
@@ -701,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) {
|
||||
if p.callbacksDisabled {
|
||||
return
|
||||
}
|
||||
s := p.player.GetStatus()
|
||||
s := p.PlaybackStatus()
|
||||
if s.TimePos > p.latestTrackPosition {
|
||||
p.latestTrackPosition = s.TimePos
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ import (
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/dlna"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/supersonic-app/go-upnpcast/device"
|
||||
"github.com/supersonic-app/go-upnpcast/services"
|
||||
)
|
||||
|
||||
// A high-level MediaProvider-aware playback engine, serves as an
|
||||
@@ -22,11 +26,23 @@ type PlaybackManager struct {
|
||||
cmdQueue *playbackCommandQueue
|
||||
cfg *AppConfig
|
||||
|
||||
localPlayer player.BasePlayer
|
||||
remotePlayersLock sync.Mutex
|
||||
remotePlayers []RemotePlaybackDevice
|
||||
currentRemotePlayer *RemotePlaybackDevice
|
||||
|
||||
autoplay bool
|
||||
|
||||
lastPlayTime float64
|
||||
}
|
||||
|
||||
type RemotePlaybackDevice struct {
|
||||
Name string
|
||||
URL string
|
||||
Protocol string
|
||||
new func() (player.BasePlayer, error)
|
||||
}
|
||||
|
||||
func NewPlaybackManager(
|
||||
ctx context.Context,
|
||||
s *ServerManager,
|
||||
@@ -43,6 +59,7 @@ func NewPlaybackManager(
|
||||
cmdQueue: q,
|
||||
cfg: appCfg,
|
||||
autoplay: playbackCfg.Autoplay,
|
||||
localPlayer: p,
|
||||
}
|
||||
pm.addOnTrackChangeHook()
|
||||
go pm.runCmdQueue(ctx)
|
||||
@@ -68,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
||||
return
|
||||
}
|
||||
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment)
|
||||
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlayerStatus().State == player.Playing {
|
||||
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing {
|
||||
p.lastPlayTime = 0
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
@@ -81,6 +98,68 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) {
|
||||
if fastScan {
|
||||
p.scanRemotePlayers(ctx, 1 /*waitSec*/)
|
||||
// continue to slow scan to detect players that take longer to respond
|
||||
}
|
||||
p.scanRemotePlayers(ctx, 10 /*waitSec*/)
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
|
||||
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
|
||||
|
||||
var discovered []RemotePlaybackDevice
|
||||
for _, d := range devices {
|
||||
p := RemotePlaybackDevice{
|
||||
Name: d.FriendlyName,
|
||||
URL: d.URL,
|
||||
Protocol: "DLNA",
|
||||
new: func() (player.BasePlayer, error) {
|
||||
return dlna.NewDLNAPlayer(d)
|
||||
},
|
||||
}
|
||||
discovered = append(discovered, p)
|
||||
}
|
||||
|
||||
p.remotePlayersLock.Lock()
|
||||
p.remotePlayers = discovered
|
||||
p.remotePlayersLock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) RemotePlayers() []RemotePlaybackDevice {
|
||||
p.remotePlayersLock.Lock()
|
||||
players := p.remotePlayers
|
||||
p.remotePlayersLock.Unlock()
|
||||
return players
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice {
|
||||
return p.currentRemotePlayer
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error {
|
||||
p.cmdQueue.Clear()
|
||||
if rp == nil {
|
||||
if err := p.engine.SetPlayer(p.localPlayer); err != nil {
|
||||
return err
|
||||
}
|
||||
p.currentRemotePlayer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
player, err := rp.new()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.engine.SetPlayer(player); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.currentRemotePlayer = rp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) CurrentPlayer() player.BasePlayer {
|
||||
return p.engine.CurrentPlayer()
|
||||
}
|
||||
@@ -397,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool {
|
||||
return p.autoplay
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayerStatus() player.Status {
|
||||
return p.engine.PlayerStatus()
|
||||
func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
|
||||
return p.engine.PlaybackStatus()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetVolume(vol int) {
|
||||
@@ -431,7 +510,7 @@ func (p *PlaybackManager) SeekSeconds(sec float64) {
|
||||
|
||||
// Seek by given relative position in the current track by seconds.
|
||||
func (p *PlaybackManager) SeekBySeconds(sec float64) {
|
||||
status := p.engine.PlayerStatus()
|
||||
status := p.engine.PlaybackStatus()
|
||||
target := status.TimePos + sec
|
||||
if target < 0 {
|
||||
target = 0
|
||||
@@ -456,6 +535,10 @@ func (p *PlaybackManager) Stop() {
|
||||
p.cmdQueue.Stop()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) Shutdown() {
|
||||
p.cmdQueue.StopAndWait()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) Pause() {
|
||||
p.cmdQueue.Pause()
|
||||
}
|
||||
@@ -465,7 +548,7 @@ func (p *PlaybackManager) Continue() {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayPause() {
|
||||
switch p.engine.PlayerStatus().State {
|
||||
switch p.engine.PlaybackStatus().State {
|
||||
case player.Playing:
|
||||
p.Pause()
|
||||
case player.Paused:
|
||||
@@ -514,7 +597,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
if len(tr.ArtistIDs) > 0 {
|
||||
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get similar tracks: %v", err)
|
||||
log.Printf("autoplay error: failed to get similar tracks: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(similar)
|
||||
}
|
||||
@@ -527,7 +610,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
}
|
||||
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get tracks by genre: %v", err)
|
||||
log.Printf("autoplay error: failed to get tracks by genre: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(byGenre)
|
||||
if len(tracks) > 0 {
|
||||
@@ -542,7 +625,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
// fallback to random tracks
|
||||
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
|
||||
if err != nil {
|
||||
log.Println("autoplay error: failed to get random tracks: %v", err)
|
||||
log.Printf("autoplay error: failed to get random tracks: %v", err)
|
||||
}
|
||||
tracks = filterRecentlyPlayed(random)
|
||||
}
|
||||
@@ -556,7 +639,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() {
|
||||
func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
|
||||
logIfErr := func(action string, err error) {
|
||||
if err != nil {
|
||||
log.Println("Playback error (%s): %v", action, err)
|
||||
log.Printf("Playback error (%s): %v", action, err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
@@ -606,6 +689,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
|
||||
mpv.ForceRestartPlayback()
|
||||
}
|
||||
}
|
||||
if c.OnDone != nil {
|
||||
c.OnDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) Stop() error {
|
||||
func (j *JukeboxPlayer) Stop(_ bool) error {
|
||||
if j.state == stopped {
|
||||
return nil
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error {
|
||||
func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error {
|
||||
if err := j.provider.JukeboxSet(track.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JukeboxPlayer) Destroy() {}
|
||||
|
||||
func (j *JukeboxPlayer) startAndUpdateTime() error {
|
||||
beforeStart := time.Now()
|
||||
if err := j.provider.JukeboxStart(); err != nil {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/supersonic-app/go-mpv"
|
||||
)
|
||||
@@ -67,6 +69,9 @@ type Player struct {
|
||||
equalizer Equalizer
|
||||
peaksEnabled bool
|
||||
|
||||
fileLoadedLock sync.Mutex
|
||||
fileLoadedSig *sync.Cond
|
||||
|
||||
bgCancel context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -79,10 +84,12 @@ func New() *Player {
|
||||
// Same as New, but sets the application name that mpv
|
||||
// reports to the system audio API.
|
||||
func NewWithClientName(c string) *Player {
|
||||
return &Player{
|
||||
p := &Player{
|
||||
vol: -1, // use 100 in Init
|
||||
clientName: c,
|
||||
}
|
||||
p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock)
|
||||
return p
|
||||
}
|
||||
|
||||
// Initializes the Player and makes it ready for playback.
|
||||
@@ -133,23 +140,32 @@ func (p *Player) Init(maxCacheMB int) error {
|
||||
}
|
||||
|
||||
// Plays the specified file, clearing the previous play queue, if any.
|
||||
func (p *Player) PlayFile(url string) error {
|
||||
func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTime float64) error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
err := p.mpv.Command([]string{"loadfile", url, "replace"})
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.lenPlaylist = 1
|
||||
if p.status.State == player.Paused {
|
||||
return p.Continue()
|
||||
}
|
||||
err = p.Continue()
|
||||
} else {
|
||||
p.setState(player.Playing)
|
||||
}
|
||||
if startTime > 0 {
|
||||
p.fileLoadedLock.Lock()
|
||||
p.fileLoadedSig.Wait()
|
||||
p.fileLoadedLock.Unlock()
|
||||
p.SeekSeconds(startTime)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Stops playback and clears the play queue.
|
||||
func (p *Player) Stop() error {
|
||||
func (p *Player) Stop(_ bool) error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
@@ -169,7 +185,7 @@ func (p *Player) Stop() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Player) SetNextFile(url string) error {
|
||||
func (p *Player) SetNextFile(url string, _ mediaprovider.MediaItemMetadata) error {
|
||||
if p.lenPlaylist > p.curPlaylistPos+1 {
|
||||
if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil {
|
||||
return err
|
||||
@@ -480,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
}
|
||||
switch e.Event_Id {
|
||||
case mpv.EVENT_PLAYBACK_RESTART:
|
||||
if p.seeking {
|
||||
p.seeking = false
|
||||
}
|
||||
fallthrough
|
||||
case mpv.EVENT_SEEK:
|
||||
p.seeking = false
|
||||
p.InvokeOnSeek()
|
||||
case mpv.EVENT_FILE_LOADED:
|
||||
p.curPlaylistPos, _ = p.getInt64Property("playlist-pos")
|
||||
@@ -493,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
p.InvokeOnSeek()
|
||||
}
|
||||
p.InvokeOnTrackChange()
|
||||
p.fileLoadedSig.Signal()
|
||||
case mpv.EVENT_IDLE:
|
||||
p.status.Duration = 0
|
||||
p.status.TimePos = 0
|
||||
|
||||
+32
-28
@@ -1,23 +1,25 @@
|
||||
package player
|
||||
|
||||
import "github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
)
|
||||
|
||||
type URLPlayer interface {
|
||||
BasePlayer
|
||||
PlayFile(url string) error
|
||||
SetNextFile(url string) error
|
||||
PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error
|
||||
SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error
|
||||
}
|
||||
|
||||
type TrackPlayer interface {
|
||||
BasePlayer
|
||||
PlayTrack(track *mediaprovider.Track) error
|
||||
PlayTrack(track *mediaprovider.Track, startTime float64) error
|
||||
SetNextTrack(track *mediaprovider.Track) error
|
||||
}
|
||||
|
||||
type BasePlayer interface {
|
||||
Continue() error
|
||||
Pause() error
|
||||
Stop() error
|
||||
Stop(force bool) error
|
||||
|
||||
SeekSeconds(secs float64) error
|
||||
IsSeeking() bool
|
||||
@@ -27,6 +29,8 @@ type BasePlayer interface {
|
||||
|
||||
GetStatus() Status
|
||||
|
||||
Destroy()
|
||||
|
||||
// Event API
|
||||
OnPaused(func())
|
||||
OnStopped(func())
|
||||
@@ -84,66 +88,66 @@ func (r ReplayGainMode) String() string {
|
||||
}
|
||||
|
||||
type BasePlayerCallbackImpl struct {
|
||||
onPaused []func()
|
||||
onStopped []func()
|
||||
onPlaying []func()
|
||||
onSeek []func()
|
||||
onTrackChange []func()
|
||||
onPaused func()
|
||||
onStopped func()
|
||||
onPlaying func()
|
||||
onSeek func()
|
||||
onTrackChange func()
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Paused state.
|
||||
// Sets a callback which is invoked when the player transitions to the Paused state.
|
||||
func (p *BasePlayerCallbackImpl) OnPaused(cb func()) {
|
||||
p.onPaused = append(p.onPaused, cb)
|
||||
p.onPaused = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Stopped state.
|
||||
// Sets a callback which is invoked when the player transitions to the Stopped state.
|
||||
func (p *BasePlayerCallbackImpl) OnStopped(cb func()) {
|
||||
p.onStopped = append(p.onStopped, cb)
|
||||
p.onStopped = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the player transitions to the Playing state.
|
||||
// Sets a callback which is invoked when the player transitions to the Playing state.
|
||||
func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) {
|
||||
p.onPlaying = append(p.onPlaying, cb)
|
||||
p.onPlaying = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked whenever a seek event occurs.
|
||||
func (p *BasePlayerCallbackImpl) OnSeek(cb func()) {
|
||||
p.onSeek = append(p.onSeek, cb)
|
||||
p.onSeek = cb
|
||||
}
|
||||
|
||||
// Registers a callback which is invoked when the currently playing track changes,
|
||||
// or when playback begins at any time from the Stopped state.
|
||||
// Callback is invoked with the index of the currently playing track (zero-based).
|
||||
func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) {
|
||||
p.onTrackChange = append(p.onTrackChange, cb)
|
||||
p.onTrackChange = cb
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnPaused() {
|
||||
for _, cb := range p.onPaused {
|
||||
cb()
|
||||
if p.onPaused != nil {
|
||||
p.onPaused()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnPlaying() {
|
||||
for _, cb := range p.onPlaying {
|
||||
cb()
|
||||
if p.onPlaying != nil {
|
||||
p.onPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnStopped() {
|
||||
for _, cb := range p.onStopped {
|
||||
cb()
|
||||
if p.onStopped != nil {
|
||||
p.onStopped()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnSeek() {
|
||||
for _, cb := range p.onSeek {
|
||||
cb()
|
||||
if p.onSeek != nil {
|
||||
p.onSeek()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() {
|
||||
for _, cb := range p.onTrackChange {
|
||||
cb()
|
||||
if p.onTrackChange != nil {
|
||||
p.onTrackChange()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct {
|
||||
// If the provided CanSavePlayQueue server is non-nil, it will also save to the server.
|
||||
func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
|
||||
queue := pm.GetPlayQueue()
|
||||
stats := pm.PlayerStatus()
|
||||
stats := pm.PlaybackStatus()
|
||||
trackIdx := pm.NowPlayingIndex()
|
||||
|
||||
trackIDs := make([]string, 0, len(queue))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error {
|
||||
_, err = io.Copy(fout, fin)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetLocalIP() (string, error) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipnet, ok := addr.(*net.IPNet)
|
||||
if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no suitable interface found")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/dweymouth/supersonic
|
||||
|
||||
go 1.21
|
||||
go 1.21.9
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.6.0-beta1
|
||||
@@ -14,12 +14,14 @@ require (
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/pelletier/go-toml/v2 v2.0.8
|
||||
github.com/quarckster/go-mpris-server v1.0.3
|
||||
github.com/supersonic-app/go-mpv v0.1.0
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5
|
||||
github.com/zalando/go-keyring v0.2.6
|
||||
golang.org/x/net v0.25.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.30.0
|
||||
golang.org/x/text v0.22.0
|
||||
)
|
||||
@@ -39,8 +41,11 @@ require (
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/koron/go-ssdp v0.0.5 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
||||
@@ -27,6 +27,8 @@ github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde h1:vSJQkJW
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde/go.mod h1:ON11afuS9jVyN+nfEtITv3jX1H+JD+Z50gxCiEC1zLw=
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs=
|
||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||
@@ -57,12 +59,26 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk=
|
||||
github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
@@ -97,6 +113,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0
|
||||
github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 h1:aoUJKPFD/ZrNZjK6fl2Xhazwsttx42esKxhSSzEE3Bo=
|
||||
github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5/go.mod h1:ibt19zDV5/vvF14jHJpTv3AOorq1EbmrMAubxnuvR5Y=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
@@ -113,8 +131,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
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 -append -prefix Res icons/coreui/playlist-add-next.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/freepik/playbutton.png >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/cast.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/disc.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/headphones.svg >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go
|
||||
|
||||
@@ -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",
|
||||
"The request timed out": "The request timed out",
|
||||
"Theme": "Theme",
|
||||
"This computer": "This computer",
|
||||
"Time": "Time",
|
||||
"Title": "Title",
|
||||
"Title (A-Z)": "Title (A-Z)",
|
||||
|
||||
@@ -3,6 +3,7 @@ package ui
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
@@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
|
||||
pm.OnVolumeChange(func(vol int) {
|
||||
fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) })
|
||||
})
|
||||
pm.OnPlayerChange(func() {
|
||||
_, local := pm.CurrentPlayer().(*mpv.Player)
|
||||
fyne.Do(func() { bp.AuxControls.SetIsRemotePlayer(!local) })
|
||||
})
|
||||
bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
|
||||
pm.SetVolume(v)
|
||||
}
|
||||
@@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
|
||||
pm.SetAutoplay(autoplay)
|
||||
}
|
||||
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
|
||||
bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu)
|
||||
|
||||
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() {
|
||||
}
|
||||
switch a.tabs.SelectedIndex() {
|
||||
case 1: /*lyrics*/
|
||||
a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos
|
||||
a.lastPlayPos = a.pm.PlaybackStatus().TimePos
|
||||
a.updateLyrics()
|
||||
case 2: /*related*/
|
||||
a.updateRelatedList()
|
||||
@@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) {
|
||||
|
||||
func (a *NowPlayingPage) formatStatusLine() {
|
||||
curPlayer := a.pm.CurrentPlayer()
|
||||
playerStats := curPlayer.GetStatus()
|
||||
playerStats := a.pm.PlaybackStatus()
|
||||
lastStatus := a.statusLabel.Text
|
||||
stopped := lang.L("Stopped")
|
||||
state := stopped
|
||||
|
||||
@@ -151,6 +151,50 @@ func (m *Controller) HaveModal() bool {
|
||||
return m.haveModal
|
||||
}
|
||||
|
||||
func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) {
|
||||
rp := m.App.PlaybackManager.CurrentRemotePlayer()
|
||||
devices := m.App.PlaybackManager.RemotePlayers()
|
||||
local := fyne.NewMenuItem(lang.L("This computer"), func() {
|
||||
if rp == nil {
|
||||
return // no-op. already not casting
|
||||
}
|
||||
onPendingPlayerChange()
|
||||
go func() {
|
||||
if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil {
|
||||
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") })
|
||||
}
|
||||
}()
|
||||
})
|
||||
local.Icon = theme.ComputerIcon()
|
||||
local.Checked = rp == nil
|
||||
|
||||
menu := fyne.NewMenu("", local)
|
||||
for _, d := range devices {
|
||||
_d := d
|
||||
isCurrent := rp != nil && _d.URL == rp.URL
|
||||
item := fyne.NewMenuItem(d.Name, func() {
|
||||
if isCurrent {
|
||||
return // no-op.
|
||||
}
|
||||
onPendingPlayerChange()
|
||||
go func() {
|
||||
if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil {
|
||||
fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) })
|
||||
}
|
||||
}()
|
||||
})
|
||||
item.Icon = myTheme.CastIcon
|
||||
item.Checked = isCurrent
|
||||
menu.Items = append(menu.Items, item)
|
||||
}
|
||||
pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas())
|
||||
canvasSize := m.MainWindow.Canvas().Size()
|
||||
pop.ShowAtPosition(fyne.NewPos(
|
||||
canvasSize.Width-pop.MinSize().Width-10,
|
||||
canvasSize.Height-pop.MinSize().Height-100,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *Controller) ShowPopUpPlayQueue() {
|
||||
m.popUpQueueMutex.Lock()
|
||||
if m.popUpQueue == nil {
|
||||
@@ -270,6 +314,7 @@ func (c *Controller) ShowAboutDialog() {
|
||||
}
|
||||
|
||||
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
|
||||
|
||||
devs, err := c.App.LocalPlayer.ListAudioDevices()
|
||||
if err != nil {
|
||||
log.Printf("error listing audio devices: %v", err)
|
||||
@@ -282,6 +327,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
_, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
|
||||
isLocalPlayer := isEqualizerPlayer
|
||||
bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
|
||||
|
||||
dlg := dialogs.NewSettingsDialog(c.App.Config,
|
||||
devs, themeFiles, bands,
|
||||
c.App.ServerManager.Server.ClientDecidesScrobble(),
|
||||
@@ -317,6 +363,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
c.ClosePopUpOnEscape(pop)
|
||||
c.haveModal = true
|
||||
pop.Show()
|
||||
|
||||
}
|
||||
|
||||
func (c *Controller) doModalClosed() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"github.com/dweymouth/supersonic/backend/player"
|
||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||
"github.com/dweymouth/supersonic/ui/shortcuts"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
"github.com/dweymouth/supersonic/ui/visualizations"
|
||||
@@ -21,12 +22,14 @@ type visualizationData struct {
|
||||
}
|
||||
|
||||
func (c *Controller) initVisualizations() {
|
||||
c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim)
|
||||
c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim)
|
||||
c.App.LocalPlayer.OnPlaying(func() {
|
||||
c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim)
|
||||
c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim)
|
||||
c.App.PlaybackManager.OnPlaying(func() {
|
||||
if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok {
|
||||
if c.peakMeter != nil {
|
||||
c.startVisualizationAnim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,13 +74,13 @@ func (c *Controller) stopVisualizationAnim() {
|
||||
if c.visualizationAnim != nil {
|
||||
c.visualizationAnim.Stop()
|
||||
c.visualizationAnim = nil
|
||||
c.App.LocalPlayer.SetPeaksEnabled(false)
|
||||
// c.App.LocalPlayer.SetPeaksEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) startVisualizationAnim() {
|
||||
if c.visualizationAnim == nil {
|
||||
c.App.LocalPlayer.SetPeaksEnabled(true)
|
||||
//c.App.LocalPlayer.SetPeaksEnabled(true)
|
||||
c.visualizationAnim = fyne.NewAnimation(
|
||||
time.Duration(math.MaxInt64), /*until stopped*/
|
||||
c.tickVisualizations)
|
||||
@@ -86,8 +89,8 @@ func (c *Controller) startVisualizationAnim() {
|
||||
}
|
||||
|
||||
func (c *Controller) tickVisualizations(_ float32) {
|
||||
lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
|
||||
//lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks()
|
||||
if c.visualizationData.peakMeter != nil {
|
||||
c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
|
||||
//c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ var (
|
||||
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
|
||||
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
|
||||
AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
|
||||
CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg)
|
||||
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
|
||||
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
|
||||
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
|
||||
|
||||
@@ -2,6 +2,7 @@ package widgets
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
@@ -25,6 +26,7 @@ type AuxControls struct {
|
||||
VolumeControl *VolumeControl
|
||||
autoplay *IconButton
|
||||
loop *IconButton
|
||||
cast *IconButton
|
||||
showQueue *IconButton
|
||||
|
||||
container *fyne.Container
|
||||
@@ -35,10 +37,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
VolumeControl: NewVolumeControl(initialVolume),
|
||||
autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
|
||||
loop: NewIconButton(myTheme.RepeatIcon, nil),
|
||||
cast: NewIconButton(myTheme.CastIcon, nil),
|
||||
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
|
||||
}
|
||||
|
||||
a.loop.IconSize = IconButtonSizeSmaller
|
||||
a.loop.SetToolTip(lang.L("Repeat"))
|
||||
a.SetLoopMode(initialLoopMode)
|
||||
|
||||
a.cast.IconSize = IconButtonSizeSmaller
|
||||
a.cast.SetToolTip(lang.L("Cast to device"))
|
||||
|
||||
a.autoplay.Highlighted = initialAutoplay
|
||||
//a.autoplay.IconSize = IconButtonSizeSmaller
|
||||
a.autoplay.SetToolTip(lang.L("Autoplay"))
|
||||
@@ -48,9 +57,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
a.OnChangeAutoplay(a.autoplay.Highlighted)
|
||||
}
|
||||
}
|
||||
a.SetLoopMode(initialLoopMode)
|
||||
|
||||
a.showQueue.IconSize = IconButtonSizeSmaller
|
||||
a.showQueue.SetToolTip(lang.L("Show play queue"))
|
||||
|
||||
a.container = container.NewHBox(
|
||||
layout.NewSpacer(),
|
||||
container.NewVBox(
|
||||
@@ -58,7 +68,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial
|
||||
a.VolumeControl,
|
||||
container.New(
|
||||
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5),
|
||||
layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)),
|
||||
layout.NewSpacer(), a.autoplay, a.loop, a.cast, a.showQueue, util.NewHSpace(5)),
|
||||
layout.NewSpacer(),
|
||||
),
|
||||
)
|
||||
@@ -88,6 +98,16 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuxControls) DisableCastButton() {
|
||||
a.cast.Disable()
|
||||
}
|
||||
|
||||
func (a *AuxControls) SetIsRemotePlayer(isRemote bool) {
|
||||
a.cast.Enable()
|
||||
a.cast.Highlighted = isRemote
|
||||
a.cast.Refresh()
|
||||
}
|
||||
|
||||
func (a *AuxControls) SetAutoplay(autoplay bool) {
|
||||
if autoplay == a.autoplay.Highlighted {
|
||||
return
|
||||
@@ -100,6 +120,12 @@ func (a *AuxControls) OnShowPlayQueue(f func()) {
|
||||
a.showQueue.OnTapped = f
|
||||
}
|
||||
|
||||
func (a *AuxControls) OnShowCastMenu(f func(func())) {
|
||||
a.cast.OnTapped = func() {
|
||||
f(a.DisableCastButton)
|
||||
}
|
||||
}
|
||||
|
||||
type volumeSlider struct {
|
||||
widget.Slider
|
||||
|
||||
@@ -145,6 +171,10 @@ type VolumeControl struct {
|
||||
muted bool
|
||||
lastVol int
|
||||
|
||||
setVolDebouncer func()
|
||||
delaySetVolume bool
|
||||
pendingVolume int
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
@@ -160,6 +190,15 @@ func NewVolumeControl(initialVol int) *VolumeControl {
|
||||
v.slider.Orientation = widget.Horizontal
|
||||
v.slider.Value = float64(v.lastVol)
|
||||
v.slider.OnChanged = v.onChanged
|
||||
|
||||
// for players that are slow to respond to volume changes
|
||||
// (e.g. DLNA), delay responding to the SetVolume call
|
||||
// to avoid hiccuping from callback "echoes"
|
||||
v.setVolDebouncer = util.NewDebouncer(100*time.Millisecond, func() {
|
||||
v.delaySetVolume = false
|
||||
v.SetVolume(v.pendingVolume)
|
||||
})
|
||||
|
||||
v.container = container.NewHBox(container.NewCenter(v.icon), v.slider)
|
||||
return v
|
||||
}
|
||||
@@ -167,6 +206,11 @@ func NewVolumeControl(initialVol int) *VolumeControl {
|
||||
// Sets the volume that is displayed in the slider.
|
||||
// Does not invoke OnSetVolume callback.
|
||||
func (v *VolumeControl) SetVolume(vol int) {
|
||||
if v.delaySetVolume {
|
||||
v.pendingVolume = vol
|
||||
return
|
||||
}
|
||||
|
||||
if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) {
|
||||
return
|
||||
}
|
||||
@@ -177,6 +221,8 @@ func (v *VolumeControl) SetVolume(vol int) {
|
||||
|
||||
func (v *VolumeControl) onChanged(volume float64) {
|
||||
vol := int(volume)
|
||||
v.delaySetVolume = true
|
||||
v.setVolDebouncer()
|
||||
v.lastVol = vol
|
||||
v.muted = false
|
||||
v.updateIconForVolume(vol)
|
||||
|
||||
Reference in New Issue
Block a user