Prechádzať zdrojové kódy

Add playlist-sync and group-wait to SyncPlay

Ionut Andrei Oanca 4 rokov pred
rodič
commit
8819a9d478
51 zmenil súbory, kde vykonal 3842 pridanie a 1121 odobranie
  1. 2 4
      Emby.Server.Implementations/Session/SessionManager.cs
  2. 681 0
      Emby.Server.Implementations/SyncPlay/GroupController.cs
  3. 218 0
      Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs
  4. 121 0
      Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs
  5. 89 104
      Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs
  6. 102 31
      Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs
  7. 653 0
      Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs
  8. 0 282
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  9. 63 66
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  10. 274 26
      Jellyfin.Api/Controllers/SyncPlayController.cs
  11. 0 267
      MediaBrowser.Api/SyncPlay/SyncPlayService.cs
  12. 6 6
      MediaBrowser.Controller/Session/ISessionManager.cs
  13. 0 154
      MediaBrowser.Controller/SyncPlay/GroupInfo.cs
  14. 13 7
      MediaBrowser.Controller/SyncPlay/GroupMember.cs
  15. 3 4
      MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs
  16. 29 11
      MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
  17. 6 6
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  18. 143 22
      MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs
  19. 149 11
      MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs
  20. 12 6
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs
  21. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/IgnoreWaitGroupRequest.cs
  22. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/MovePlaylistItemGroupRequest.cs
  23. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/NextTrackGroupRequest.cs
  24. 3 3
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs
  25. 3 4
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs
  26. 22 3
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs
  27. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PreviousTrackGroupRequest.cs
  28. 37 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/QueueGroupRequest.cs
  29. 12 6
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs
  30. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/RemoveFromPlaylistGroupRequest.cs
  31. 3 3
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs
  32. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetCurrentItemGroupRequest.cs
  33. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetRepeatModeGroupRequest.cs
  34. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/SetShuffleModeGroupRequest.cs
  35. 24 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/StopGroupRequest.cs
  36. 24 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/UnpauseGroupRequest.cs
  37. 596 0
      MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
  38. 0 65
      MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs
  39. 13 13
      MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
  40. 23 0
      MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
  41. 18 0
      MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
  42. 22 0
      MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
  43. 4 4
      MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
  44. 2 2
      MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
  45. 16 0
      MediaBrowser.Model/SyncPlay/NewGroupRequest.cs
  46. 52 0
      MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
  47. 58 0
      MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
  48. 62 8
      MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
  49. 24 0
      MediaBrowser.Model/SyncPlay/QueueItem.cs
  50. 6 0
      MediaBrowser.Model/SyncPlay/SendCommand.cs
  51. 8 3
      MediaBrowser.Model/SyncPlay/SendCommandType.cs

+ 2 - 4
Emby.Server.Implementations/Session/SessionManager.cs

@@ -1182,18 +1182,16 @@ namespace Emby.Server.Implementations.Session
         }
 
         /// <inheritdoc />
-        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);
         }
 
         /// <inheritdoc />
-        public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken)
+        public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken)
         {
             CheckDisposed();
-            var session = GetSessionToRemoteControl(sessionId);
             await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false);
         }
 

+ 681 - 0
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
+{
+    /// <summary>
+    /// Class SyncPlayGroupController.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class SyncPlayGroupController : ISyncPlayGroupController, ISyncPlayStateContext
+    {
+        /// <summary>
+        /// Gets the default ping value used for sessions.
+        /// </summary>
+        public long DefaultPing { get; } = 500;
+
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger _logger;
+
+        /// <summary>
+        /// The user manager.
+        /// </summary>
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// The session manager.
+        /// </summary>
+        private readonly ISessionManager _sessionManager;
+
+        /// <summary>
+        /// The library manager.
+        /// </summary>
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// The SyncPlay manager.
+        /// </summary>
+        private readonly ISyncPlayManager _syncPlayManager;
+
+        /// <summary>
+        /// Internal group state.
+        /// </summary>
+        /// <value>The group's state.</value>
+        private ISyncPlayState State;
+
+        /// <summary>
+        /// Gets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public Guid GroupId { get; } = Guid.NewGuid();
+
+        /// <summary>
+        /// Gets the group name.
+        /// </summary>
+        /// <value>The group name.</value>
+        public string GroupName { get; private set; }
+
+        /// <summary>
+        /// Gets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public PlayQueueManager PlayQueue { get; } = new PlayQueueManager();
+
+        /// <summary>
+        /// Gets or sets the runtime ticks of current playing item.
+        /// </summary>
+        /// <value>The runtime ticks of current playing item.</value>
+        public long RunTimeTicks { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the last activity.
+        /// </summary>
+        /// <value>The last activity.</value>
+        public DateTime LastActivity { get; set; }
+
+        /// <summary>
+        /// Gets the participants.
+        /// </summary>
+        /// <value>The participants, or members of the group.</value>
+        public Dictionary<string, GroupMember> Participants { get; } =
+            new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SyncPlayGroupController" /> class.
+        /// </summary>
+        /// <param name="logger">The logger.</param>
+        /// <param name="userManager">The user manager.</param>
+        /// <param name="sessionManager">The session manager.</param>
+        /// <param name="libraryManager">The library manager.</param>
+        /// <param name="syncPlayManager">The SyncPlay manager.</param>
+        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);
+        }
+
+        /// <summary>
+        /// Checks if a session is in this group.
+        /// </summary>
+        /// <param name="sessionId">The session id to check.</param>
+        /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns>
+        private bool ContainsSession(string sessionId)
+        {
+            return Participants.ContainsKey(sessionId);
+        }
+
+        /// <summary>
+        /// Adds the session to the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        private void AddSession(SessionInfo session)
+        {
+            Participants.TryAdd(
+                session.Id,
+                new GroupMember
+                {
+                    Session = session,
+                    Ping = DefaultPing,
+                    IsBuffering = false
+                });
+        }
+
+        /// <summary>
+        /// Removes the session from the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        private void RemoveSession(SessionInfo session)
+        {
+            Participants.Remove(session.Id);
+        }
+
+        /// <summary>
+        /// Filters sessions of this group.
+        /// </summary>
+        /// <param name="from">The current session.</param>
+        /// <param name="type">The filtering type.</param>
+        /// <returns>The array of sessions matching the filter.</returns>
+        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<SessionInfo>();
+            }
+        }
+
+        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();
+        }
+
+        /// <inheritdoc />
+        public bool IsGroupEmpty() => Participants.Count == 0;
+
+        /// <inheritdoc />
+        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());
+        }
+
+        /// <inheritdoc />
+        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());
+        }
+
+        /// <inheritdoc />
+        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());
+        }
+
+        /// <inheritdoc />
+        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());
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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)
+            };
+        }
+
+        /// <inheritdoc />
+        public bool HasAccessToPlayQueue(User user)
+        {
+            var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToArray();
+            return HasAccessToQueue(user, items);
+        }
+
+        /// <inheritdoc />
+        public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait)
+        {
+            if (!ContainsSession(session.Id))
+            {
+                return;
+            }
+
+            Participants[session.Id].IgnoreGroupWait = ignoreGroupWait;
+        }
+
+        /// <inheritdoc />
+        public void SetState(ISyncPlayState state)
+        {
+            _logger.LogInformation("SetState: {0} switching from {1} to {2}.", GroupId.ToString(), State.GetGroupState(), state.GetGroupState());
+            this.State = state;
+        }
+
+        /// <inheritdoc />
+        public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
+        {
+            IEnumerable<Task> GetTasks()
+            {
+                foreach (var session in FilterSessions(from, type))
+                {
+                    yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken);
+                }
+            }
+
+            return Task.WhenAll(GetTasks());
+        }
+
+        /// <inheritdoc />
+        public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken)
+        {
+            IEnumerable<Task> GetTasks()
+            {
+                foreach (var session in FilterSessions(from, type))
+                {
+                    yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken);
+                }
+            }
+
+            return Task.WhenAll(GetTasks());
+        }
+
+        /// <inheritdoc />
+        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)
+            };
+        }
+
+        /// <inheritdoc />
+        public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
+        {
+            return new GroupUpdate<T>()
+            {
+                GroupId = GroupId.ToString(),
+                Type = type,
+                Data = data
+            };
+        }
+
+        /// <inheritdoc />
+        public string DateToUTCString(DateTime dateTime)
+        {
+            return dateTime.ToUniversalTime().ToString("o");
+        }
+
+        /// <inheritdoc />
+        public long SanitizePositionTicks(long? positionTicks)
+        {
+            var ticks = positionTicks ?? 0;
+            ticks = ticks >= 0 ? ticks : 0;
+            ticks = ticks > RunTimeTicks ? RunTimeTicks : ticks;
+            return ticks;
+        }
+
+        /// <inheritdoc />
+        public void UpdatePing(SessionInfo session, long ping)
+        {
+            if (Participants.TryGetValue(session.Id, out GroupMember value))
+            {
+                value.Ping = ping;
+            }
+        }
+
+        /// <inheritdoc />
+        public long GetHighestPing()
+        {
+            long max = long.MinValue;
+            foreach (var session in Participants.Values)
+            {
+                max = Math.Max(max, session.Ping);
+            }
+
+            return max;
+        }
+
+        /// <inheritdoc />
+        public void SetBuffering(SessionInfo session, bool isBuffering)
+        {
+            if (Participants.TryGetValue(session.Id, out GroupMember value))
+            {
+                value.IsBuffering = isBuffering;
+            }
+        }
+
+        /// <inheritdoc />
+        public void SetAllBuffering(bool isBuffering)
+        {
+            foreach (var session in Participants.Values)
+            {
+                session.IsBuffering = isBuffering;
+            }
+        }
+
+        /// <inheritdoc />
+        public bool IsBuffering()
+        {
+            foreach (var session in Participants.Values)
+            {
+                if (session.IsBuffering && !session.IgnoreGroupWait)
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc />
+        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;
+        }
+
+        /// <inheritdoc />
+        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;
+        }
+
+        /// <inheritdoc />
+        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;
+        }
+
+        /// <inheritdoc />
+        public bool MoveItemInPlayQueue(string playlistItemId, int newIndex)
+        {
+            return PlayQueue.MovePlaylistItem(playlistItemId, newIndex);
+        }
+
+        /// <inheritdoc />
+        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;
+        }
+
+        /// <inheritdoc />
+        public void RestartCurrentItem()
+        {
+            PositionTicks = 0;
+            LastActivity = DateTime.UtcNow;
+        }
+
+        /// <inheritdoc />
+        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;
+            }
+        }
+
+        /// <inheritdoc />
+        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;
+            }
+        }
+
+        /// <inheritdoc />
+        public void SetRepeatMode(string mode) {
+            PlayQueue.SetRepeatMode(mode);
+        }
+
+        /// <inheritdoc />
+        public void SetShuffleMode(string mode) {
+            PlayQueue.SetShuffleMode(mode);
+        }
+
+        /// <inheritdoc />
+        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
+            };
+        }
+
+    }
+}

