817 lines
23 KiB
Go
817 lines
23 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"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
|
|
// intermediary between the frontend and various Player backends.
|
|
type PlaybackManager struct {
|
|
engine *playbackEngine
|
|
wfmGen *WaveformImageGenerator
|
|
cache *AudioCache
|
|
cmdQueue *playbackCommandQueue
|
|
cfg *AppConfig
|
|
|
|
localPlayer player.BasePlayer
|
|
remotePlayersLock sync.Mutex
|
|
remotePlayers []RemotePlaybackDevice
|
|
currentRemotePlayer *RemotePlaybackDevice
|
|
|
|
onWaveformImgUpdate []func(*WaveformImage)
|
|
|
|
autoplay bool
|
|
|
|
lastPlayTime float64
|
|
lastPlayingID string
|
|
wfmUpdateImageCancel context.CancelFunc
|
|
wfmImageJobs [3]*WaveformImageJob
|
|
}
|
|
|
|
type RemotePlaybackDevice struct {
|
|
Name string
|
|
URL string
|
|
Protocol string
|
|
new func() (player.BasePlayer, error)
|
|
}
|
|
|
|
func NewPlaybackManager(
|
|
ctx context.Context,
|
|
s *ServerManager,
|
|
c *AudioCache,
|
|
p player.BasePlayer,
|
|
playbackCfg *PlaybackConfig,
|
|
scrobbleCfg *ScrobbleConfig,
|
|
transcodeCfg *TranscodingConfig,
|
|
appCfg *AppConfig,
|
|
) *PlaybackManager {
|
|
e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg)
|
|
q := NewCommandQueue()
|
|
pm := &PlaybackManager{
|
|
engine: e,
|
|
cmdQueue: q,
|
|
cfg: appCfg,
|
|
autoplay: playbackCfg.Autoplay,
|
|
localPlayer: p,
|
|
cache: c,
|
|
}
|
|
if c != nil {
|
|
pm.wfmGen = NewWaveformImageGenerator(c)
|
|
}
|
|
pm.addOnTrackChangeHook()
|
|
go pm.runCmdQueue(ctx)
|
|
return pm
|
|
}
|
|
|
|
func (p *PlaybackManager) findWfmImageJob(id string, uncanceledOnly bool) (*WaveformImageJob, bool) {
|
|
for _, j := range p.wfmImageJobs {
|
|
if j != nil && j.ItemID == id && (!uncanceledOnly || !j.Canceled()) {
|
|
return j, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (p *PlaybackManager) addWfmImageJob(job *WaveformImageJob) {
|
|
p.wfmImageJobs[0].Cancel()
|
|
p.wfmImageJobs[0] = p.wfmImageJobs[1]
|
|
p.wfmImageJobs[1] = p.wfmImageJobs[2]
|
|
p.wfmImageJobs[2] = job
|
|
}
|
|
|
|
func (p *PlaybackManager) addOnTrackChangeHook() {
|
|
// See https://github.com/dweymouth/supersonic/issues/483
|
|
// On Windows, MPV sometimes fails to start playback when switching to a track
|
|
// with a different sample rate than the previous. If this is detected,
|
|
// send a command to the MPV player to force restart playback.
|
|
p.OnPlayTimeUpdate(func(curTime, _ float64, _ bool) {
|
|
p.lastPlayTime = curTime
|
|
})
|
|
|
|
p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) {
|
|
if p.engine.playbackCfg.UseWaveformSeekbar {
|
|
if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
|
if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok {
|
|
// start generating waveform image for next-up track
|
|
p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track)))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
|
// Autoplay if enabled and we are on the last track
|
|
if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
|
|
p.enqueueAutoplayTracks()
|
|
}
|
|
p.handleWaveformImageSongChange(item)
|
|
|
|
if runtime.GOOS != "windows" {
|
|
return
|
|
}
|
|
// workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment)
|
|
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing {
|
|
p.lastPlayTime = 0
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
if p.lastPlayTime == 0 {
|
|
log.Println("Play stall detected!")
|
|
p.cmdQueue.addCommand(playbackCommand{Type: cmdForceRestartPlayback})
|
|
}
|
|
}()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.MediaItem) {
|
|
if p.wfmUpdateImageCancel != nil {
|
|
p.wfmUpdateImageCancel()
|
|
}
|
|
if !p.engine.playbackCfg.UseWaveformSeekbar {
|
|
return
|
|
}
|
|
|
|
updateUnfinishedJob := func(job *WaveformImageJob) {
|
|
ctx, c := context.WithCancel(p.cache.rootCtx)
|
|
p.wfmUpdateImageCancel = c
|
|
go func(ctx context.Context, job *WaveformImageJob) {
|
|
for {
|
|
time.Sleep(333 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
img := job.Get()
|
|
for _, cb := range p.onWaveformImgUpdate {
|
|
cb(img)
|
|
}
|
|
if job.Done() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}(ctx, job)
|
|
}
|
|
|
|
if item != nil {
|
|
// cancel possible waveform generation job for previous track
|
|
if p.lastPlayingID != item.Metadata().ID {
|
|
if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok {
|
|
old.Cancel()
|
|
}
|
|
p.lastPlayingID = item.Metadata().ID
|
|
}
|
|
|
|
var job *WaveformImageJob
|
|
if j, ok := p.findWfmImageJob(item.Metadata().ID, true); ok {
|
|
job = j
|
|
} else if tr, ok := item.(*mediaprovider.Track); ok {
|
|
job = p.wfmGen.StartWaveformGeneration(tr)
|
|
p.addWfmImageJob(job)
|
|
}
|
|
if job != nil {
|
|
img := job.Get()
|
|
for _, cb := range p.onWaveformImgUpdate {
|
|
cb(img)
|
|
}
|
|
if !job.done && job != nil {
|
|
updateUnfinishedJob(job)
|
|
}
|
|
}
|
|
}
|
|
|
|
if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack {
|
|
// set a zero waveform image when we're not playing anything
|
|
// or playing a media type we can't derive a waveform from (e.g. radio)
|
|
img := NewWaveformImage()
|
|
for _, cb := range p.onWaveformImgUpdate {
|
|
cb(img)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
func (p *PlaybackManager) OnPlayerChange(cb func()) {
|
|
p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb)
|
|
}
|
|
|
|
func (p *PlaybackManager) OnWaveformImgUpdate(cb func(*WaveformImage)) {
|
|
p.onWaveformImgUpdate = append(p.onWaveformImgUpdate, cb)
|
|
}
|
|
|
|
func (p *PlaybackManager) IsSeeking() bool {
|
|
return p.engine.IsSeeking()
|
|
}
|
|
|
|
// Should only be called before quitting.
|
|
// Disables playback state callbacks being sent
|
|
func (p *PlaybackManager) DisableCallbacks() {
|
|
p.engine.callbacksDisabled = true
|
|
}
|
|
|
|
// Gets the now playing media item, if any.
|
|
func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem {
|
|
return p.engine.NowPlaying()
|
|
}
|
|
|
|
func (p *PlaybackManager) NowPlayingIndex() int {
|
|
return p.engine.NowPlayingIndex()
|
|
}
|
|
|
|
// Sets a callback that is notified whenever a new song begins playing.
|
|
func (p *PlaybackManager) OnSongChange(cb func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)) {
|
|
p.engine.onSongChange = append(p.engine.onSongChange, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the play time should be updated.
|
|
func (p *PlaybackManager) OnPlayTimeUpdate(cb func(curTime float64, totalTime float64, seeked bool)) {
|
|
p.engine.onPlayTimeUpdate = append(p.engine.onPlayTimeUpdate, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the loop mode changes.
|
|
func (p *PlaybackManager) OnLoopModeChange(cb func(LoopMode)) {
|
|
p.engine.onLoopModeChange = append(p.engine.onLoopModeChange, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the volume changes.
|
|
func (p *PlaybackManager) OnVolumeChange(cb func(int)) {
|
|
p.engine.onVolumeChange = append(p.engine.onVolumeChange, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the play queue changes.
|
|
func (p *PlaybackManager) OnQueueChange(cb func()) {
|
|
p.engine.onQueueChange = append(p.engine.onQueueChange, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the player has been seeked.
|
|
func (p *PlaybackManager) OnSeek(cb func()) {
|
|
p.engine.onSeek = append(p.engine.onSeek, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the player has been paused.
|
|
func (p *PlaybackManager) OnPaused(cb func()) {
|
|
p.engine.onPaused = append(p.engine.onPaused, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the player is stopped.
|
|
func (p *PlaybackManager) OnStopped(cb func()) {
|
|
p.engine.onStopped = append(p.engine.onStopped, cb)
|
|
}
|
|
|
|
// Registers a callback that is notified whenever the player begins playing.
|
|
func (p *PlaybackManager) OnPlaying(cb func()) {
|
|
p.engine.onPlaying = append(p.engine.onPlaying, cb)
|
|
}
|
|
|
|
// Loads the specified album into the play queue.
|
|
func (p *PlaybackManager) LoadAlbum(albumID string, insertQueueMode InsertQueueMode, shuffle bool) error {
|
|
album, err := p.engine.sm.Server.GetAlbum(albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.LoadTracks(album.Tracks, insertQueueMode, shuffle)
|
|
return nil
|
|
}
|
|
|
|
// Loads the specified playlist into the play queue.
|
|
func (p *PlaybackManager) LoadPlaylist(playlistID string, insertQueueMode InsertQueueMode, shuffle bool) error {
|
|
playlist, err := p.engine.sm.Server.GetPlaylist(playlistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.LoadTracks(playlist.Tracks, insertQueueMode, shuffle)
|
|
return nil
|
|
}
|
|
|
|
// Load tracks into the play queue.
|
|
// If replacing the current queue (!appendToQueue), playback will be stopped.
|
|
func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueMode InsertQueueMode, shuffle bool) {
|
|
items := copyTrackSliceToMediaItemSlice(tracks)
|
|
p.cmdQueue.LoadItems(items, insertQueueMode, shuffle)
|
|
}
|
|
|
|
// Load items into the play queue.
|
|
// If replacing the current queue (!appendToQueue), playback will be stopped.
|
|
func (p *PlaybackManager) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) {
|
|
p.cmdQueue.LoadItems(items, insertQueueMode, shuffle)
|
|
}
|
|
|
|
// Replaces the play queue with the given set of tracks.
|
|
// Does not stop playback if the currently playing track is in the new queue,
|
|
// but updates the now playing index to point to the first instance of the track in the new queue.
|
|
func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) {
|
|
p.cmdQueue.UpdatePlayQueue(items)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error {
|
|
if err := p.LoadAlbum(albumID, Replace, shuffle); err != nil {
|
|
return err
|
|
}
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainAlbum)
|
|
}
|
|
p.PlayTrackAt(firstTrack)
|
|
return nil
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffle bool) error {
|
|
if err := p.LoadPlaylist(playlistID, Replace, shuffle); err != nil {
|
|
return err
|
|
}
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainTrack)
|
|
}
|
|
p.PlayTrackAt(firstTrack)
|
|
return nil
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayTrack(trackID string) error {
|
|
tr, err := p.engine.sm.Server.GetTrack(trackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.LoadTracks([]*mediaprovider.Track{tr}, Replace, false)
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainTrack)
|
|
}
|
|
p.PlayFromBeginning()
|
|
return nil
|
|
}
|
|
|
|
func (p *PlaybackManager) ShuffleArtistAlbums(artistID string) {
|
|
artist, err := p.engine.sm.Server.GetArtist(artistID)
|
|
if err != nil {
|
|
log.Printf("failed to get artist: %v\n", err)
|
|
return
|
|
}
|
|
if len(artist.Albums) == 0 {
|
|
return
|
|
}
|
|
|
|
rand.Shuffle(len(artist.Albums), func(i, j int) {
|
|
artist.Albums[i], artist.Albums[j] = artist.Albums[j], artist.Albums[i]
|
|
})
|
|
p.StopAndClearPlayQueue()
|
|
for _, al := range artist.Albums {
|
|
p.LoadAlbum(al.ID, Append, false)
|
|
}
|
|
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainAlbum)
|
|
}
|
|
p.PlayFromBeginning()
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayArtistDiscography(artistID string, shuffleTracks bool) {
|
|
tr, err := p.engine.sm.Server.GetArtistTracks(artistID)
|
|
if err != nil {
|
|
log.Printf("failed to get artist tracks: %v\n", err)
|
|
return
|
|
}
|
|
p.LoadTracks(tr, Replace, shuffleTracks)
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
if shuffleTracks {
|
|
p.SetReplayGainMode(player.ReplayGainTrack)
|
|
} else {
|
|
p.SetReplayGainMode(player.ReplayGainAlbum)
|
|
}
|
|
}
|
|
p.PlayFromBeginning()
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayFromBeginning() {
|
|
p.cmdQueue.PlayTrackAt(0)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayTrackAt(idx int) {
|
|
p.cmdQueue.PlayTrackAt(idx)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayRandomSongs(genreName string) error {
|
|
return p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) {
|
|
return p.engine.sm.Server.GetRandomTracks(genreName, p.cfg.EnqueueBatchSize)
|
|
})
|
|
}
|
|
|
|
func (p *PlaybackManager) PlaySimilarSongs(id string) error {
|
|
return p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) {
|
|
return p.engine.sm.Server.GetSimilarTracks(id, p.cfg.EnqueueBatchSize)
|
|
})
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayRandomAlbums(genreName string) error {
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainAlbum)
|
|
}
|
|
|
|
mp := p.engine.sm.Server
|
|
var options mediaprovider.AlbumFilterOptions
|
|
if genreName != "" {
|
|
options = mediaprovider.AlbumFilterOptions{
|
|
Genres: []string{genreName},
|
|
}
|
|
}
|
|
iter := mp.IterateAlbums(mediaprovider.AlbumSortRandom, mediaprovider.NewAlbumFilter(options))
|
|
insertMode := Replace
|
|
for i := 0; i < 20; i++ {
|
|
al := iter.Next()
|
|
if al, err := mp.GetAlbum(al.ID); err == nil {
|
|
p.LoadTracks(al.Tracks, insertMode, false)
|
|
if i == 0 {
|
|
p.PlayFromBeginning()
|
|
insertMode = Append
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, queueMode InsertQueueMode) {
|
|
p.cmdQueue.LoadRadioStation(station, queueMode)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayRadioStation(station *mediaprovider.RadioStation) {
|
|
p.LoadRadioStation(station, Replace)
|
|
p.PlayFromBeginning()
|
|
}
|
|
|
|
func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) error {
|
|
if songs, err := fetchFn(); err != nil {
|
|
return err
|
|
} else {
|
|
p.LoadTracks(songs, Replace, false)
|
|
if p.engine.replayGainCfg.Mode == ReplayGainAuto {
|
|
p.SetReplayGainMode(player.ReplayGainTrack)
|
|
}
|
|
p.PlayFromBeginning()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) GetPlayQueue() []mediaprovider.MediaItem {
|
|
return p.engine.GetPlayQueue()
|
|
}
|
|
|
|
// Any time the user changes the favorite status of a track elsewhere in the app,
|
|
// this should be called to ensure the in-memory track model is updated.
|
|
func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) {
|
|
p.engine.OnTrackFavoriteStatusChanged(id, fav)
|
|
}
|
|
|
|
// Any time the user changes the rating of a track elsewhere in the app,
|
|
// this should be called to ensure the in-memory track model is updated.
|
|
func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) {
|
|
p.engine.OnTrackRatingChanged(id, rating)
|
|
}
|
|
|
|
func (p *PlaybackManager) RemoveTracksFromQueue(idxs []int) {
|
|
p.cmdQueue.RemoveItemsFromQueue(idxs)
|
|
}
|
|
|
|
// Stop playback and clear the play queue.
|
|
func (p *PlaybackManager) StopAndClearPlayQueue() {
|
|
p.cmdQueue.StopAndClearPlayQueue()
|
|
}
|
|
|
|
func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) {
|
|
p.engine.SetReplayGainOptions(config)
|
|
}
|
|
|
|
func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) {
|
|
p.engine.SetReplayGainMode(mode)
|
|
}
|
|
|
|
// Changes the loop mode of the player to the next one.
|
|
// Useful for toggling UI elements, to change modes without knowing the current player mode.
|
|
func (p *PlaybackManager) SetNextLoopMode() {
|
|
switch p.engine.loopMode {
|
|
case LoopNone:
|
|
p.cmdQueue.SetLoopMode(LoopAll)
|
|
case LoopAll:
|
|
p.cmdQueue.SetLoopMode(LoopOne)
|
|
case LoopOne:
|
|
p.cmdQueue.SetLoopMode(LoopNone)
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) {
|
|
p.cmdQueue.SetLoopMode(loopMode)
|
|
}
|
|
|
|
func (p *PlaybackManager) GetLoopMode() LoopMode {
|
|
return p.engine.loopMode
|
|
}
|
|
|
|
func (p *PlaybackManager) IsAutoplay() bool {
|
|
return p.autoplay
|
|
}
|
|
|
|
func (p *PlaybackManager) PlaybackStatus() PlaybackStatus {
|
|
return p.engine.PlaybackStatus()
|
|
}
|
|
|
|
func (p *PlaybackManager) SetVolume(vol int) {
|
|
p.cmdQueue.SetVolume(vol)
|
|
}
|
|
|
|
func (p *PlaybackManager) SetAutoplay(autoplay bool) {
|
|
p.autoplay = autoplay
|
|
if autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
|
|
p.enqueueAutoplayTracks()
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) Volume() int {
|
|
return p.engine.CurrentPlayer().GetVolume()
|
|
}
|
|
|
|
func (p *PlaybackManager) SeekNext() {
|
|
p.cmdQueue.SeekNext()
|
|
}
|
|
|
|
func (p *PlaybackManager) SeekBackOrPrevious() {
|
|
p.cmdQueue.SeekBackOrPrevious()
|
|
}
|
|
|
|
// Seek to given absolute position in the current track by seconds.
|
|
func (p *PlaybackManager) SeekSeconds(sec float64) {
|
|
p.cmdQueue.SeekSeconds(sec)
|
|
}
|
|
|
|
// Seek by given relative position in the current track by seconds.
|
|
func (p *PlaybackManager) SeekBySeconds(sec float64) {
|
|
status := p.engine.PlaybackStatus()
|
|
target := status.TimePos + sec
|
|
if target < 0 {
|
|
target = 0
|
|
} else if target > status.Duration {
|
|
target = status.Duration
|
|
}
|
|
p.cmdQueue.SeekSeconds(target)
|
|
}
|
|
|
|
// Seek to a fractional position in the current track [0..1]
|
|
func (p *PlaybackManager) SeekFraction(fraction float64) {
|
|
if fraction < 0 {
|
|
fraction = 0
|
|
} else if fraction > 1 {
|
|
fraction = 1
|
|
}
|
|
target := p.engine.curTrackDuration * fraction
|
|
p.cmdQueue.SeekSeconds(target)
|
|
}
|
|
|
|
func (p *PlaybackManager) Stop() {
|
|
p.cmdQueue.Stop()
|
|
}
|
|
|
|
func (p *PlaybackManager) Shutdown() {
|
|
p.cmdQueue.StopAndWait()
|
|
}
|
|
|
|
func (p *PlaybackManager) Pause() {
|
|
p.cmdQueue.Pause()
|
|
}
|
|
|
|
func (p *PlaybackManager) Continue() {
|
|
p.cmdQueue.Continue()
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayPause() {
|
|
switch p.engine.PlaybackStatus().State {
|
|
case player.Playing:
|
|
p.Pause()
|
|
case player.Paused:
|
|
p.Continue()
|
|
case player.Stopped:
|
|
p.PlayTrackAt(0)
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) SetStopAfterCurrent(stopAfterCurrent bool) {
|
|
p.engine.SetStopAfterCurrent(stopAfterCurrent)
|
|
}
|
|
|
|
func (p *PlaybackManager) IsStopAfterCurrent() bool {
|
|
return p.engine.stopAfterCurrent
|
|
}
|
|
|
|
func (p *PlaybackManager) enqueueAutoplayTracks() {
|
|
nowPlaying := p.NowPlaying()
|
|
if nowPlaying == nil {
|
|
return
|
|
}
|
|
|
|
s := p.engine.sm.Server
|
|
if s == nil {
|
|
return
|
|
}
|
|
|
|
// last 500 played items
|
|
queue := p.GetPlayQueue()
|
|
if l := len(queue); l > 500 {
|
|
queue = queue[l-500:]
|
|
}
|
|
|
|
// tracks we will enqueue
|
|
var tracks []*mediaprovider.Track
|
|
|
|
filterRecentlyPlayed := func(tracks []*mediaprovider.Track) []*mediaprovider.Track {
|
|
return sharedutil.FilterSlice(tracks, func(t *mediaprovider.Track) bool {
|
|
return !slices.ContainsFunc(queue, func(i mediaprovider.MediaItem) bool {
|
|
return i.Metadata().Type == mediaprovider.MediaItemTypeTrack && i.Metadata().ID == t.ID
|
|
})
|
|
})
|
|
}
|
|
|
|
// since this func is invoked in a callback from the playback engine,
|
|
// need to do the rest async as it may take time and block other callbacks
|
|
go func() {
|
|
// first 2 strategies - similar by artist, and similar by genres - only work for tracks
|
|
if nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeTrack {
|
|
tr := nowPlaying.(*mediaprovider.Track)
|
|
|
|
// similar tracks by artist
|
|
if len(tr.ArtistIDs) > 0 {
|
|
similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
|
|
if err != nil {
|
|
log.Printf("autoplay error: failed to get similar tracks: %v", err)
|
|
}
|
|
tracks = filterRecentlyPlayed(similar)
|
|
}
|
|
|
|
// fallback to random tracks from genre
|
|
if len(tracks) == 0 {
|
|
for _, g := range tr.Genres {
|
|
if g == "" {
|
|
continue
|
|
}
|
|
byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
|
|
if err != nil {
|
|
log.Printf("autoplay error: failed to get tracks by genre: %v", err)
|
|
}
|
|
tracks = filterRecentlyPlayed(byGenre)
|
|
if len(tracks) > 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// random tracks works regardless of the type of the last playing media
|
|
if len(tracks) == 0 {
|
|
// fallback to random tracks
|
|
random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
|
|
if err != nil {
|
|
log.Printf("autoplay error: failed to get random tracks: %v", err)
|
|
}
|
|
tracks = filterRecentlyPlayed(random)
|
|
}
|
|
|
|
if len(tracks) > 0 {
|
|
p.LoadTracks(tracks, Append, false /*no need to shuffle, already random*/)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
|
|
logIfErr := func(action string, err error) {
|
|
if err != nil {
|
|
log.Printf("Playback error (%s): %v", action, err)
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case c := <-p.cmdQueue.C():
|
|
switch c.Type {
|
|
case cmdStop:
|
|
logIfErr("Stop", p.engine.Stop())
|
|
case cmdContinue:
|
|
logIfErr("Continue", p.engine.Continue())
|
|
case cmdPause:
|
|
logIfErr("Pause", p.engine.Pause())
|
|
case cmdPlayTrackAt:
|
|
logIfErr("PlayTrackAt", p.engine.PlayTrackAt(c.Arg.(int)))
|
|
case cmdSeekSeconds:
|
|
logIfErr("SeekSeconds", p.engine.SeekSeconds(c.Arg.(float64)))
|
|
case cmdSeekFwdBackN:
|
|
action := fmt.Sprintf("SeekFwdBack[%d]", c.Arg.(int))
|
|
logIfErr(action, p.engine.SeekFwdBackN(c.Arg.(int)))
|
|
case cmdVolume:
|
|
logIfErr("Volume", p.engine.SetVolume(c.Arg.(int)))
|
|
case cmdLoopMode:
|
|
p.engine.SetLoopMode(c.Arg.(LoopMode))
|
|
case cmdStopAndClearPlayQueue:
|
|
p.engine.StopAndClearPlayQueue()
|
|
case cmdUpdatePlayQueue:
|
|
logIfErr("UpdatePlayQueue", p.engine.UpdatePlayQueue(c.Arg.([]mediaprovider.MediaItem)))
|
|
case cmdRemoveTracksFromQueue:
|
|
p.engine.RemoveTracksFromQueue(c.Arg.([]int))
|
|
case cmdLoadItems:
|
|
err := p.engine.LoadItems(
|
|
c.Arg.([]mediaprovider.MediaItem),
|
|
c.Arg2.(InsertQueueMode),
|
|
c.Arg3.(bool),
|
|
)
|
|
logIfErr("LoadItems", err)
|
|
case cmdLoadRadioStation:
|
|
p.engine.LoadRadioStation(
|
|
c.Arg.(*mediaprovider.RadioStation),
|
|
c.Arg2.(InsertQueueMode),
|
|
)
|
|
case cmdForceRestartPlayback:
|
|
if mpv, ok := p.engine.CurrentPlayer().(*mpv.Player); ok {
|
|
log.Println("Force-restarting MPV playback")
|
|
mpv.ForceRestartPlayback()
|
|
}
|
|
}
|
|
if c.OnDone != nil {
|
|
c.OnDone()
|
|
}
|
|
}
|
|
}
|
|
}
|