node build fixed
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
package playbackmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/db_bridge"
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/mediaplayers/mediaplayer"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
const (
|
||||
LocalFilePlayback PlaybackType = "localfile"
|
||||
StreamPlayback PlaybackType = "stream"
|
||||
ManualTrackingPlayback PlaybackType = "manual"
|
||||
)
|
||||
|
||||
var playbackStatePool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &PlaybackState{}
|
||||
},
|
||||
}
|
||||
|
||||
type (
|
||||
PlaybackType string
|
||||
|
||||
// PlaybackManager manages video playback (local and stream) and progress tracking for desktop media players.
|
||||
// It receives and dispatch appropriate events for:
|
||||
// - Syncing progress with AniList, etc.
|
||||
// - Sending notifications to the client
|
||||
PlaybackManager struct {
|
||||
Logger *zerolog.Logger
|
||||
Database *db.Database
|
||||
MediaPlayerRepository *mediaplayer.Repository // MediaPlayerRepository is used to control the media player
|
||||
continuityManager *continuity.Manager
|
||||
|
||||
settings *Settings
|
||||
|
||||
discordPresence *discordrpc_presence.Presence // DiscordPresence is used to update the user's Discord presence
|
||||
mediaPlayerRepoSubscriber *mediaplayer.RepositorySubscriber // Used to listen for media player events
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
platform platform.Platform
|
||||
metadataProvider metadata.Provider
|
||||
refreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection
|
||||
mu sync.Mutex
|
||||
eventMu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
|
||||
// historyMap stores a PlaybackState whose state is "completed"
|
||||
// Since PlaybackState is sent to client continuously, once a PlaybackState is stored in historyMap, only IT will be sent to the client.
|
||||
// This is so when the user seeks back to a video, the client can show the last known "completed" state of the video
|
||||
historyMap map[string]PlaybackState
|
||||
currentPlaybackType PlaybackType
|
||||
currentMediaPlaybackStatus *mediaplayer.PlaybackStatus // The current video playback status (can be nil)
|
||||
|
||||
autoPlayMu sync.Mutex
|
||||
nextEpisodeLocalFile mo.Option[*anime.LocalFile] // The next episode's local file (for local file playback)
|
||||
|
||||
// currentMediaListEntry for Local file playback & stream playback
|
||||
// For Local file playback, it MUST be set
|
||||
// For Stream playback, it is optional
|
||||
// See [progress_tracking.go] for how it is handled
|
||||
currentMediaListEntry mo.Option[*anilist.AnimeListEntry] // List Entry for the current video playback
|
||||
|
||||
// \/ Local file playback
|
||||
currentLocalFile mo.Option[*anime.LocalFile] // Local file for the current video playback
|
||||
currentLocalFileWrapperEntry mo.Option[*anime.LocalFileWrapperEntry] // This contains the current media entry local file data
|
||||
|
||||
// \/ Stream playback
|
||||
// The current episode being streamed, set in [StartStreamingUsingMediaPlayer] by finding the episode in currentStreamEpisodeCollection
|
||||
currentStreamEpisode mo.Option[*anime.Episode]
|
||||
// The current media being streamed, set in [StartStreamingUsingMediaPlayer]
|
||||
currentStreamMedia mo.Option[*anilist.BaseAnime]
|
||||
currentStreamAniDbEpisode mo.Option[string]
|
||||
|
||||
// \/ Manual progress tracking (non-integrated external player)
|
||||
manualTrackingCtx context.Context
|
||||
manualTrackingCtxCancel context.CancelFunc
|
||||
manualTrackingPlaybackState PlaybackState
|
||||
currentManualTrackingState mo.Option[*ManualTrackingState]
|
||||
manualTrackingWg sync.WaitGroup
|
||||
|
||||
// \/ Playlist
|
||||
playlistHub *playlistHub // The playlist hub
|
||||
|
||||
isOffline *bool
|
||||
animeCollection mo.Option[*anilist.AnimeCollection]
|
||||
|
||||
playbackStatusSubscribers *result.Map[string, *PlaybackStatusSubscriber]
|
||||
}
|
||||
|
||||
// PlaybackStatusSubscriber provides a single event channel for all playback events
|
||||
PlaybackStatusSubscriber struct {
|
||||
EventCh chan PlaybackEvent
|
||||
canceled atomic.Bool
|
||||
}
|
||||
|
||||
// PlaybackEvent is the base interface for all playback events
|
||||
PlaybackEvent interface {
|
||||
Type() string
|
||||
}
|
||||
|
||||
PlaybackStartingEvent struct {
|
||||
Filepath string
|
||||
PlaybackType PlaybackType
|
||||
Media *anilist.BaseAnime
|
||||
AniDbEpisode string
|
||||
EpisodeNumber int
|
||||
WindowTitle string
|
||||
}
|
||||
|
||||
// Local file playback events
|
||||
|
||||
PlaybackStatusChangedEvent struct {
|
||||
Status mediaplayer.PlaybackStatus
|
||||
State PlaybackState
|
||||
}
|
||||
|
||||
VideoStartedEvent struct {
|
||||
Filename string
|
||||
Filepath string
|
||||
}
|
||||
|
||||
VideoStoppedEvent struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
VideoCompletedEvent struct {
|
||||
Filename string
|
||||
}
|
||||
|
||||
// Stream playback events
|
||||
StreamStateChangedEvent struct {
|
||||
State PlaybackState
|
||||
}
|
||||
|
||||
StreamStatusChangedEvent struct {
|
||||
Status mediaplayer.PlaybackStatus
|
||||
}
|
||||
|
||||
StreamStartedEvent struct {
|
||||
Filename string
|
||||
Filepath string
|
||||
}
|
||||
|
||||
StreamStoppedEvent struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
StreamCompletedEvent struct {
|
||||
Filename string
|
||||
}
|
||||
|
||||
PlaybackStateType string
|
||||
|
||||
// PlaybackState is used to keep track of the user's current video playback
|
||||
// It is sent to the client each time the video playback state is picked up -- this is used to update the client's UI
|
||||
PlaybackState struct {
|
||||
EpisodeNumber int `json:"episodeNumber"` // The episode number
|
||||
AniDbEpisode string `json:"aniDbEpisode"` // The AniDB episode number
|
||||
MediaTitle string `json:"mediaTitle"` // The title of the media
|
||||
MediaCoverImage string `json:"mediaCoverImage"` // The cover image of the media
|
||||
MediaTotalEpisodes int `json:"mediaTotalEpisodes"` // The total number of episodes
|
||||
Filename string `json:"filename"` // The filename
|
||||
CompletionPercentage float64 `json:"completionPercentage"` // The completion percentage
|
||||
CanPlayNext bool `json:"canPlayNext"` // Whether the next episode can be played
|
||||
ProgressUpdated bool `json:"progressUpdated"` // Whether the progress has been updated
|
||||
MediaId int `json:"mediaId"` // The media ID
|
||||
}
|
||||
|
||||
NewPlaybackManagerOptions struct {
|
||||
WSEventManager events.WSEventManagerInterface
|
||||
Logger *zerolog.Logger
|
||||
Platform platform.Platform
|
||||
MetadataProvider metadata.Provider
|
||||
Database *db.Database
|
||||
RefreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection
|
||||
DiscordPresence *discordrpc_presence.Presence
|
||||
IsOffline *bool
|
||||
ContinuityManager *continuity.Manager
|
||||
}
|
||||
|
||||
Settings struct {
|
||||
AutoPlayNextEpisode bool
|
||||
}
|
||||
)
|
||||
|
||||
// Event type implementations
|
||||
func (e PlaybackStatusChangedEvent) Type() string { return "playback_status_changed" }
|
||||
func (e VideoStartedEvent) Type() string { return "video_started" }
|
||||
func (e VideoStoppedEvent) Type() string { return "video_stopped" }
|
||||
func (e VideoCompletedEvent) Type() string { return "video_completed" }
|
||||
func (e StreamStateChangedEvent) Type() string { return "stream_state_changed" }
|
||||
func (e StreamStatusChangedEvent) Type() string { return "stream_status_changed" }
|
||||
func (e StreamStartedEvent) Type() string { return "stream_started" }
|
||||
func (e StreamStoppedEvent) Type() string { return "stream_stopped" }
|
||||
func (e StreamCompletedEvent) Type() string { return "stream_completed" }
|
||||
func (e PlaybackStartingEvent) Type() string { return "playback_starting" }
|
||||
|
||||
func New(opts *NewPlaybackManagerOptions) *PlaybackManager {
|
||||
pm := &PlaybackManager{
|
||||
Logger: opts.Logger,
|
||||
Database: opts.Database,
|
||||
settings: &Settings{},
|
||||
discordPresence: opts.DiscordPresence,
|
||||
wsEventManager: opts.WSEventManager,
|
||||
platform: opts.Platform,
|
||||
metadataProvider: opts.MetadataProvider,
|
||||
refreshAnimeCollectionFunc: opts.RefreshAnimeCollectionFunc,
|
||||
mu: sync.Mutex{},
|
||||
autoPlayMu: sync.Mutex{},
|
||||
eventMu: sync.RWMutex{},
|
||||
historyMap: make(map[string]PlaybackState),
|
||||
isOffline: opts.IsOffline,
|
||||
nextEpisodeLocalFile: mo.None[*anime.LocalFile](),
|
||||
currentStreamEpisode: mo.None[*anime.Episode](),
|
||||
currentStreamMedia: mo.None[*anilist.BaseAnime](),
|
||||
currentStreamAniDbEpisode: mo.None[string](),
|
||||
animeCollection: mo.None[*anilist.AnimeCollection](),
|
||||
currentManualTrackingState: mo.None[*ManualTrackingState](),
|
||||
currentLocalFile: mo.None[*anime.LocalFile](),
|
||||
currentLocalFileWrapperEntry: mo.None[*anime.LocalFileWrapperEntry](),
|
||||
currentMediaListEntry: mo.None[*anilist.AnimeListEntry](),
|
||||
continuityManager: opts.ContinuityManager,
|
||||
playbackStatusSubscribers: result.NewResultMap[string, *PlaybackStatusSubscriber](),
|
||||
}
|
||||
|
||||
pm.playlistHub = newPlaylistHub(pm)
|
||||
|
||||
return pm
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) SetAnimeCollection(ac *anilist.AnimeCollection) {
|
||||
pm.animeCollection = mo.Some(ac)
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) SetSettings(s *Settings) {
|
||||
pm.settings = s
|
||||
}
|
||||
|
||||
// SetMediaPlayerRepository sets the media player repository and starts listening to media player events
|
||||
// - This method is called when the media player is mounted (due to settings change or when the app starts)
|
||||
func (pm *PlaybackManager) SetMediaPlayerRepository(mediaPlayerRepository *mediaplayer.Repository) {
|
||||
go func() {
|
||||
// If a previous context exists, cancel it
|
||||
if pm.cancel != nil {
|
||||
pm.cancel()
|
||||
}
|
||||
|
||||
pm.playlistHub.reset()
|
||||
|
||||
// Create a new context for listening to the MediaPlayer instance's event
|
||||
// When this is canceled above, the previous listener goroutine will stop -- this is done to prevent multiple listeners
|
||||
var ctx context.Context
|
||||
ctx, pm.cancel = context.WithCancel(context.Background())
|
||||
|
||||
pm.mu.Lock()
|
||||
// Set the new media player repository instance
|
||||
pm.MediaPlayerRepository = mediaPlayerRepository
|
||||
// Set up event listeners for the media player instance
|
||||
pm.mediaPlayerRepoSubscriber = pm.MediaPlayerRepository.Subscribe("playbackmanager")
|
||||
pm.mu.Unlock()
|
||||
|
||||
// Start listening to new media player events
|
||||
pm.listenToMediaPlayerEvents(ctx)
|
||||
|
||||
// DEVNOTE: pm.listenToClientPlayerEvents()
|
||||
}()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type StartPlayingOptions struct {
|
||||
Payload string // url or path
|
||||
UserAgent string
|
||||
ClientId string
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) StartPlayingUsingMediaPlayer(opts *StartPlayingOptions) error {
|
||||
|
||||
event := &LocalFilePlaybackRequestedEvent{
|
||||
Path: opts.Payload,
|
||||
}
|
||||
err := hook.GlobalHookManager.OnLocalFilePlaybackRequested().Trigger(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Payload = event.Path
|
||||
|
||||
if event.DefaultPrevented {
|
||||
pm.Logger.Debug().Msg("playback manager: Local file playback prevented by hook")
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.playlistHub.reset()
|
||||
if err := pm.checkOrLoadAnimeCollection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cancel manual tracking if active
|
||||
if pm.manualTrackingCtxCancel != nil {
|
||||
pm.manualTrackingCtxCancel()
|
||||
}
|
||||
|
||||
// Send the media file to the media player
|
||||
err = pm.MediaPlayerRepository.Play(opts.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trackingEvent := &PlaybackBeforeTrackingEvent{
|
||||
IsStream: false,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if trackingEvent.DefaultPrevented {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start tracking
|
||||
pm.MediaPlayerRepository.StartTracking()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartUntrackedStreamingUsingMediaPlayer starts a stream using the media player without any tracking.
|
||||
func (pm *PlaybackManager) StartUntrackedStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartUntrackedStreamingUsingMediaPlayer", &err)
|
||||
|
||||
event := &StreamPlaybackRequestedEvent{
|
||||
WindowTitle: windowTitle,
|
||||
Payload: opts.Payload,
|
||||
Media: nil,
|
||||
AniDbEpisode: "",
|
||||
}
|
||||
err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.DefaultPrevented {
|
||||
pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook")
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Starting the media player")
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
episodeNumber := 0
|
||||
|
||||
err = pm.MediaPlayerRepository.Stream(opts.Payload, episodeNumber, 0, windowTitle)
|
||||
if err != nil {
|
||||
pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming")
|
||||
return err
|
||||
}
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Sent stream to media player")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartStreamingUsingMediaPlayer starts streaming a video using the media player.
|
||||
// This sets PlaybackManager.currentStreamMedia and PlaybackManager.currentStreamEpisode used for progress tracking.
|
||||
// Note that PlaybackManager.currentStreamEpisodeCollection is not required to start streaming but is needed for progress tracking.
|
||||
func (pm *PlaybackManager) StartStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions, media *anilist.BaseAnime, aniDbEpisode string) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartStreamingUsingMediaPlayer", &err)
|
||||
|
||||
event := &StreamPlaybackRequestedEvent{
|
||||
WindowTitle: windowTitle,
|
||||
Payload: opts.Payload,
|
||||
Media: media,
|
||||
AniDbEpisode: aniDbEpisode,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aniDbEpisode = event.AniDbEpisode
|
||||
windowTitle = event.WindowTitle
|
||||
|
||||
if event.DefaultPrevented {
|
||||
pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook")
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.playlistHub.reset()
|
||||
if *pm.isOffline {
|
||||
return errors.New("cannot stream when offline")
|
||||
}
|
||||
|
||||
if event.Media == nil || aniDbEpisode == "" {
|
||||
pm.Logger.Error().Msg("playback manager: cannot start streaming, missing options [StartStreamingUsingMediaPlayer]")
|
||||
return errors.New("cannot start streaming, not enough data provided")
|
||||
}
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Starting the media player")
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Cancel manual tracking if active
|
||||
if pm.manualTrackingCtxCancel != nil {
|
||||
pm.manualTrackingCtxCancel()
|
||||
}
|
||||
|
||||
pm.currentStreamMedia = mo.Some(event.Media)
|
||||
episodeNumber := 0
|
||||
|
||||
// Find the current episode being stream
|
||||
episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{
|
||||
AnimeMetadata: nil,
|
||||
Media: event.Media,
|
||||
MetadataProvider: pm.metadataProvider,
|
||||
Logger: pm.Logger,
|
||||
})
|
||||
|
||||
pm.currentStreamAniDbEpisode = mo.Some(aniDbEpisode)
|
||||
|
||||
if episode, ok := episodeCollection.FindEpisodeByAniDB(aniDbEpisode); ok {
|
||||
episodeNumber = episode.EpisodeNumber
|
||||
pm.currentStreamEpisode = mo.Some(episode)
|
||||
} else {
|
||||
pm.Logger.Warn().Str("episode", aniDbEpisode).Msg("playback manager: Failed to find episode in episode collection")
|
||||
}
|
||||
|
||||
err = pm.MediaPlayerRepository.Stream(event.Payload, episodeNumber, event.Media.ID, windowTitle)
|
||||
if err != nil {
|
||||
pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming")
|
||||
return err
|
||||
}
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Sent stream to media player")
|
||||
|
||||
trackingEvent := &PlaybackBeforeTrackingEvent{
|
||||
IsStream: true,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if trackingEvent.DefaultPrevented {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.MediaPlayerRepository.StartTrackingTorrentStream()
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Started tracking torrent stream")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlayNextEpisode plays the next episode of the local media that is being watched
|
||||
// - Called when the user clicks on the "Next" button in the client
|
||||
// - Should not be called when the user is watching a playlist
|
||||
// - Should not be called when no next episode is available
|
||||
func (pm *PlaybackManager) PlayNextEpisode() (err error) {
|
||||
defer util.HandlePanicInModuleWithError("library/playbackmanager/PlayNextEpisode", &err)
|
||||
|
||||
switch pm.currentPlaybackType {
|
||||
case LocalFilePlayback:
|
||||
if pm.currentLocalFile.IsAbsent() || pm.currentMediaListEntry.IsAbsent() || pm.currentLocalFileWrapperEntry.IsAbsent() {
|
||||
return errors.New("could not play next episode")
|
||||
}
|
||||
|
||||
nextLf, found := pm.currentLocalFileWrapperEntry.MustGet().FindNextEpisode(pm.currentLocalFile.MustGet())
|
||||
if !found {
|
||||
return errors.New("could not play next episode")
|
||||
}
|
||||
|
||||
err = pm.MediaPlayerRepository.Play(nextLf.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Start tracking the video
|
||||
pm.MediaPlayerRepository.StartTracking()
|
||||
|
||||
case StreamPlayback:
|
||||
// TODO: Implement it for torrentstream
|
||||
// Check if torrent stream etc...
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNextEpisode gets the next [anime.LocalFile] of the local media that is being watched.
|
||||
// It will return nil if there is no next episode.
|
||||
// This is used by the client's "Auto Play" feature.
|
||||
func (pm *PlaybackManager) GetNextEpisode() (ret *anime.LocalFile) {
|
||||
defer util.HandlePanicInModuleThen("library/playbackmanager/GetNextEpisode", func() {
|
||||
ret = nil
|
||||
})
|
||||
|
||||
switch pm.currentPlaybackType {
|
||||
case LocalFilePlayback:
|
||||
if lf, found := pm.nextEpisodeLocalFile.Get(); found {
|
||||
ret = lf
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoPlayNextEpisode will play the next episode of the local media that is being watched.
|
||||
// This calls [PlaybackManager.PlayNextEpisode] only once if multiple clients made the request.
|
||||
func (pm *PlaybackManager) AutoPlayNextEpisode() error {
|
||||
pm.autoPlayMu.Lock()
|
||||
defer pm.autoPlayMu.Unlock()
|
||||
|
||||
pm.Logger.Trace().Msg("playback manager: Auto play request received")
|
||||
|
||||
if !pm.settings.AutoPlayNextEpisode {
|
||||
return nil
|
||||
}
|
||||
|
||||
lf := pm.GetNextEpisode()
|
||||
// This shouldn't happen because the client should check if there is a next episode before sending the request.
|
||||
// However, it will happen if there are multiple clients launching the request.
|
||||
if lf == nil {
|
||||
pm.Logger.Warn().Msg("playback manager: No next episode to play")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := pm.PlayNextEpisode(); err != nil {
|
||||
pm.Logger.Error().Err(err).Msg("playback manager: Failed to auto play next episode")
|
||||
return fmt.Errorf("failed to auto play next episode: %w", err)
|
||||
}
|
||||
|
||||
// Remove the next episode from the queue
|
||||
pm.nextEpisodeLocalFile = mo.None[*anime.LocalFile]()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Pause pauses the current media player playback.
|
||||
func (pm *PlaybackManager) Pause() error {
|
||||
return pm.MediaPlayerRepository.Pause()
|
||||
}
|
||||
|
||||
// Resume resumes the current media player playback.
|
||||
func (pm *PlaybackManager) Resume() error {
|
||||
return pm.MediaPlayerRepository.Resume()
|
||||
}
|
||||
|
||||
// Seek seeks to the specified time in the current media.
|
||||
func (pm *PlaybackManager) Seek(seconds float64) error {
|
||||
return pm.MediaPlayerRepository.Seek(seconds)
|
||||
}
|
||||
|
||||
// PullStatus pulls the current media player playback status at the time of the call.
|
||||
func (pm *PlaybackManager) PullStatus() (*mediaplayer.PlaybackStatus, bool) {
|
||||
return pm.MediaPlayerRepository.PullStatus()
|
||||
}
|
||||
|
||||
// Cancel stops the current media player playback and publishes a "normal" event.
|
||||
func (pm *PlaybackManager) Cancel() error {
|
||||
pm.MediaPlayerRepository.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Playlist
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// CancelCurrentPlaylist cancels the current playlist.
|
||||
// This is an action triggered by the client.
|
||||
func (pm *PlaybackManager) CancelCurrentPlaylist() error {
|
||||
go pm.playlistHub.reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestNextPlaylistFile will play the next file in the playlist.
|
||||
// This is an action triggered by the client.
|
||||
func (pm *PlaybackManager) RequestNextPlaylistFile() error {
|
||||
go pm.playlistHub.playNextFile()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartPlaylist starts a playlist.
|
||||
// This action is triggered by the client.
|
||||
func (pm *PlaybackManager) StartPlaylist(playlist *anime.Playlist) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartPlaylist", &err)
|
||||
|
||||
pm.playlistHub.loadPlaylist(playlist)
|
||||
|
||||
_ = pm.checkOrLoadAnimeCollection()
|
||||
|
||||
// Play the first video in the playlist
|
||||
firstVidPath := playlist.LocalFiles[0].Path
|
||||
err = pm.MediaPlayerRepository.Play(firstVidPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start tracking the video
|
||||
pm.MediaPlayerRepository.StartTracking()
|
||||
|
||||
// Create a new context for the playlist hub
|
||||
var ctx context.Context
|
||||
ctx, pm.playlistHub.cancel = context.WithCancel(context.Background())
|
||||
|
||||
// Listen to new play requests
|
||||
go func() {
|
||||
pm.Logger.Debug().Msg("playback manager: Listening for new file requests")
|
||||
for {
|
||||
select {
|
||||
// When the playlist hub context is cancelled (No playlist is being played)
|
||||
case <-ctx.Done():
|
||||
pm.Logger.Debug().Msg("playback manager: Playlist context cancelled")
|
||||
// Send event to the client -- nil signals that no playlist is being played
|
||||
pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil)
|
||||
return
|
||||
case path := <-pm.playlistHub.requestNewFileCh:
|
||||
// requestNewFileCh receives the path of the next video to play
|
||||
// The channel is fed when it's time to play the next video or when the client requests the next video
|
||||
// see: RequestNextPlaylistFile, playlistHub code
|
||||
pm.Logger.Debug().Str("path", path).Msg("playback manager: Playing next file")
|
||||
// Send notification to the client
|
||||
pm.wsEventManager.SendEvent(events.InfoToast, "Playing next file in playlist")
|
||||
// Play the requested video
|
||||
err := pm.MediaPlayerRepository.Play(path)
|
||||
if err != nil {
|
||||
pm.Logger.Error().Err(err).Msg("playback manager: Failed to play next file in playlist")
|
||||
pm.playlistHub.cancel()
|
||||
return
|
||||
}
|
||||
// Start tracking the video
|
||||
pm.MediaPlayerRepository.StartTracking()
|
||||
case <-pm.playlistHub.endOfPlaylistCh:
|
||||
pm.Logger.Debug().Msg("playback manager: End of playlist")
|
||||
pm.wsEventManager.SendEvent(events.InfoToast, "End of playlist")
|
||||
// Send event to the client -- nil signals that no playlist is being played
|
||||
pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil)
|
||||
go pm.MediaPlayerRepository.Stop()
|
||||
pm.playlistHub.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Delete playlist in goroutine
|
||||
go func() {
|
||||
err := db_bridge.DeletePlaylist(pm.Database, playlist.DbId)
|
||||
if err != nil {
|
||||
pm.Logger.Error().Err(err).Str("name", playlist.Name).Msgf("playback manager: Failed to delete playlist")
|
||||
return
|
||||
}
|
||||
pm.Logger.Debug().Str("name", playlist.Name).Msgf("playback manager: Deleted playlist")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (pm *PlaybackManager) checkOrLoadAnimeCollection() (err error) {
|
||||
defer util.HandlePanicInModuleWithError("library/playbackmanager/checkOrLoadAnimeCollection", &err)
|
||||
|
||||
if pm.animeCollection.IsAbsent() {
|
||||
// If the anime collection is not present, we retrieve it from the platform
|
||||
collection, err := pm.platform.GetAnimeCollection(context.Background(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pm.animeCollection = mo.Some(collection)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) SubscribeToPlaybackStatus(id string) *PlaybackStatusSubscriber {
|
||||
subscriber := &PlaybackStatusSubscriber{
|
||||
EventCh: make(chan PlaybackEvent, 100),
|
||||
}
|
||||
pm.playbackStatusSubscribers.Set(id, subscriber)
|
||||
return subscriber
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) RegisterMediaPlayerCallback(callback func(event PlaybackEvent, cancelFunc func())) (cancel func()) {
|
||||
id := uuid.NewString()
|
||||
playbackSubscriber := pm.SubscribeToPlaybackStatus(id)
|
||||
cancel = func() {
|
||||
pm.UnsubscribeFromPlaybackStatus(id)
|
||||
}
|
||||
go func(playbackSubscriber *PlaybackStatusSubscriber) {
|
||||
for event := range playbackSubscriber.EventCh {
|
||||
callback(event, cancel)
|
||||
}
|
||||
}(playbackSubscriber)
|
||||
|
||||
return cancel
|
||||
}
|
||||
|
||||
func (pm *PlaybackManager) UnsubscribeFromPlaybackStatus(id string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
pm.Logger.Warn().Msg("playback manager: Failed to unsubscribe from playback status")
|
||||
}
|
||||
}()
|
||||
subscriber, ok := pm.playbackStatusSubscribers.Get(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subscriber.canceled.Store(true)
|
||||
pm.playbackStatusSubscribers.Delete(id)
|
||||
close(subscriber.EventCh)
|
||||
}
|
||||
Reference in New Issue
Block a user