+ 218 - 0
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
+{
+    /// <summary>
+    /// Class AbstractGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public abstract class AbstractGroupState : ISyncPlayState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        protected readonly ILogger _logger;
+
+        /// <summary>
+        /// Default constructor.
+        /// </summary>
+        public AbstractGroupState(ILogger logger)
+        {
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Sends a group state update to all group.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="reason">The reason of the state change.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        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);
+        }
+
+        /// <inheritdoc />
+        public abstract GroupState GetGroupState();
+
+        /// <inheritdoc />
+        public abstract void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <inheritdoc />
+        public abstract void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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());
+        }
+    }
+}

+ 121 - 0
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
+{
+    /// <summary>
+    /// Class IdleGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class IdleGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// Default constructor.
+        /// </summary>
+        public IdleGroupState(ILogger logger) : base(logger)
+        {
+            // Do nothing
+        }
+
+        /// <inheritdoc />
+        public override GroupState GetGroupState()
+        {
+            return GroupState.Idle;
+        }
+
+        /// <inheritdoc />
+        public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, GetGroupState(), session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+            }
+        }
+    }
+}

+ 89 - 104
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
     /// <remarks>
     /// Class is not thread-safe, external locking is required when accessing methods.
     /// </remarks>
-    public class PausedGroupState : SyncPlayAbstractState
+    public class PausedGroupState : AbstractGroupState
     {
+        /// <summary>
+        /// Default constructor.
+        /// </summary>
+        public PausedGroupState(ILogger logger) : base(logger)
+        {
+            // Do nothing
+        }
+
         /// <inheritdoc />
         public override GroupState GetGroupState()
         {
@@ -24,31 +29,56 @@ namespace MediaBrowser.Controller.SyncPlay
         }
 
         /// <inheritdoc />
-        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)
+        {
+            // Wait for session to be ready
+            var waitingState = new WaitingGroupState(_logger);
+            context.SetState(waitingState);
+            waitingState.SessionJoined(context, GetGroupState(), session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
         {
             // Change state
-            var playingState = new PlayingGroupState();
+            var playingState = new PlayingGroupState(_logger);
             context.SetState(playingState);
-            return playingState.HandleRequest(context, true, request, session, cancellationToken);
+            playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken);
         }
 
         /// <inheritdoc />
-        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)
         {
-            if (newState)
+            if (!prevState.Equals(GetGroupState()))
             {
-                GroupInfo group = context.GetGroup();
-
                 // 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;
         }
 
         /// <inheritdoc />
-        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);
         }
 
         /// <inheritdoc />
-        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();
-
-            if (newState)
-            {
-                // 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);
+            // Change state
+            var waitingState = new WaitingGroupState(_logger);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken);
+        }
 
-                // Send pause command to all non-buffering sessions
-                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
-                context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+        /// <inheritdoc />
+        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);
+        }
 
-                var updateOthers = context.NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
-                context.SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-            }
-            else
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (prevState.Equals(GetGroupState()))
             {
-                // 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);
+            }
         }
 
         /// <inheritdoc />
-        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();
-
-            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);
-            }
+            // Change state
+            var waitingState = new WaitingGroupState(_logger);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken);
+        }
 
-            return true;
+        /// <inheritdoc />
+        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);
         }
     }
 }

+ 102 - 31
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
     /// <remarks>
     /// Class is not thread-safe, external locking is required when accessing methods.
     /// </remarks>
-    public class PlayingGroupState : SyncPlayAbstractState
+    public class PlayingGroupState : AbstractGroupState
     {
+        /// <summary>
+        /// Ignore requests for buffering.
+        /// </summary>
+        public bool IgnoreBuffering { get; set; }
+
+        /// <summary>
+        /// Default constructor.
+        /// </summary>
+        public PlayingGroupState(ILogger logger) : base(logger)
+        {
+            // Do nothing
+        }
+
         /// <inheritdoc />
         public override GroupState GetGroupState()
         {
@@ -24,71 +34,132 @@ namespace MediaBrowser.Controller.SyncPlay
         }
 
         /// <inheritdoc />
-        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)
+        /// <inheritdoc />
+        public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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;
         }
 
         /// <inheritdoc />
-        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);
         }
 
         /// <inheritdoc />
-        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);
         }
 
         /// <inheritdoc />
-        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);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (IgnoreBuffering)
+            {
+                return;
+            }
+
+            // Change state
+            var waitingState = new WaitingGroupState(_logger);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken);
         }
 
         /// <inheritdoc />
-        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest 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 (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);
+            }
+        }
+
+        /// <inheritdoc />
+        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);
+        }
 
-            return true;
+        /// <inheritdoc />
+        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);
         }
     }
 }

+ 653 - 0
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
+{
+    /// <summary>
+    /// Class WaitingGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class WaitingGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// Tells the state to switch to after buffering is done.
+        /// </summary>
+        public bool ResumePlaying { get; set; } = false;
+
+        /// <summary>
+        /// Whether the initial state has been set.
+        /// </summary>
+        private bool InitialStateSet { get; set; } = false;
+
+        /// <summary>
+        /// The group state before the first ever event.
+        /// </summary>
+        private GroupState InitialState { get; set; }
+
+        /// <summary>
+        /// Default constructor.
+        /// </summary>
+        public WaitingGroupState(ILogger logger) : base(logger)
+        {
+            // Do nothing
+        }
+
+        /// <inheritdoc />
+        public override GroupState GetGroupState()
+        {
+            return GroupState.Waiting;
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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());
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        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());
+        }
+
+        /// <inheritdoc />
+        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());
+            }
+        }
+
+        /// <inheritdoc />
+        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);
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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);
+        }
+
+        /// <inheritdoc />
+        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());
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        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());
+            }
+        }
+
+        /// <inheritdoc />
+        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());
+            }
+        }
+    }
+}

