From 8819a9d478e6fc11dbfdcff80d9a2dc175953373 Mon Sep 17 00:00:00 2001 From: Ionut Andrei Oanca Date: Thu, 24 Sep 2020 23:04:21 +0200 Subject: [PATCH] Add playlist-sync and group-wait to SyncPlay --- .../Session/SessionManager.cs | 6 +- .../SyncPlay/GroupController.cs | 681 ++++++++++++++++++ .../GroupStates/AbstractGroupState.cs | 218 ++++++ .../SyncPlay/GroupStates/IdleGroupState.cs | 121 ++++ .../SyncPlay/GroupStates/PausedGroupState.cs | 199 +++-- .../SyncPlay/GroupStates/PlayingGroupState.cs | 133 +++- .../SyncPlay/GroupStates/WaitingGroupState.cs | 653 +++++++++++++++++ .../SyncPlay/SyncPlayController.cs | 282 -------- .../SyncPlay/SyncPlayManager.cs | 131 ++-- .../Controllers/SyncPlayController.cs | 300 +++++++- MediaBrowser.Api/SyncPlay/SyncPlayService.cs | 267 ------- .../Session/ISessionManager.cs | 12 +- MediaBrowser.Controller/SyncPlay/GroupInfo.cs | 154 ---- .../SyncPlay/GroupMember.cs | 20 +- .../SyncPlay/IPlaybackGroupRequest.cs | 7 +- .../SyncPlay/ISyncPlayController.cs | 40 +- .../SyncPlay/ISyncPlayManager.cs | 12 +- .../SyncPlay/ISyncPlayState.cs | 165 ++++- .../SyncPlay/ISyncPlayStateContext.cs | 160 +++- .../PlaybackRequest/BufferGroupRequest.cs | 18 +- .../PlaybackRequest/IgnoreWaitGroupRequest.cs | 30 + .../MovePlaylistItemGroupRequest.cs | 36 + .../PlaybackRequest/NextTrackGroupRequest.cs | 30 + .../PlaybackRequest/PauseGroupRequest.cs | 6 +- .../PlaybackRequest/PingGroupRequest.cs | 7 +- .../PlaybackRequest/PlayGroupRequest.cs | 25 +- .../PreviousTrackGroupRequest.cs | 30 + .../PlaybackRequest/QueueGroupRequest.cs | 37 + .../PlaybackRequest/ReadyGroupRequest.cs | 18 +- .../RemoveFromPlaylistGroupRequest.cs | 30 + .../PlaybackRequest/SeekGroupRequest.cs | 6 +- .../SetCurrentItemGroupRequest.cs | 30 + .../SetRepeatModeGroupRequest.cs | 30 + .../SetShuffleModeGroupRequest.cs | 30 + .../PlaybackRequest/StopGroupRequest.cs | 24 + .../PlaybackRequest/UnpauseGroupRequest.cs | 24 + .../SyncPlay/Queue/PlayQueueManager.cs | 596 +++++++++++++++ .../SyncPlay/SyncPlayAbstractState.cs | 65 -- MediaBrowser.Model/SyncPlay/GroupInfoDto.cs | 26 +- .../SyncPlay/GroupRepeatMode.cs | 23 + .../SyncPlay/GroupShuffleMode.cs | 18 + .../SyncPlay/GroupStateUpdate.cs | 22 + .../SyncPlay/GroupUpdateType.cs | 8 +- .../SyncPlay/JoinGroupRequest.cs | 4 +- .../SyncPlay/NewGroupRequest.cs | 16 + .../SyncPlay/PlayQueueUpdate.cs | 52 ++ .../SyncPlay/PlayQueueUpdateReason.cs | 58 ++ .../SyncPlay/PlaybackRequestType.cs | 70 +- MediaBrowser.Model/SyncPlay/QueueItem.cs | 24 + MediaBrowser.Model/SyncPlay/SendCommand.cs | 6 + .../SyncPlay/SendCommandType.cs | 11 +- 51 files changed, 3846 insertions(+), 1125 deletions(-) create mode 100644 Emby.Server.Implementations/SyncPlay/GroupController.cs create mode 100644 Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs create mode 100644 Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs create mode 100644 Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs delete mode 100644 Emby.Server.Implementations/SyncPlay/SyncPlayController.cs delete mode 100644 MediaBrowser.Api/SyncPlay/SyncPlayService.cs delete mode 100644 MediaBrowser.Controller/SyncPlay/GroupInfo.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/IgnoreWaitGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/MovePlaylistItemGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/NextTrackGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/PreviousTrackGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/QueueGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/RemoveFromPlaylistGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetCurrentItemGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetRepeatModeGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetShuffleModeGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/StopGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/PlaybackRequest/UnpauseGroupRequest.cs create mode 100644 MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs delete mode 100644 MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs create mode 100644 MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs create mode 100644 MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs create mode 100644 MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs create mode 100644 MediaBrowser.Model/SyncPlay/NewGroupRequest.cs create mode 100644 MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs create mode 100644 MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs create mode 100644 MediaBrowser.Model/SyncPlay/QueueItem.cs diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 607b322f2e..9a94d05eb0 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1182,18 +1182,16 @@ namespace Emby.Server.Implementations.Session } /// - public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// - public async Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate(SessionInfo session, GroupUpdate command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/SyncPlay/GroupController.cs b/Emby.Server.Implementations/SyncPlay/GroupController.cs new file mode 100644 index 0000000000..ee2e9eb8f1 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupController.cs @@ -0,0 +1,681 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// + /// Class SyncPlayGroupController. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class SyncPlayGroupController : ISyncPlayGroupController, ISyncPlayStateContext + { + /// + /// Gets the default ping value used for sessions. + /// + public long DefaultPing { get; } = 500; + + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// The user manager. + /// + private readonly IUserManager _userManager; + + /// + /// The session manager. + /// + private readonly ISessionManager _sessionManager; + + /// + /// The library manager. + /// + private readonly ILibraryManager _libraryManager; + + /// + /// The SyncPlay manager. + /// + private readonly ISyncPlayManager _syncPlayManager; + + /// + /// Internal group state. + /// + /// The group's state. + private ISyncPlayState State; + + /// + /// Gets the group identifier. + /// + /// The group identifier. + public Guid GroupId { get; } = Guid.NewGuid(); + + /// + /// Gets the group name. + /// + /// The group name. + public string GroupName { get; private set; } + + /// + /// Gets the group identifier. + /// + /// The group identifier. + public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); + + /// + /// Gets or sets the runtime ticks of current playing item. + /// + /// The runtime ticks of current playing item. + public long RunTimeTicks { get; private set; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; set; } + + /// + /// Gets or sets the last activity. + /// + /// The last activity. + public DateTime LastActivity { get; set; } + + /// + /// Gets the participants. + /// + /// The participants, or members of the group. + public Dictionary Participants { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The user manager. + /// The session manager. + /// The library manager. + /// The SyncPlay manager. + public SyncPlayGroupController( + ILogger logger, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager, + ISyncPlayManager syncPlayManager) + { + _logger = logger; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _syncPlayManager = syncPlayManager; + + State = new IdleGroupState(_logger); + } + + /// + /// Checks if a session is in this group. + /// + /// The session id to check. + /// true if the session is in this group; false otherwise. + private bool ContainsSession(string sessionId) + { + return Participants.ContainsKey(sessionId); + } + + /// + /// Adds the session to the group. + /// + /// The session. + private void AddSession(SessionInfo session) + { + Participants.TryAdd( + session.Id, + new GroupMember + { + Session = session, + Ping = DefaultPing, + IsBuffering = false + }); + } + + /// + /// Removes the session from the group. + /// + /// The session. + private void RemoveSession(SessionInfo session) + { + Participants.Remove(session.Id); + } + + /// + /// Filters sessions of this group. + /// + /// The current session. + /// The filtering type. + /// The array of sessions matching the filter. + private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + switch (type) + { + case SyncPlayBroadcastType.CurrentSession: + return new SessionInfo[] { from }; + case SyncPlayBroadcastType.AllGroup: + return Participants.Values.Select( + session => session.Session).ToArray(); + case SyncPlayBroadcastType.AllExceptCurrentSession: + return Participants.Values.Select( + session => session.Session).Where( + session => !session.Id.Equals(from.Id)).ToArray(); + case SyncPlayBroadcastType.AllReady: + return Participants.Values.Where( + session => !session.IsBuffering).Select( + session => session.Session).ToArray(); + default: + return Array.Empty(); + } + } + + private bool HasAccessToItem(User user, BaseItem item) + { + var collections = _libraryManager.GetCollectionFolders(item) + .Select(folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); + return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); + } + + private bool HasAccessToQueue(User user, Guid[] queue) + { + if (queue == null || queue.Length == 0) + { + return true; + } + + var items = queue.ToList() + .Select(item => _libraryManager.GetItemById(item)); + + // Find the highest rating value, which becomes the required minimum for the user + var MinParentalRatingAccessRequired = items + .Select(item => item.InheritedParentalRatingValue) + .Min(); + + // Check ParentalRating access, user must have the minimum required access level + var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue + || MinParentalRatingAccessRequired <= user.MaxParentalAgeRating; + + // Check that user has access to all required folders + if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) + { + // Get list of items that are not accessible + var blockedItems = items.Where(item => !HasAccessToItem(user, item)); + + // We need the user to be able to access all items + return !blockedItems.Any(); + } + + return hasParentalRatingAccess; + } + + private bool AllUsersHaveAccessToQueue(Guid[] queue) + { + if (queue == null || queue.Length == 0) + { + return true; + } + + // Get list of users + var users = Participants.Values + .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + + // Find problematic users + var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); + + // All users must be able to access the queue + return !usersWithNoAccess.Any(); + } + + /// + public bool IsGroupEmpty() => Participants.Count == 0; + + /// + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + _syncPlayManager.AddSessionToGroup(session, this); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToArray(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Mantain playstate + var waitingState = new WaitingGroupState(_logger); + waitingState.ResumePlaying = !session.PlayState.IsPaused; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("InitGroup: {0} created group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + _syncPlayManager.AddSessionToGroup(session, this); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("SessionJoin: {0} joined group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// + public void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("SessionRestore: {0} re-joined group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// + public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) + { + State.SessionLeaving(this, State.GetGroupState(), session, cancellationToken); + + RemoveSession(session); + _syncPlayManager.RemoveSessionFromGroup(session, this); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _logger.LogInformation("SessionLeave: {0} left group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// + public void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken) + { + // The server's job is to maintain a consistent state for clients to reference + // and notify clients of state changes. The actual syncing of media playback + // happens client side. Clients are aware of the server's time and use it to sync. + _logger.LogInformation("HandleRequest: {0} requested {1}, group {2} in {3} state.", + session.Id.ToString(), request.GetRequestType(), GroupId.ToString(), State.GetGroupState()); + request.Apply(this, State, session, cancellationToken); + } + + /// + public GroupInfoDto GetInfo() + { + return new GroupInfoDto() + { + GroupId = GroupId.ToString(), + GroupName = GroupName, + State = State.GetGroupState(), + Participants = Participants.Values.Select(session => session.Session.UserName).Distinct().ToList(), + LastUpdatedAt = DateToUTCString(DateTime.UtcNow) + }; + } + + /// + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToArray(); + return HasAccessToQueue(user, items); + } + + /// + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (!ContainsSession(session.Id)) + { + return; + } + + Participants[session.Id].IgnoreGroupWait = ignoreGroupWait; + } + + /// + public void SetState(ISyncPlayState state) + { + _logger.LogInformation("SetState: {0} switching from {1} to {2}.", GroupId.ToString(), State.GetGroupState(), state.GetGroupState()); + this.State = state; + } + + /// + public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) + { + IEnumerable GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// + public SendCommand NewSyncPlayCommand(SendCommandType type) + { + return new SendCommand() + { + GroupId = GroupId.ToString(), + PlaylistItemId = PlayQueue.GetPlayingItemPlaylistId(), + PositionTicks = PositionTicks, + Command = type, + When = DateToUTCString(LastActivity), + EmittedAt = DateToUTCString(DateTime.UtcNow) + }; + } + + /// + public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) + { + return new GroupUpdate() + { + GroupId = GroupId.ToString(), + Type = type, + Data = data + }; + } + + /// + public string DateToUTCString(DateTime dateTime) + { + return dateTime.ToUniversalTime().ToString("o"); + } + + /// + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + ticks = ticks >= 0 ? ticks : 0; + ticks = ticks > RunTimeTicks ? RunTimeTicks : ticks; + return ticks; + } + + /// + public void UpdatePing(SessionInfo session, long ping) + { + if (Participants.TryGetValue(session.Id, out GroupMember value)) + { + value.Ping = ping; + } + } + + /// + public long GetHighestPing() + { + long max = long.MinValue; + foreach (var session in Participants.Values) + { + max = Math.Max(max, session.Ping); + } + + return max; + } + + /// + public void SetBuffering(SessionInfo session, bool isBuffering) + { + if (Participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IsBuffering = isBuffering; + } + } + + /// + public void SetAllBuffering(bool isBuffering) + { + foreach (var session in Participants.Values) + { + session.IsBuffering = isBuffering; + } + } + + /// + public bool IsBuffering() + { + foreach (var session in Participants.Values) + { + if (session.IsBuffering && !session.IgnoreGroupWait) + { + return true; + } + } + + return false; + } + + /// + public bool SetPlayQueue(Guid[] playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position + if (playQueue.Length < 1 || playingItemPosition >= playQueue.Length || playingItemPosition < 0) + { + return false; + } + + // Check is participants can access the new playing queue + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// + public bool SetPlayingItem(string playlistItemId) + { + var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); + + if (itemFound) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + + return itemFound; + } + + /// + public bool RemoveFromPlayQueue(string[] playlistItemIds) + { + var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); + if (playingItemRemoved) + { + var itemId = PlayQueue.GetPlayingItemId(); + if (!itemId.Equals(Guid.Empty)) + { + var item = _libraryManager.GetItemById(itemId); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + } + + return playingItemRemoved; + } + + /// + public bool MoveItemInPlayQueue(string playlistItemId, int newIndex) + { + return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); + } + + /// + public bool AddToPlayQueue(Guid[] newItems, string mode) + { + // Ignore on empty list + if (newItems.Length < 1) + { + return false; + } + + // Check is participants can access the new playing queue + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals("next")) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// + public void RestartCurrentItem() + { + PositionTicks = 0; + LastActivity = DateTime.UtcNow; + } + + /// + public bool NextItemInQueue() + { + var update = PlayQueue.Next(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// + public bool PreviousItemInQueue() + { + var update = PlayQueue.Previous(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// + public void SetRepeatMode(string mode) { + PlayQueue.SetRepeatMode(mode); + } + + /// + public void SetShuffleMode(string mode) { + PlayQueue.SetShuffleMode(mode); + } + + /// + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (State.GetGroupState().Equals(GroupState.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Event may happen during the delay added to account for latency + startPositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; + } + + return new PlayQueueUpdate() + { + Reason = reason, + LastUpdate = DateToUTCString(PlayQueue.LastChange), + Playlist = PlayQueue.GetPlaylist(), + PlayingItemIndex = PlayQueue.PlayingItemIndex, + StartPositionTicks = startPositionTicks, + ShuffleMode = PlayQueue.ShuffleMode, + RepeatMode = PlayQueue.RepeatMode + }; + } + + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs new file mode 100644 index 0000000000..1f0cb42870 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs @@ -0,0 +1,218 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class AbstractGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public abstract class AbstractGroupState : ISyncPlayState + { + /// + /// The logger. + /// + protected readonly ILogger _logger; + + /// + /// Default constructor. + /// + public AbstractGroupState(ILogger logger) + { + _logger = logger; + } + + /// + /// Sends a group state update to all group. + /// + /// The context of the state. + /// The reason of the state change. + /// The session. + /// The cancellation token. + protected void SendGroupStateUpdate(ISyncPlayStateContext context, IPlaybackGroupRequest reason, SessionInfo session, CancellationToken cancellationToken) + { + // Notify relevant state change event + var stateUpdate = new GroupStateUpdate() + { + State = GetGroupState(), + Reason = reason.GetRequestType() + }; + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public abstract GroupState GetGroupState(); + + /// + public abstract void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + public abstract void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, RemoveFromPlaylistGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + if (playingItemRemoved) + { + var PlayingItemIndex = context.PlayQueue.PlayingItemIndex; + if (context.PlayQueue.PlayingItemIndex == -1) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, play queue is empty.", request.GetRequestType(), context.GroupId.ToString()); + + ISyncPlayState idleState = new IdleGroupState(_logger); + context.SetState(idleState); + var stopRequest = new StopGroupRequest(); + idleState.HandleRequest(context, GetGroupState(), stopRequest, session, cancellationToken); + } + } + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex); + + if (!result) + { + _logger.LogError("HandleRequest: {0} in group {1}, unable to move item in play queue.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.AddToPlayQueue(request.ItemIds, request.Mode); + + if (!result) + { + _logger.LogError("HandleRequest: {0} in group {1}, unable to add items to play queue.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var reason = request.Mode.Equals("next") ? PlayQueueUpdateReason.QueueNext : PlayQueueUpdateReason.Queue; + var playQueueUpdate = context.GetPlayQueueUpdate(reason); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetRepeatModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetRepeatMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetShuffleModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetShuffleMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Collected pings are used to account for network latency when unpausing playback + context.UpdatePing(session, request.Ping); + } + + /// + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + } + + private void UnhandledRequest(IPlaybackGroupRequest request) + { + _logger.LogWarning("HandleRequest: unhandled {0} request for {1} state.", request.GetRequestType(), this.GetGroupState()); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs new file mode 100644 index 0000000000..d6b981c584 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs @@ -0,0 +1,121 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class IdleGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class IdleGroupState : AbstractGroupState + { + /// + /// Default constructor. + /// + public IdleGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + + /// + public override GroupState GetGroupState() + { + return GroupState.Idle; + } + + /// + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, GetGroupState(), session, cancellationToken); + } + + /// + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + private void SendStopCommand(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + var command = context.NewSyncPlayCommand(SendCommandType.Stop); + if (!prevState.Equals(GetGroupState())) + { + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + } + else + { + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs index d3bf24f747..39c0511d9f 100644 --- a/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs @@ -1,11 +1,8 @@ -using System.Linq; using System; using System.Threading; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.SyncPlay { @@ -15,8 +12,16 @@ namespace MediaBrowser.Controller.SyncPlay /// /// Class is not thread-safe, external locking is required when accessing methods. /// - public class PausedGroupState : SyncPlayAbstractState + public class PausedGroupState : AbstractGroupState { + /// + /// Default constructor. + /// + public PausedGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + /// public override GroupState GetGroupState() { @@ -24,31 +29,56 @@ namespace MediaBrowser.Controller.SyncPlay } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) { - // Change state - var playingState = new PlayingGroupState(); - context.SetState(playingState); - return playingState.HandleRequest(context, true, request, session, cancellationToken); + // Wait for session to be ready + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.SessionJoined(context, GetGroupState(), session, cancellationToken); } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) { - if (newState) - { - GroupInfo group = context.GetGroup(); + // Do nothing + } + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var playingState = new PlayingGroupState(_logger); + context.SetState(playingState); + playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(GetGroupState())) + { // Pause group and compute the media playback position var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - group.LastActivity; - group.LastActivity = currentTime; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; // Seek only if playback actually started // Pause request may be issued during the delay added to account for latency - group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; + context.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; var command = context.NewSyncPlayCommand(SendCommandType.Pause); context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); } else { @@ -56,116 +86,71 @@ namespace MediaBrowser.Controller.SyncPlay var command = context.NewSyncPlayCommand(SendCommandType.Pause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } - - return true; } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); - - // Sanitize PositionTicks - var ticks = context.SanitizePositionTicks(request.PositionTicks); - - // Seek - group.PositionTicks = ticks; - group.LastActivity = DateTime.UtcNow; - - var command = context.NewSyncPlayCommand(SendCommandType.Seek); - context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - - return true; + // Change state + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - if (newState) + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(GetGroupState())) { - // Pause group and compute the media playback position - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - group.LastActivity; - group.LastActivity = currentTime; - group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - group.SetBuffering(session, true); - - // Send pause command to all non-buffering sessions - var command = context.NewSyncPlayCommand(SendCommandType.Pause); - context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); - - var updateOthers = context.NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); - context.SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - else - { - // TODO: no idea? - // group.SetBuffering(session, true); - // Client got lost, sending current state var command = context.NewSyncPlayCommand(SendCommandType.Pause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } + else if (prevState.Equals(GroupState.Waiting)) + { + // Sending current state to all clients + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - return true; + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - group.SetBuffering(session, false); - - var requestTicks = context.SanitizePositionTicks(request.PositionTicks); - - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - request.When; - var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; - var delay = group.PositionTicks - clientPosition.Ticks; - - if (group.IsBuffering()) - { - // Others are still buffering, tell this client to pause when ready - var command = context.NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = context.DateToUTCString(pauseAtTime); - context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - if (delay > group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - group.LastActivity = currentTime.AddMilliseconds( - delay - ); - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing); - - group.LastActivity = currentTime.AddMilliseconds( - delay - ); - - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - } - - // Change state - var playingState = new PlayingGroupState(); - context.SetState(playingState); - } - - return true; + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } } } diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs index 42c7779c19..e2909ff91c 100644 --- a/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs @@ -1,11 +1,8 @@ -using System.Linq; using System; using System.Threading; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.SyncPlay { @@ -15,8 +12,21 @@ namespace MediaBrowser.Controller.SyncPlay /// /// Class is not thread-safe, external locking is required when accessing methods. /// - public class PlayingGroupState : SyncPlayAbstractState + public class PlayingGroupState : AbstractGroupState { + /// + /// Ignore requests for buffering. + /// + public bool IgnoreBuffering { get; set; } + + /// + /// Default constructor. + /// + public PlayingGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + /// public override GroupState GetGroupState() { @@ -24,71 +34,132 @@ namespace MediaBrowser.Controller.SyncPlay } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); + // Wait for session to be ready + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.SessionJoined(context, GetGroupState(), session, cancellationToken); + } - if (newState) + /// + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(GetGroupState())) { // Pick a suitable time that accounts for latency - var delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing); + var delayMillis = Math.Max(context.GetHighestPing() * 2, context.DefaultPing); // Unpause group and set starting point in future // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) // The added delay does not guarantee, of course, that the command will be received in time // Playback synchronization will mainly happen client side - group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay + context.LastActivity = DateTime.UtcNow.AddMilliseconds( + delayMillis ); - var command = context.NewSyncPlayCommand(SendCommandType.Play); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); } else { // Client got lost, sending current state - var command = context.NewSyncPlayCommand(SendCommandType.Play); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } - - return true; } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); + var pausedState = new PausedGroupState(_logger); context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + pausedState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); - context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); - context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - // Group was not waiting, make sure client has latest state - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + if (IgnoreBuffering) + { + return; + } - return true; + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(GetGroupState())) + { + // Group was not waiting, make sure client has latest state + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupState.Waiting)) + { + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } } } diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs new file mode 100644 index 0000000000..9d839b268b --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs @@ -0,0 +1,653 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class WaitingGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class WaitingGroupState : AbstractGroupState + { + /// + /// Tells the state to switch to after buffering is done. + /// + public bool ResumePlaying { get; set; } = false; + + /// + /// Whether the initial state has been set. + /// + private bool InitialStateSet { get; set; } = false; + + /// + /// The group state before the first ever event. + /// + private GroupState InitialState { get; set; } + + /// + /// Default constructor. + /// + public WaitingGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + + /// + public override GroupState GetGroupState() + { + return GroupState.Waiting; + } + + /// + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.Playing)) { + ResumePlaying = true; + // Pause group and compute the media playback position + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Seek only if playback actually started + // Event may happen during the delay added to account for latency + context.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; + } + + // Prepare new session + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + + context.SetBuffering(session, true); + + // Send pause command to all non-buffering sessions + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + + /// + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + context.SetBuffering(session, false); + + if (!context.IsBuffering()) + { + if (ResumePlaying) + { + // Client, that was buffering, left the group + var playingState = new PlayingGroupState(_logger); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(context, GetGroupState(), unpauseRequest, session, cancellationToken); + + _logger.LogDebug("SessionLeaving: {0} left the group {1}, notifying others to resume.", session.Id.ToString(), context.GroupId.ToString()); + } + else + { + // Group is ready, returning to previous state + var pausedState = new PausedGroupState(_logger); + context.SetState(pausedState); + + _logger.LogDebug("SessionLeaving: {0} left the group {1}, returning to previous state.", session.Id.ToString(), context.GroupId.ToString()); + } + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + if (!setQueueStatus) + { + _logger.LogError("HandleRequest: {0} in group {1}, unable to set playing queue.", request.GetRequestType(), context.GroupId.ToString()); + + // Ignore request and return to previous state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} set a new play queue.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var result = context.SetPlayingItem(request.PlaylistItemId); + if (result) + { + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, unable to change current playing item.", request.GetRequestType(), context.GroupId.ToString()); + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.Idle)) + { + ResumePlaying = true; + context.RestartCurrentItem(); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + + _logger.LogDebug("HandleRequest: {0} in group {1}, waiting for all ready events.", request.GetRequestType(), context.GroupId.ToString()); + } + else + { + if (ResumePlaying) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, ignoring sessions that are not ready and forcing the playback to start.", request.GetRequestType(), context.GroupId.ToString()); + + // An Unpause request is forcing the playback to start, ignoring sessions that are not ready + context.SetAllBuffering(false); + + // Change state + var playingState = new PlayingGroupState(_logger); + playingState.IgnoreBuffering = true; + context.SetState(playingState); + playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + else + { + // Group would have gone to paused state, now will go to playing state when ready + ResumePlaying = true; + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Wait for sessions to be ready, then switch to paused state + ResumePlaying = false; + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Change state + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.Playing)) + { + ResumePlaying = true; + } + else if(prevState.Equals(GroupState.Paused)) + { + ResumePlaying = false; + } + + // Sanitize PositionTicks + var ticks = context.SanitizePositionTicks(request.PositionTicks); + + // Seek + context.PositionTicks = ticks; + context.LastActivity = DateTime.UtcNow; + + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} has wrong playlist item.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + if (prevState.Equals(GroupState.Playing)) + { + // Resume playback when all ready + ResumePlaying = true; + + context.SetBuffering(session, true); + + // Pause group and compute the media playback position + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + context.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; + + // Send pause command to all non-buffering sessions + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + else if (prevState.Equals(GroupState.Paused)) + { + // Don't resume playback when all ready + ResumePlaying = false; + + context.SetBuffering(session, true); + + // Send pause command to buffering session + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupState.Waiting)) + { + // Another session is now buffering + context.SetBuffering(session, true); + + if (!ResumePlaying) + { + // Force update for this session that should be paused + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} has wrong playlist item.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + var requestTicks = context.SanitizePositionTicks(request.PositionTicks); + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - request.When; + if (!request.IsPlaying) + { + elapsedTime = TimeSpan.Zero; + } + + var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; + var delayTicks = context.PositionTicks - clientPosition.Ticks; + + if (delayTicks > TimeSpan.FromSeconds(5).Ticks) + { + // The client is really behind, other participants will have to wait a lot of time... + _logger.LogWarning("HandleRequest: {0} in group {1}, {2} got lost in time.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + + if (ResumePlaying) + { + // Handle case where session reported as ready but in reality + // it has no clue of the real position nor the playback state + if (!request.IsPlaying && Math.Abs(context.PositionTicks - requestTicks) > TimeSpan.FromSeconds(0.5).Ticks) { + // Session not ready at all + context.SetBuffering(session, true); + + // Correcting session's position + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} got lost in time, correcting.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + return; + } + + // Session is ready + context.SetBuffering(session, false); + + if (context.IsBuffering()) + { + // Others are still buffering, tell this client to pause when ready + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + var pauseAtTime = currentTime.AddTicks(delayTicks); + command.When = context.DateToUTCString(pauseAtTime); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, others still buffering, {2} will pause when ready.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + else + { + // If all ready, then start playback + // Let other clients resume as soon as the buffering client catches up + if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond) + { + // Client that was buffering is recovering, notifying others to resume + context.LastActivity = currentTime.AddTicks(delayTicks); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + var filter = SyncPlayBroadcastType.AllExceptCurrentSession; + if (!request.IsPlaying) + { + filter = SyncPlayBroadcastType.AllGroup; + } + + context.SendCommand(session, filter, command, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} is recovering, notifying others to resume.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + else + { + // Client, that was buffering, resumed playback but did not update others in time + delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; + delayTicks = delayTicks < context.DefaultPing ? context.DefaultPing : delayTicks; + + context.LastActivity = currentTime.AddTicks(delayTicks); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} resumed playback but did not update others in time.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + + // Change state + var playingState = new PlayingGroupState(_logger); + context.SetState(playingState); + playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + } + else + { + // Check that session is really ready, tollerate half second difference to account for player imperfections + if (Math.Abs(context.PositionTicks - requestTicks) > TimeSpan.FromSeconds(0.5).Ticks) + { + // Session still not ready + context.SetBuffering(session, true); + + // Session is seeking to wrong position, correcting + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} was seeking to wrong position, correcting.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + return; + } else { + // Session is ready + context.SetBuffering(session, false); + } + + if (!context.IsBuffering()) + { + // Group is ready, returning to previous state + var pausedState = new PausedGroupState(_logger); + context.SetState(pausedState); + + if (InitialState.Equals(GroupState.Playing)) + { + // Group went from playing to waiting state and a pause request occured while waiting + var pauserequest = new PauseGroupRequest(); + pausedState.HandleRequest(context, GetGroupState(), pauserequest, session, cancellationToken); + } + else if (InitialState.Equals(GroupState.Paused)) + { + pausedState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} is ready, returning to previous state.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, client provided the wrong playlist id.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var newItem = context.NextItemInQueue(); + if (newItem) + { + // Send playing-queue update + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextTrack); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, no next track available.", request.GetRequestType(), context.GroupId.ToString()); + } + } + + /// + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, client provided the wrong playlist id.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var newItem = context.PreviousItemInQueue(); + if (newItem) + { + // Send playing-queue update + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousTrack); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, no previous track available.", request.GetRequestType(), context.GroupId.ToString()); + } + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 225be7430d..0000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// - /// Class SyncPlayController. - /// - /// - /// Class is not thread-safe, external locking is required when accessing methods. - /// - public class SyncPlayController : ISyncPlayController, ISyncPlayStateContext - { - /// - /// The session manager. - /// - private readonly ISessionManager _sessionManager; - - /// - /// The SyncPlay manager. - /// - private readonly ISyncPlayManager _syncPlayManager; - - /// - /// The logger. - /// - private readonly ILogger _logger; - - /// - /// The group to manage. - /// - private readonly GroupInfo _group = new GroupInfo(); - - /// - /// Internal group state. - /// - /// The group's state. - private ISyncPlayState State = new PausedGroupState(); - - /// - public GroupInfo GetGroup() - { - return _group; - } - - /// - public void SetState(ISyncPlayState state) - { - _logger.LogInformation("SetState: {0} -> {1}.", State.GetGroupState(), state.GetGroupState()); - this.State = state; - } - - /// - public Guid GetGroupId() => _group.GroupId; - - /// - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// - /// Initializes a new instance of the class. - /// - /// The session manager. - /// The SyncPlay manager. - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager, - ILogger logger) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - _logger = logger; - } - - /// - /// Filters sessions of this group. - /// - /// The current session. - /// The filtering type. - /// The array of sessions matching the filter. - private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type) - { - switch (type) - { - case SyncPlayBroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case SyncPlayBroadcastType.AllGroup: - return _group.Participants.Values.Select( - session => session.Session).ToArray(); - case SyncPlayBroadcastType.AllExceptCurrentSession: - return _group.Participants.Values.Select( - session => session.Session).Where( - session => !session.Id.Equals(from.Id)).ToArray(); - case SyncPlayBroadcastType.AllReady: - return _group.Participants.Values.Where( - session => !session.IsBuffering).Select( - session => session.Session).ToArray(); - default: - return Array.Empty(); - } - } - - /// - public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) - { - IEnumerable GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// - public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// - public SendCommand NewSyncPlayCommand(SendCommandType type) - { - return new SendCommand() - { - GroupId = _group.GroupId.ToString(), - Command = type, - PositionTicks = _group.PositionTicks, - When = DateToUTCString(_group.LastActivity), - EmittedAt = DateToUTCString(DateTime.UtcNow) - }; - } - - /// - public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) - { - return new GroupUpdate() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// - public string DateToUTCString(DateTime _date) - { - return _date.ToUniversalTime().ToString("o"); - } - - /// - public long SanitizePositionTicks(long? positionTicks) - { - var ticks = positionTicks ?? 0; - ticks = ticks >= 0 ? ticks : 0; - if (_group.PlayingItem != null) - { - var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0; - ticks = ticks > runTimeTicks ? runTimeTicks : ticks; - } - - return ticks; - } - - /// - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - State = new PausedGroupState(); - - _group.PlayingItem = session.FullNowPlayingItem; - // TODO: looks like new groups should mantain playstate (and not force to pause) - // _group.IsPaused = session.PlayState.IsPaused; - _group.PositionTicks = session.PlayState.PositionTicks ?? 0; - _group.LastActivity = DateTime.UtcNow; - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - // TODO: looks like new groups should mantain playstate (and not force to pause) - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, SyncPlayBroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - - /// - public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) - { - if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (State.GetGroupState().Equals(GroupState.Playing)) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, SyncPlayBroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, SyncPlayBroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - } - else - { - var playRequest = new PlayRequest - { - ItemIds = new Guid[] { _group.PlayingItem.Id }, - StartPositionTicks = _group.PositionTicks - }; - var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// - public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) - { - _group.RemoveSession(session); - _syncPlayManager.RemoveSessionFromGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); - SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - - /// - public void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken) - { - // The server's job is to maintain a consistent state for clients to reference - // and notify clients of state changes. The actual syncing of media playback - // happens client side. Clients are aware of the server's time and use it to sync. - _logger.LogInformation("HandleRequest: {0}:{1}.", request.GetType(), State.GetGroupState()); - _ = request.Apply(this, State, session, cancellationToken); - // TODO: do something with returned value - } - - /// - public GroupInfoDto GetInfo() - { - return new GroupInfoDto() - { - GroupId = GetGroupId().ToString(), - PlayingItemName = _group.PlayingItem.Name, - PlayingItemId = _group.PlayingItem.Id.ToString(), - PositionTicks = _group.PositionTicks, - Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() - }; - } - } -} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index b85f3c1496..a8e30a9eca 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; @@ -41,14 +39,14 @@ namespace Emby.Server.Implementations.SyncPlay /// /// The map between sessions and groups. /// - private readonly Dictionary _sessionToGroupMap = - new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _sessionToGroupMap = + new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// The groups. /// - private readonly Dictionary _groups = - new Dictionary(); + private readonly Dictionary _groups = + new Dictionary(); /// /// Lock used for accesing any group. @@ -75,7 +73,9 @@ namespace Emby.Server.Implementations.SyncPlay _sessionManager = sessionManager; _libraryManager = libraryManager; + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; _sessionManager.SessionEnded += OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; } @@ -83,7 +83,7 @@ namespace Emby.Server.Implementations.SyncPlay /// Gets all groups. /// /// All groups. - public IEnumerable Groups => _groups.Values; + public IEnumerable Groups => _groups.Values; /// public void Dispose() @@ -103,12 +103,30 @@ namespace Emby.Server.Implementations.SyncPlay return; } + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; _disposed = true; } + private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) + { + var session = e.SessionInfo; + if (!IsSessionInGroup(session)) + { + return; + } + + var groupId = GetSessionGroup(session) ?? Guid.Empty; + var request = new JoinGroupRequest() + { + GroupId = groupId + }; + JoinGroup(session, groupId, request, CancellationToken.None); + } + private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) { var session = e.SessionInfo; @@ -117,7 +135,18 @@ namespace Emby.Server.Implementations.SyncPlay return; } - LeaveGroup(session, CancellationToken.None); + // TODO: probably remove this event, not used at the moment + } + + private void OnSessionManagerPlaybackStart(object sender, PlaybackProgressEventArgs e) + { + var session = e.Session; + if (!IsSessionInGroup(session)) + { + return; + } + + // TODO: probably remove this event, not used at the moment } private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) @@ -128,7 +157,7 @@ namespace Emby.Server.Implementations.SyncPlay return; } - LeaveGroup(session, CancellationToken.None); + // TODO: probably remove this event, not used at the moment } private bool IsSessionInGroup(SessionInfo session) @@ -136,33 +165,14 @@ namespace Emby.Server.Implementations.SyncPlay return _sessionToGroupMap.ContainsKey(session.Id); } - private bool HasAccessToItem(User user, Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - - // Check ParentalRating access - var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; - - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) - { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); - - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); - } - - return hasParentalRatingAccess; - } - private Guid? GetSessionGroup(SessionInfo session) { _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); + return group?.GroupId; } /// - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { var user = _userManager.GetUserById(session.UserId); @@ -174,8 +184,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.CreateGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -186,10 +195,10 @@ namespace Emby.Server.Implementations.SyncPlay LeaveGroup(session, cancellationToken); } - var group = new SyncPlayController(_sessionManager, this, _logger); - _groups[group.GetGroupId()] = group; + var group = new SyncPlayGroupController(_logger, _userManager, _sessionManager, _libraryManager, this); + _groups[group.GroupId] = group; - group.CreateGroup(session, cancellationToken); + group.CreateGroup(session, request, cancellationToken); } } @@ -206,14 +215,13 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.JoinGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } lock (_groupsLock) { - ISyncPlayController group; + ISyncPlayGroupController group; _groups.TryGetValue(groupId, out group); if (group == null) @@ -224,20 +232,20 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.GroupDoesNotExist }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } - if (!HasAccessToItem(user, group.GetPlayingItemId())) + if (!group.HasAccessToPlayQueue(user)) { - _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId()); + _logger.LogWarning("JoinGroup: {0} does not have access to some content from the playing queue of group {1}.", session.Id, group.GroupId.ToString()); var error = new GroupUpdate() { - GroupId = group.GetGroupId().ToString(), + GroupId = group.GroupId.ToString(), Type = GroupUpdateType.LibraryAccessDenied }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -245,6 +253,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (GetSessionGroup(session).Equals(groupId)) { + group.SessionRestore(session, request, cancellationToken); return; } @@ -271,7 +280,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.NotInGroup }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -279,14 +288,14 @@ namespace Emby.Server.Implementations.SyncPlay if (group.IsGroupEmpty()) { - _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId()); - _groups.Remove(group.GetGroupId(), out _); + _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GroupId); + _groups.Remove(group.GroupId, out _); } } } /// - public List ListGroups(SessionInfo session, Guid filterItemId) + public List ListGroups(SessionInfo session) { var user = _userManager.GetUserById(session.UserId); @@ -295,20 +304,9 @@ namespace Emby.Server.Implementations.SyncPlay return new List(); } - // Filter by item if requested - if (!filterItemId.Equals(Guid.Empty)) - { - return _groups.Values.Where( - group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); - } - else - { - // Otherwise show all available groups - return _groups.Values.Where( - group => HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); - } + return _groups.Values.Where( + group => group.HasAccessToPlayQueue(user)).Select( + group => group.GetInfo()).ToList(); } /// @@ -324,8 +322,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.JoinGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -341,7 +338,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.NotInGroup }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -350,7 +347,7 @@ namespace Emby.Server.Implementations.SyncPlay } /// - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) + public void AddSessionToGroup(SessionInfo session, ISyncPlayGroupController group) { if (IsSessionInGroup(session)) { @@ -361,7 +358,7 @@ namespace Emby.Server.Implementations.SyncPlay } /// - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) + public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayGroupController group) { if (!IsSessionInGroup(session)) { @@ -369,7 +366,7 @@ namespace Emby.Server.Implementations.SyncPlay } _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) + if (!tempGroup.GroupId.Equals(group.GroupId)) { throw new InvalidOperationException("Session was in wrong group!"); } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index e16a10ba4d..847c3ab117 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -43,14 +43,20 @@ namespace Jellyfin.Api.Controllers /// /// Create a new SyncPlay group. /// + /// The name of the new group. /// New group created. /// A indicating success. [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayCreateGroup() + public ActionResult SyncPlayCreateGroup( + [FromQuery, Required] string groupName) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + var newGroupRequest = new NewGroupRequest() + { + GroupName = groupName + }; + _syncPlayManager.NewGroup(currentSession, newGroupRequest, CancellationToken.None); return NoContent(); } @@ -62,15 +68,14 @@ namespace Jellyfin.Api.Controllers /// A indicating success. [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + public ActionResult SyncPlayJoinGroup( + [FromQuery, Required] Guid groupId) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var joinRequest = new JoinGroupRequest() { GroupId = groupId }; - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); return NoContent(); } @@ -92,35 +97,143 @@ namespace Jellyfin.Api.Controllers /// /// Gets all SyncPlay groups. /// - /// Optional. Filter by item id. /// Groups returned. /// An containing the available SyncPlay groups. [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + public ActionResult> SyncPlayGetGroups() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + return Ok(_syncPlayManager.ListGroups(currentSession)); } /// /// Request play in SyncPlay group. /// + /// The playing queue. Item ids in the playing queue, comma delimited. + /// The playing item position from the queue. + /// The start position ticks. /// Play request sent to all group members. /// A indicating success. [HttpPost("Play")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPlay() + public ActionResult SyncPlayPlay( + [FromQuery, Required] string playingQueue, + [FromQuery, Required] int playingItemPosition, + [FromQuery, Required] long startPositionTicks) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() + var syncPlayRequest = new PlayGroupRequest() { - Type = PlaybackRequestType.Play + PlayingQueue = RequestHelpers.GetGuids(playingQueue), + PlayingItemPosition = playingItemPosition, + StartPositionTicks = startPositionTicks }; _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } + /// + /// Request to change playlist item in SyncPlay group. + /// + /// The playlist id of the item. + /// Queue update request sent to all group members. + /// A indicating success. + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetPlaylistItem( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetPlaylistItemGroupRequest() + { + PlaylistItemId = playlistItemId + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to remove items from the playlist in SyncPlay group. + /// + /// The playlist ids of the items to remove. + /// Queue update request sent to all group members. + /// A indicating success. + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayRemoveFromPlaylist( + [FromQuery, Required] string[] playlistItemIds) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest() + { + PlaylistItemIds = playlistItemIds + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to move an item in the playlist in SyncPlay group. + /// + /// The playlist id of the item to move. + /// The new position. + /// Queue update request sent to all group members. + /// A indicating success. + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayMovePlaylistItem( + [FromQuery, Required] string playlistItemId, + [FromQuery, Required] int newIndex) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new MovePlaylistItemGroupRequest() + { + PlaylistItemId = playlistItemId, + NewIndex = newIndex + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to queue items to the playlist of a SyncPlay group. + /// + /// The items to add. Item ids, comma delimited. + /// The mode in which to queue items. + /// Queue update request sent to all group members. + /// A indicating success. + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayQueue( + [FromQuery, Required] string itemIds, + [FromQuery, Required] string mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new QueueGroupRequest() + { + ItemIds = RequestHelpers.GetGuids(itemIds), + Mode = mode + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request unpause in SyncPlay group. + /// + /// Unpause request sent to all group members. + /// A indicating success. + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayUnpause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new UnpauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + /// /// Request pause in SyncPlay group. /// @@ -131,10 +244,22 @@ namespace Jellyfin.Api.Controllers public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request stop in SyncPlay group. + /// + /// Stop request sent to all group members. + /// A indicating success. + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayStop() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -147,12 +272,12 @@ namespace Jellyfin.Api.Controllers /// A indicating success. [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + public ActionResult SyncPlaySeek( + [FromQuery, Required] long positionTicks) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() + var syncPlayRequest = new SeekGroupRequest() { - Type = PlaybackRequestType.Seek, PositionTicks = positionTicks }; _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); @@ -164,19 +289,142 @@ namespace Jellyfin.Api.Controllers /// /// When the request has been made by the client. /// The playback position in ticks. + /// Whether the client's playback is playing or not. + /// The playlist item id. /// Whether the buffering is done. /// Buffering request sent to all group members. /// A indicating success. [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + public ActionResult SyncPlayBuffering( + [FromQuery, Required] DateTime when, + [FromQuery, Required] long positionTicks, + [FromQuery, Required] bool isPlaying, + [FromQuery, Required] string playlistItemId, + [FromQuery, Required] bool bufferingDone) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() + IPlaybackGroupRequest syncPlayRequest; + if (!bufferingDone) { - Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = when, - PositionTicks = positionTicks + syncPlayRequest = new BufferGroupRequest() + { + When = when, + PositionTicks = positionTicks, + IsPlaying = isPlaying, + PlaylistItemId = playlistItemId + }; + } + else + { + syncPlayRequest = new ReadyGroupRequest() + { + When = when, + PositionTicks = positionTicks, + IsPlaying = isPlaying, + PlaylistItemId = playlistItemId + }; + } + + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request SyncPlay group to ignore member during group-wait. + /// + /// Whether to ignore the member. + /// Member state updated. + /// A indicating success. + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetIgnoreWait( + [FromQuery, Required] bool ignoreWait) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new IgnoreWaitGroupRequest() + { + IgnoreWait = ignoreWait + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request next track in SyncPlay group. + /// + /// The playing item id. + /// Next track request sent to all group members. + /// A indicating success. + [HttpPost("NextTrack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayNextTrack( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new NextTrackGroupRequest() + { + PlaylistItemId = playlistItemId + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request previous track in SyncPlay group. + /// + /// The playing item id. + /// Previous track request sent to all group members. + /// A indicating success. + [HttpPost("PreviousTrack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPreviousTrack( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PreviousTrackGroupRequest() + { + PlaylistItemId = playlistItemId + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to set repeat mode in SyncPlay group. + /// + /// The repeat mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetRepeatMode( + [FromQuery, Required] string mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetRepeatModeGroupRequest() + { + Mode = mode + }; + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to set shuffle mode in SyncPlay group. + /// + /// The shuffle mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetShuffleMode( + [FromQuery, Required] string mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetShuffleModeGroupRequest() + { + Mode = mode }; _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -190,12 +438,12 @@ namespace Jellyfin.Api.Controllers /// A indicating success. [HttpPost("Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPing([FromQuery] double ping) + public ActionResult SyncPlayPing( + [FromQuery, Required] double ping) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() + var syncPlayRequest = new PingGroupRequest() { - Type = PlaybackRequestType.Ping, Ping = Convert.ToInt64(ping) }; _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); diff --git a/MediaBrowser.Api/SyncPlay/SyncPlayService.cs b/MediaBrowser.Api/SyncPlay/SyncPlayService.cs deleted file mode 100644 index bd9670f07a..0000000000 --- a/MediaBrowser.Api/SyncPlay/SyncPlayService.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System.Threading; -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.SyncPlay; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.SyncPlay -{ - [Route("/SyncPlay/New", "POST", Summary = "Create a new SyncPlay group")] - [Authenticated] - public class SyncPlayNew : IReturnVoid - { - } - - [Route("/SyncPlay/Join", "POST", Summary = "Join an existing SyncPlay group")] - [Authenticated] - public class SyncPlayJoin : IReturnVoid - { - /// - /// Gets or sets the Group id. - /// - /// The Group id to join. - [ApiMember(Name = "GroupId", Description = "Group Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string GroupId { get; set; } - } - - [Route("/SyncPlay/Leave", "POST", Summary = "Leave joined SyncPlay group")] - [Authenticated] - public class SyncPlayLeave : IReturnVoid - { - } - - [Route("/SyncPlay/List", "GET", Summary = "List SyncPlay groups")] - [Authenticated] - public class SyncPlayList : IReturnVoid - { - /// - /// Gets or sets the filter item id. - /// - /// The filter item id. - [ApiMember(Name = "FilterItemId", Description = "Filter by item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string FilterItemId { get; set; } - } - - [Route("/SyncPlay/Play", "POST", Summary = "Request play in SyncPlay group")] - [Authenticated] - public class SyncPlayPlay : IReturnVoid - { - } - - [Route("/SyncPlay/Pause", "POST", Summary = "Request pause in SyncPlay group")] - [Authenticated] - public class SyncPlayPause : IReturnVoid - { - } - - [Route("/SyncPlay/Seek", "POST", Summary = "Request seek in SyncPlay group")] - [Authenticated] - public class SyncPlaySeek : IReturnVoid - { - [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")] - public long PositionTicks { get; set; } - } - - [Route("/SyncPlay/Buffering", "POST", Summary = "Request group wait in SyncPlay group while buffering")] - [Authenticated] - public class SyncPlayBuffering : IReturnVoid - { - /// - /// Gets or sets the date used to pin PositionTicks in time. - /// - /// The date related to PositionTicks. - [ApiMember(Name = "When", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string When { get; set; } - - [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")] - public long PositionTicks { get; set; } - - /// - /// Gets or sets whether this is a buffering or a ready request. - /// - /// true if buffering is complete; false otherwise. - [ApiMember(Name = "BufferingDone", IsRequired = true, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool BufferingDone { get; set; } - } - - [Route("/SyncPlay/Ping", "POST", Summary = "Update session ping")] - [Authenticated] - public class SyncPlayPing : IReturnVoid - { - [ApiMember(Name = "Ping", IsRequired = true, DataType = "double", ParameterType = "query", Verb = "POST")] - public double Ping { get; set; } - } - - /// - /// Class SyncPlayService. - /// - public class SyncPlayService : BaseApiService - { - /// - /// The session context. - /// - private readonly ISessionContext _sessionContext; - - /// - /// The SyncPlay manager. - /// - private readonly ISyncPlayManager _syncPlayManager; - - public SyncPlayService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionContext sessionContext, - ISyncPlayManager syncPlayManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionContext = sessionContext; - _syncPlayManager = syncPlayManager; - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayNew request) - { - var currentSession = GetSession(_sessionContext); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayJoin request) - { - var currentSession = GetSession(_sessionContext); - - Guid groupId; - if (!Guid.TryParse(request.GroupId, out groupId)) - { - Logger.LogError("JoinGroup: {0} is not a valid format for GroupId. Ignoring request.", request.GroupId); - return; - } - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayLeave request) - { - var currentSession = GetSession(_sessionContext); - _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - /// The requested list of groups. - public List Get(SyncPlayList request) - { - var currentSession = GetSession(_sessionContext); - var filterItemId = Guid.Empty; - - if (!string.IsNullOrEmpty(request.FilterItemId) && !Guid.TryParse(request.FilterItemId, out filterItemId)) - { - Logger.LogWarning("ListGroups: {0} is not a valid format for FilterItemId. Ignoring filter.", request.FilterItemId); - } - - return _syncPlayManager.ListGroups(currentSession, filterItemId); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayPlay request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PlayGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayPause request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlaySeek request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new SeekGroupRequest() - { - PositionTicks = request.PositionTicks - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayBuffering request) - { - var currentSession = GetSession(_sessionContext); - - IPlaybackGroupRequest syncPlayRequest; - if (!request.BufferingDone) - { - syncPlayRequest = new BufferGroupRequest() - { - When = DateTime.Parse(request.When), - PositionTicks = request.PositionTicks - }; - } - else - { - syncPlayRequest = new ReadyGroupRequest() - { - When = DateTime.Parse(request.When), - PositionTicks = request.PositionTicks - }; - } - - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - - /// - /// Handles the specified request. - /// - /// The request. - public void Post(SyncPlayPing request) - { - var currentSession = GetSession(_sessionContext); - var syncPlayRequest = new PingGroupRequest() - { - Ping = Convert.ToInt64(request.Ping) - }; - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 04c3004ee6..9ad8557ce6 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken); /// - /// Sends the SyncPlayCommand. + /// Sends a SyncPlayCommand to a session. /// - /// The session id. + /// The session. /// The command. /// The cancellation token. /// Task. - Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken); + Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken); /// - /// Sends the SyncPlayGroupUpdate. + /// Sends a SyncPlayGroupUpdate to a session. /// - /// The session id. + /// The session. /// The group update. /// The cancellation token. /// Task. - Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken); + Task SendSyncPlayGroupUpdate(SessionInfo session, GroupUpdate command, CancellationToken cancellationToken); /// /// Sends the browse command. diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs deleted file mode 100644 index cdd24d0b59..0000000000 --- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Session; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// - /// Class GroupInfo. - /// - /// - /// Class is not thread-safe, external locking is required when accessing methods. - /// - public class GroupInfo - { - /// - /// The default ping value used for sessions. - /// - public const long DefaultPing = 500; - - /// - /// Gets the group identifier. - /// - /// The group identifier. - public Guid GroupId { get; } = Guid.NewGuid(); - - /// - /// Gets or sets the playing item. - /// - /// The playing item. - public BaseItem PlayingItem { get; set; } - - /// - /// Gets or sets a value indicating whether there are position ticks. - /// - /// The position ticks. - public long PositionTicks { get; set; } - - /// - /// Gets or sets the last activity. - /// - /// The last activity. - public DateTime LastActivity { get; set; } - - /// - /// Gets the participants. - /// - /// The participants, or members of the group. - public Dictionary Participants { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Checks if a session is in this group. - /// - /// The session id to check. - /// true if the session is in this group; false otherwise. - public bool ContainsSession(string sessionId) - { - return Participants.ContainsKey(sessionId); - } - - /// - /// Adds the session to the group. - /// - /// The session. - public void AddSession(SessionInfo session) - { - Participants.TryAdd( - session.Id, - new GroupMember - { - Session = session, - Ping = DefaultPing, - IsBuffering = false - }); - } - - /// - /// Removes the session from the group. - /// - /// The session. - public void RemoveSession(SessionInfo session) - { - Participants.Remove(session.Id); - } - - /// - /// Updates the ping of a session. - /// - /// The session. - /// The ping. - public void UpdatePing(SessionInfo session, long ping) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.Ping = ping; - } - } - - /// - /// Gets the highest ping in the group. - /// - /// The highest ping in the group. - public long GetHighestPing() - { - long max = long.MinValue; - foreach (var session in Participants.Values) - { - max = Math.Max(max, session.Ping); - } - - return max; - } - - /// - /// Sets the session's buffering state. - /// - /// The session. - /// The state. - public void SetBuffering(SessionInfo session, bool isBuffering) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.IsBuffering = isBuffering; - } - } - - /// - /// Gets the group buffering state. - /// - /// true if there is a session buffering in the group; false otherwise. - public bool IsBuffering() - { - foreach (var session in Participants.Values) - { - if (session.IsBuffering) - { - return true; - } - } - - return false; - } - - /// - /// Checks if the group is empty. - /// - /// true if the group is empty; false otherwise. - public bool IsEmpty() - { - return Participants.Count == 0; - } - } -} diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index cde6f8e8ce..9a9d30277f 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs @@ -7,12 +7,6 @@ namespace MediaBrowser.Controller.SyncPlay /// public class GroupMember { - /// - /// Gets or sets a value indicating whether this member is buffering. - /// - /// true if member is buffering; false otherwise. - public bool IsBuffering { get; set; } - /// /// Gets or sets the session. /// @@ -20,9 +14,21 @@ namespace MediaBrowser.Controller.SyncPlay public SessionInfo Session { get; set; } /// - /// Gets or sets the ping. + /// Gets or sets the ping, in milliseconds. /// /// The ping. public long Ping { get; set; } + + /// + /// Gets or sets a value indicating whether this member is buffering. + /// + /// true if member is buffering; false otherwise. + public bool IsBuffering { get; set; } + + /// + /// Gets or sets a value indicating whether this member is following group playback. + /// + /// true to ignore member on group wait; false if they're following group playback. + public bool IgnoreGroupWait { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs index a6e87a007f..35ca64c8df 100644 --- a/MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs @@ -12,13 +12,12 @@ namespace MediaBrowser.Controller.SyncPlay /// /// Gets the playback request type. /// - /// The playback request type. - PlaybackRequestType Type(); + /// The playback request type. + PlaybackRequestType GetRequestType(); /// /// Applies the request to a group. /// - /// The operation completion status. - bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken); + void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs index 5ac2aeb247..9a4e1ee1ee 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs @@ -1,39 +1,41 @@ using System; using System.Threading; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Session; using MediaBrowser.Model.SyncPlay; namespace MediaBrowser.Controller.SyncPlay { /// - /// Interface ISyncPlayController. + /// Interface ISyncPlayGroupController. /// - public interface ISyncPlayController + public interface ISyncPlayGroupController { /// - /// Gets the group id. + /// Gets the group identifier. /// - /// The group id. - Guid GetGroupId(); + /// The group identifier. + Guid GroupId { get; } /// - /// Gets the playing item id. + /// Gets the play queue. /// - /// The playing item id. - Guid GetPlayingItemId(); + /// The play queue. + PlayQueueManager PlayQueue { get; } /// /// Checks if the group is empty. /// - /// If the group is empty. + /// If the group is empty. bool IsGroupEmpty(); /// /// Initializes the group with the session's info. /// /// The session. + /// The request. /// The cancellation token. - void CreateGroup(SessionInfo session, CancellationToken cancellationToken); + void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// /// Adds the session to the group. @@ -43,6 +45,14 @@ namespace MediaBrowser.Controller.SyncPlay /// The cancellation token. void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); + /// + /// Restores the state of a session that already joined the group. + /// + /// The session. + /// The request. + /// The cancellation token. + void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); + /// /// Removes the session from the group. /// @@ -61,7 +71,15 @@ namespace MediaBrowser.Controller.SyncPlay /// /// Gets the info about the group for the clients. /// - /// The group info for the clients. + /// The group info for the clients. GroupInfoDto GetInfo(); + + /// + /// Checks if a user has access to all content in the play queue. + /// + /// The user. + /// true if the user can access the play queue; false otherwise. + bool HasAccessToPlayQueue(User user); + } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index 6fa94e2ce4..9bef3f5593 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -15,8 +15,9 @@ namespace MediaBrowser.Controller.SyncPlay /// Creates a new group. /// /// The session that's creating the group. + /// The request. /// The cancellation token. - void NewGroup(SessionInfo session, CancellationToken cancellationToken); + void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// /// Adds the session to a group. @@ -38,9 +39,8 @@ namespace MediaBrowser.Controller.SyncPlay /// Gets list of available groups for a session. /// /// The session. - /// The item id to filter by. - /// The list of available groups. - List ListGroups(SessionInfo session, Guid filterItemId); + /// The list of available groups. + List ListGroups(SessionInfo session); /// /// Handle a request by a session in a group. @@ -56,7 +56,7 @@ namespace MediaBrowser.Controller.SyncPlay /// The session. /// The group. /// - void AddSessionToGroup(SessionInfo session, ISyncPlayController group); + void AddSessionToGroup(SessionInfo session, ISyncPlayGroupController group); /// /// Unmaps a session from a group. @@ -64,6 +64,6 @@ namespace MediaBrowser.Controller.SyncPlay /// The session. /// The group. /// - void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group); + void RemoveSessionFromGroup(SessionInfo session, ISyncPlayGroupController group); } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs index 55c9ee938f..290576e30f 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs @@ -15,81 +15,202 @@ namespace MediaBrowser.Controller.SyncPlay /// The group state. GroupState GetGroupState(); + /// + /// Handle a session that joined the group. + /// + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handle a session that is leaving the group. + /// + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + /// /// Generic handle. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. - /// The play action. + /// The previous state. + /// The generic action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Handles a play action requested by a session. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The play action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a playlist-item change requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The playlist-item change action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a remove-items change requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The remove-items change action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, RemoveFromPlaylistGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a move-item change requested by a session. Context's state should not change. + /// + /// The context of the state. + /// The previous state. + /// The move-item change action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a queue change requested by a session. Context's state should not change. + /// + /// The context of the state. + /// The previous state. + /// The queue action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles an unpause action requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The unpause action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Handles a pause action requested by a session. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The pause action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a stop action requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The stop action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Handles a seek action requested by a session. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The seek action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Handles a buffering action requested by a session. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The buffering action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Handles a buffering-done action requested by a session. Context's state can change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The buffering-done action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a next-track action requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The next-track action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a previous-track action requested by a session. Context's state can change. + /// + /// The context of the state. + /// The previous state. + /// The previous-track action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a repeat-mode change requested by a session. Context's state should not change. + /// + /// The context of the state. + /// The previous state. + /// The repeat-mode action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetRepeatModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a shuffle-mode change requested by a session. Context's state should not change. + /// + /// The context of the state. + /// The previous state. + /// The shuffle-mode action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetShuffleModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken); /// /// Updates ping of a session. Context's state should not change. /// /// The context of the state. - /// Whether the state has been just set. + /// The previous state. /// The buffering-done action. /// The session. /// The cancellation token. - /// The operation completion status. - bool HandleRequest(ISyncPlayStateContext context, bool newState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Updates whether the session should be considered during group wait. Context's state should not change. + /// + /// The context of the state. + /// The previous state. + /// The ignore-wait action. + /// The session. + /// The cancellation token. + void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs index 9bdb1ace6c..18a6857491 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs @@ -12,10 +12,34 @@ namespace MediaBrowser.Controller.SyncPlay public interface ISyncPlayStateContext { /// - /// Gets the context's group. + /// Gets the default ping value used for sessions, in milliseconds. /// - /// The group. - GroupInfo GetGroup(); + /// The default ping value used for sessions, in milliseconds. + long DefaultPing { get; } + + /// + /// Gets the group identifier. + /// + /// The group identifier. + Guid GroupId { get; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + long PositionTicks { get; set; } + + /// + /// Gets or sets the last activity. + /// + /// The last activity. + DateTime LastActivity { get; set; } + + /// + /// Gets the play queue. + /// + /// The play queue. + PlayQueueManager PlayQueue { get; } /// /// Sets a new state. @@ -30,7 +54,7 @@ namespace MediaBrowser.Controller.SyncPlay /// The filtering type. /// The message to send. /// The cancellation token. - /// The task. + /// The task. Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken); /// @@ -40,14 +64,14 @@ namespace MediaBrowser.Controller.SyncPlay /// The filtering type. /// The message to send. /// The cancellation token. - /// The task. + /// The task. Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken); /// /// Builds a new playback command with some default values. /// /// The command type. - /// The SendCommand. + /// The SendCommand. SendCommand NewSyncPlayCommand(SendCommandType type); /// @@ -55,21 +79,135 @@ namespace MediaBrowser.Controller.SyncPlay /// /// The update type. /// The data to send. - /// The GroupUpdate. + /// The GroupUpdate. GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data); /// /// Converts DateTime to UTC string. /// - /// The date to convert. - /// The UTC string. - string DateToUTCString(DateTime date); + /// The date to convert. + /// The UTC string. + string DateToUTCString(DateTime dateTime); /// /// Sanitizes the PositionTicks, considers the current playing item when available. /// /// The PositionTicks. - /// The sanitized PositionTicks. + /// The sanitized PositionTicks. long SanitizePositionTicks(long? positionTicks); + + /// + /// Updates the ping of a session, in milliseconds. + /// + /// The session. + /// The ping, in milliseconds. + void UpdatePing(SessionInfo session, long ping); + + /// + /// Gets the highest ping in the group, in milliseconds. + /// + /// The highest ping in the group. + long GetHighestPing(); + + /// + /// Sets the session's buffering state. + /// + /// The session. + /// The state. + void SetBuffering(SessionInfo session, bool isBuffering); + + /// + /// Sets the buffering state of all the sessions. + /// + /// The state. + void SetAllBuffering(bool isBuffering); + + /// + /// Gets the group buffering state. + /// + /// true if there is a session buffering in the group; false otherwise. + bool IsBuffering(); + + /// + /// Sets the session's group wait state. + /// + /// The session. + /// The state. + void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait); + + /// + /// Sets a new play queue. + /// + /// The new play queue. + /// The playing item position in the play queue. + /// The start position ticks. + /// true if the play queue has been changed; false is something went wrong. + bool SetPlayQueue(Guid[] playQueue, int playingItemPosition, long startPositionTicks); + + /// + /// Sets the playing item. + /// + /// The new playing item id. + /// true if the play queue has been changed; false is something went wrong. + bool SetPlayingItem(string playlistItemId); + + /// + /// Removes items from the play queue. + /// + /// The items to remove. + /// true if playing item got removed; false otherwise. + bool RemoveFromPlayQueue(string[] playlistItemIds); + + /// + /// Moves an item in the play queue. + /// + /// The playlist id of the item to move. + /// The new position. + /// true if item has been moved; false is something went wrong. + bool MoveItemInPlayQueue(string playlistItemId, int newIndex); + + /// + /// Updates the play queue. + /// + /// The new items to add to the play queue. + /// The mode with which the items will be added. + /// true if the play queue has been changed; false is something went wrong. + bool AddToPlayQueue(Guid[] newItems, string mode); + + /// + /// Restarts current item in play queue. + /// + void RestartCurrentItem(); + + /// + /// Picks next item in play queue. + /// + /// true if the item changed; false otherwise. + bool NextItemInQueue(); + + /// + /// Picks previous item in play queue. + /// + /// true if the item changed; false otherwise. + bool PreviousItemInQueue(); + + /// + /// Sets the repeat mode. + /// + /// The new mode. + void SetRepeatMode(string mode); + + /// + /// Sets the shuffle mode. + /// + /// The new mode. + void SetShuffleMode(string mode); + + /// + /// Creates a play queue update. + /// + /// The reason for the update. + /// The play queue update. + PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason); } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs index 21dae8e4e6..0815dd79b2 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs @@ -23,21 +23,27 @@ namespace MediaBrowser.Controller.SyncPlay public long PositionTicks { get; set; } /// - /// Gets or sets the playing item id. + /// Gets or sets the client playback status. /// - /// The playing item id. - public Guid PlayingItemId { get; set; } + /// The client playback status. + public bool IsPlaying { get; set; } + + /// + /// Gets or sets the playlist item id of the playing item. + /// + /// The playlist item id. + public string PlaylistItemId { get; set; } /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Buffer; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/IgnoreWaitGroupRequest.cs new file mode 100644 index 0000000000..5466cbe2f7 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/IgnoreWaitGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class IgnoreWaitGroupRequest. + /// + public class IgnoreWaitGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the client group-wait status. + /// + /// The client group-wait status. + public bool IgnoreWait { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.IgnoreWait; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/MovePlaylistItemGroupRequest.cs new file mode 100644 index 0000000000..7a293c02fd --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/MovePlaylistItemGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class MovePlaylistItemGroupRequest. + /// + public class MovePlaylistItemGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the playlist id of the item. + /// + /// The playlist id of the item. + public string PlaylistItemId { get; set; } + + /// + /// Gets or sets the new position. + /// + /// The new position. + public int NewIndex { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.Queue; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/NextTrackGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/NextTrackGroupRequest.cs new file mode 100644 index 0000000000..d19df2c6a0 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/NextTrackGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class NextTrackGroupRequest. + /// + public class NextTrackGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the playing item id. + /// + /// The playing item id. + public string PlaylistItemId { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.NextTrack; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs index 21a46add8c..facb25155c 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs @@ -10,15 +10,15 @@ namespace MediaBrowser.Controller.SyncPlay public class PauseGroupRequest : IPlaybackGroupRequest { /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Pause; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs index 2f78edfc56..631bf245b1 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs @@ -2,7 +2,6 @@ using System.Threading; using MediaBrowser.Model.SyncPlay; using MediaBrowser.Controller.Session; -// FIXME: not really group related, can be moved up to SyncPlayController maybe? namespace MediaBrowser.Controller.SyncPlay { /// @@ -17,15 +16,15 @@ namespace MediaBrowser.Controller.SyncPlay public long Ping { get; set; } /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Ping; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs index 942229a775..f3dd769e46 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using MediaBrowser.Model.SyncPlay; using MediaBrowser.Controller.Session; @@ -9,16 +10,34 @@ namespace MediaBrowser.Controller.SyncPlay /// public class PlayGroupRequest : IPlaybackGroupRequest { + /// + /// Gets or sets the playing queue. + /// + /// The playing queue. + public Guid[] PlayingQueue { get; set; } + + /// + /// Gets or sets the playing item from the queue. + /// + /// The playing item. + public int PlayingItemPosition { get; set; } + + /// + /// Gets or sets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; set; } + /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Play; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PreviousTrackGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PreviousTrackGroupRequest.cs new file mode 100644 index 0000000000..663011b429 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/PreviousTrackGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class PreviousTrackGroupRequest. + /// + public class PreviousTrackGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the playing item id. + /// + /// The playing item id. + public string PlaylistItemId { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.PreviousTrack; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/QueueGroupRequest.cs new file mode 100644 index 0000000000..01c08cc860 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/QueueGroupRequest.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class QueueGroupRequest. + /// + public class QueueGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the items to queue. + /// + /// The items to queue. + public Guid[] ItemIds { get; set; } + + /// + /// Gets or sets the mode in which to add the new items. + /// + /// The mode. + public string Mode { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.Queue; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs index ee88ddddbb..16bc67c617 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs @@ -23,21 +23,27 @@ namespace MediaBrowser.Controller.SyncPlay public long PositionTicks { get; set; } /// - /// Gets or sets the playing item id. + /// Gets or sets the client playback status. /// - /// The playing item id. - public Guid PlayingItemId { get; set; } + /// The client playback status. + public bool IsPlaying { get; set; } + + /// + /// Gets or sets the playlist item id of the playing item. + /// + /// The playlist item id. + public string PlaylistItemId { get; set; } /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Ready; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/RemoveFromPlaylistGroupRequest.cs new file mode 100644 index 0000000000..3fc77f6771 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/RemoveFromPlaylistGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class RemoveFromPlaylistGroupRequest. + /// + public class RemoveFromPlaylistGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the playlist ids ot the items. + /// + /// The playlist ids ot the items. + public string[] PlaylistItemIds { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.Queue; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs index bb5e7a343e..24d9be5073 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs @@ -16,15 +16,15 @@ namespace MediaBrowser.Controller.SyncPlay public long PositionTicks { get; set; } /// - public PlaybackRequestType Type() + public PlaybackRequestType GetRequestType() { return PlaybackRequestType.Seek; } /// - public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) { - return state.HandleRequest(context, false, this, session, cancellationToken); + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); } } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetCurrentItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetCurrentItemGroupRequest.cs new file mode 100644 index 0000000000..d70559899a --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetCurrentItemGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class SetPlaylistItemGroupRequest. + /// + public class SetPlaylistItemGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the playlist id of the playing item. + /// + /// The playlist id of the playing item. + public string PlaylistItemId { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.SetPlaylistItem; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetRepeatModeGroupRequest.cs new file mode 100644 index 0000000000..5f36f60e45 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetRepeatModeGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class SetRepeatModeGroupRequest. + /// + public class SetRepeatModeGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the repeat mode. + /// + /// The repeat mode. + public string Mode { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.SetRepeatMode; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetShuffleModeGroupRequest.cs new file mode 100644 index 0000000000..472455fd3b --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetShuffleModeGroupRequest.cs @@ -0,0 +1,30 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class SetShuffleModeGroupRequest. + /// + public class SetShuffleModeGroupRequest : IPlaybackGroupRequest + { + /// + /// Gets or sets the shuffle mode. + /// + /// The shuffle mode. + public string Mode { get; set; } + + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.SetShuffleMode; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/StopGroupRequest.cs new file mode 100644 index 0000000000..f1581c98d9 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/StopGroupRequest.cs @@ -0,0 +1,24 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class StopGroupRequest. + /// + public class StopGroupRequest : IPlaybackGroupRequest + { + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.Stop; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequest/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/UnpauseGroupRequest.cs new file mode 100644 index 0000000000..1072952081 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequest/UnpauseGroupRequest.cs @@ -0,0 +1,24 @@ +using System.Threading; +using MediaBrowser.Model.SyncPlay; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Class UnpauseGroupRequest. + /// + public class UnpauseGroupRequest : IPlaybackGroupRequest + { + /// + public PlaybackRequestType GetRequestType() + { + return PlaybackRequestType.Unpause; + } + + /// + public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs new file mode 100644 index 0000000000..701982cc00 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + static class ListShuffleExtension + { + private static Random rng = new Random(); + public static void Shuffle(this IList list) + { + int n = list.Count; + while (n > 1) + { + n--; + int k = rng.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + } + + /// + /// Class PlayQueueManager. + /// + public class PlayQueueManager : IDisposable + { + /// + /// Gets or sets the playing item index. + /// + /// The playing item index. + public int PlayingItemIndex { get; private set; } + + /// + /// Gets or sets the last time the queue has been changed. + /// + /// The last time the queue has been changed. + public DateTime LastChange { get; private set; } + + /// + /// Gets the sorted playlist. + /// + /// The sorted playlist, or play queue of the group. + private List SortedPlaylist { get; set; } = new List(); + + /// + /// Gets the shuffled playlist. + /// + /// The shuffled playlist, or play queue of the group. + private List ShuffledPlaylist { get; set; } = new List(); + + /// + /// Gets or sets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted; + + /// + /// Gets or sets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone; + + /// + /// Gets or sets the progressive id counter. + /// + /// The progressive id. + private int ProgressiveId { get; set; } = 0; + + private bool _disposed = false; + + /// + /// Initializes a new instance of the class. + /// + public PlayQueueManager() + { + Reset(); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + /// + /// Gets the next available id. + /// + /// The next available id. + private int GetNextProgressiveId() { + return ProgressiveId++; + } + + /// + /// Creates a list from the array of items. Each item is given an unique playlist id. + /// + /// The list of queue items. + private List CreateQueueItemsFromArray(Guid[] items) + { + return items.ToList() + .Select(item => new QueueItem() + { + ItemId = item, + PlaylistItemId = "syncPlayItem" + GetNextProgressiveId() + }) + .ToList(); + } + + /// + /// Gets the current playlist, depending on the shuffle mode. + /// + /// The playlist. + private List GetPlaylistAsList() + { + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist; + } + else + { + return SortedPlaylist; + } + } + + /// + /// Gets the current playlist as an array, depending on the shuffle mode. + /// + /// The array of items in the playlist. + public QueueItem[] GetPlaylist() { + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist.ToArray(); + } + else + { + return SortedPlaylist.ToArray(); + } + } + + /// + /// Sets a new playlist. Playing item is set to none. Resets shuffle mode and repeat mode as well. + /// + /// The new items of the playlist. + public void SetPlaylist(Guid[] items) + { + SortedPlaylist = CreateQueueItemsFromArray(items); + PlayingItemIndex = -1; + ShuffleMode = GroupShuffleMode.Sorted; + RepeatMode = GroupRepeatMode.RepeatNone; + LastChange = DateTime.UtcNow; + } + + /// + /// Appends new items to the playlist. The specified order is mantained for the sorted playlist, whereas items get shuffled for the shuffled playlist. + /// + /// The items to add to the playlist. + public void Queue(Guid[] items) + { + var newItems = CreateQueueItemsFromArray(items); + SortedPlaylist.AddRange(newItems); + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + newItems.Shuffle(); + ShuffledPlaylist.AddRange(newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Shuffles the playlist. Shuffle mode is changed. + /// + public void ShufflePlaylist() + { + if (SortedPlaylist.Count() == 0) + { + return; + } + + if (PlayingItemIndex < 0) { + ShuffledPlaylist = SortedPlaylist.ToList(); + ShuffledPlaylist.Shuffle(); + } + else + { + var playingItem = SortedPlaylist[PlayingItemIndex]; + ShuffledPlaylist = SortedPlaylist.ToList(); + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + ShuffledPlaylist.Shuffle(); + ShuffledPlaylist = ShuffledPlaylist.Prepend(playingItem).ToList(); + PlayingItemIndex = 0; + } + + ShuffleMode = GroupShuffleMode.Shuffle; + LastChange = DateTime.UtcNow; + } + + /// + /// Resets the playlist to sorted mode. Shuffle mode is changed. + /// + public void SortShuffledPlaylist() + { + if (PlayingItemIndex >= 0) + { + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + PlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + } + + ShuffledPlaylist.Clear(); + + ShuffleMode = GroupShuffleMode.Sorted; + LastChange = DateTime.UtcNow; + } + + /// + /// Clears the playlist. + /// + /// Whether to remove the playing item as well. + public void ClearPlaylist(bool clearPlayingItem) + { + var playingItem = SortedPlaylist[PlayingItemIndex]; + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + LastChange = DateTime.UtcNow; + + if (!clearPlayingItem && playingItem != null) + { + SortedPlaylist.Add(playingItem); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + SortedPlaylist.Add(playingItem); + } + } + } + + /// + /// Adds new items to the playlist right after the playing item. The specified order is mantained for the sorted playlist, whereas items get shuffled for the shuffled playlist. + /// + /// The items to add to the playlist. + public void QueueNext(Guid[] items) + { + var newItems = CreateQueueItemsFromArray(items); + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + // Append items to sorted playlist as they are + SortedPlaylist.AddRange(newItems); + // Shuffle items before adding to shuffled playlist + newItems.Shuffle(); + ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + else + { + SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Gets playlist id of the playing item, if any. + /// + /// The playlist id of the playing item. + public string GetPlayingItemPlaylistId() + { + if (PlayingItemIndex < 0) + { + return null; + } + + var list = GetPlaylistAsList(); + + if (list.Count() > 0) + { + return list[PlayingItemIndex].PlaylistItemId; + } + else + { + return null; + } + } + + /// + /// Gets the playing item id, if any. + /// + /// The playing item id. + public Guid GetPlayingItemId() + { + if (PlayingItemIndex < 0) + { + return Guid.Empty; + } + + var list = GetPlaylistAsList(); + + if (list.Count() > 0) + { + return list[PlayingItemIndex].ItemId; + } + else + { + return Guid.Empty; + } + } + + /// + /// Sets the playing item using its id. If not in the playlist, the playing item is reset. + /// + /// The new playing item id. + public void SetPlayingItemById(Guid itemId) + { + var itemIds = GetPlaylistAsList().Select(queueItem => queueItem.ItemId).ToList(); + PlayingItemIndex = itemIds.IndexOf(itemId); + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the playing item using its playlist id. If not in the playlist, the playing item is reset. + /// + /// The new playing item id. + /// true if playing item has been set; false if item is not in the playlist. + public bool SetPlayingItemByPlaylistId(string playlistItemId) + { + var playlistIds = GetPlaylistAsList().Select(queueItem => queueItem.PlaylistItemId).ToList(); + PlayingItemIndex = playlistIds.IndexOf(playlistItemId); + LastChange = DateTime.UtcNow; + return PlayingItemIndex != -1; + } + + /// + /// Sets the playing item using its position. If not in range, the playing item is reset. + /// + /// The new playing item index. + public void SetPlayingItemByIndex(int playlistIndex) + { + var list = GetPlaylistAsList(); + if (playlistIndex < 0 || playlistIndex > list.Count()) + { + PlayingItemIndex = -1; + } + else + { + PlayingItemIndex = playlistIndex; + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Removes items from the playlist. If not removed, the playing item is preserved. + /// + /// The items to remove. + /// true if playing item got removed; false otherwise. + public bool RemoveFromPlaylist(string[] playlistItemIds) + { + var playingItem = SortedPlaylist[PlayingItemIndex]; + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + playingItem = ShuffledPlaylist[PlayingItemIndex]; + } + + var playlistItemIdsList = playlistItemIds.ToList(); + SortedPlaylist.RemoveAll(item => playlistItemIdsList.Contains(item.PlaylistItemId)); + ShuffledPlaylist.RemoveAll(item => playlistItemIdsList.Contains(item.PlaylistItemId)); + + LastChange = DateTime.UtcNow; + + if (playingItem != null) + { + if (playlistItemIds.Contains(playingItem.PlaylistItemId)) + { + // Playing item has been removed, picking previous item + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + // Was first element, picking next if available + PlayingItemIndex = SortedPlaylist.Count() > 0 ? 0 : -1; + } + + return true; + } + else + { + // Restoring playing item + SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); + return false; + } + } + else + { + return false; + } + } + + /// + /// Moves an item in the playlist to another position. + /// + /// The item to move. + /// The new position. + /// true if the item has been moved; false otherwise. + public bool MovePlaylistItem(string playlistItemId, int newIndex) + { + var list = GetPlaylistAsList(); + var playingItem = list[PlayingItemIndex]; + + var playlistIds = list.Select(queueItem => queueItem.PlaylistItemId).ToList(); + var oldIndex = playlistIds.IndexOf(playlistItemId); + if (oldIndex < 0) { + return false; + } + + var queueItem = list[oldIndex]; + list.RemoveAt(oldIndex); + newIndex = newIndex > list.Count() ? list.Count() : newIndex; + newIndex = newIndex < 0 ? 0 : newIndex; + list.Insert(newIndex, queueItem); + + LastChange = DateTime.UtcNow; + PlayingItemIndex = list.IndexOf(playingItem); + return true; + } + + /// + /// Resets the playlist to its initial state. + /// + public void Reset() + { + ProgressiveId = 0; + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + PlayingItemIndex = -1; + ShuffleMode = GroupShuffleMode.Sorted; + RepeatMode = GroupRepeatMode.RepeatNone; + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the repeat mode. + /// + /// The new mode. + public void SetRepeatMode(string mode) + { + switch (mode) + { + case "RepeatOne": + RepeatMode = GroupRepeatMode.RepeatOne; + break; + case "RepeatAll": + RepeatMode = GroupRepeatMode.RepeatAll; + break; + default: + RepeatMode = GroupRepeatMode.RepeatNone; + break; + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the shuffle mode. + /// + /// The new mode. + public void SetShuffleMode(string mode) + { + switch (mode) + { + case "Shuffle": + ShufflePlaylist(); + break; + default: + SortShuffledPlaylist(); + break; + } + } + + /// + /// Toggles the shuffle mode between sorted and shuffled. + /// + public void ToggleShuffleMode() + { + SetShuffleMode(ShuffleMode.Equals(GroupShuffleMode.Shuffle) ? "Shuffle" : ""); + } + + /// + /// Gets the next item in the playlist considering repeat mode and shuffle mode. + /// + /// The next item in the playlist. + public QueueItem GetNextItemPlaylistId() + { + int newIndex; + var playlist = GetPlaylistAsList(); + + switch (RepeatMode) + { + case GroupRepeatMode.RepeatOne: + newIndex = PlayingItemIndex; + break; + case GroupRepeatMode.RepeatAll: + newIndex = PlayingItemIndex + 1; + if (newIndex >= playlist.Count()) + { + newIndex = 0; + } + break; + default: + newIndex = PlayingItemIndex + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.Count()) + { + return null; + } + + return playlist[newIndex]; + } + + /// + /// Sets the next item in the queue as playing item. + /// + /// true if the playing item changed; false otherwise. + public bool Next() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex++; + if (PlayingItemIndex >= SortedPlaylist.Count()) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex--; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// + /// Sets the previous item in the queue as playing item. + /// + /// true if the playing item changed; false otherwise. + public bool Previous() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = SortedPlaylist.Count() - 1; + } + else + { + PlayingItemIndex++; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs b/MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs deleted file mode 100644 index 0b72d16686..0000000000 --- a/MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Threading; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// - /// Class SyncPlayAbstractState. - /// - /// - /// Class is not thread-safe, external locking is required when accessing methods. - /// - public abstract class SyncPlayAbstractState : ISyncPlayState - { - /// - public abstract GroupState GetGroupState(); - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - return true; - } - - /// - public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken) - { - GroupInfo group = context.GetGroup(); - - // Collected pings are used to account for network latency when unpausing playback - group.UpdatePing(session, request.Ping); - - return true; - } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs index ac84a26dc3..255f6812bd 100644 --- a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs +++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; namespace MediaBrowser.Model.SyncPlay { /// - /// Class GroupInfoView. + /// Class GroupInfoDto. /// public class GroupInfoDto { @@ -16,27 +16,27 @@ namespace MediaBrowser.Model.SyncPlay public string GroupId { get; set; } /// - /// Gets or sets the playing item id. + /// Gets or sets the group name. /// - /// The playing item id. - public string PlayingItemId { get; set; } + /// The group name. + public string GroupName { get; set; } /// - /// Gets or sets the playing item name. + /// Gets or sets the group state. /// - /// The playing item name. - public string PlayingItemName { get; set; } - - /// - /// Gets or sets the position ticks. - /// - /// The position ticks. - public long PositionTicks { get; set; } + /// The group state. + public GroupState State { get; set; } /// /// Gets or sets the participants. /// /// The participants. public IReadOnlyList Participants { get; set; } + + /// + /// Gets or sets the date when this dto has been updated. + /// + /// The date when this dto has been updated. + public string LastUpdatedAt { get; set; } } } diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs new file mode 100644 index 0000000000..4895e57b7f --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupRepeatMode. + /// + public enum GroupRepeatMode + { + /// + /// Repeat one item only. + /// + RepeatOne = 0, + + /// + /// Cycle the playlist. + /// + RepeatAll = 1, + + /// + /// Do not repeat. + /// + RepeatNone = 2 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs new file mode 100644 index 0000000000..de860883c0 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupShuffleMode. + /// + public enum GroupShuffleMode + { + /// + /// Sorted playlist. + /// + Sorted = 0, + + /// + /// Shuffled playlist. + /// + Shuffle = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs new file mode 100644 index 0000000000..7c7b267e6f --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs @@ -0,0 +1,22 @@ +#nullable disable + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class GroupStateUpdate. + /// + public class GroupStateUpdate + { + /// + /// Gets or sets the state of the group. + /// + /// The state of the group. + public GroupState State { get; set; } + + /// + /// Gets or sets the reason of the state change. + /// + /// The reason of the state change. + public PlaybackRequestType Reason { get; set; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs index c749f7b13a..7423bff117 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs @@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay GroupLeft, /// - /// The group-wait update. Tells members of the group that a user is buffering. + /// The group-state update. Tells members of the group that the state changed. /// - GroupWait, + StateUpdate, /// - /// The prepare-session update. Tells a user to load some content. + /// The play-queue update. Tells a user what's the playing queue of the group. /// - PrepareSession, + PlayQueue, /// /// The not-in-group error. Tells a user that they don't belong to a group. diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs index 0c77a61322..04f3a73b17 100644 --- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs +++ b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs @@ -8,9 +8,9 @@ namespace MediaBrowser.Model.SyncPlay public class JoinGroupRequest { /// - /// Gets or sets the Group id. + /// Gets or sets the group id. /// - /// The Group id to join. + /// The id of the group to join. public Guid GroupId { get; set; } } } diff --git a/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs b/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs new file mode 100644 index 0000000000..ccab5313f7 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs @@ -0,0 +1,16 @@ +#nullable disable + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class NewGroupRequest. + /// + public class NewGroupRequest + { + /// + /// Gets or sets the group name. + /// + /// The name of the new group. + public string GroupName { get; set; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs new file mode 100644 index 0000000000..5e2740a892 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -0,0 +1,52 @@ +#nullable disable + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class PlayQueueUpdate. + /// + public class PlayQueueUpdate + { + /// + /// Gets or sets the request type that originated this update. + /// + /// The reason for the update. + public PlayQueueUpdateReason Reason { get; set; } + + /// + /// Gets or sets the UTC time of the last change to the playing queue. + /// + /// The UTC time of the last change to the playing queue. + public string LastUpdate { get; set; } + + /// + /// Gets or sets the playlist. + /// + /// The playlist. + public QueueItem[] Playlist { get; set; } + + /// + /// Gets or sets the playing item index in the playlist. + /// + /// The playing item index in the playlist. + public int PlayingItemIndex { get; set; } + + /// + /// Gets or sets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; set; } + + /// + /// Gets or sets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode ShuffleMode { get; set; } + + /// + /// Gets or sets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode RepeatMode { get; set; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs new file mode 100644 index 0000000000..4b3f6eb4d6 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs @@ -0,0 +1,58 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum PlayQueueUpdateReason. + /// + public enum PlayQueueUpdateReason + { + /// + /// A user is requesting to play a new playlist. + /// + NewPlaylist = 0, + + /// + /// A user is changing the playing item. + /// + SetCurrentItem = 1, + + /// + /// A user is removing items from the playlist. + /// + RemoveItems = 2, + + /// + /// A user is moving an item in the playlist. + /// + MoveItem = 3, + + /// + /// A user is making changes to the queue. + /// + Queue = 4, + + /// + /// A user is making changes to the queue. + /// + QueueNext = 5, + + /// + /// A user is requesting the next item in queue. + /// + NextTrack = 6, + + /// + /// A user is requesting the previous item in queue. + /// + PreviousTrack = 7, + + /// + /// A user is changing repeat mode. + /// + RepeatMode = 8, + + /// + /// A user is changing shuffle mode. + /// + ShuffleMode = 9 + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs index e89efeed8a..0d0f48ea97 100644 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs +++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs @@ -6,33 +6,87 @@ namespace MediaBrowser.Model.SyncPlay public enum PlaybackRequestType { /// - /// A user is requesting a play command for the group. + /// A user is setting a new playlist. /// Play = 0, + /// + /// A user is changing the playlist item. + /// + SetPlaylistItem = 1, + + /// + /// A user is removing items from the playlist. + /// + RemoveFromPlaylist = 2, + + /// + /// A user is moving an item in the playlist. + /// + MovePlaylistItem = 3, + + /// + /// A user is adding items to the playlist. + /// + Queue = 4, + + /// + /// A user is requesting an unpause command for the group. + /// + Unpause = 5, + /// /// A user is requesting a pause command for the group. /// - Pause = 1, + Pause = 6, + + /// + /// A user is requesting a stop command for the group. + /// + Stop = 7, /// /// A user is requesting a seek command for the group. /// - Seek = 2, + Seek = 8, - /// + /// /// A user is signaling that playback is buffering. /// - Buffer = 3, + Buffer = 9, /// /// A user is signaling that playback resumed. /// - Ready = 4, + Ready = 10, /// - /// A user is reporting its ping. + /// A user is requesting next track in playlist. /// - Ping = 5 + NextTrack = 11, + + /// + /// A user is requesting previous track in playlist. + /// + PreviousTrack = 12, + /// + /// A user is setting the repeat mode. + /// + SetRepeatMode = 13, + + /// + /// A user is setting the shuffle mode. + /// + SetShuffleMode = 14, + + /// + /// A user is reporting their ping. + /// + Ping = 15, + + /// + /// A user is requesting to be ignored on group wait. + /// + IgnoreWait = 16 } } diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs new file mode 100644 index 0000000000..dc9cfbc229 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs @@ -0,0 +1,24 @@ +#nullable disable + +using System; + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class QueueItem. + /// + public class QueueItem + { + /// + /// Gets or sets the item id. + /// + /// The item id. + public Guid ItemId { get; set; } + + /// + /// Gets or sets the playlist id of the item. + /// + /// The playlist id of the item. + public string PlaylistItemId { get; set; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs index 0f0be0152d..779f711af0 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommand.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs @@ -13,6 +13,12 @@ namespace MediaBrowser.Model.SyncPlay /// The group identifier. public string GroupId { get; set; } + /// + /// Gets or sets the playlist id of the playing item. + /// + /// The playlist id of the playing item. + public string PlaylistItemId { get; set; } + /// /// Gets or sets the UTC time when to execute the command. /// diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs index 86dec9e900..e6b17c60ae 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs @@ -6,18 +6,23 @@ namespace MediaBrowser.Model.SyncPlay public enum SendCommandType { /// - /// The play command. Instructs users to start playback. + /// The unpause command. Instructs users to unpause playback. /// - Play = 0, + Unpause = 0, /// /// The pause command. Instructs users to pause playback. /// Pause = 1, + /// + /// The stop command. Instructs users to stop playback. + /// + Stop = 2, + /// /// The seek command. Instructs users to seek to a specified time. /// - Seek = 2 + Seek = 3 } }