+ 0 - 282
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -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
-{
-    /// <summary>
-    /// Class SyncPlayController.
-    /// </summary>
-    /// <remarks>
-    /// Class is not thread-safe, external locking is required when accessing methods.
-    /// </remarks>
-    public class SyncPlayController : ISyncPlayController, ISyncPlayStateContext
-    {
-        /// <summary>
-        /// The session manager.
-        /// </summary>
-        private readonly ISessionManager _sessionManager;
-
-        /// <summary>
-        /// The SyncPlay manager.
-        /// </summary>
-        private readonly ISyncPlayManager _syncPlayManager;
-
-        /// <summary>
-        /// The logger.
-        /// </summary>
-        private readonly ILogger _logger;
-
-        /// <summary>
-        /// The group to manage.
-        /// </summary>
-        private readonly GroupInfo _group = new GroupInfo();
-
-        /// <summary>
-        /// Internal group state.
-        /// </summary>
-        /// <value>The group's state.</value>
-        private ISyncPlayState State = new PausedGroupState();
-
-        /// <inheritdoc />
-        public GroupInfo GetGroup()
-        {
-            return _group;
-        }
-
-        /// <inheritdoc />
-        public void SetState(ISyncPlayState state)
-        {
-            _logger.LogInformation("SetState: {0} -> {1}.", State.GetGroupState(), state.GetGroupState());
-            this.State = state;
-        }
-
-        /// <inheritdoc />
-        public Guid GetGroupId() => _group.GroupId;
-
-        /// <inheritdoc />
-        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
-        /// <inheritdoc />
-        public bool IsGroupEmpty() => _group.IsEmpty();
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
-        /// </summary>
-        /// <param name="sessionManager">The session manager.</param>
-        /// <param name="syncPlayManager">The SyncPlay manager.</param>
-        public SyncPlayController(
-            ISessionManager sessionManager,
-            ISyncPlayManager syncPlayManager,
-            ILogger logger)
-        {
-            _sessionManager = sessionManager;
-            _syncPlayManager = syncPlayManager;
-            _logger = logger;
-        }
-
-        /// <summary>
-        /// Filters sessions of this group.
-        /// </summary>
-        /// <param name="from">The current session.</param>
-        /// <param name="type">The filtering type.</param>
-        /// <value>The array of sessions matching the filter.</value>
-        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<SessionInfo>();
-            }
-        }
-
-        /// <inheritdoc />
-        public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
-        {
-            IEnumerable<Task> GetTasks()
-            {
-                foreach (var session in FilterSessions(from, type))
-                {
-                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken);
-                }
-            }
-
-            return Task.WhenAll(GetTasks());
-        }
-
-        /// <inheritdoc />
-        public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken)
-        {
-            IEnumerable<Task> GetTasks()
-            {
-                foreach (var session in FilterSessions(from, type))
-                {
-                    yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken);
-                }
-            }
-
-            return Task.WhenAll(GetTasks());
-        }
-
-        /// <inheritdoc />
-        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)
-            };
-        }
-
-        /// <inheritdoc />
-        public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
-        {
-            return new GroupUpdate<T>()
-            {
-                GroupId = _group.GroupId.ToString(),
-                Type = type,
-                Data = data
-            };
-        }
-
-        /// <inheritdoc />
-        public string DateToUTCString(DateTime _date)
-        {
-            return _date.ToUniversalTime().ToString("o");
-        }
-
-        /// <inheritdoc />
-        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;
-        }
-
-        /// <inheritdoc />
-        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);
-        }
-
-        /// <inheritdoc />
-        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);
-            }
-        }
-
-        /// <inheritdoc />
-        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);
-        }
-
-        /// <inheritdoc />
-        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
-        }
-
-        /// <inheritdoc />
-        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()
-            };
-        }
-    }
-}

+ 63 - 66
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
         /// <summary>
         /// The map between sessions and groups.
         /// </summary>
-        private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap =
-            new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase);
+        private readonly Dictionary<string, ISyncPlayGroupController> _sessionToGroupMap =
+            new Dictionary<string, ISyncPlayGroupController>(StringComparer.OrdinalIgnoreCase);
 
         /// <summary>
         /// The groups.
         /// </summary>
-        private readonly Dictionary<Guid, ISyncPlayController> _groups =
-            new Dictionary<Guid, ISyncPlayController>();
+        private readonly Dictionary<Guid, ISyncPlayGroupController> _groups =
+            new Dictionary<Guid, ISyncPlayGroupController>();
 
         /// <summary>
         /// 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.
         /// </summary>
         /// <value>All groups.</value>
-        public IEnumerable<ISyncPlayController> Groups => _groups.Values;
+        public IEnumerable<ISyncPlayGroupController> Groups => _groups.Values;
 
         /// <inheritdoc />
         public void Dispose()
@@ -103,13 +103,15 @@ namespace Emby.Server.Implementations.SyncPlay
                 return;
             }
 
+            _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
             _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
+            _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart;
             _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
 
             _disposed = true;
         }
 
-        private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
+        private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
         {
             var session = e.SessionInfo;
             if (!IsSessionInGroup(session))
@@ -117,52 +119,60 @@ namespace Emby.Server.Implementations.SyncPlay
                 return;
             }
 
-            LeaveGroup(session, CancellationToken.None);
+            var groupId = GetSessionGroup(session) ?? Guid.Empty;
+            var request = new JoinGroupRequest()
+            {
+                GroupId = groupId
+            };
+            JoinGroup(session, groupId, request, CancellationToken.None);
         }
 
-        private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
+        private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
         {
-            var session = e.Session;
+            var session = e.SessionInfo;
             if (!IsSessionInGroup(session))
             {
                 return;
             }
 
-            LeaveGroup(session, CancellationToken.None);
+            // TODO: probably remove this event, not used at the moment
         }
 
-        private bool IsSessionInGroup(SessionInfo session)
+        private void OnSessionManagerPlaybackStart(object sender, PlaybackProgressEventArgs e)
         {
-            return _sessionToGroupMap.ContainsKey(session.Id);
+            var session = e.Session;
+            if (!IsSessionInGroup(session))
+            {
+                return;
+            }
+
+            // TODO: probably remove this event, not used at the moment
         }
 
-        private bool HasAccessToItem(User user, Guid itemId)
+        private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
         {
-            var item = _libraryManager.GetItemById(itemId);
-
-            // Check ParentalRating access
-            var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
-                || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
-
-            if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
+            var session = e.Session;
+            if (!IsSessionInGroup(session))
             {
-                var collections = _libraryManager.GetCollectionFolders(item).Select(
-                    folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
-
-                return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
+                return;
             }
 
-            return hasParentalRatingAccess;
+            // TODO: probably remove this event, not used at the moment
+        }
+
+        private bool IsSessionInGroup(SessionInfo session)
+        {
+            return _sessionToGroupMap.ContainsKey(session.Id);
         }
 
         private Guid? GetSessionGroup(SessionInfo session)
         {
             _sessionToGroupMap.TryGetValue(session.Id, out var group);
-            return group?.GetGroupId();
+            return group?.GroupId;
         }
 
         /// <inheritdoc />
-        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<string>()
                     {
-                        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 _);
                 }
             }
         }
 
         /// <inheritdoc />
-        public List<GroupInfoDto> ListGroups(SessionInfo session, Guid filterItemId)
+        public List<GroupInfoDto> ListGroups(SessionInfo session)
         {
             var user = _userManager.GetUserById(session.UserId);
 
@@ -295,20 +304,9 @@ namespace Emby.Server.Implementations.SyncPlay
                 return new List<GroupInfoDto>();
             }
 
-            // 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();
         }
 
         /// <inheritdoc />
@@ -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
         }
 
         /// <inheritdoc />
-        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
         }
 
         /// <inheritdoc />
-        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!");
             }

+ 274 - 26
Jellyfin.Api/Controllers/SyncPlayController.cs

@@ -43,14 +43,20 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Create a new SyncPlay group.
         /// </summary>
+        /// <param name="groupName">The name of the new group.</param>
         /// <response code="204">New group created.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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
         /// <summary>
         /// Gets all SyncPlay groups.
         /// </summary>
-        /// <param name="filterItemId">Optional. Filter by item id.</param>
         /// <response code="200">Groups returned.</response>
         /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
         [HttpGet("List")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId)
+        public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty));
+            return Ok(_syncPlayManager.ListGroups(currentSession));
         }
 
         /// <summary>
         /// Request play in SyncPlay group.
         /// </summary>
+        /// <param name="playingQueue">The playing queue. Item ids in the playing queue, comma delimited.</param>
+        /// <param name="playingItemPosition">The playing item position from the queue.</param>
+        /// <param name="startPositionTicks">The start position ticks.</param>
         /// <response code="204">Play request sent to all group members.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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 PlayGroupRequest()
+            {
+                PlayingQueue = RequestHelpers.GetGuids(playingQueue),
+                PlayingItemPosition = playingItemPosition,
+                StartPositionTicks = startPositionTicks
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to change playlist item in SyncPlay group.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist id of the item.</param>
+        /// <response code="204">Queue update request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request to remove items from the playlist in SyncPlay group.
+        /// </summary>
+        /// <param name="playlistItemIds">The playlist ids of the items to remove.</param>
+        /// <response code="204">Queue update request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request to move an item in the playlist in SyncPlay group.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist id of the item to move.</param>
+        /// <param name="newIndex">The new position.</param>
+        /// <response code="204">Queue update request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request to queue items to the playlist of a SyncPlay group.
+        /// </summary>
+        /// <param name="itemIds">The items to add. Item ids, comma delimited.</param>
+        /// <param name="mode">The mode in which to queue items.</param>
+        /// <response code="204">Queue update request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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 PlaybackRequest()
+            var syncPlayRequest = new QueueGroupRequest()
             {
-                Type = PlaybackRequestType.Play
+                ItemIds = RequestHelpers.GetGuids(itemIds),
+                Mode = mode
             };
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
 
+        /// <summary>
+        /// Request unpause in SyncPlay group.
+        /// </summary>
+        /// <response code="204">Unpause request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
         /// <summary>
         /// Request pause in SyncPlay group.
         /// </summary>
@@ -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();
+        }
+
+        /// <summary>
+        /// Request stop in SyncPlay group.
+        /// </summary>
+        /// <response code="204">Stop request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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
         /// </summary>
         /// <param name="when">When the request has been made by the client.</param>
         /// <param name="positionTicks">The playback position in ticks.</param>
+        /// <param name="isPlaying">Whether the client's playback is playing or not.</param>
+        /// <param name="playlistItemId">The playlist item id.</param>
         /// <param name="bufferingDone">Whether the buffering is done.</param>
         /// <response code="204">Buffering request sent to all group members.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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();
+        }
+
+        /// <summary>
+        /// Request SyncPlay group to ignore member during group-wait.
+        /// </summary>
+        /// <param name="ignoreWait">Whether to ignore the member.</param>
+        /// <response code="204">Member state updated.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request next track in SyncPlay group.
+        /// </summary>
+        /// <param name="playlistItemId">The playing item id.</param>
+        /// <response code="204">Next track request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request previous track in SyncPlay group.
+        /// </summary>
+        /// <param name="playlistItemId">The playing item id.</param>
+        /// <response code="204">Previous track request sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request to set repeat mode in SyncPlay group.
+        /// </summary>
+        /// <param name="mode">The repeat mode.</param>
+        /// <response code="204">Play queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Request to set shuffle mode in SyncPlay group.
+        /// </summary>
+        /// <param name="mode">The shuffle mode.</param>
+        /// <response code="204">Play queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [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
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [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);

+ 0 - 267
MediaBrowser.Api/SyncPlay/SyncPlayService.cs

@@ -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
-    {
-        /// <summary>
-        /// Gets or sets the Group id.
-        /// </summary>
-        /// <value>The Group id to join.</value>
-        [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
-    {
-        /// <summary>
-        /// Gets or sets the filter item id.
-        /// </summary>
-        /// <value>The filter item id.</value>
-        [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
-    {
-        /// <summary>
-        /// Gets or sets the date used to pin PositionTicks in time.
-        /// </summary>
-        /// <value>The date related to PositionTicks.</value>
-        [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; }
-
-        /// <summary>
-        /// Gets or sets whether this is a buffering or a ready request.
-        /// </summary>
-        /// <value><c>true</c> if buffering is complete; <c>false</c> otherwise.</value>
-        [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; }
-    }
-
-    /// <summary>
-    /// Class SyncPlayService.
-    /// </summary>
-    public class SyncPlayService : BaseApiService
-    {
-        /// <summary>
-        /// The session context.
-        /// </summary>
-        private readonly ISessionContext _sessionContext;
-
-        /// <summary>
-        /// The SyncPlay manager.
-        /// </summary>
-        private readonly ISyncPlayManager _syncPlayManager;
-
-        public SyncPlayService(
-            ILogger<SyncPlayService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            ISessionContext sessionContext,
-            ISyncPlayManager syncPlayManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _sessionContext = sessionContext;
-            _syncPlayManager = syncPlayManager;
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlayNew request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        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);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlayLeave request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <value>The requested list of groups.</value>
-        public List<GroupInfoDto> 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);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlayPlay request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlayGroupRequest();
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlayPause request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PauseGroupRequest();
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlaySeek request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new SeekGroupRequest()
-            {
-                PositionTicks = request.PositionTicks
-            };
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        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);
-        }
-
-        /// <summary>
-        /// Handles the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(SyncPlayPing request)
-        {
-            var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PingGroupRequest()
-            {
-                Ping = Convert.ToInt64(request.Ping)
-            };
-            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
-        }
-    }
-}

+ 6 - 6
MediaBrowser.Controller/Session/ISessionManager.cs

@@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session
         Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
 
         /// <summary>
-        /// Sends the SyncPlayCommand.
+        /// Sends a SyncPlayCommand to a session.
         /// </summary>
-        /// <param name="sessionId">The session id.</param>
+        /// <param name="session">The session.</param>
         /// <param name="command">The command.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task.</returns>
-        Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken);
+        Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken);
 
         /// <summary>
-        /// Sends the SyncPlayGroupUpdate.
+        /// Sends a SyncPlayGroupUpdate to a session.
         /// </summary>
-        /// <param name="sessionId">The session id.</param>
+        /// <param name="session">The session.</param>
         /// <param name="command">The group update.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task.</returns>
-        Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken);
+        Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken);
 
         /// <summary>
         /// Sends the browse command.

+ 0 - 154
MediaBrowser.Controller/SyncPlay/GroupInfo.cs

@@ -1,154 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Session;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
-    /// <summary>
-    /// Class GroupInfo.
-    /// </summary>
-    /// <remarks>
-    /// Class is not thread-safe, external locking is required when accessing methods.
-    /// </remarks>
-    public class GroupInfo
-    {
-        /// <summary>
-        /// The default ping value used for sessions.
-        /// </summary>
-        public const long DefaultPing = 500;
-
-        /// <summary>
-        /// Gets the group identifier.
-        /// </summary>
-        /// <value>The group identifier.</value>
-        public Guid GroupId { get; } = Guid.NewGuid();
-
-        /// <summary>
-        /// Gets or sets the playing item.
-        /// </summary>
-        /// <value>The playing item.</value>
-        public BaseItem PlayingItem { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether there are position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        public long PositionTicks { get; set; }
-
-        /// <summary>
-        /// Gets or sets the last activity.
-        /// </summary>
-        /// <value>The last activity.</value>
-        public DateTime LastActivity { get; set; }
-
-        /// <summary>
-        /// Gets the participants.
-        /// </summary>
-        /// <value>The participants, or members of the group.</value>
-        public Dictionary<string, GroupMember> Participants { get; } =
-            new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
-
-        /// <summary>
-        /// Checks if a session is in this group.
-        /// </summary>
-        /// <param name="sessionId">The session id to check.</param>
-        /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns>
-        public bool ContainsSession(string sessionId)
-        {
-            return Participants.ContainsKey(sessionId);
-        }
-
-        /// <summary>
-        /// Adds the session to the group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        public void AddSession(SessionInfo session)
-        {
-            Participants.TryAdd(
-                session.Id,
-                new GroupMember
-                {
-                    Session = session,
-                    Ping = DefaultPing,
-                    IsBuffering = false
-                });
-        }
-
-        /// <summary>
-        /// Removes the session from the group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        public void RemoveSession(SessionInfo session)
-        {
-            Participants.Remove(session.Id);
-        }
-
-        /// <summary>
-        /// Updates the ping of a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="ping">The ping.</param>
-        public void UpdatePing(SessionInfo session, long ping)
-        {
-            if (Participants.TryGetValue(session.Id, out GroupMember value))
-            {
-                value.Ping = ping;
-            }
-        }
-
-        /// <summary>
-        /// Gets the highest ping in the group.
-        /// </summary>
-        /// <returns>The highest ping in the group.</returns>
-        public long GetHighestPing()
-        {
-            long max = long.MinValue;
-            foreach (var session in Participants.Values)
-            {
-                max = Math.Max(max, session.Ping);
-            }
-
-            return max;
-        }
-
-        /// <summary>
-        /// Sets the session's buffering state.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="isBuffering">The state.</param>
-        public void SetBuffering(SessionInfo session, bool isBuffering)
-        {
-            if (Participants.TryGetValue(session.Id, out GroupMember value))
-            {
-                value.IsBuffering = isBuffering;
-            }
-        }
-
-        /// <summary>
-        /// Gets the group buffering state.
-        /// </summary>
-        /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
-        public bool IsBuffering()
-        {
-            foreach (var session in Participants.Values)
-            {
-                if (session.IsBuffering)
-                {
-                    return true;
-                }
-            }
-
-            return false;
-        }
-
-        /// <summary>
-        /// Checks if the group is empty.
-        /// </summary>
-        /// <returns><c>true</c> if the group is empty; <c>false</c> otherwise.</returns>
-        public bool IsEmpty()
-        {
-            return Participants.Count == 0;
-        }
-    }
-}

+ 13 - 7
MediaBrowser.Controller/SyncPlay/GroupMember.cs

@@ -7,12 +7,6 @@ namespace MediaBrowser.Controller.SyncPlay
     /// </summary>
     public class GroupMember
     {
-        /// <summary>
-        /// Gets or sets a value indicating whether this member is buffering.
-        /// </summary>
-        /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
-        public bool IsBuffering { get; set; }
-
         /// <summary>
         /// Gets or sets the session.
         /// </summary>
@@ -20,9 +14,21 @@ namespace MediaBrowser.Controller.SyncPlay
         public SessionInfo Session { get; set; }
 
         /// <summary>
-        /// Gets or sets the ping.
+        /// Gets or sets the ping, in milliseconds.
         /// </summary>
         /// <value>The ping.</value>
         public long Ping { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this member is buffering.
+        /// </summary>
+        /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
+        public bool IsBuffering { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this member is following group playback.
+        /// </summary>
+        /// <value><c>true</c> to ignore member on group wait; <c>false</c> if they're following group playback.</value>
+        public bool IgnoreGroupWait { get; set; }
     }
 }

+ 3 - 4
MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs

@@ -12,13 +12,12 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <summary>
         /// Gets the playback request type.
         /// </summary>
-        /// <value>The playback request type.</value>
-        PlaybackRequestType Type();
+        /// <returns>The playback request type.</returns>
+        PlaybackRequestType GetRequestType();
 
         /// <summary>
         /// Applies the request to a group.
         /// </summary>
-        /// <value>The operation completion status.</value>
-        bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken);
+        void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken);
     }
 }

+ 29 - 11
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
 {
     /// <summary>
-    /// Interface ISyncPlayController.
+    /// Interface ISyncPlayGroupController.
     /// </summary>
-    public interface ISyncPlayController
+    public interface ISyncPlayGroupController
     {
         /// <summary>
-        /// Gets the group id.
+        /// Gets the group identifier.
         /// </summary>
-        /// <value>The group id.</value>
-        Guid GetGroupId();
+        /// <value>The group identifier.</value>
+        Guid GroupId { get; }
 
         /// <summary>
-        /// Gets the playing item id.
+        /// Gets the play queue.
         /// </summary>
-        /// <value>The playing item id.</value>
-        Guid GetPlayingItemId();
+        /// <value>The play queue.</value>
+        PlayQueueManager PlayQueue { get; }
 
         /// <summary>
         /// Checks if the group is empty.
         /// </summary>
-        /// <value>If the group is empty.</value>
+        /// <returns>If the group is empty.</returns>
         bool IsGroupEmpty();
 
         /// <summary>
         /// Initializes the group with the session's info.
         /// </summary>
         /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void CreateGroup(SessionInfo session, CancellationToken cancellationToken);
+        void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// Adds the session to the group.
@@ -43,6 +45,14 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="cancellationToken">The cancellation token.</param>
         void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
 
+        /// <summary>
+        /// Restores the state of a session that already joined the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
+
         /// <summary>
         /// Removes the session from the group.
         /// </summary>
@@ -61,7 +71,15 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <summary>
         /// Gets the info about the group for the clients.
         /// </summary>
-        /// <value>The group info for the clients.</value>
+        /// <returns>The group info for the clients.</returns>
         GroupInfoDto GetInfo();
+
+        /// <summary>
+        /// Checks if a user has access to all content in the play queue.
+        /// </summary>
+        /// <param name="user">The user.</param>
+        /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns>
+        bool HasAccessToPlayQueue(User user);
+
     }
 }

+ 6 - 6
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs

@@ -15,8 +15,9 @@ namespace MediaBrowser.Controller.SyncPlay
         /// Creates a new group.
         /// </summary>
         /// <param name="session">The session that's creating the group.</param>
+        /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void NewGroup(SessionInfo session, CancellationToken cancellationToken);
+        void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// Adds the session to a group.
@@ -38,9 +39,8 @@ namespace MediaBrowser.Controller.SyncPlay
         /// Gets list of available groups for a session.
         /// </summary>
         /// <param name="session">The session.</param>
-        /// <param name="filterItemId">The item id to filter by.</param>
-        /// <value>The list of available groups.</value>
-        List<GroupInfoDto> ListGroups(SessionInfo session, Guid filterItemId);
+        /// <returns>The list of available groups.</returns>
+        List<GroupInfoDto> ListGroups(SessionInfo session);
 
         /// <summary>
         /// Handle a request by a session in a group.
@@ -56,7 +56,7 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="session">The session.</param>
         /// <param name="group">The group.</param>
         /// <exception cref="InvalidOperationException"></exception>
-        void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
+        void AddSessionToGroup(SessionInfo session, ISyncPlayGroupController group);
 
         /// <summary>
         /// Unmaps a session from a group.
@@ -64,6 +64,6 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="session">The session.</param>
         /// <param name="group">The group.</param>
         /// <exception cref="InvalidOperationException"></exception>
-        void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
+        void RemoveSessionFromGroup(SessionInfo session, ISyncPlayGroupController group);
     }
 }

+ 143 - 22
MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs

@@ -15,81 +15,202 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <value>The group state.</value>
         GroupState GetGroupState();
 
+        /// <summary>
+        /// Handle a session that joined the group.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handle a session that is leaving the group.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken);
+
         /// <summary>
         /// Generic handle. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
-        /// <param name="request">The play action.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The generic action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Handles a play action requested by a session. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The play action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a playlist-item change requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The playlist-item change action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a remove-items change requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The remove-items change action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, RemoveFromPlaylistGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a move-item change requested by a session. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The move-item change action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a queue change requested by a session. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The queue action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles an unpause action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The unpause action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Handles a pause action requested by a session. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The pause action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a stop action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The stop action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Handles a seek action requested by a session. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The seek action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Handles a buffering action requested by a session. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The buffering action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Handles a buffering-done action requested by a session. Context's state can change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The buffering-done action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a next-track action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The next-track action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a previous-track action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The previous-track action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a repeat-mode change requested by a session. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The repeat-mode action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetRepeatModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a shuffle-mode change requested by a session. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The shuffle-mode action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetShuffleModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
 
         /// <summary>
         /// Updates ping of a session. Context's state should not change.
         /// </summary>
         /// <param name="context">The context of the state.</param>
-        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="prevState">The previous state.</param>
         /// <param name="request">The buffering-done action.</param>
         /// <param name="session">The session.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The operation completion status.</value>
-        bool HandleRequest(ISyncPlayStateContext context, bool newState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Updates whether the session should be considered during group wait. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="prevState">The previous state.</param>
+        /// <param name="request">The ignore-wait action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
     }
 }

+ 149 - 11
MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs

@@ -12,10 +12,34 @@ namespace MediaBrowser.Controller.SyncPlay
     public interface ISyncPlayStateContext
     {
         /// <summary>
-        /// Gets the context's group.
+        /// Gets the default ping value used for sessions, in milliseconds.
         /// </summary>
-        /// <value>The group.</value>
-        GroupInfo GetGroup();
+        /// <value>The default ping value used for sessions, in milliseconds.</value>
+        long DefaultPing { get; }
+
+        /// <summary>
+        /// Gets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        Guid GroupId { get; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the last activity.
+        /// </summary>
+        /// <value>The last activity.</value>
+        DateTime LastActivity { get; set; }
+
+        /// <summary>
+        /// Gets the play queue.
+        /// </summary>
+        /// <value>The play queue.</value>
+        PlayQueueManager PlayQueue { get; }
 
         /// <summary>
         /// Sets a new state.
@@ -30,7 +54,7 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="type">The filtering type.</param>
         /// <param name="message">The message to send.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The task.</value>
+        /// <returns>The task.</returns>
         Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken);
 
         /// <summary>
@@ -40,14 +64,14 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="type">The filtering type.</param>
         /// <param name="message">The message to send.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        /// <value>The task.</value>
+        /// <returns>The task.</returns>
         Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken);
 
         /// <summary>
         /// Builds a new playback command with some default values.
         /// </summary>
         /// <param name="type">The command type.</param>
-        /// <value>The SendCommand.</value>
+        /// <returns>The SendCommand.</returns>
         SendCommand NewSyncPlayCommand(SendCommandType type);
 
         /// <summary>
@@ -55,21 +79,135 @@ namespace MediaBrowser.Controller.SyncPlay
         /// </summary>
         /// <param name="type">The update type.</param>
         /// <param name="data">The data to send.</param>
-        /// <value>The GroupUpdate.</value>
+        /// <returns>The GroupUpdate.</returns>
         GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data);
 
         /// <summary>
         /// Converts DateTime to UTC string.
         /// </summary>
-        /// <param name="date">The date to convert.</param>
-        /// <value>The UTC string.</value>
-        string DateToUTCString(DateTime date);
+        /// <param name="dateTime">The date to convert.</param>
+        /// <returns>The UTC string.</returns>
+        string DateToUTCString(DateTime dateTime);
 
         /// <summary>
         /// Sanitizes the PositionTicks, considers the current playing item when available.
         /// </summary>
         /// <param name="positionTicks">The PositionTicks.</param>
-        /// <value>The sanitized PositionTicks.</value>
+        /// <returns>The sanitized PositionTicks.</returns>
         long SanitizePositionTicks(long? positionTicks);
+
+        /// <summary>
+        /// Updates the ping of a session, in milliseconds.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="ping">The ping, in milliseconds.</param>
+        void UpdatePing(SessionInfo session, long ping);
+
+        /// <summary>
+        /// Gets the highest ping in the group, in milliseconds.
+        /// </summary>
+        /// <returns>The highest ping in the group.</returns>
+        long GetHighestPing();
+
+        /// <summary>
+        /// Sets the session's buffering state.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="isBuffering">The state.</param>
+        void SetBuffering(SessionInfo session, bool isBuffering);
+
+        /// <summary>
+        /// Sets the buffering state of all the sessions.
+        /// </summary>
+        /// <param name="isBuffering">The state.</param>
+        void SetAllBuffering(bool isBuffering);
+
+        /// <summary>
+        /// Gets the group buffering state.
+        /// </summary>
+        /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
+        bool IsBuffering();
+
+        /// <summary>
+        /// Sets the session's group wait state.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="ignoreGroupWait">The state.</param>
+        void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait);
+
+        /// <summary>
+        /// Sets a new play queue.
+        /// </summary>
+        /// <param name="playQueue">The new play queue.</param>
+        /// <param name="playingItemPosition">The playing item position in the play queue.</param>
+        /// <param name="startPositionTicks">The start position ticks.</param>
+        /// <returns><c>true</c> if the play queue has been changed; <c>false</c> is something went wrong.</returns>
+        bool SetPlayQueue(Guid[] playQueue, int playingItemPosition, long startPositionTicks);
+
+        /// <summary>
+        /// Sets the playing item.
+        /// </summary>
+        /// <param name="playlistItemId">The new playing item id.</param>
+        /// <returns><c>true</c> if the play queue has been changed; <c>false</c> is something went wrong.</returns>
+        bool SetPlayingItem(string playlistItemId);
+
+        /// <summary>
+        /// Removes items from the play queue.
+        /// </summary>
+        /// <param name="playlistItemIds">The items to remove.</param>
+        /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+        bool RemoveFromPlayQueue(string[] playlistItemIds);
+
+        /// <summary>
+        /// Moves an item in the play queue.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist id of the item to move.</param>
+        /// <param name="newIndex">The new position.</param>
+        /// <returns><c>true</c> if item has been moved; <c>false</c> is something went wrong.</returns>
+        bool MoveItemInPlayQueue(string playlistItemId, int newIndex);
+
+        /// <summary>
+        /// Updates the play queue.
+        /// </summary>
+        /// <param name="newItems">The new items to add to the play queue.</param>
+        /// <param name="mode">The mode with which the items will be added.</param>
+        /// <returns><c>true</c> if the play queue has been changed; <c>false</c> is something went wrong.</returns>
+        bool AddToPlayQueue(Guid[] newItems, string mode);
+
+        /// <summary>
+        /// Restarts current item in play queue.
+        /// </summary>
+        void RestartCurrentItem();
+
+        /// <summary>
+        /// Picks next item in play queue.
+        /// </summary>
+        /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+        bool NextItemInQueue();
+
+        /// <summary>
+        /// Picks previous item in play queue.
+        /// </summary>
+        /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+        bool PreviousItemInQueue();
+
+        /// <summary>
+        /// Sets the repeat mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        void SetRepeatMode(string mode);
+
+        /// <summary>
+        /// Sets the shuffle mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        void SetShuffleMode(string mode);
+
+        /// <summary>
+        /// Creates a play queue update.
+        /// </summary>
+        /// <param name="reason">The reason for the update.</param>
+        /// <returns>The play queue update.</returns>
+        PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason);
     }
 }

+ 12 - 6
MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs

@@ -23,21 +23,27 @@ namespace MediaBrowser.Controller.SyncPlay
         public long PositionTicks { get; set; }
 
         /// <summary>
-        /// Gets or sets the playing item id.
+        /// Gets or sets the client playback status.
         /// </summary>
-        /// <value>The playing item id.</value>
-        public Guid PlayingItemId { get; set; }
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist item id of the playing item.
+        /// </summary>
+        /// <value>The playlist item id.</value>
+        public string PlaylistItemId { get; set; }
 
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Buffer;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class IgnoreWaitGroupRequest.
+    /// </summary>
+    public class IgnoreWaitGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the client group-wait status.
+        /// </summary>
+        /// <value>The client group-wait status.</value>
+        public bool IgnoreWait { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.IgnoreWait;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
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
+{
+    /// <summary>
+    /// Class MovePlaylistItemGroupRequest.
+    /// </summary>
+    public class MovePlaylistItemGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the playlist id of the item.
+        /// </summary>
+        /// <value>The playlist id of the item.</value>
+        public string PlaylistItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the new position.
+        /// </summary>
+        /// <value>The new position.</value>
+        public int NewIndex { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.Queue;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class NextTrackGroupRequest.
+    /// </summary>
+    public class NextTrackGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        public string PlaylistItemId { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.NextTrack;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 3 - 3
MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs

@@ -10,15 +10,15 @@ namespace MediaBrowser.Controller.SyncPlay
     public class PauseGroupRequest : IPlaybackGroupRequest
     {
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Pause;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 3 - 4
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
 {
     /// <summary>
@@ -17,15 +16,15 @@ namespace MediaBrowser.Controller.SyncPlay
         public long Ping { get; set; }
 
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Ping;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 22 - 3
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
     /// </summary>
     public class PlayGroupRequest : IPlaybackGroupRequest
     {
+        /// <summary>
+        /// Gets or sets the playing queue.
+        /// </summary>
+        /// <value>The playing queue.</value>
+        public Guid[] PlayingQueue { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item from the queue.
+        /// </summary>
+        /// <value>The playing item.</value>
+        public int PlayingItemPosition { get; set; }
+
+        /// <summary>
+        /// Gets or sets the start position ticks.
+        /// </summary>
+        /// <value>The start position ticks.</value>
+        public long StartPositionTicks { get; set; }
+
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Play;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class PreviousTrackGroupRequest.
+    /// </summary>
+    public class PreviousTrackGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        public string PlaylistItemId { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.PreviousTrack;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 37 - 0
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
+{
+    /// <summary>
+    /// Class QueueGroupRequest.
+    /// </summary>
+    public class QueueGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the items to queue.
+        /// </summary>
+        /// <value>The items to queue.</value>
+        public Guid[] ItemIds { get; set; }
+
+        /// <summary>
+        /// Gets or sets the mode in which to add the new items.
+        /// </summary>
+        /// <value>The mode.</value>
+        public string Mode { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.Queue;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 12 - 6
MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs

@@ -23,21 +23,27 @@ namespace MediaBrowser.Controller.SyncPlay
         public long PositionTicks { get; set; }
 
         /// <summary>
-        /// Gets or sets the playing item id.
+        /// Gets or sets the client playback status.
         /// </summary>
-        /// <value>The playing item id.</value>
-        public Guid PlayingItemId { get; set; }
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist item id of the playing item.
+        /// </summary>
+        /// <value>The playlist item id.</value>
+        public string PlaylistItemId { get; set; }
 
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Ready;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class RemoveFromPlaylistGroupRequest.
+    /// </summary>
+    public class RemoveFromPlaylistGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the playlist ids ot the items.
+        /// </summary>
+        /// <value>The playlist ids ot the items.</value>
+        public string[] PlaylistItemIds { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.Queue;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 3 - 3
MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs

@@ -16,15 +16,15 @@ namespace MediaBrowser.Controller.SyncPlay
         public long PositionTicks { get; set; }
 
         /// <inheritdoc />
-        public PlaybackRequestType Type()
+        public PlaybackRequestType GetRequestType()
         {
             return PlaybackRequestType.Seek;
         }
 
         /// <inheritdoc />
-        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);
         }
     }
 }

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class SetPlaylistItemGroupRequest.
+    /// </summary>
+    public class SetPlaylistItemGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the playlist id of the playing item.
+        /// </summary>
+        /// <value>The playlist id of the playing item.</value>
+        public string PlaylistItemId { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.SetPlaylistItem;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class SetRepeatModeGroupRequest.
+    /// </summary>
+    public class SetRepeatModeGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public string Mode { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.SetRepeatMode;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 30 - 0
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
+{
+    /// <summary>
+    /// Class SetShuffleModeGroupRequest.
+    /// </summary>
+    public class SetShuffleModeGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public string Mode { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.SetShuffleMode;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 24 - 0
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
+{
+    /// <summary>
+    /// Class StopGroupRequest.
+    /// </summary>
+    public class StopGroupRequest : IPlaybackGroupRequest
+    {
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.Stop;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 24 - 0
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
+{
+    /// <summary>
+    /// Class UnpauseGroupRequest.
+    /// </summary>
+    public class UnpauseGroupRequest : IPlaybackGroupRequest
+    {
+        /// <inheritdoc />
+        public PlaybackRequestType GetRequestType()
+        {
+            return PlaybackRequestType.Unpause;
+        }
+
+        /// <inheritdoc />
+        public void Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(context, state.GetGroupState(), this, session, cancellationToken);
+        }
+    }
+}

+ 596 - 0
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<T>(this IList<T> 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;
+            }
+        }
+    }
+
+    /// <summary>
+    /// Class PlayQueueManager.
+    /// </summary>
+    public class PlayQueueManager : IDisposable
+    {
+        /// <summary>
+        /// Gets or sets the playing item index.
+        /// </summary>
+        /// <value>The playing item index.</value>
+        public int PlayingItemIndex { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the last time the queue has been changed.
+        /// </summary>
+        /// <value>The last time the queue has been changed.</value>
+        public DateTime LastChange { get; private set; }
+
+        /// <summary>
+        /// Gets the sorted playlist.
+        /// </summary>
+        /// <value>The sorted playlist, or play queue of the group.</value>
+        private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>();
+
+        /// <summary>
+        /// Gets the shuffled playlist.
+        /// </summary>
+        /// <value>The shuffled playlist, or play queue of the group.</value>
+        private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>();
+
+        /// <summary>
+        /// Gets or sets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted;
+
+        /// <summary>
+        /// Gets or sets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone;
+
+        /// <summary>
+        /// Gets or sets the progressive id counter.
+        /// </summary>
+        /// <value>The progressive id.</value>
+        private int ProgressiveId { get; set; } = 0;
+
+        private bool _disposed = false;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayQueueManager" /> class.
+        /// </summary>
+        public PlayQueueManager()
+        {
+            Reset();
+        }
+
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Releases unmanaged and optionally managed resources.
+        /// </summary>
+        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            _disposed = true;
+        }
+
+        /// <summary>
+        /// Gets the next available id.
+        /// </summary>
+        /// <returns>The next available id.</returns>
+        private int GetNextProgressiveId() {
+            return ProgressiveId++;
+        }
+
+        /// <summary>
+        /// Creates a list from the array of items. Each item is given an unique playlist id.
+        /// </summary>
+        /// <returns>The list of queue items.</returns>
+        private List<QueueItem> CreateQueueItemsFromArray(Guid[] items)
+        {
+            return items.ToList()
+                .Select(item => new QueueItem()
+                {
+                    ItemId = item,
+                    PlaylistItemId = "syncPlayItem" + GetNextProgressiveId()
+                })
+                .ToList();
+        }
+
+        /// <summary>
+        /// Gets the current playlist, depending on the shuffle mode.
+        /// </summary>
+        /// <returns>The playlist.</returns>
+        private List<QueueItem> GetPlaylistAsList()
+        {
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                return ShuffledPlaylist;
+            }
+            else
+            {
+                return SortedPlaylist;
+            }
+        }
+
+        /// <summary>
+        /// Gets the current playlist as an array, depending on the shuffle mode.
+        /// </summary>
+        /// <returns>The array of items in the playlist.</returns>
+        public QueueItem[] GetPlaylist() {
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                return ShuffledPlaylist.ToArray();
+            }
+            else
+            {
+                return SortedPlaylist.ToArray();
+            }
+        }
+
+        /// <summary>
+        /// Sets a new playlist. Playing item is set to none. Resets shuffle mode and repeat mode as well.
+        /// </summary>
+        /// <param name="items">The new items of the playlist.</param>
+        public void SetPlaylist(Guid[] items)
+        {
+            SortedPlaylist = CreateQueueItemsFromArray(items);
+            PlayingItemIndex = -1;
+            ShuffleMode = GroupShuffleMode.Sorted;
+            RepeatMode = GroupRepeatMode.RepeatNone;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Appends new items to the playlist. The specified order is mantained for the sorted playlist, whereas items get shuffled for the shuffled playlist.
+        /// </summary>
+        /// <param name="items">The items to add to the playlist.</param>
+        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;
+        }
+
+        /// <summary>
+        /// Shuffles the playlist. Shuffle mode is changed.
+        /// </summary>
+        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;
+        }
+
+        /// <summary>
+        /// Resets the playlist to sorted mode. Shuffle mode is changed.
+        /// </summary>
+        public void SortShuffledPlaylist()
+        {
+            if (PlayingItemIndex >= 0)
+            {
+                var playingItem = ShuffledPlaylist[PlayingItemIndex];
+                PlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+            }
+
+            ShuffledPlaylist.Clear();
+
+            ShuffleMode = GroupShuffleMode.Sorted;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Clears the playlist.
+        /// </summary>
+        /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param>
+        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);
+                }
+            }
+        }
+
+        /// <summary>
+        /// 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.
+        /// </summary>
+        /// <param name="items">The items to add to the playlist.</param>
+        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;
+        }
+
+        /// <summary>
+        /// Gets playlist id of the playing item, if any.
+        /// </summary>
+        /// <returns>The playlist id of the playing item.</returns>
+        public string GetPlayingItemPlaylistId()
+        {
+            if (PlayingItemIndex < 0)
+            {
+                return null;
+            }
+
+            var list = GetPlaylistAsList();
+
+            if (list.Count() > 0)
+            {
+                return list[PlayingItemIndex].PlaylistItemId;
+            }
+            else
+            {
+                return null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the playing item id, if any.
+        /// </summary>
+        /// <returns>The playing item id.</returns>
+        public Guid GetPlayingItemId()
+        {
+            if (PlayingItemIndex < 0)
+            {
+                return Guid.Empty;
+            }
+
+            var list = GetPlaylistAsList();
+
+            if (list.Count() > 0)
+            {
+                return list[PlayingItemIndex].ItemId;
+            }
+            else
+            {
+                return Guid.Empty;
+            }
+        }
+
+        /// <summary>
+        /// Sets the playing item using its id. If not in the playlist, the playing item is reset.
+        /// </summary>
+        /// <param name="itemId">The new playing item id.</param>
+        public void SetPlayingItemById(Guid itemId)
+        {
+            var itemIds = GetPlaylistAsList().Select(queueItem => queueItem.ItemId).ToList();
+            PlayingItemIndex = itemIds.IndexOf(itemId);
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Sets the playing item using its playlist id. If not in the playlist, the playing item is reset.
+        /// </summary>
+        /// <param name="playlistItemId">The new playing item id.</param>
+        /// <returns><c>true</c> if playing item has been set; <c>false</c> if item is not in the playlist.</returns>
+        public bool SetPlayingItemByPlaylistId(string playlistItemId)
+        {
+            var playlistIds = GetPlaylistAsList().Select(queueItem => queueItem.PlaylistItemId).ToList();
+            PlayingItemIndex = playlistIds.IndexOf(playlistItemId);
+            LastChange = DateTime.UtcNow;
+            return PlayingItemIndex != -1;
+        }
+
+        /// <summary>
+        /// Sets the playing item using its position. If not in range, the playing item is reset.
+        /// </summary>
+        /// <param name="playlistIndex">The new playing item index.</param>
+        public void SetPlayingItemByIndex(int playlistIndex)
+        {
+            var list = GetPlaylistAsList();
+            if (playlistIndex < 0 || playlistIndex > list.Count())
+            {
+                PlayingItemIndex = -1;
+            }
+            else
+            {
+                PlayingItemIndex = playlistIndex;
+            }
+
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Removes items from the playlist. If not removed, the playing item is preserved.
+        /// </summary>
+        /// <param name="playlistItemIds">The items to remove.</param>
+        /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+        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;
+            }
+        }
+
+        /// <summary>
+        /// Moves an item in the playlist to another position.
+        /// </summary>
+        /// <param name="playlistItemId">The item to move.</param>
+        /// <param name="newIndex">The new position.</param>
+        /// <returns><c>true</c> if the item has been moved; <c>false</c> otherwise.</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Resets the playlist to its initial state.
+        /// </summary>
+        public void Reset()
+        {
+            ProgressiveId = 0;
+            SortedPlaylist.Clear();
+            ShuffledPlaylist.Clear();
+            PlayingItemIndex = -1;
+            ShuffleMode = GroupShuffleMode.Sorted;
+            RepeatMode = GroupRepeatMode.RepeatNone;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Sets the repeat mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        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;
+        }
+
+        /// <summary>
+        /// Sets the shuffle mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        public void SetShuffleMode(string mode)
+        {
+            switch (mode)
+            {
+                case "Shuffle":
+                    ShufflePlaylist();
+                    break;
+                default:
+                    SortShuffledPlaylist();
+                    break;
+            }
+        }
+
+        /// <summary>
+        /// Toggles the shuffle mode between sorted and shuffled.
+        /// </summary>
+        public void ToggleShuffleMode()
+        {
+            SetShuffleMode(ShuffleMode.Equals(GroupShuffleMode.Shuffle) ? "Shuffle" : "");
+        }
+
+        /// <summary>
+        /// Gets the next item in the playlist considering repeat mode and shuffle mode.
+        /// </summary>
+        /// <returns>The next item in the playlist.</returns>
+        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];
+        }
+
+        /// <summary>
+        /// Sets the next item in the queue as playing item.
+        /// </summary>
+        /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Sets the previous item in the queue as playing item.
+        /// </summary>
+        /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+        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;
+        }
+    }
+}

+ 0 - 65
MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs

@@ -1,65 +0,0 @@
-using System.Threading;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
-    /// <summary>
-    /// Class SyncPlayAbstractState.
-    /// </summary>
-    /// <remarks>
-    /// Class is not thread-safe, external locking is required when accessing methods.
-    /// </remarks>
-    public abstract class SyncPlayAbstractState : ISyncPlayState
-    {
-        /// <inheritdoc />
-        public abstract GroupState GetGroupState();
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
-        {
-            return true;
-        }
-
-        /// <inheritdoc />
-        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;
-        }
-    }
-}

+ 13 - 13
MediaBrowser.Model/SyncPlay/GroupInfoDto.cs

@@ -5,7 +5,7 @@ using System.Collections.Generic;
 namespace MediaBrowser.Model.SyncPlay
 {
     /// <summary>
-    /// Class GroupInfoView.
+    /// Class GroupInfoDto.
     /// </summary>
     public class GroupInfoDto
     {
@@ -16,27 +16,27 @@ namespace MediaBrowser.Model.SyncPlay
         public string GroupId { get; set; }
 
         /// <summary>
-        /// Gets or sets the playing item id.
+        /// Gets or sets the group name.
         /// </summary>
-        /// <value>The playing item id.</value>
-        public string PlayingItemId { get; set; }
+        /// <value>The group name.</value>
+        public string GroupName { get; set; }
 
         /// <summary>
-        /// Gets or sets the playing item name.
+        /// Gets or sets the group state.
         /// </summary>
-        /// <value>The playing item name.</value>
-        public string PlayingItemName { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        public long PositionTicks { get; set; }
+        /// <value>The group state.</value>
+        public GroupState State { get; set; }
 
         /// <summary>
         /// Gets or sets the participants.
         /// </summary>
         /// <value>The participants.</value>
         public IReadOnlyList<string> Participants { get; set; }
+
+        /// <summary>
+        /// Gets or sets the date when this dto has been updated.
+        /// </summary>
+        /// <value>The date when this dto has been updated.</value>
+        public string LastUpdatedAt { get; set; }
     }
 }

+ 23 - 0
MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs

@@ -0,0 +1,23 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum GroupRepeatMode.
+    /// </summary>
+    public enum GroupRepeatMode
+    {
+        /// <summary>
+        /// Repeat one item only.
+        /// </summary>
+        RepeatOne = 0,
+
+        /// <summary>
+        /// Cycle the playlist.
+        /// </summary>
+        RepeatAll = 1,
+
+        /// <summary>
+        /// Do not repeat.
+        /// </summary>
+        RepeatNone = 2
+    }
+}

+ 18 - 0
MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs

@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum GroupShuffleMode.
+    /// </summary>
+    public enum GroupShuffleMode
+    {
+        /// <summary>
+        /// Sorted playlist.
+        /// </summary>
+        Sorted = 0,
+
+        /// <summary>
+        /// Shuffled playlist.
+        /// </summary>
+        Shuffle = 1
+    }
+}

+ 22 - 0
MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs

@@ -0,0 +1,22 @@
+#nullable disable
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class GroupStateUpdate.
+    /// </summary>
+    public class GroupStateUpdate
+    {
+        /// <summary>
+        /// Gets or sets the state of the group.
+        /// </summary>
+        /// <value>The state of the group.</value>
+        public GroupState State { get; set; }
+
+        /// <summary>
+        /// Gets or sets the reason of the state change.
+        /// </summary>
+        /// <value>The reason of the state change.</value>
+        public PlaybackRequestType Reason { get; set; }
+    }
+}

+ 4 - 4
MediaBrowser.Model/SyncPlay/GroupUpdateType.cs

@@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay
         GroupLeft,
 
         /// <summary>
-        /// 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.
         /// </summary>
-        GroupWait,
+        StateUpdate,
 
         /// <summary>
-        /// 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.
         /// </summary>
-        PrepareSession,
+        PlayQueue,
 
         /// <summary>
         /// The not-in-group error. Tells a user that they don't belong to a group.

+ 2 - 2
MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs

@@ -8,9 +8,9 @@ namespace MediaBrowser.Model.SyncPlay
     public class JoinGroupRequest
     {
         /// <summary>
-        /// Gets or sets the Group id.
+        /// Gets or sets the group id.
         /// </summary>
-        /// <value>The Group id to join.</value>
+        /// <value>The id of the group to join.</value>
         public Guid GroupId { get; set; }
     }
 }

+ 16 - 0
MediaBrowser.Model/SyncPlay/NewGroupRequest.cs

@@ -0,0 +1,16 @@
+#nullable disable
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class NewGroupRequest.
+    /// </summary>
+    public class NewGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the group name.
+        /// </summary>
+        /// <value>The name of the new group.</value>
+        public string GroupName { get; set; }
+    }
+}

+ 52 - 0
MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs

@@ -0,0 +1,52 @@
+#nullable disable
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class PlayQueueUpdate.
+    /// </summary>
+    public class PlayQueueUpdate
+    {
+        /// <summary>
+        /// Gets or sets the request type that originated this update.
+        /// </summary>
+        /// <value>The reason for the update.</value>
+        public PlayQueueUpdateReason Reason { get; set; }
+
+        /// <summary>
+        /// Gets or sets the UTC time of the last change to the playing queue.
+        /// </summary>
+        /// <value>The UTC time of the last change to the playing queue.</value>
+        public string LastUpdate { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist.
+        /// </summary>
+        /// <value>The playlist.</value>
+        public QueueItem[] Playlist { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item index in the playlist.
+        /// </summary>
+        /// <value>The playing item index in the playlist.</value>
+        public int PlayingItemIndex { get; set; }
+
+        /// <summary>
+        /// Gets or sets the start position ticks.
+        /// </summary>
+        /// <value>The start position ticks.</value>
+        public long StartPositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode ShuffleMode { get; set; }
+
+        /// <summary>
+        /// Gets or sets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode RepeatMode { get; set; }
+    }
+}

+ 58 - 0
MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs

@@ -0,0 +1,58 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum PlayQueueUpdateReason.
+    /// </summary>
+    public enum PlayQueueUpdateReason
+    {
+        /// <summary>
+        /// A user is requesting to play a new playlist.
+        /// </summary>
+        NewPlaylist = 0,
+
+        /// <summary>
+        /// A user is changing the playing item.
+        /// </summary>
+        SetCurrentItem = 1,
+
+        /// <summary>
+        /// A user is removing items from the playlist.
+        /// </summary>
+        RemoveItems = 2,
+
+        /// <summary>
+        /// A user is moving an item in the playlist.
+        /// </summary>
+        MoveItem = 3,
+
+        /// <summary>
+        /// A user is making changes to the queue.
+        /// </summary>
+        Queue = 4,
+
+        /// <summary>
+        /// A user is making changes to the queue.
+        /// </summary>
+        QueueNext = 5,
+
+        /// <summary>
+        /// A user is requesting the next item in queue.
+        /// </summary>
+        NextTrack = 6,
+
+        /// <summary>
+        /// A user is requesting the previous item in queue.
+        /// </summary>
+        PreviousTrack = 7,
+
+        /// <summary>
+        /// A user is changing repeat mode.
+        /// </summary>
+        RepeatMode = 8,
+
+        /// <summary>
+        /// A user is changing shuffle mode.
+        /// </summary>
+        ShuffleMode = 9
+    }
+}

+ 62 - 8
MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs

@@ -6,33 +6,87 @@ namespace MediaBrowser.Model.SyncPlay
     public enum PlaybackRequestType
     {
         /// <summary>
-        /// A user is requesting a play command for the group.
+        /// A user is setting a new playlist.
         /// </summary>
         Play = 0,
 
+        /// <summary>
+        /// A user is changing the playlist item.
+        /// </summary>
+        SetPlaylistItem = 1,
+
+        /// <summary>
+        /// A user is removing items from the playlist.
+        /// </summary>
+        RemoveFromPlaylist = 2,
+
+        /// <summary>
+        /// A user is moving an item in the playlist.
+        /// </summary>
+        MovePlaylistItem = 3,
+
+        /// <summary>
+        /// A user is adding items to the playlist.
+        /// </summary>
+        Queue = 4,
+
+        /// <summary>
+        /// A user is requesting an unpause command for the group.
+        /// </summary>
+        Unpause = 5,
+
         /// <summary>
         /// A user is requesting a pause command for the group.
         /// </summary>
-        Pause = 1,
+        Pause = 6,
 
         /// <summary>
-        /// A user is requesting a seek command for the group.
+        /// A user is requesting a stop command for the group.
         /// </summary>
-        Seek = 2,
+        Stop = 7,
 
         /// <summary>
+        /// A user is requesting a seek command for the group.
+        /// </summary>
+        Seek = 8,
+
+         /// <summary>
         /// A user is signaling that playback is buffering.
         /// </summary>
-        Buffer = 3,
+        Buffer = 9,
 
         /// <summary>
         /// A user is signaling that playback resumed.
         /// </summary>
-        Ready = 4,
+        Ready = 10,
+
+        /// <summary>
+        /// A user is requesting next track in playlist.
+        /// </summary>
+        NextTrack = 11,
+
+        /// <summary>
+        /// A user is requesting previous track in playlist.
+        /// </summary>
+        PreviousTrack = 12,
+        /// <summary>
+        /// A user is setting the repeat mode.
+        /// </summary>
+        SetRepeatMode = 13,
+
+        /// <summary>
+        /// A user is setting the shuffle mode.
+        /// </summary>
+        SetShuffleMode = 14,
+
+        /// <summary>
+        /// A user is reporting their ping.
+        /// </summary>
+        Ping = 15,
 
         /// <summary>
-        /// A user is reporting its ping.
+        /// A user is requesting to be ignored on group wait.
         /// </summary>
-        Ping = 5
+        IgnoreWait = 16
     }
 }

+ 24 - 0
MediaBrowser.Model/SyncPlay/QueueItem.cs

@@ -0,0 +1,24 @@
+#nullable disable
+
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class QueueItem.
+    /// </summary>
+    public class QueueItem
+    {
+        /// <summary>
+        /// Gets or sets the item id.
+        /// </summary>
+        /// <value>The item id.</value>
+        public Guid ItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist id of the item.
+        /// </summary>
+        /// <value>The playlist id of the item.</value>
+        public string PlaylistItemId { get; set; }
+    }
+}

+ 6 - 0
MediaBrowser.Model/SyncPlay/SendCommand.cs

@@ -13,6 +13,12 @@ namespace MediaBrowser.Model.SyncPlay
         /// <value>The group identifier.</value>
         public string GroupId { get; set; }
 
+        /// <summary>
+        /// Gets or sets the playlist id of the playing item.
+        /// </summary>
+        /// <value>The playlist id of the playing item.</value>
+        public string PlaylistItemId { get; set; }
+
         /// <summary>
         /// Gets or sets the UTC time when to execute the command.
         /// </summary>

+ 8 - 3
MediaBrowser.Model/SyncPlay/SendCommandType.cs

@@ -6,18 +6,23 @@ namespace MediaBrowser.Model.SyncPlay
     public enum SendCommandType
     {
         /// <summary>
-        /// The play command. Instructs users to start playback.
+        /// The unpause command. Instructs users to unpause playback.
         /// </summary>
-        Play = 0,
+        Unpause = 0,
 
         /// <summary>
         /// The pause command. Instructs users to pause playback.
         /// </summary>
         Pause = 1,
 
+        /// <summary>
+        /// The stop command. Instructs users to stop playback.
+        /// </summary>
+        Stop = 2,
+
         /// <summary>
         /// The seek command. Instructs users to seek to a specified time.
         /// </summary>
-        Seek = 2
+        Seek = 3
     }
 }