Browse Source

Merge pull request #3194 from OancaAndrei/syncplay-enhanced

SyncPlay for TV series (and Music)
Joshua M. Boniface 4 years ago
parent
commit
bba01bf7b9
91 changed files with 5472 additions and 1273 deletions
  1. 1 0
      CONTRIBUTORS.md
  2. 15 26
      Emby.Server.Implementations/HttpServer/WebSocketManager.cs
  3. 2 4
      Emby.Server.Implementations/Session/SessionManager.cs
  4. 36 44
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  5. 674 0
      Emby.Server.Implementations/SyncPlay/Group.cs
  6. 0 514
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  7. 179 210
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  8. 58 0
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
  9. 33 0
      Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
  10. 10 0
      Jellyfin.Api/Constants/Policies.cs
  11. 257 56
      Jellyfin.Api/Controllers/SyncPlayController.cs
  12. 5 9
      Jellyfin.Api/Controllers/TimeSyncController.cs
  13. 42 0
      Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs
  14. 14 0
      Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs
  15. 16 0
      Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs
  16. 30 0
      Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs
  17. 22 0
      Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs
  18. 24 0
      Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs
  19. 14 0
      Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs
  20. 37 0
      Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs
  21. 24 0
      Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs
  22. 32 0
      Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs
  23. 42 0
      Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs
  24. 25 0
      Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs
  25. 14 0
      Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs
  26. 24 0
      Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs
  27. 16 0
      Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs
  28. 16 0
      Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs
  29. 1 1
      Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
  30. 5 7
      Jellyfin.Server/CoreAppHost.cs
  31. 17 0
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  32. 3 0
      MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
  33. 8 1
      MediaBrowser.Controller/Net/IWebSocketListener.cs
  34. 0 8
      MediaBrowser.Controller/Net/IWebSocketManager.cs
  35. 6 6
      MediaBrowser.Controller/Session/ISessionManager.cs
  36. 0 160
      MediaBrowser.Controller/SyncPlay/GroupInfo.cs
  37. 21 6
      MediaBrowser.Controller/SyncPlay/GroupMember.cs
  38. 222 0
      MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs
  39. 126 0
      MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs
  40. 165 0
      MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs
  41. 168 0
      MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs
  42. 680 0
      MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
  43. 27 0
      MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs
  44. 217 0
      MediaBrowser.Controller/SyncPlay/IGroupState.cs
  45. 222 0
      MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
  46. 0 67
      MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
  47. 10 24
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  48. 16 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs
  49. 29 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs
  50. 61 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs
  51. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs
  52. 45 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs
  53. 37 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs
  54. 21 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs
  55. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs
  56. 54 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs
  57. 37 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs
  58. 46 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs
  59. 61 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs
  60. 38 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs
  61. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs
  62. 37 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs
  63. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs
  64. 36 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs
  65. 21 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs
  66. 21 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs
  67. 577 0
      MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
  68. 29 0
      MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs
  69. 13 0
      MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs
  70. 13 0
      MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs
  71. 28 0
      MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs
  72. 58 0
      MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
  73. 0 42
      MediaBrowser.Model/SyncPlay/GroupInfoView.cs
  74. 18 0
      MediaBrowser.Model/SyncPlay/GroupQueueMode.cs
  75. 23 0
      MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
  76. 18 0
      MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
  77. 28 0
      MediaBrowser.Model/SyncPlay/GroupStateType.cs
  78. 31 0
      MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
  79. 22 8
      MediaBrowser.Model/SyncPlay/GroupUpdate.cs
  80. 4 4
      MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
  81. 0 16
      MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
  82. 74 0
      MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
  83. 58 0
      MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
  84. 0 34
      MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
  85. 63 8
      MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
  86. 31 0
      MediaBrowser.Model/SyncPlay/QueueItem.cs
  87. 33 0
      MediaBrowser.Model/SyncPlay/RequestType.cs
  88. 35 10
      MediaBrowser.Model/SyncPlay/SendCommand.cs
  89. 8 3
      MediaBrowser.Model/SyncPlay/SendCommandType.cs
  90. 28 0
      MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs
  91. 16 5
      MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs

+ 1 - 0
CONTRIBUTORS.md

@@ -79,6 +79,7 @@
  - [Nickbert7](https://github.com/Nickbert7)
  - [nvllsvm](https://github.com/nvllsvm)
  - [nyanmisaka](https://github.com/nyanmisaka)
+ - [OancaAndrei](https://github.com/OancaAndrei)
  - [oddstr13](https://github.com/oddstr13)
  - [orryverducci](https://github.com/orryverducci)
  - [petermcneil](https://github.com/petermcneil)

+ 15 - 26
Emby.Server.Implementations/HttpServer/WebSocketManager.cs

@@ -2,9 +2,9 @@
 
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Net.WebSockets;
 using System.Threading.Tasks;
-using Jellyfin.Data.Events;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
@@ -13,32 +13,23 @@ namespace Emby.Server.Implementations.HttpServer
 {
     public class WebSocketManager : IWebSocketManager
     {
-        private readonly Lazy<IEnumerable<IWebSocketListener>> _webSocketListeners;
+        private readonly IWebSocketListener[] _webSocketListeners;
         private readonly ILogger<WebSocketManager> _logger;
         private readonly ILoggerFactory _loggerFactory;
 
-        private bool _disposed = false;
-
         public WebSocketManager(
-            Lazy<IEnumerable<IWebSocketListener>> webSocketListeners,
+            IEnumerable<IWebSocketListener> webSocketListeners,
             ILogger<WebSocketManager> logger,
             ILoggerFactory loggerFactory)
         {
-            _webSocketListeners = webSocketListeners;
+            _webSocketListeners = webSocketListeners.ToArray();
             _logger = logger;
             _loggerFactory = loggerFactory;
         }
 
-        public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
         /// <inheritdoc />
         public async Task WebSocketRequestHandler(HttpContext context)
         {
-            if (_disposed)
-            {
-                return;
-            }
-
             try
             {
                 _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
@@ -54,7 +45,13 @@ namespace Emby.Server.Implementations.HttpServer
                     OnReceive = ProcessWebSocketMessageReceived
                 };
 
-                WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
+                var tasks = new Task[_webSocketListeners.Length];
+                for (var i = 0; i < _webSocketListeners.Length; ++i)
+                {
+                    tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection);
+                }
+
+                await Task.WhenAll(tasks).ConfigureAwait(false);
 
                 await connection.ProcessAsync().ConfigureAwait(false);
                 _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
@@ -75,21 +72,13 @@ namespace Emby.Server.Implementations.HttpServer
         /// <param name="result">The result.</param>
         private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
         {
-            if (_disposed)
-            {
-                return Task.CompletedTask;
-            }
-
-            IEnumerable<Task> GetTasks()
+            var tasks = new Task[_webSocketListeners.Length];
+            for (var i = 0; i < _webSocketListeners.Length; ++i)
             {
-                var listeners = _webSocketListeners.Value;
-                foreach (var x in listeners)
-                {
-                    yield return x.ProcessMessageAsync(result);
-                }
+                tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result);
             }
 
-            return Task.WhenAll(GetTasks());
+            return Task.WhenAll(tasks);
         }
     }
 }

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

@@ -1181,18 +1181,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);
         }
 

+ 36 - 44
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -4,7 +4,6 @@ using System.Linq;
 using System.Net.WebSockets;
 using System.Threading;
 using System.Threading.Tasks;
-using Jellyfin.Data.Events;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Net;
@@ -22,50 +21,48 @@ namespace Emby.Server.Implementations.Session
         /// <summary>
         /// The timeout in seconds after which a WebSocket is considered to be lost.
         /// </summary>
-        public const int WebSocketLostTimeout = 60;
+        private const int WebSocketLostTimeout = 60;
 
         /// <summary>
         /// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets.
         /// </summary>
-        public const float IntervalFactor = 0.2f;
+        private const float IntervalFactor = 0.2f;
 
         /// <summary>
         /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent.
         /// </summary>
-        public const float ForceKeepAliveFactor = 0.75f;
+        private const float ForceKeepAliveFactor = 0.75f;
 
         /// <summary>
-        /// The _session manager.
+        /// Lock used for accesing the KeepAlive cancellation token.
         /// </summary>
-        private readonly ISessionManager _sessionManager;
+        private readonly object _keepAliveLock = new object();
 
         /// <summary>
-        /// The _logger.
+        /// The WebSocket watchlist.
         /// </summary>
-        private readonly ILogger<SessionWebSocketListener> _logger;
-        private readonly ILoggerFactory _loggerFactory;
-
-        private readonly IWebSocketManager _webSocketManager;
+        private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
 
         /// <summary>
-        /// The KeepAlive cancellation token.
+        /// Lock used for accessing the WebSockets watchlist.
         /// </summary>
-        private CancellationTokenSource _keepAliveCancellationToken;
+        private readonly object _webSocketsLock = new object();
 
         /// <summary>
-        /// Lock used for accesing the KeepAlive cancellation token.
+        /// The _session manager.
         /// </summary>
-        private readonly object _keepAliveLock = new object();
+        private readonly ISessionManager _sessionManager;
 
         /// <summary>
-        /// The WebSocket watchlist.
+        /// The _logger.
         /// </summary>
-        private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
+        private readonly ILogger<SessionWebSocketListener> _logger;
+        private readonly ILoggerFactory _loggerFactory;
 
         /// <summary>
-        /// Lock used for accesing the WebSockets watchlist.
+        /// The KeepAlive cancellation token.
         /// </summary>
-        private readonly object _webSocketsLock = new object();
+        private CancellationTokenSource _keepAliveCancellationToken;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
@@ -73,32 +70,42 @@ namespace Emby.Server.Implementations.Session
         /// <param name="logger">The logger.</param>
         /// <param name="sessionManager">The session manager.</param>
         /// <param name="loggerFactory">The logger factory.</param>
-        /// <param name="webSocketManager">The HTTP server.</param>
         public SessionWebSocketListener(
             ILogger<SessionWebSocketListener> logger,
             ISessionManager sessionManager,
-            ILoggerFactory loggerFactory,
-            IWebSocketManager webSocketManager)
+            ILoggerFactory loggerFactory)
         {
             _logger = logger;
             _sessionManager = sessionManager;
             _loggerFactory = loggerFactory;
-            _webSocketManager = webSocketManager;
+        }
 
-            webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected;
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            StopKeepAlive();
         }
 
-        private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
+        /// <summary>
+        /// Processes the message.
+        /// </summary>
+        /// <param name="message">The message.</param>
+        /// <returns>Task.</returns>
+        public Task ProcessMessageAsync(WebSocketMessageInfo message)
+            => Task.CompletedTask;
+
+        /// <inheritdoc />
+        public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection)
         {
-            var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
+            var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString());
             if (session != null)
             {
-                EnsureController(session, e.Argument);
-                await KeepAliveWebSocket(e.Argument).ConfigureAwait(false);
+                EnsureController(session, connection);
+                await KeepAliveWebSocket(connection).ConfigureAwait(false);
             }
             else
             {
-                _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString);
+                _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString);
             }
         }
 
@@ -119,21 +126,6 @@ namespace Emby.Server.Implementations.Session
             return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint);
         }
 
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            _webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected;
-            StopKeepAlive();
-        }
-
-        /// <summary>
-        /// Processes the message.
-        /// </summary>
-        /// <param name="message">The message.</param>
-        /// <returns>Task.</returns>
-        public Task ProcessMessageAsync(WebSocketMessageInfo message)
-            => Task.CompletedTask;
-
         private void EnsureController(SessionInfo session, IWebSocketConnection connection)
         {
             var controllerInfo = session.EnsureController<WebSocketController>(

+ 674 - 0
Emby.Server.Implementations/SyncPlay/Group.cs

@@ -0,0 +1,674 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.SyncPlay
+{
+    /// <summary>
+    /// Class Group.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class Group : IGroupStateContext
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<Group> _logger;
+
+        /// <summary>
+        /// The logger factory.
+        /// </summary>
+        private readonly ILoggerFactory _loggerFactory;
+
+        /// <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 participants, or members of the group.
+        /// </summary>
+        private readonly Dictionary<string, GroupMember> _participants =
+            new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+
+        /// <summary>
+        /// The internal group state.
+        /// </summary>
+        private IGroupState _state;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Group" /> class.
+        /// </summary>
+        /// <param name="loggerFactory">The logger factory.</param>
+        /// <param name="userManager">The user manager.</param>
+        /// <param name="sessionManager">The session manager.</param>
+        /// <param name="libraryManager">The library manager.</param>
+        public Group(
+            ILoggerFactory loggerFactory,
+            IUserManager userManager,
+            ISessionManager sessionManager,
+            ILibraryManager libraryManager)
+        {
+            _loggerFactory = loggerFactory;
+            _userManager = userManager;
+            _sessionManager = sessionManager;
+            _libraryManager = libraryManager;
+            _logger = loggerFactory.CreateLogger<Group>();
+
+            _state = new IdleGroupState(loggerFactory);
+        }
+
+        /// <summary>
+        /// Gets the default ping value used for sessions.
+        /// </summary>
+        /// <value>The default ping.</value>
+        public long DefaultPing { get; } = 500;
+
+        /// <summary>
+        /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+        /// </summary>
+        /// <value>The maximum time offset error.</value>
+        public long TimeSyncOffset { get; } = 2000;
+
+        /// <summary>
+        /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+        /// </summary>
+        /// <value>The maximum offset error.</value>
+        public long MaxPlaybackOffset { get; } = 500;
+
+        /// <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 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>
+        /// 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)
+                {
+                    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 list of sessions matching the filter.</returns>
+        private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type)
+        {
+            return type switch
+            {
+                SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from },
+                SyncPlayBroadcastType.AllGroup => _participants
+                    .Values
+                    .Select(session => session.Session),
+                SyncPlayBroadcastType.AllExceptCurrentSession => _participants
+                    .Values
+                    .Select(session => session.Session)
+                    .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)),
+                SyncPlayBroadcastType.AllReady => _participants
+                    .Values
+                    .Where(session => !session.IsBuffering)
+                    .Select(session => session.Session),
+                _ => Enumerable.Empty<SessionInfo>()
+            };
+        }
+
+        /// <summary>
+        /// Checks if a given user can access all items of a given queue, that is,
+        /// the user has the required minimum parental access and has access to all required folders.
+        /// </summary>
+        /// <param name="user">The user.</param>
+        /// <param name="queue">The queue.</param>
+        /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns>
+        private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue)
+        {
+            // Check if queue is empty.
+            if (queue == null || queue.Count == 0)
+            {
+                return true;
+            }
+
+            foreach (var itemId in queue)
+            {
+                var item = _libraryManager.GetItemById(itemId);
+                if (!item.IsVisibleStandalone(user))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue)
+        {
+            // Check if queue is empty.
+            if (queue == null || queue.Count == 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();
+        }
+
+        /// <summary>
+        /// Checks if the group is empty.
+        /// </summary>
+        /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns>
+        public bool IsGroupEmpty() => _participants.Count == 0;
+
+        /// <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>
+        public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
+        {
+            GroupName = request.GroupName;
+            AddSession(session);
+
+            var sessionIsPlayingAnItem = session.FullNowPlayingItem != null;
+
+            RestartCurrentItem();
+
+            if (sessionIsPlayingAnItem)
+            {
+                var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList();
+                PlayQueue.Reset();
+                PlayQueue.SetPlaylist(playlist);
+                PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id);
+                RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0;
+                PositionTicks = session.PlayState.PositionTicks ?? 0;
+
+                // Maintain playstate.
+                var waitingState = new WaitingGroupState(_loggerFactory)
+                {
+                    ResumePlaying = !session.PlayState.IsPaused
+                };
+                SetState(waitingState);
+            }
+
+            var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+            SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+            _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+            _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString());
+        }
+
+        /// <summary>
+        /// Adds the session to the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
+        {
+            AddSession(session);
+
+            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.Type, session, cancellationToken);
+
+            _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString());
+        }
+
+        /// <summary>
+        /// Removes the session from the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
+        {
+            _state.SessionLeaving(this, _state.Type, session, cancellationToken);
+
+            RemoveSession(session);
+
+            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("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString());
+        }
+
+        /// <summary>
+        /// Handles the requested action by the session.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The requested action.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        public void HandleRequest(SessionInfo session, IGroupPlaybackRequest 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("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type);
+
+            // Apply requested changes to this group given its current state.
+            // Every request has a slightly different outcome depending on the group's state.
+            // There are currently four different group states that accomplish different goals:
+            // - Idle: in this state no media is playing and clients should be idle (playback is stopped).
+            // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback,
+            //      that is, they've either finished loading the media for the first time or they've finished buffering.
+            //      Once all clients report to be ready the group's state can change to Playing or Paused.
+            // - Playing: clients have some media loaded and playback is unpaused.
+            // - Paused: clients have some media loaded but playback is currently paused.
+            request.Apply(this, _state, session, cancellationToken);
+        }
+
+        /// <summary>
+        /// Gets the info about the group for the clients.
+        /// </summary>
+        /// <returns>The group info for the clients.</returns>
+        public GroupInfoDto GetInfo()
+        {
+            var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList();
+            return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow);
+        }
+
+        /// <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>
+        public bool HasAccessToPlayQueue(User user)
+        {
+            var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList();
+            return HasAccessToQueue(user, items);
+        }
+
+        /// <inheritdoc />
+        public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait)
+        {
+            if (_participants.TryGetValue(session.Id, out GroupMember value))
+            {
+                value.IgnoreGroupWait = ignoreGroupWait;
+            }
+        }
+
+        /// <inheritdoc />
+        public void SetState(IGroupState state)
+        {
+            _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
+            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,
+                PlayQueue.GetPlayingItemPlaylistId(),
+                LastActivity,
+                type,
+                PositionTicks,
+                DateTime.UtcNow);
+        }
+
+        /// <inheritdoc />
+        public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
+        {
+            return new GroupUpdate<T>(GroupId, type, data);
+        }
+
+        /// <inheritdoc />
+        public long SanitizePositionTicks(long? positionTicks)
+        {
+            var ticks = positionTicks ?? 0;
+            return Math.Clamp(ticks, 0, RunTimeTicks);
+        }
+
+        /// <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(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks)
+        {
+            // Ignore on empty queue or invalid item position.
+            if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0)
+            {
+                return false;
+            }
+
+            // Check if participants can access the new playing queue.
+            if (!AllUsersHaveAccessToQueue(playQueue))
+            {
+                return false;
+            }
+
+            PlayQueue.Reset();
+            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(Guid 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(IReadOnlyList<Guid> 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(Guid playlistItemId, int newIndex)
+        {
+            return PlayQueue.MovePlaylistItem(playlistItemId, newIndex);
+        }
+
+        /// <inheritdoc />
+        public bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode)
+        {
+            // Ignore on empty list.
+            if (newItems.Count == 0)
+            {
+                return false;
+            }
+
+            // Check if participants can access the new playing queue.
+            if (!AllUsersHaveAccessToQueue(newItems))
+            {
+                return false;
+            }
+
+            if (mode.Equals(GroupQueueMode.QueueNext))
+            {
+                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(GroupRepeatMode mode)
+        {
+            PlayQueue.SetRepeatMode(mode);
+        }
+
+        /// <inheritdoc />
+        public void SetShuffleMode(GroupShuffleMode mode)
+        {
+            PlayQueue.SetShuffleMode(mode);
+        }
+
+        /// <inheritdoc />
+        public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason)
+        {
+            var startPositionTicks = PositionTicks;
+
+            if (_state.Type.Equals(GroupStateType.Playing))
+            {
+                var currentTime = DateTime.UtcNow;
+                var elapsedTime = currentTime - LastActivity;
+                // Elapsed time is negative if event happens
+                // during the delay added to account for latency.
+                // In this phase clients haven't started the playback yet.
+                // In other words, LastActivity is in the future,
+                // when playback unpause is supposed to happen.
+                // Adjust ticks only if playback actually started.
+                startPositionTicks += Math.Max(elapsedTime.Ticks, 0);
+            }
+
+            return new PlayQueueUpdate(
+                reason,
+                PlayQueue.LastChange,
+                PlayQueue.GetPlaylist(),
+                PlayQueue.PlayingItemIndex,
+                startPositionTicks,
+                PlayQueue.ShuffleMode,
+                PlayQueue.RepeatMode);
+        }
+    }
+}

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

@@ -1,514 +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;
-
-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
-    {
-        /// <summary>
-        /// Used to filter the sessions of a group.
-        /// </summary>
-        private enum BroadcastType
-        {
-            /// <summary>
-            /// All sessions will receive the message.
-            /// </summary>
-            AllGroup = 0,
-
-            /// <summary>
-            /// Only the specified session will receive the message.
-            /// </summary>
-            CurrentSession = 1,
-
-            /// <summary>
-            /// All sessions, except the current one, will receive the message.
-            /// </summary>
-            AllExceptCurrentSession = 2,
-
-            /// <summary>
-            /// Only sessions that are not buffering will receive the message.
-            /// </summary>
-            AllReady = 3
-        }
-
-        /// <summary>
-        /// The session manager.
-        /// </summary>
-        private readonly ISessionManager _sessionManager;
-
-        /// <summary>
-        /// The SyncPlay manager.
-        /// </summary>
-        private readonly ISyncPlayManager _syncPlayManager;
-
-        /// <summary>
-        /// The group to manage.
-        /// </summary>
-        private readonly GroupInfo _group = new GroupInfo();
-
-        /// <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)
-        {
-            _sessionManager = sessionManager;
-            _syncPlayManager = syncPlayManager;
-        }
-
-        /// <inheritdoc />
-        public Guid GetGroupId() => _group.GroupId;
-
-        /// <inheritdoc />
-        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
-        /// <inheritdoc />
-        public bool IsGroupEmpty() => _group.IsEmpty();
-
-        /// <summary>
-        /// Converts DateTime to UTC string.
-        /// </summary>
-        /// <param name="date">The date to convert.</param>
-        /// <value>The UTC string.</value>
-        private string DateToUTCString(DateTime date)
-        {
-            return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
-        }
-
-        /// <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 IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type)
-        {
-            switch (type)
-            {
-                case BroadcastType.CurrentSession:
-                    return new SessionInfo[] { from };
-                case BroadcastType.AllGroup:
-                    return _group.Participants.Values
-                        .Select(session => session.Session);
-                case BroadcastType.AllExceptCurrentSession:
-                    return _group.Participants.Values
-                        .Select(session => session.Session)
-                        .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal));
-                case BroadcastType.AllReady:
-                    return _group.Participants.Values
-                        .Where(session => !session.IsBuffering)
-                        .Select(session => session.Session);
-                default:
-                    return Array.Empty<SessionInfo>();
-            }
-        }
-
-        /// <summary>
-        /// Sends a GroupUpdate message to the interested sessions.
-        /// </summary>
-        /// <param name="from">The current session.</param>
-        /// <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>
-        private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType 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());
-        }
-
-        /// <summary>
-        /// Sends a playback command to the interested sessions.
-        /// </summary>
-        /// <param name="from">The current session.</param>
-        /// <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>
-        private Task SendCommand(SessionInfo from, BroadcastType 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());
-        }
-
-        /// <summary>
-        /// Builds a new playback command with some default values.
-        /// </summary>
-        /// <param name="type">The command type.</param>
-        /// <value>The SendCommand.</value>
-        private SendCommand NewSyncPlayCommand(SendCommandType type)
-        {
-            return new SendCommand()
-            {
-                GroupId = _group.GroupId.ToString(),
-                Command = type,
-                PositionTicks = _group.PositionTicks,
-                When = DateToUTCString(_group.LastActivity),
-                EmittedAt = DateToUTCString(DateTime.UtcNow)
-            };
-        }
-
-        /// <summary>
-        /// Builds a new group update message.
-        /// </summary>
-        /// <param name="type">The update type.</param>
-        /// <param name="data">The data to send.</param>
-        /// <value>The GroupUpdate.</value>
-        private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
-        {
-            return new GroupUpdate<T>()
-            {
-                GroupId = _group.GroupId.ToString(),
-                Type = type,
-                Data = data
-            };
-        }
-
-        /// <inheritdoc />
-        public void CreateGroup(SessionInfo session, CancellationToken cancellationToken)
-        {
-            _group.AddSession(session);
-            _syncPlayManager.AddSessionToGroup(session, this);
-
-            _group.PlayingItem = session.FullNowPlayingItem;
-            _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, BroadcastType.CurrentSession, updateSession, 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, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
-                var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
-                SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-
-                // Syncing will happen client-side
-                if (!_group.IsPaused)
-                {
-                    var playCommand = NewSyncPlayCommand(SendCommandType.Play);
-                    SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken);
-                }
-                else
-                {
-                    var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
-                    SendCommand(session, BroadcastType.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, BroadcastType.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, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
-            var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
-            SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-        }
-
-        /// <inheritdoc />
-        public void HandleRequest(SessionInfo session, PlaybackRequest 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.
-            switch (request.Type)
-            {
-                case PlaybackRequestType.Play:
-                    HandlePlayRequest(session, request, cancellationToken);
-                    break;
-                case PlaybackRequestType.Pause:
-                    HandlePauseRequest(session, request, cancellationToken);
-                    break;
-                case PlaybackRequestType.Seek:
-                    HandleSeekRequest(session, request, cancellationToken);
-                    break;
-                case PlaybackRequestType.Buffer:
-                    HandleBufferingRequest(session, request, cancellationToken);
-                    break;
-                case PlaybackRequestType.Ready:
-                    HandleBufferingDoneRequest(session, request, cancellationToken);
-                    break;
-                case PlaybackRequestType.Ping:
-                    HandlePingUpdateRequest(session, request);
-                    break;
-            }
-        }
-
-        /// <summary>
-        /// Handles a play action requested by a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The play action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
-        {
-            if (_group.IsPaused)
-            {
-                // Pick a suitable time that accounts for latency
-                var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.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.IsPaused = false;
-                _group.LastActivity = DateTime.UtcNow.AddMilliseconds(
-                    delay);
-
-                var command = NewSyncPlayCommand(SendCommandType.Play);
-                SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
-            }
-            else
-            {
-                // Client got lost, sending current state
-                var command = NewSyncPlayCommand(SendCommandType.Play);
-                SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
-            }
-        }
-
-        /// <summary>
-        /// Handles a pause action requested by a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The pause action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
-        {
-            if (!_group.IsPaused)
-            {
-                // Pause group and compute the media playback position
-                _group.IsPaused = true;
-                var currentTime = DateTime.UtcNow;
-                var elapsedTime = currentTime - _group.LastActivity;
-                _group.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;
-
-                var command = NewSyncPlayCommand(SendCommandType.Pause);
-                SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
-            }
-            else
-            {
-                // Client got lost, sending current state
-                var command = NewSyncPlayCommand(SendCommandType.Pause);
-                SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
-            }
-        }
-
-        /// <summary>
-        /// Handles a seek action requested by a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The seek action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
-        {
-            // Sanitize PositionTicks
-            var ticks = SanitizePositionTicks(request.PositionTicks);
-
-            // Pause and seek
-            _group.IsPaused = true;
-            _group.PositionTicks = ticks;
-            _group.LastActivity = DateTime.UtcNow;
-
-            var command = NewSyncPlayCommand(SendCommandType.Seek);
-            SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
-        }
-
-        /// <summary>
-        /// Handles a buffering action requested by a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The buffering action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
-        {
-            if (!_group.IsPaused)
-            {
-                // Pause group and compute the media playback position
-                _group.IsPaused = true;
-                var currentTime = DateTime.UtcNow;
-                var elapsedTime = currentTime - _group.LastActivity;
-                _group.LastActivity = currentTime;
-                _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
-
-                _group.SetBuffering(session, true);
-
-                // Send pause command to all non-buffering sessions
-                var command = NewSyncPlayCommand(SendCommandType.Pause);
-                SendCommand(session, BroadcastType.AllReady, command, cancellationToken);
-
-                var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
-                SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-            }
-            else
-            {
-                // Client got lost, sending current state
-                var command = NewSyncPlayCommand(SendCommandType.Pause);
-                SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
-            }
-        }
-
-        /// <summary>
-        /// Handles a buffering-done action requested by a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The buffering-done action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
-        {
-            if (_group.IsPaused)
-            {
-                _group.SetBuffering(session, false);
-
-                var requestTicks = SanitizePositionTicks(request.PositionTicks);
-
-                var when = request.When ?? DateTime.UtcNow;
-                var currentTime = DateTime.UtcNow;
-                var elapsedTime = currentTime - 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 = NewSyncPlayCommand(SendCommandType.Pause);
-                    var pauseAtTime = currentTime.AddMilliseconds(delay);
-                    command.When = DateToUTCString(pauseAtTime);
-                    SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
-                }
-                else
-                {
-                    // Let other clients resume as soon as the buffering client catches up
-                    _group.IsPaused = false;
-
-                    if (delay > _group.GetHighestPing() * 2)
-                    {
-                        // Client that was buffering is recovering, notifying others to resume
-                        _group.LastActivity = currentTime.AddMilliseconds(
-                            delay);
-                        var command = NewSyncPlayCommand(SendCommandType.Play);
-                        SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken);
-                    }
-                    else
-                    {
-                        // Client, that was buffering, resumed playback but did not update others in time
-                        delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing);
-
-                        _group.LastActivity = currentTime.AddMilliseconds(
-                            delay);
-
-                        var command = NewSyncPlayCommand(SendCommandType.Play);
-                        SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
-                    }
-                }
-            }
-            else
-            {
-                // Group was not waiting, make sure client has latest state
-                var command = NewSyncPlayCommand(SendCommandType.Play);
-                SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
-            }
-        }
-
-        /// <summary>
-        /// Sanitizes the PositionTicks, considers the current playing item when available.
-        /// </summary>
-        /// <param name="positionTicks">The PositionTicks.</param>
-        /// <value>The sanitized PositionTicks.</value>
-        private 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;
-        }
-
-        /// <summary>
-        /// Updates ping of a session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The update.</param>
-        private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request)
-        {
-            // Collected pings are used to account for network latency when unpausing playback
-            _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing);
-        }
-
-        /// <inheritdoc />
-        public GroupInfoView GetInfo()
-        {
-            return new GroupInfoView()
-            {
-                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()
-            };
-        }
-    }
-}

+ 179 - 210
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -1,13 +1,11 @@
 using System;
+using System.Collections.Concurrent;
 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;
 using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.Requests;
 using MediaBrowser.Model.SyncPlay;
 using Microsoft.Extensions.Logging;
 
@@ -23,6 +21,11 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         private readonly ILogger<SyncPlayManager> _logger;
 
+        /// <summary>
+        /// The logger factory.
+        /// </summary>
+        private readonly ILoggerFactory _loggerFactory;
+
         /// <summary>
         /// The user manager.
         /// </summary>
@@ -41,18 +44,21 @@ 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 ConcurrentDictionary<string, Group> _sessionToGroupMap =
+            new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase);
 
         /// <summary>
         /// The groups.
         /// </summary>
-        private readonly Dictionary<Guid, ISyncPlayController> _groups =
-            new Dictionary<Guid, ISyncPlayController>();
+        private readonly ConcurrentDictionary<Guid, Group> _groups =
+            new ConcurrentDictionary<Guid, Group>();
 
         /// <summary>
-        /// Lock used for accessing any group.
+        /// Lock used for accessing multiple groups at once.
         /// </summary>
+        /// <remarks>
+        /// This lock has priority on locks made on <see cref="Group"/>.
+        /// </remarks>
         private readonly object _groupsLock = new object();
 
         private bool _disposed = false;
@@ -60,31 +66,24 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayManager" /> class.
         /// </summary>
-        /// <param name="logger">The logger.</param>
+        /// <param name="loggerFactory">The logger factory.</param>
         /// <param name="userManager">The user manager.</param>
         /// <param name="sessionManager">The session manager.</param>
         /// <param name="libraryManager">The library manager.</param>
         public SyncPlayManager(
-            ILogger<SyncPlayManager> logger,
+            ILoggerFactory loggerFactory,
             IUserManager userManager,
             ISessionManager sessionManager,
             ILibraryManager libraryManager)
         {
-            _logger = logger;
+            _loggerFactory = loggerFactory;
             _userManager = userManager;
             _sessionManager = sessionManager;
             _libraryManager = libraryManager;
-
-            _sessionManager.SessionEnded += OnSessionManagerSessionEnded;
-            _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped;
+            _logger = loggerFactory.CreateLogger<SyncPlayManager>();
+            _sessionManager.SessionStarted += OnSessionManagerSessionStarted;
         }
 
-        /// <summary>
-        /// Gets all groups.
-        /// </summary>
-        /// <value>All groups.</value>
-        public IEnumerable<ISyncPlayController> Groups => _groups.Values;
-
         /// <inheritdoc />
         public void Dispose()
         {
@@ -92,286 +91,256 @@ namespace Emby.Server.Implementations.SyncPlay
             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)
+        /// <inheritdoc />
+        public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
         {
-            if (_disposed)
+            if (session == null)
             {
-                return;
+                throw new InvalidOperationException("Session is null!");
             }
 
-            _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
-            _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
-
-            _disposed = true;
-        }
-
-        private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
-        {
-            var session = e.SessionInfo;
-            if (!IsSessionInGroup(session))
+            if (request == null)
             {
-                return;
+                throw new InvalidOperationException("Request is null!");
             }
 
-            LeaveGroup(session, CancellationToken.None);
-        }
-
-        private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
-        {
-            var session = e.Session;
-            if (!IsSessionInGroup(session))
+            // Locking required to access list of groups.
+            lock (_groupsLock)
             {
-                return;
-            }
-
-            LeaveGroup(session, CancellationToken.None);
-        }
-
-        private bool IsSessionInGroup(SessionInfo session)
-        {
-            return _sessionToGroupMap.ContainsKey(session.Id);
-        }
-
-        private bool HasAccessToItem(User user, Guid itemId)
-        {
-            var item = _libraryManager.GetItemById(itemId);
+                // Make sure that session has not joined another group.
+                if (_sessionToGroupMap.ContainsKey(session.Id))
+                {
+                    var leaveGroupRequest = new LeaveGroupRequest();
+                    LeaveGroup(session, leaveGroupRequest, cancellationToken);
+                }
 
-            // Check ParentalRating access
-            var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
-                || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
+                var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+                _groups[group.GroupId] = group;
 
-            if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
-            {
-                var collections = _libraryManager.GetCollectionFolders(item).Select(
-                    folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+                if (!_sessionToGroupMap.TryAdd(session.Id, group))
+                {
+                    throw new InvalidOperationException("Could not add session to group!");
+                }
 
-                return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
+                group.CreateGroup(session, request, cancellationToken);
             }
-
-            return hasParentalRatingAccess;
-        }
-
-        private Guid? GetSessionGroup(SessionInfo session)
-        {
-            _sessionToGroupMap.TryGetValue(session.Id, out var group);
-            return group?.GetGroupId();
         }
 
         /// <inheritdoc />
-        public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
+        public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
         {
-            var user = _userManager.GetUserById(session.UserId);
-
-            if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+            if (session == null)
             {
-                _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
-
-                var error = new GroupUpdate<string>
-                {
-                    Type = GroupUpdateType.CreateGroupDenied
-                };
-
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                return;
+                throw new InvalidOperationException("Session is null!");
             }
 
-            lock (_groupsLock)
+            if (request == null)
             {
-                if (IsSessionInGroup(session))
-                {
-                    LeaveGroup(session, cancellationToken);
-                }
-
-                var group = new SyncPlayController(_sessionManager, this);
-                _groups[group.GetGroupId()] = group;
-
-                group.CreateGroup(session, cancellationToken);
+                throw new InvalidOperationException("Request is null!");
             }
-        }
 
-        /// <inheritdoc />
-        public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
-        {
             var user = _userManager.GetUserById(session.UserId);
 
-            if (user.SyncPlayAccess == SyncPlayAccess.None)
-            {
-                _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
-
-                var error = new GroupUpdate<string>()
-                {
-                    Type = GroupUpdateType.JoinGroupDenied
-                };
-
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                return;
-            }
-
+            // Locking required to access list of groups.
             lock (_groupsLock)
             {
-                ISyncPlayController group;
-                _groups.TryGetValue(groupId, out group);
+                _groups.TryGetValue(request.GroupId, out Group group);
 
                 if (group == null)
                 {
-                    _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
+                    _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId);
 
-                    var error = new GroupUpdate<string>()
-                    {
-                        Type = GroupUpdateType.GroupDoesNotExist
-                    };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
+                    var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty);
+                    _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
                     return;
                 }
 
-                if (!HasAccessToItem(user, group.GetPlayingItemId()))
+                // Group lock required to let other requests end first.
+                lock (group)
                 {
-                    _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
+                    if (!group.HasAccessToPlayQueue(user))
+                    {
+                        _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString());
 
-                    var error = new GroupUpdate<string>()
+                        var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty);
+                        _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+                        return;
+                    }
+
+                    if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup))
                     {
-                        GroupId = group.GetGroupId().ToString(),
-                        Type = GroupUpdateType.LibraryAccessDenied
-                    };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                    return;
-                }
+                        if (existingGroup.GroupId.Equals(request.GroupId))
+                        {
+                            // Restore session.
+                            group.SessionJoin(session, request, cancellationToken);
+                            return;
+                        }
+
+                        var leaveGroupRequest = new LeaveGroupRequest();
+                        LeaveGroup(session, leaveGroupRequest, cancellationToken);
+                    }
 
-                if (IsSessionInGroup(session))
-                {
-                    if (GetSessionGroup(session).Equals(groupId))
+                    if (!_sessionToGroupMap.TryAdd(session.Id, group))
                     {
-                        return;
+                        throw new InvalidOperationException("Could not add session to group!");
                     }
 
-                    LeaveGroup(session, cancellationToken);
+                    group.SessionJoin(session, request, cancellationToken);
                 }
-
-                group.SessionJoin(session, request, cancellationToken);
             }
         }
 
         /// <inheritdoc />
-        public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
+        public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
         {
-            // TODO: determine what happens to users that are in a group and get their permissions revoked
-            lock (_groupsLock)
+            if (session == null)
             {
-                _sessionToGroupMap.TryGetValue(session.Id, out var group);
+                throw new InvalidOperationException("Session is null!");
+            }
 
-                if (group == null)
-                {
-                    _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
+            if (request == null)
+            {
+                throw new InvalidOperationException("Request is null!");
+            }
 
-                    var error = new GroupUpdate<string>()
+            // Locking required to access list of groups.
+            lock (_groupsLock)
+            {
+                if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+                {
+                    // Group lock required to let other requests end first.
+                    lock (group)
                     {
-                        Type = GroupUpdateType.NotInGroup
-                    };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                    return;
+                        if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup))
+                        {
+                            if (!tempGroup.GroupId.Equals(group.GroupId))
+                            {
+                                throw new InvalidOperationException("Session was in wrong group!");
+                            }
+                        }
+                        else
+                        {
+                            throw new InvalidOperationException("Could not remove session from group!");
+                        }
+
+                        group.SessionLeave(session, request, cancellationToken);
+
+                        if (group.IsGroupEmpty())
+                        {
+                            _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
+                            _groups.Remove(group.GroupId, out _);
+                        }
+                    }
                 }
-
-                group.SessionLeave(session, cancellationToken);
-
-                if (group.IsGroupEmpty())
+                else
                 {
-                    _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
-                    _groups.Remove(group.GetGroupId(), out _);
+                    _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
+
+                    var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+                    _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+                    return;
                 }
             }
         }
 
         /// <inheritdoc />
-        public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
+        public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request)
         {
-            var user = _userManager.GetUserById(session.UserId);
-
-            if (user.SyncPlayAccess == SyncPlayAccess.None)
+            if (session == null)
             {
-                return new List<GroupInfoView>();
+                throw new InvalidOperationException("Session is null!");
             }
 
-            // Filter by item if requested
-            if (!filterItemId.Equals(Guid.Empty))
+            if (request == null)
             {
-                return _groups.Values.Where(
-                    group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select(
-                    group => group.GetInfo()).ToList();
+                throw new InvalidOperationException("Request is null!");
             }
-            else
+
+            var user = _userManager.GetUserById(session.UserId);
+            List<GroupInfoDto> list = new List<GroupInfoDto>();
+
+            foreach (var group in _groups.Values)
             {
-                // Otherwise show all available groups
-                return _groups.Values.Where(
-                    group => HasAccessToItem(user, group.GetPlayingItemId())).Select(
-                    group => group.GetInfo()).ToList();
+                // Locking required as group is not thread-safe.
+                lock (group)
+                {
+                    if (group.HasAccessToPlayQueue(user))
+                    {
+                        list.Add(group.GetInfo());
+                    }
+                }
             }
+
+            return list;
         }
 
         /// <inheritdoc />
-        public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+        public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
         {
-            var user = _userManager.GetUserById(session.UserId);
-
-            if (user.SyncPlayAccess == SyncPlayAccess.None)
+            if (session == null)
             {
-                _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
-
-                var error = new GroupUpdate<string>()
-                {
-                    Type = GroupUpdateType.JoinGroupDenied
-                };
-
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                return;
+                throw new InvalidOperationException("Session is null!");
             }
 
-            lock (_groupsLock)
+            if (request == null)
             {
-                _sessionToGroupMap.TryGetValue(session.Id, out var group);
+                throw new InvalidOperationException("Request is null!");
+            }
 
-                if (group == null)
+            if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+            {
+                // Group lock required as Group is not thread-safe.
+                lock (group)
                 {
-                    _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
+                    // Make sure that session still belongs to this group.
+                    if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
+                    {
+                        // Drop request.
+                        return;
+                    }
 
-                    var error = new GroupUpdate<string>()
+                    // Drop request if group is empty.
+                    if (group.IsGroupEmpty())
                     {
-                        Type = GroupUpdateType.NotInGroup
-                    };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
-                    return;
+                        return;
+                    }
+
+                    // Apply requested changes to group.
+                    group.HandleRequest(session, request, cancellationToken);
                 }
+            }
+            else
+            {
+                _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
 
-                group.HandleRequest(session, request, cancellationToken);
+                var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+                _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
             }
         }
 
-        /// <inheritdoc />
-        public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
+        /// <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 (IsSessionInGroup(session))
+            if (_disposed)
             {
-                throw new InvalidOperationException("Session in other group already!");
+                return;
             }
 
-            _sessionToGroupMap[session.Id] = group;
+            _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+            _disposed = true;
         }
 
-        /// <inheritdoc />
-        public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
+        private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
         {
-            if (!IsSessionInGroup(session))
-            {
-                throw new InvalidOperationException("Session not in any group!");
-            }
+            var session = e.SessionInfo;
 
-            _sessionToGroupMap.Remove(session.Id, out var tempGroup);
-            if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
+            if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
             {
-                throw new InvalidOperationException("Session was in wrong group!");
+                var request = new JoinGroupRequest(group.GroupId);
+                JoinGroup(session, request, CancellationToken.None);
             }
         }
     }

+ 58 - 0
Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs

@@ -0,0 +1,58 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+    /// <summary>
+    /// Default authorization handler.
+    /// </summary>
+    public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
+    {
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public SyncPlayAccessHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+            _userManager = userManager;
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
+        {
+            if (!ValidateClaims(context.User))
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            var userId = ClaimHelpers.GetUserId(context.User);
+            var user = _userManager.GetUserById(userId!.Value);
+
+            if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess)
+                || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups)
+            {
+                context.Succeed(requirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 33 - 0
Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs

@@ -0,0 +1,33 @@
+using Jellyfin.Data.Enums;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+    /// <summary>
+    /// The default authorization requirement.
+    /// </summary>
+    public class SyncPlayAccessRequirement : IAuthorizationRequirement
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+        /// </summary>
+        /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param>
+        public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess)
+        {
+            RequiredAccess = requiredAccess;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+        /// </summary>
+        public SyncPlayAccessRequirement()
+        {
+            RequiredAccess = null;
+        }
+
+        /// <summary>
+        /// Gets the required SyncPlay access.
+        /// </summary>
+        public SyncPlayAccess? RequiredAccess { get; }
+    }
+}

+ 10 - 0
Jellyfin.Api/Constants/Policies.cs

@@ -49,5 +49,15 @@ namespace Jellyfin.Api.Constants
         /// Policy name for escaping schedule controls or requiring first time setup.
         /// </summary>
         public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
+
+        /// <summary>
+        /// Policy name for requiring access to SyncPlay.
+        /// </summary>
+        public const string SyncPlayAccess = "SyncPlayAccess";
+
+        /// <summary>
+        /// Policy name for requiring group creation access to SyncPlay.
+        /// </summary>
+        public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess";
     }
 }

+ 257 - 56
Jellyfin.Api/Controllers/SyncPlayController.cs

@@ -4,9 +4,12 @@ using System.ComponentModel.DataAnnotations;
 using System.Threading;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.SyncPlayDtos;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
 using MediaBrowser.Model.SyncPlay;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -17,7 +20,7 @@ namespace Jellyfin.Api.Controllers
     /// <summary>
     /// The sync play controller.
     /// </summary>
-    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Authorize(Policy = Policies.SyncPlayAccess)]
     public class SyncPlayController : BaseJellyfinApiController
     {
         private readonly ISessionManager _sessionManager;
@@ -43,35 +46,36 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Create a new SyncPlay group.
         /// </summary>
+        /// <param name="requestData">The settings 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()
+        [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)]
+        public ActionResult SyncPlayCreateGroup(
+            [FromBody, Required] NewGroupRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
+            var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
+            _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
 
         /// <summary>
         /// Join an existing SyncPlay group.
         /// </summary>
-        /// <param name="groupId">The sync play group id.</param>
+        /// <param name="requestData">The group to join.</param>
         /// <response code="204">Group join successful.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Join")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId)
+        [Authorize(Policy = Policies.SyncPlayAccess)]
+        public ActionResult SyncPlayJoinGroup(
+            [FromBody, Required] JoinGroupRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-
-            var joinRequest = new JoinGroupRequest()
-            {
-                GroupId = groupId
-            };
-
-            _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None);
+            var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
+            _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
 
@@ -85,38 +89,125 @@ namespace Jellyfin.Api.Controllers
         public ActionResult SyncPlayLeaveGroup()
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
+            var syncPlayRequest = new LeaveGroupRequest();
+            _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
 
         /// <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)
+        [Authorize(Policy = Policies.SyncPlayAccess)]
+        public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups()
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new ListGroupsRequest();
+            return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest));
+        }
+
+        /// <summary>
+        /// Request to set new playlist in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The new playlist to play in the group.</param>
+        /// <response code="204">Queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("SetNewQueue")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlaySetNewQueue(
+            [FromBody, Required] PlayRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty));
+            var syncPlayRequest = new PlayGroupRequest(
+                requestData.PlayingQueue,
+                requestData.PlayingItemPosition,
+                requestData.StartPositionTicks);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
         }
 
         /// <summary>
-        /// Request play in SyncPlay group.
+        /// Request to change playlist item in SyncPlay group.
         /// </summary>
-        /// <response code="204">Play request sent to all group members.</response>
+        /// <param name="requestData">The new item to play.</param>
+        /// <response code="204">Queue update sent to all group members.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-        [HttpPost("Play")]
+        [HttpPost("SetPlaylistItem")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SyncPlayPlay()
+        public ActionResult SyncPlaySetPlaylistItem(
+            [FromBody, Required] SetPlaylistItemRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = PlaybackRequestType.Play
-            };
+            var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to remove items from the playlist in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The items to remove.</param>
+        /// <response code="204">Queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("RemoveFromPlaylist")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayRemoveFromPlaylist(
+            [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to move an item in the playlist in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The new position for the item.</param>
+        /// <response code="204">Queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("MovePlaylistItem")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayMovePlaylistItem(
+            [FromBody, Required] MovePlaylistItemRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to queue items to the playlist of a SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The items to add.</param>
+        /// <response code="204">Queue update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Queue")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayQueue(
+            [FromBody, Required] QueueRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request unpause in SyncPlay group.
+        /// </summary>
+        /// <response code="204">Unpause update 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();
         }
@@ -124,17 +215,29 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Request pause in SyncPlay group.
         /// </summary>
-        /// <response code="204">Pause request sent to all group members.</response>
+        /// <response code="204">Pause update sent to all group members.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Pause")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         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 update 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();
         }
@@ -142,42 +245,143 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Request seek in SyncPlay group.
         /// </summary>
-        /// <param name="positionTicks">The playback position in ticks.</param>
-        /// <response code="204">Seek request sent to all group members.</response>
+        /// <param name="requestData">The new playback position.</param>
+        /// <response code="204">Seek update sent to all group members.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Seek")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SyncPlaySeek([FromQuery] long positionTicks)
+        public ActionResult SyncPlaySeek(
+            [FromBody, Required] SeekRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = PlaybackRequestType.Seek,
-                PositionTicks = positionTicks
-            };
+            var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
 
         /// <summary>
-        /// Request group wait in SyncPlay group while buffering.
+        /// Notify SyncPlay group that member is buffering.
         /// </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="bufferingDone">Whether the buffering is done.</param>
-        /// <response code="204">Buffering request sent to all group members.</response>
+        /// <param name="requestData">The player status.</param>
+        /// <response code="204">Group state update 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(
+            [FromBody, Required] BufferRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new BufferGroupRequest(
+                requestData.When,
+                requestData.PositionTicks,
+                requestData.IsPlaying,
+                requestData.PlaylistItemId);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Notify SyncPlay group that member is ready for playback.
+        /// </summary>
+        /// <param name="requestData">The player status.</param>
+        /// <response code="204">Group state update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("Ready")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayReady(
+            [FromBody, Required] ReadyRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new ReadyGroupRequest(
+                requestData.When,
+                requestData.PositionTicks,
+                requestData.IsPlaying,
+                requestData.PlaylistItemId);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request SyncPlay group to ignore member during group-wait.
+        /// </summary>
+        /// <param name="requestData">The settings to set.</param>
+        /// <response code="204">Member state updated.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("SetIgnoreWait")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlaySetIgnoreWait(
+            [FromBody, Required] IgnoreWaitRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request next item in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The current item information.</param>
+        /// <response code="204">Next item update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("NextItem")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayNextItem(
+            [FromBody, Required] NextItemRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request previous item in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The current item information.</param>
+        /// <response code="204">Previous item update sent to all group members.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("PreviousItem")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SyncPlayPreviousItem(
+            [FromBody, Required] PreviousItemRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to set repeat mode in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The new 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(
+            [FromBody, Required] SetRepeatModeRequestDto requestData)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+            var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Request to set shuffle mode in SyncPlay group.
+        /// </summary>
+        /// <param name="requestData">The new 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(
+            [FromBody, Required] SetShuffleModeRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer,
-                When = when,
-                PositionTicks = positionTicks
-            };
+            var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }
@@ -185,19 +389,16 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Update session ping.
         /// </summary>
-        /// <param name="ping">The ping.</param>
+        /// <param name="requestData">The new ping.</param>
         /// <response code="204">Ping updated.</response>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Ping")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult SyncPlayPing([FromQuery] double ping)
+        public ActionResult SyncPlayPing(
+            [FromBody, Required] PingRequestDto requestData)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = PlaybackRequestType.Ping,
-                Ping = Convert.ToInt64(ping)
-            };
+            var syncPlayRequest = new PingGroupRequest(requestData.Ping);
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
             return NoContent();
         }

+ 5 - 9
Jellyfin.Api/Controllers/TimeSyncController.cs

@@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers
     public class TimeSyncController : BaseJellyfinApiController
     {
         /// <summary>
-        /// Gets the current utc time.
+        /// Gets the current UTC time.
         /// </summary>
         /// <response code="200">Time returned.</response>
         /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
@@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<UtcTimeResponse> GetUtcTime()
         {
             // Important to keep the following line at the beginning
-            var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
+            var requestReceptionTime = DateTime.UtcNow.ToUniversalTime();
 
-            var response = new UtcTimeResponse();
-            response.RequestReceptionTime = requestReceptionTime;
-
-            // Important to keep the following two lines at the end
-            var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
-            response.ResponseTransmissionTime = responseTransmissionTime;
+            // Important to keep the following line at the end
+            var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime();
 
             // Implementing NTP on such a high level results in this useless
             // information being sent. On the other hand it enables future additions.
-            return response;
+            return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
         }
     }
 }

+ 42 - 0
Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs

@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class BufferRequestDto.
+    /// </summary>
+    public class BufferRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BufferRequestDto"/> class.
+        /// </summary>
+        public BufferRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets when the request has been made by the client.
+        /// </summary>
+        /// <value>The date of the request.</value>
+        public DateTime When { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the client playback is unpaused.
+        /// </summary>
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist item identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist item identifier.</value>
+        public Guid PlaylistItemId { get; set; }
+    }
+}

+ 14 - 0
Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs

@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class IgnoreWaitRequestDto.
+    /// </summary>
+    public class IgnoreWaitRequestDto
+    {
+        /// <summary>
+        /// Gets or sets a value indicating whether the client should be ignored.
+        /// </summary>
+        /// <value>The client group-wait status.</value>
+        public bool IgnoreWait { get; set; }
+    }
+}

+ 16 - 0
Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs

@@ -0,0 +1,16 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class JoinGroupRequestDto.
+    /// </summary>
+    public class JoinGroupRequestDto
+    {
+        /// <summary>
+        /// Gets or sets the group identifier.
+        /// </summary>
+        /// <value>The identifier of the group to join.</value>
+        public Guid GroupId { get; set; }
+    }
+}

+ 30 - 0
Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs

@@ -0,0 +1,30 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class MovePlaylistItemRequestDto.
+    /// </summary>
+    public class MovePlaylistItemRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class.
+        /// </summary>
+        public MovePlaylistItemRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the playlist identifier of the item.
+        /// </summary>
+        /// <value>The playlist identifier of the item.</value>
+        public Guid PlaylistItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the new position.
+        /// </summary>
+        /// <value>The new position.</value>
+        public int NewIndex { get; set; }
+    }
+}

+ 22 - 0
Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs

@@ -0,0 +1,22 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class NewGroupRequestDto.
+    /// </summary>
+    public class NewGroupRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class.
+        /// </summary>
+        public NewGroupRequestDto()
+        {
+            GroupName = string.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the group name.
+        /// </summary>
+        /// <value>The name of the new group.</value>
+        public string GroupName { get; set; }
+    }
+}

+ 24 - 0
Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs

@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class NextItemRequestDto.
+    /// </summary>
+    public class NextItemRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class.
+        /// </summary>
+        public NextItemRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the playing item identifier.
+        /// </summary>
+        /// <value>The playing item identifier.</value>
+        public Guid PlaylistItemId { get; set; }
+    }
+}

+ 14 - 0
Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs

@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class PingRequestDto.
+    /// </summary>
+    public class PingRequestDto
+    {
+        /// <summary>
+        /// Gets or sets the ping time.
+        /// </summary>
+        /// <value>The ping time.</value>
+        public long Ping { get; set; }
+    }
+}

+ 37 - 0
Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs

@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class PlayRequestDto.
+    /// </summary>
+    public class PlayRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayRequestDto"/> class.
+        /// </summary>
+        public PlayRequestDto()
+        {
+            PlayingQueue = Array.Empty<Guid>();
+        }
+
+        /// <summary>
+        /// Gets or sets the playing queue.
+        /// </summary>
+        /// <value>The playing queue.</value>
+        public IReadOnlyList<Guid> PlayingQueue { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position of the playing item in the queue.
+        /// </summary>
+        /// <value>The playing item position.</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; }
+    }
+}

+ 24 - 0
Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs

@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class PreviousItemRequestDto.
+    /// </summary>
+    public class PreviousItemRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class.
+        /// </summary>
+        public PreviousItemRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the playing item identifier.
+        /// </summary>
+        /// <value>The playing item identifier.</value>
+        public Guid PlaylistItemId { get; set; }
+    }
+}

+ 32 - 0
Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs

@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class QueueRequestDto.
+    /// </summary>
+    public class QueueRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="QueueRequestDto"/> class.
+        /// </summary>
+        public QueueRequestDto()
+        {
+            ItemIds = Array.Empty<Guid>();
+        }
+
+        /// <summary>
+        /// Gets or sets the items to enqueue.
+        /// </summary>
+        /// <value>The items to enqueue.</value>
+        public IReadOnlyList<Guid> ItemIds { get; set; }
+
+        /// <summary>
+        /// Gets or sets the mode in which to add the new items.
+        /// </summary>
+        /// <value>The enqueue mode.</value>
+        public GroupQueueMode Mode { get; set; }
+    }
+}

+ 42 - 0
Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs

@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class ReadyRequest.
+    /// </summary>
+    public class ReadyRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class.
+        /// </summary>
+        public ReadyRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets when the request has been made by the client.
+        /// </summary>
+        /// <value>The date of the request.</value>
+        public DateTime When { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the client playback is unpaused.
+        /// </summary>
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playlist item identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist item identifier.</value>
+        public Guid PlaylistItemId { get; set; }
+    }
+}

+ 25 - 0
Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs

@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class RemoveFromPlaylistRequestDto.
+    /// </summary>
+    public class RemoveFromPlaylistRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class.
+        /// </summary>
+        public RemoveFromPlaylistRequestDto()
+        {
+            PlaylistItemIds = Array.Empty<Guid>();
+        }
+
+        /// <summary>
+        /// Gets or sets the playlist identifiers ot the items.
+        /// </summary>
+        /// <value>The playlist identifiers ot the items.</value>
+        public IReadOnlyList<Guid> PlaylistItemIds { get; set; }
+    }
+}

+ 14 - 0
Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs

@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class SeekRequestDto.
+    /// </summary>
+    public class SeekRequestDto
+    {
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+    }
+}

+ 24 - 0
Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs

@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class SetPlaylistItemRequestDto.
+    /// </summary>
+    public class SetPlaylistItemRequestDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class.
+        /// </summary>
+        public SetPlaylistItemRequestDto()
+        {
+            PlaylistItemId = Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets or sets the playlist identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist identifier of the playing item.</value>
+        public Guid PlaylistItemId { get; set; }
+    }
+}

+ 16 - 0
Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs

@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class SetRepeatModeRequestDto.
+    /// </summary>
+    public class SetRepeatModeRequestDto
+    {
+        /// <summary>
+        /// Gets or sets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode Mode { get; set; }
+    }
+}

+ 16 - 0
Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs

@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+    /// <summary>
+    /// Class SetShuffleModeRequestDto.
+    /// </summary>
+    public class SetShuffleModeRequestDto
+    {
+        /// <summary>
+        /// Gets or sets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode Mode { get; set; }
+    }
+}

+ 1 - 1
Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs

@@ -58,7 +58,7 @@ namespace Jellyfin.Api.WebSocketListeners
 
         private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
         {
-            SendData(true);
+            SendData(true).GetAwaiter().GetResult();
         }
     }
 }

+ 5 - 7
Jellyfin.Server/CoreAppHost.cs

@@ -82,13 +82,11 @@ namespace Jellyfin.Server
             ServiceCollection.AddSingleton<IUserManager, UserManager>();
             ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
 
-            ServiceCollection.AddScoped<IWebSocketListener, SessionWebSocketListener>();
-            ServiceCollection.AddScoped<IWebSocketListener, ActivityLogWebSocketListener>();
-            ServiceCollection.AddScoped<IWebSocketListener, ScheduledTasksWebSocketListener>();
-            ServiceCollection.AddScoped<IWebSocketListener, SessionInfoWebSocketListener>();
-
-            // TODO fix circular dependency on IWebSocketManager
-            ServiceCollection.AddScoped(serviceProvider => new Lazy<IEnumerable<IWebSocketListener>>(serviceProvider.GetRequiredService<IEnumerable<IWebSocketListener>>));
+            // TODO search the assemblies instead of adding them manually?
+            ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
+            ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>();
+            ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>();
+            ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>();
 
             base.RegisterServices();
         }

+ 17 - 0
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -15,9 +15,11 @@ using Jellyfin.Api.Auth.IgnoreParentalControlPolicy;
 using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy;
 using Jellyfin.Api.Auth.LocalAccessPolicy;
 using Jellyfin.Api.Auth.RequiresElevationPolicy;
+using Jellyfin.Api.Auth.SyncPlayAccessPolicy;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
 using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Enums;
 using Jellyfin.Server.Configuration;
 using Jellyfin.Server.Filters;
 using Jellyfin.Server.Formatters;
@@ -58,6 +60,7 @@ namespace Jellyfin.Server.Extensions
             serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>();
             serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>();
             serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>();
+            serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>();
             return serviceCollection.AddAuthorizationCore(options =>
             {
                 options.AddPolicy(
@@ -123,6 +126,20 @@ namespace Jellyfin.Server.Extensions
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
                         policy.AddRequirements(new RequiresElevationRequirement());
                     });
+                options.AddPolicy(
+                    Policies.SyncPlayAccess,
+                    policy =>
+                    {
+                        policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups));
+                    });
+                options.AddPolicy(
+                    Policies.SyncPlayCreateGroupAccess,
+                    policy =>
+                    {
+                        policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+                        policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups));
+                    });
             });
         }
 

+ 3 - 0
MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs

@@ -92,6 +92,9 @@ namespace MediaBrowser.Controller.Net
             return Task.CompletedTask;
         }
 
+        /// <inheritdoc />
+        public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask;
+
         /// <summary>
         /// Starts sending messages over a web socket.
         /// </summary>

+ 8 - 1
MediaBrowser.Controller/Net/IWebSocketListener.cs

@@ -3,7 +3,7 @@ using System.Threading.Tasks;
 namespace MediaBrowser.Controller.Net
 {
     /// <summary>
-    ///This is an interface for listening to messages coming through a web socket connection.
+    /// Interface for listening to messages coming through a web socket connection.
     /// </summary>
     public interface IWebSocketListener
     {
@@ -13,5 +13,12 @@ namespace MediaBrowser.Controller.Net
         /// <param name="message">The message.</param>
         /// <returns>Task.</returns>
         Task ProcessMessageAsync(WebSocketMessageInfo message);
+
+        /// <summary>
+        /// Processes a new web socket connection.
+        /// </summary>
+        /// <param name="connection">An instance of the <see cref="IWebSocketConnection"/> interface.</param>
+        /// <returns>Task.</returns>
+        Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection);
     }
 }

+ 0 - 8
MediaBrowser.Controller/Net/IWebSocketManager.cs

@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
 using System.Threading.Tasks;
-using Jellyfin.Data.Events;
 using Microsoft.AspNetCore.Http;
 
 namespace MediaBrowser.Controller.Net
@@ -11,11 +8,6 @@ namespace MediaBrowser.Controller.Net
     /// </summary>
     public interface IWebSocketManager
     {
-        /// <summary>
-        /// Occurs when [web socket connected].
-        /// </summary>
-        event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
         /// <summary>
         /// The HTTP request handler.
         /// </summary>

+ 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 - 160
MediaBrowser.Controller/SyncPlay/GroupInfo.cs

@@ -1,160 +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 playback is paused.
-        /// </summary>
-        /// <value>Playback is paused.</value>
-        public bool IsPaused { 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;
-        }
-    }
-}

+ 21 - 6
MediaBrowser.Controller/SyncPlay/GroupMember.cs

@@ -8,21 +8,36 @@ namespace MediaBrowser.Controller.SyncPlay
     public class GroupMember
     {
         /// <summary>
-        /// Gets or sets a value indicating whether this member is buffering.
+        /// Initializes a new instance of the <see cref="GroupMember"/> class.
         /// </summary>
-        /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
-        public bool IsBuffering { get; set; }
+        /// <param name="session">The session.</param>
+        public GroupMember(SessionInfo session)
+        {
+            Session = session;
+        }
 
         /// <summary>
-        /// Gets or sets the session.
+        /// Gets the session.
         /// </summary>
         /// <value>The session.</value>
-        public SessionInfo Session { get; set; }
+        public SessionInfo Session { get; }
 
         /// <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; }
     }
 }

+ 222 - 0
MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs

@@ -0,0 +1,222 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+    /// <summary>
+    /// Class AbstractGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public abstract class AbstractGroupState : IGroupState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<AbstractGroupState> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AbstractGroupState"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        protected AbstractGroupState(ILoggerFactory loggerFactory)
+        {
+            LoggerFactory = loggerFactory;
+            _logger = loggerFactory.CreateLogger<AbstractGroupState>();
+        }
+
+        /// <inheritdoc />
+        public abstract GroupStateType Type { get; }
+
+        /// <summary>
+        /// Gets the logger factory.
+        /// </summary>
+        protected ILoggerFactory LoggerFactory { get; }
+
+        /// <inheritdoc />
+        public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <inheritdoc />
+        public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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 && !context.PlayQueue.IsItemPlaying())
+            {
+                _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString());
+
+                IGroupState idleState = new IdleGroupState(LoggerFactory);
+                context.SetState(idleState);
+                var stopRequest = new StopGroupRequest();
+                idleState.HandleRequest(stopRequest, context, Type, session, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex);
+
+            if (!result)
+            {
+                _logger.LogError("Unable to move item in group {GroupId}.", 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(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            var result = context.AddToPlayQueue(request.ItemIds, request.Mode);
+
+            if (!result)
+            {
+                _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString());
+                return;
+            }
+
+            var reason = request.Mode switch
+            {
+                GroupQueueMode.QueueNext => 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(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            UnhandledRequest(request);
+        }
+
+        /// <inheritdoc />
+        public virtual void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            context.SetIgnoreGroupWait(session, request.IgnoreWait);
+        }
+
+        /// <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(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Notify relevant state change event.
+            var stateUpdate = new GroupStateUpdate(Type, reason.Action);
+            var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate);
+            context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+        }
+
+        private void UnhandledRequest(IGroupPlaybackRequest request)
+        {
+            _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Action, Type);
+        }
+    }
+}

+ 126 - 0
MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs

@@ -0,0 +1,126 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+    /// <summary>
+    /// Class IdleGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class IdleGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<IdleGroupState> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IdleGroupState"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public IdleGroupState(ILoggerFactory loggerFactory)
+            : base(loggerFactory)
+        {
+            _logger = LoggerFactory.CreateLogger<IdleGroupState>();
+        }
+
+        /// <inheritdoc />
+        public override GroupStateType Type { get; } = GroupStateType.Idle;
+
+        /// <inheritdoc />
+        public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing.
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            SendStopCommand(context, prevState, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            var command = context.NewSyncPlayCommand(SendCommandType.Stop);
+            if (!prevState.Equals(Type))
+            {
+                context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+            }
+            else
+            {
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+        }
+    }
+}

+ 165 - 0
MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs

@@ -0,0 +1,165 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+    /// <summary>
+    /// Class PausedGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class PausedGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<PausedGroupState> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PausedGroupState"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public PausedGroupState(ILoggerFactory loggerFactory)
+            : base(loggerFactory)
+        {
+            _logger = LoggerFactory.CreateLogger<PausedGroupState>();
+        }
+
+        /// <inheritdoc />
+        public override GroupStateType Type { get; } = GroupStateType.Paused;
+
+        /// <inheritdoc />
+        public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Wait for session to be ready.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.SessionJoined(context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing.
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var playingState = new PlayingGroupState(LoggerFactory);
+            context.SetState(playingState);
+            playingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (!prevState.Equals(Type))
+            {
+                // Pause group and compute the media playback position.
+                var currentTime = DateTime.UtcNow;
+                var elapsedTime = currentTime - context.LastActivity;
+                context.LastActivity = currentTime;
+                // Elapsed time is negative if event happens
+                // during the delay added to account for latency.
+                // In this phase clients haven't started the playback yet.
+                // In other words, LastActivity is in the future,
+                // when playback unpause is supposed to happen.
+                // Seek only if playback actually started.
+                context.PositionTicks += Math.Max(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
+            {
+                // Client got lost, sending current state.
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var idleState = new IdleGroupState(LoggerFactory);
+            context.SetState(idleState);
+            idleState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (prevState.Equals(Type))
+            {
+                // Client got lost, sending current state.
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+            else if (prevState.Equals(GroupStateType.Waiting))
+            {
+                // Sending current state to all clients.
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+                // Notify relevant state change event.
+                SendGroupStateUpdate(context, request, session, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+    }
+}

+ 168 - 0
MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs

@@ -0,0 +1,168 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+    /// <summary>
+    /// Class PlayingGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class PlayingGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<PlayingGroupState> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayingGroupState"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public PlayingGroupState(ILoggerFactory loggerFactory)
+            : base(loggerFactory)
+        {
+            _logger = LoggerFactory.CreateLogger<PlayingGroupState>();
+        }
+
+        /// <inheritdoc />
+        public override GroupStateType Type { get; } = GroupStateType.Playing;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether requests for buffering should be ignored.
+        /// </summary>
+        public bool IgnoreBuffering { get; set; }
+
+        /// <inheritdoc />
+        public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Wait for session to be ready.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.SessionJoined(context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Do nothing.
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (!prevState.Equals(Type))
+            {
+                // Pick a suitable time that accounts for latency.
+                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.
+                context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis);
+
+                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.Unpause);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var pausedState = new PausedGroupState(LoggerFactory);
+            context.SetState(pausedState);
+            pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var idleState = new IdleGroupState(LoggerFactory);
+            context.SetState(idleState);
+            idleState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (IgnoreBuffering)
+            {
+                return;
+            }
+
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (prevState.Equals(Type))
+            {
+                // 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(GroupStateType.Waiting))
+            {
+                // Notify relevant state change event.
+                SendGroupStateUpdate(context, request, session, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state.
+            var waitingState = new WaitingGroupState(LoggerFactory);
+            context.SetState(waitingState);
+            waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+    }
+}

+ 680 - 0
MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs

@@ -0,0 +1,680 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+    /// <summary>
+    /// Class WaitingGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class WaitingGroupState : AbstractGroupState
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger<WaitingGroupState> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="WaitingGroupState"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public WaitingGroupState(ILoggerFactory loggerFactory)
+            : base(loggerFactory)
+        {
+            _logger = LoggerFactory.CreateLogger<WaitingGroupState>();
+        }
+
+        /// <inheritdoc />
+        public override GroupStateType Type { get; } = GroupStateType.Waiting;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether playback should resume when group is ready.
+        /// </summary>
+        public bool ResumePlaying { get; set; } = false;
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the initial state has been set.
+        /// </summary>
+        private bool InitialStateSet { get; set; } = false;
+
+        /// <summary>
+        /// Gets or sets the group state before the first ever event.
+        /// </summary>
+        private GroupStateType InitialState { get; set; }
+
+        /// <inheritdoc />
+        public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Save state if first event.
+            if (!InitialStateSet)
+            {
+                InitialState = prevState;
+                InitialStateSet = true;
+            }
+
+            if (prevState.Equals(GroupStateType.Playing))
+            {
+                ResumePlaying = true;
+                // Pause group and compute the media playback position.
+                var currentTime = DateTime.UtcNow;
+                var elapsedTime = currentTime - context.LastActivity;
+                context.LastActivity = currentTime;
+                // Elapsed time is negative if event happens
+                // during the delay added to account for latency.
+                // In this phase clients haven't started the playback yet.
+                // In other words, LastActivity is in the future,
+                // when playback unpause is supposed to happen.
+                // Seek only if playback actually started.
+                context.PositionTicks += Math.Max(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(IGroupStateContext context, GroupStateType 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)
+                {
+                    _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString());
+
+                    // Client, that was buffering, left the group.
+                    var playingState = new PlayingGroupState(LoggerFactory);
+                    context.SetState(playingState);
+                    var unpauseRequest = new UnpauseGroupRequest();
+                    playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+                }
+                else
+                {
+                    _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString());
+
+                    // Group is ready, returning to previous state.
+                    var pausedState = new PausedGroupState(LoggerFactory);
+                    context.SetState(pausedState);
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString());
+
+                // Ignore request and return to previous state.
+                IGroupState newState = prevState switch {
+                    GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+                    GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+                    _ => new IdleGroupState(LoggerFactory)
+                };
+
+                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.
+            context.SetAllBuffering(true);
+
+            _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString());
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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.
+                context.SetAllBuffering(true);
+            }
+            else
+            {
+                // Return to old state.
+                IGroupState newState = prevState switch
+                {
+                    GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+                    GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+                    _ => new IdleGroupState(LoggerFactory)
+                };
+
+                context.SetState(newState);
+
+                _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString());
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Save state if first event.
+            if (!InitialStateSet)
+            {
+                InitialState = prevState;
+                InitialStateSet = true;
+            }
+
+            if (prevState.Equals(GroupStateType.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.
+                context.SetAllBuffering(true);
+
+                _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString());
+            }
+            else
+            {
+                if (ResumePlaying)
+                {
+                    _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", 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(LoggerFactory)
+                    {
+                        IgnoreBuffering = true
+                    };
+                    context.SetState(playingState);
+                    playingState.HandleRequest(request, context, Type, 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(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Save state if first event.
+            if (!InitialStateSet)
+            {
+                InitialState = prevState;
+                InitialStateSet = true;
+            }
+
+            // Change state.
+            var idleState = new IdleGroupState(LoggerFactory);
+            context.SetState(idleState);
+            idleState.HandleRequest(request, context, Type, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Save state if first event.
+            if (!InitialStateSet)
+            {
+                InitialState = prevState;
+                InitialStateSet = true;
+            }
+
+            if (prevState.Equals(GroupStateType.Playing))
+            {
+                ResumePlaying = true;
+            }
+            else if (prevState.Equals(GroupStateType.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.
+            context.SetAllBuffering(true);
+
+            // Notify relevant state change event.
+            SendGroupStateUpdate(context, request, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.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(GroupStateType.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;
+                // Elapsed time is negative if event happens
+                // during the delay added to account for latency.
+                // In this phase clients haven't started the playback yet.
+                // In other words, LastActivity is in the future,
+                // when playback unpause is supposed to happen.
+                // Seek only if playback actually started.
+                context.PositionTicks += Math.Max(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(GroupStateType.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(GroupStateType.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(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.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;
+            }
+
+            // Compute elapsed time between the client reported time and now.
+            // Elapsed time is used to estimate the client position when playback is unpaused.
+            // Ideally, the request is received and handled without major delays.
+            // However, to avoid waiting indefinitely when a client is not reporting a correct time,
+            // the elapsed time is ignored after a certain threshold.
+            var currentTime = DateTime.UtcNow;
+            var elapsedTime = currentTime.Subtract(request.When);
+            var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks;
+            if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks)
+            {
+                _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id);
+
+                elapsedTime = TimeSpan.Zero;
+            }
+
+            // Ignore elapsed time if client is paused.
+            if (!request.IsPlaying)
+            {
+                elapsedTime = TimeSpan.Zero;
+            }
+
+            var requestTicks = context.SanitizePositionTicks(request.PositionTicks);
+            var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
+            var delayTicks = context.PositionTicks - clientPosition.Ticks;
+            var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks;
+
+            _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.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(delayTicks) > maxPlaybackOffsetTicks)
+                {
+                    // 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.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id);
+                    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);
+                    command.When = currentTime.AddTicks(delayTicks);
+                    context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+                    _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.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.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+                    }
+                    else
+                    {
+                        // Client, that was buffering, resumed playback but did not update others in time.
+                        delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
+                        delayTicks = Math.Max(delayTicks, context.DefaultPing);
+
+                        context.LastActivity = currentTime.AddTicks(delayTicks);
+
+                        var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+                        context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+                        _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+                    }
+
+                    // Change state.
+                    var playingState = new PlayingGroupState(LoggerFactory);
+                    context.SetState(playingState);
+                    playingState.HandleRequest(request, context, Type, session, cancellationToken);
+                }
+            }
+            else
+            {
+                // Check that session is really ready, tolerate player imperfections under a certain threshold.
+                if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks)
+                {
+                    // 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.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id);
+                    return;
+                }
+                else
+                {
+                    // Session is ready.
+                    context.SetBuffering(session, false);
+                }
+
+                if (!context.IsBuffering())
+                {
+                    _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+                    // Group is ready, returning to previous state.
+                    var pausedState = new PausedGroupState(LoggerFactory);
+                    context.SetState(pausedState);
+
+                    if (InitialState.Equals(GroupStateType.Playing))
+                    {
+                        // Group went from playing to waiting state and a pause request occured while waiting.
+                        var pauseRequest = new PauseGroupRequest();
+                        pausedState.HandleRequest(pauseRequest, context, Type, session, cancellationToken);
+                    }
+                    else if (InitialState.Equals(GroupStateType.Paused))
+                    {
+                        pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+                    }
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+                return;
+            }
+
+            var newItem = context.NextItemInQueue();
+            if (newItem)
+            {
+                // Send playing-queue update.
+                var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem);
+                var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+                context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+                // Reset status of sessions and await for all Ready events.
+                context.SetAllBuffering(true);
+            }
+            else
+            {
+                // Return to old state.
+                IGroupState newState = prevState switch
+                {
+                    GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+                    GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+                    _ => new IdleGroupState(LoggerFactory)
+                };
+
+                context.SetState(newState);
+
+                _logger.LogDebug("No next item available in group {GroupId}.", context.GroupId.ToString());
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, 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("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+                return;
+            }
+
+            var newItem = context.PreviousItemInQueue();
+            if (newItem)
+            {
+                // Send playing-queue update.
+                var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem);
+                var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+                context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+                // Reset status of sessions and await for all Ready events.
+                context.SetAllBuffering(true);
+            }
+            else
+            {
+                // Return to old state.
+                IGroupState newState = prevState switch
+                {
+                    GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+                    GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+                    _ => new IdleGroupState(LoggerFactory)
+                };
+
+                context.SetState(newState);
+
+                _logger.LogDebug("No previous item available in group {GroupId}.", context.GroupId.ToString());
+            }
+        }
+
+        /// <inheritdoc />
+        public override void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+        {
+            context.SetIgnoreGroupWait(session, request.IgnoreWait);
+
+            if (!context.IsBuffering())
+            {
+                _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+                if (ResumePlaying)
+                {
+                    // Client, that was buffering, stopped following playback.
+                    var playingState = new PlayingGroupState(LoggerFactory);
+                    context.SetState(playingState);
+                    var unpauseRequest = new UnpauseGroupRequest();
+                    playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+                }
+                else
+                {
+                    // Group is ready, returning to previous state.
+                    var pausedState = new PausedGroupState(LoggerFactory);
+                    context.SetState(pausedState);
+                }
+            }
+        }
+    }
+}

+ 27 - 0
MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs

@@ -0,0 +1,27 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface IGroupPlaybackRequest.
+    /// </summary>
+    public interface IGroupPlaybackRequest : ISyncPlayRequest
+    {
+        /// <summary>
+        /// Gets the playback request type.
+        /// </summary>
+        /// <returns>The playback request type.</returns>
+        PlaybackRequestType Action { get; }
+
+        /// <summary>
+        /// Applies the request to a group.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="state">The current state.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+    }
+}

+ 217 - 0
MediaBrowser.Controller/SyncPlay/IGroupState.cs

@@ -0,0 +1,217 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface IGroupState.
+    /// </summary>
+    public interface IGroupState
+    {
+        /// <summary>
+        /// Gets the group state type.
+        /// </summary>
+        /// <value>The group state type.</value>
+        GroupStateType Type { get; }
+
+        /// <summary>
+        /// Handles 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(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles 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(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Generic handler. Context's state can change.
+        /// </summary>
+        /// <param name="request">The generic request.</param>
+        /// <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 HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a play request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The play request.</param>
+        /// <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 HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a set-playlist-item request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The set-playlist-item request.</param>
+        /// <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 HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a remove-items request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The remove-items request.</param>
+        /// <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 HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a move-playlist-item request from a session. Context's state should not change.
+        /// </summary>
+        /// <param name="request">The move-playlist-item request.</param>
+        /// <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 HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a queue request from a session. Context's state should not change.
+        /// </summary>
+        /// <param name="request">The queue request.</param>
+        /// <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 HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles an unpause request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The unpause request.</param>
+        /// <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 HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a pause request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The pause request.</param>
+        /// <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 HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a stop request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The stop request.</param>
+        /// <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 HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a seek request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The seek request.</param>
+        /// <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 HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a buffer request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The buffer request.</param>
+        /// <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 HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a ready request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The ready request.</param>
+        /// <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 HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a next-item request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The next-item request.</param>
+        /// <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 HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a previous-item request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The previous-item request.</param>
+        /// <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 HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a set-repeat-mode request from a session. Context's state should not change.
+        /// </summary>
+        /// <param name="request">The repeat-mode request.</param>
+        /// <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 HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a set-shuffle-mode request from a session. Context's state should not change.
+        /// </summary>
+        /// <param name="request">The shuffle-mode request.</param>
+        /// <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 HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Updates the ping of a session. Context's state should not change.
+        /// </summary>
+        /// <param name="request">The ping request.</param>
+        /// <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 HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a ignore-wait request from a session. Context's state can change.
+        /// </summary>
+        /// <param name="request">The ignore-wait request.</param>
+        /// <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 HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+    }
+}

+ 222 - 0
MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs

@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface IGroupStateContext.
+    /// </summary>
+    public interface IGroupStateContext
+    {
+        /// <summary>
+        /// Gets the default ping value used for sessions, in milliseconds.
+        /// </summary>
+        /// <value>The default ping value used for sessions, in milliseconds.</value>
+        long DefaultPing { get; }
+
+        /// <summary>
+        /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+        /// </summary>
+        /// <value>The maximum offset error accepted, in milliseconds.</value>
+        long TimeSyncOffset { get; }
+
+        /// <summary>
+        /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+        /// </summary>
+        /// <value>The maximum offset error accepted, in milliseconds.</value>
+        long MaxPlaybackOffset { 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.
+        /// </summary>
+        /// <param name="state">The new state.</param>
+        void SetState(IGroupState state);
+
+        /// <summary>
+        /// Sends a GroupUpdate message to the interested sessions.
+        /// </summary>
+        /// <typeparam name="T">The type of the data of the message.</typeparam>
+        /// <param name="from">The current session.</param>
+        /// <param name="type">The filtering type.</param>
+        /// <param name="message">The message to send.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>The task.</returns>
+        Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Sends a playback command to the interested sessions.
+        /// </summary>
+        /// <param name="from">The current session.</param>
+        /// <param name="type">The filtering type.</param>
+        /// <param name="message">The message to send.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <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>
+        /// <returns>The command.</returns>
+        SendCommand NewSyncPlayCommand(SendCommandType type);
+
+        /// <summary>
+        /// Builds a new group update message.
+        /// </summary>
+        /// <typeparam name="T">The type of the data of the message.</typeparam>
+        /// <param name="type">The update type.</param>
+        /// <param name="data">The data to send.</param>
+        /// <returns>The group update.</returns>
+        GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data);
+
+        /// <summary>
+        /// Sanitizes the PositionTicks, considers the current playing item when available.
+        /// </summary>
+        /// <param name="positionTicks">The PositionTicks.</param>
+        /// <returns>The sanitized position ticks.</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> if something went wrong.</returns>
+        bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks);
+
+        /// <summary>
+        /// Sets the playing item.
+        /// </summary>
+        /// <param name="playlistItemId">The new playing item identifier.</param>
+        /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+        bool SetPlayingItem(Guid 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(IReadOnlyList<Guid> playlistItemIds);
+
+        /// <summary>
+        /// Moves an item in the play queue.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist identifier 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> if something went wrong.</returns>
+        bool MoveItemInPlayQueue(Guid 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> if something went wrong.</returns>
+        bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode 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(GroupRepeatMode mode);
+
+        /// <summary>
+        /// Sets the shuffle mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        void SetShuffleMode(GroupShuffleMode 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);
+    }
+}

+ 0 - 67
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs

@@ -1,67 +0,0 @@
-using System;
-using System.Threading;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
-    /// <summary>
-    /// Interface ISyncPlayController.
-    /// </summary>
-    public interface ISyncPlayController
-    {
-        /// <summary>
-        /// Gets the group id.
-        /// </summary>
-        /// <value>The group id.</value>
-        Guid GetGroupId();
-
-        /// <summary>
-        /// Gets the playing item id.
-        /// </summary>
-        /// <value>The playing item id.</value>
-        Guid GetPlayingItemId();
-
-        /// <summary>
-        /// Checks if the group is empty.
-        /// </summary>
-        /// <value>If the group is empty.</value>
-        bool IsGroupEmpty();
-
-        /// <summary>
-        /// Initializes the group with the session's info.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        void CreateGroup(SessionInfo session, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Adds the session to the group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The request.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Removes the session from the group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        void SessionLeave(SessionInfo session, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Handles the requested action by the session.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="request">The requested action.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Gets the info about the group for the clients.
-        /// </summary>
-        /// <value>The group info for the clients.</value>
-        GroupInfoView GetInfo();
-    }
-}

+ 10 - 24
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using System.Threading;
 using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
 using MediaBrowser.Model.SyncPlay;
 
 namespace MediaBrowser.Controller.SyncPlay
@@ -15,32 +16,33 @@ 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.
         /// </summary>
         /// <param name="session">The session.</param>
-        /// <param name="groupId">The group id.</param>
         /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken);
+        void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// Removes the session from a group.
         /// </summary>
         /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void LeaveGroup(SessionInfo session, CancellationToken cancellationToken);
+        void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// 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<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId);
+        /// <param name="request">The request.</param>
+        /// <returns>The list of available groups.</returns>
+        List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request);
 
         /// <summary>
         /// Handle a request by a session in a group.
@@ -48,22 +50,6 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <param name="session">The session.</param>
         /// <param name="request">The request.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
-        /// <summary>
-        /// Maps a session to a group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="group">The group.</param>
-        /// <exception cref="InvalidOperationException"></exception>
-        void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
-
-        /// <summary>
-        /// Unmaps a session from a group.
-        /// </summary>
-        /// <param name="session">The session.</param>
-        /// <param name="group">The group.</param>
-        /// <exception cref="InvalidOperationException"></exception>
-        void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
+        void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken);
     }
 }

+ 16 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs

@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface ISyncPlayRequest.
+    /// </summary>
+    public interface ISyncPlayRequest
+    {
+        /// <summary>
+        /// Gets the request type.
+        /// </summary>
+        /// <returns>The request type.</returns>
+        RequestType Type { get; }
+    }
+}

+ 29 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs

@@ -0,0 +1,29 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class AbstractPlaybackRequest.
+    /// </summary>
+    public abstract class AbstractPlaybackRequest : IGroupPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AbstractPlaybackRequest"/> class.
+        /// </summary>
+        protected AbstractPlaybackRequest()
+        {
+            // Do nothing.
+        }
+
+        /// <inheritdoc />
+        public RequestType Type { get; } = RequestType.Playback;
+
+        /// <inheritdoc />
+        public abstract PlaybackRequestType Action { get; }
+
+        /// <inheritdoc />
+        public abstract void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+    }
+}

+ 61 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs

@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class BufferGroupRequest.
+    /// </summary>
+    public class BufferGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BufferGroupRequest"/> class.
+        /// </summary>
+        /// <param name="when">When the request has been made, as reported by the client.</param>
+        /// <param name="positionTicks">The position ticks.</param>
+        /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+        /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+        public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+        {
+            When = when;
+            PositionTicks = positionTicks;
+            IsPlaying = isPlaying;
+            PlaylistItemId = playlistItemId;
+        }
+
+        /// <summary>
+        /// Gets when the request has been made by the client.
+        /// </summary>
+        /// <value>The date of the request.</value>
+        public DateTime When { get; }
+
+        /// <summary>
+        /// Gets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether the client playback is unpaused.
+        /// </summary>
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; }
+
+        /// <summary>
+        /// Gets the playlist item identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist item identifier.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Buffer;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs

@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class IgnoreWaitGroupRequest.
+    /// </summary>
+    public class IgnoreWaitGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IgnoreWaitGroupRequest"/> class.
+        /// </summary>
+        /// <param name="ignoreWait">Whether the client should be ignored.</param>
+        public IgnoreWaitGroupRequest(bool ignoreWait)
+        {
+            IgnoreWait = ignoreWait;
+        }
+
+        /// <summary>
+        /// Gets a value indicating whether the client should be ignored.
+        /// </summary>
+        /// <value>The client group-wait status.</value>
+        public bool IgnoreWait { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.IgnoreWait;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 45 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs

@@ -0,0 +1,45 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class MovePlaylistItemGroupRequest.
+    /// </summary>
+    public class MovePlaylistItemGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MovePlaylistItemGroupRequest"/> class.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist identifier of the item.</param>
+        /// <param name="newIndex">The new position.</param>
+        public MovePlaylistItemGroupRequest(Guid playlistItemId, int newIndex)
+        {
+            PlaylistItemId = playlistItemId;
+            NewIndex = newIndex;
+        }
+
+        /// <summary>
+        /// Gets the playlist identifier of the item.
+        /// </summary>
+        /// <value>The playlist identifier of the item.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <summary>
+        /// Gets the new position.
+        /// </summary>
+        /// <value>The new position.</value>
+        public int NewIndex { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.MovePlaylistItem;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 37 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs

@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class NextItemGroupRequest.
+    /// </summary>
+    public class NextItemGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NextItemGroupRequest"/> class.
+        /// </summary>
+        /// <param name="playlistItemId">The playing item identifier.</param>
+        public NextItemGroupRequest(Guid playlistItemId)
+        {
+            PlaylistItemId = playlistItemId;
+        }
+
+        /// <summary>
+        /// Gets the playing item identifier.
+        /// </summary>
+        /// <value>The playing item identifier.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.NextItem;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 21 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs

@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class PauseGroupRequest.
+    /// </summary>
+    public class PauseGroupRequest : AbstractPlaybackRequest
+    {
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Pause;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs

@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class PingGroupRequest.
+    /// </summary>
+    public class PingGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PingGroupRequest"/> class.
+        /// </summary>
+        /// <param name="ping">The ping time.</param>
+        public PingGroupRequest(long ping)
+        {
+            Ping = ping;
+        }
+
+        /// <summary>
+        /// Gets the ping time.
+        /// </summary>
+        /// <value>The ping time.</value>
+        public long Ping { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ping;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 54 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs

@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class PlayGroupRequest.
+    /// </summary>
+    public class PlayGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayGroupRequest"/> class.
+        /// </summary>
+        /// <param name="playingQueue">The playing queue.</param>
+        /// <param name="playingItemPosition">The playing item position.</param>
+        /// <param name="startPositionTicks">The start position ticks.</param>
+        public PlayGroupRequest(IReadOnlyList<Guid> playingQueue, int playingItemPosition, long startPositionTicks)
+        {
+            PlayingQueue = playingQueue ?? Array.Empty<Guid>();
+            PlayingItemPosition = playingItemPosition;
+            StartPositionTicks = startPositionTicks;
+        }
+
+        /// <summary>
+        /// Gets the playing queue.
+        /// </summary>
+        /// <value>The playing queue.</value>
+        public IReadOnlyList<Guid> PlayingQueue { get; }
+
+        /// <summary>
+        /// Gets the position of the playing item in the queue.
+        /// </summary>
+        /// <value>The playing item position.</value>
+        public int PlayingItemPosition { get; }
+
+        /// <summary>
+        /// Gets the start position ticks.
+        /// </summary>
+        /// <value>The start position ticks.</value>
+        public long StartPositionTicks { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Play;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 37 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs

@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class PreviousItemGroupRequest.
+    /// </summary>
+    public class PreviousItemGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PreviousItemGroupRequest"/> class.
+        /// </summary>
+        /// <param name="playlistItemId">The playing item identifier.</param>
+        public PreviousItemGroupRequest(Guid playlistItemId)
+        {
+            PlaylistItemId = playlistItemId;
+        }
+
+        /// <summary>
+        /// Gets the playing item identifier.
+        /// </summary>
+        /// <value>The playing item identifier.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.PreviousItem;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 46 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs

@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class QueueGroupRequest.
+    /// </summary>
+    public class QueueGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="QueueGroupRequest"/> class.
+        /// </summary>
+        /// <param name="items">The items to add to the queue.</param>
+        /// <param name="mode">The enqueue mode.</param>
+        public QueueGroupRequest(IReadOnlyList<Guid> items, GroupQueueMode mode)
+        {
+            ItemIds = items ?? Array.Empty<Guid>();
+            Mode = mode;
+        }
+
+        /// <summary>
+        /// Gets the items to enqueue.
+        /// </summary>
+        /// <value>The items to enqueue.</value>
+        public IReadOnlyList<Guid> ItemIds { get; }
+
+        /// <summary>
+        /// Gets the mode in which to add the new items.
+        /// </summary>
+        /// <value>The enqueue mode.</value>
+        public GroupQueueMode Mode { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Queue;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 61 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs

@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class ReadyGroupRequest.
+    /// </summary>
+    public class ReadyGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ReadyGroupRequest"/> class.
+        /// </summary>
+        /// <param name="when">When the request has been made, as reported by the client.</param>
+        /// <param name="positionTicks">The position ticks.</param>
+        /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+        /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+        public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+        {
+            When = when;
+            PositionTicks = positionTicks;
+            IsPlaying = isPlaying;
+            PlaylistItemId = playlistItemId;
+        }
+
+        /// <summary>
+        /// Gets when the request has been made by the client.
+        /// </summary>
+        /// <value>The date of the request.</value>
+        public DateTime When { get; }
+
+        /// <summary>
+        /// Gets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether the client playback is unpaused.
+        /// </summary>
+        /// <value>The client playback status.</value>
+        public bool IsPlaying { get; }
+
+        /// <summary>
+        /// Gets the playlist item identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist item identifier.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ready;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 38 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs

@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class RemoveFromPlaylistGroupRequest.
+    /// </summary>
+    public class RemoveFromPlaylistGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class.
+        /// </summary>
+        /// <param name="items">The playlist ids of the items to remove.</param>
+        public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items)
+        {
+            PlaylistItemIds = items ?? Array.Empty<Guid>();
+        }
+
+        /// <summary>
+        /// Gets the playlist identifiers ot the items.
+        /// </summary>
+        /// <value>The playlist identifiers ot the items.</value>
+        public IReadOnlyList<Guid> PlaylistItemIds { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs

@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class SeekGroupRequest.
+    /// </summary>
+    public class SeekGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SeekGroupRequest"/> class.
+        /// </summary>
+        /// <param name="positionTicks">The position ticks.</param>
+        public SeekGroupRequest(long positionTicks)
+        {
+            PositionTicks = positionTicks;
+        }
+
+        /// <summary>
+        /// Gets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Seek;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 37 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs

@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class SetPlaylistItemGroupRequest.
+    /// </summary>
+    public class SetPlaylistItemGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SetPlaylistItemGroupRequest"/> class.
+        /// </summary>
+        /// <param name="playlistItemId">The playlist identifier of the item.</param>
+        public SetPlaylistItemGroupRequest(Guid playlistItemId)
+        {
+            PlaylistItemId = playlistItemId;
+        }
+
+        /// <summary>
+        /// Gets the playlist identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist identifier of the playing item.</value>
+        public Guid PlaylistItemId { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetPlaylistItem;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs

@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class SetRepeatModeGroupRequest.
+    /// </summary>
+    public class SetRepeatModeGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SetRepeatModeGroupRequest"/> class.
+        /// </summary>
+        /// <param name="mode">The repeat mode.</param>
+        public SetRepeatModeGroupRequest(GroupRepeatMode mode)
+        {
+            Mode = mode;
+        }
+
+        /// <summary>
+        /// Gets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode Mode { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetRepeatMode;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 36 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs

@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class SetShuffleModeGroupRequest.
+    /// </summary>
+    public class SetShuffleModeGroupRequest : AbstractPlaybackRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SetShuffleModeGroupRequest"/> class.
+        /// </summary>
+        /// <param name="mode">The shuffle mode.</param>
+        public SetShuffleModeGroupRequest(GroupShuffleMode mode)
+        {
+            Mode = mode;
+        }
+
+        /// <summary>
+        /// Gets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode Mode { get; }
+
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetShuffleMode;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 21 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs

@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class StopGroupRequest.
+    /// </summary>
+    public class StopGroupRequest : AbstractPlaybackRequest
+    {
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Stop;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 21 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs

@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+    /// <summary>
+    /// Class UnpauseGroupRequest.
+    /// </summary>
+    public class UnpauseGroupRequest : AbstractPlaybackRequest
+    {
+        /// <inheritdoc />
+        public override PlaybackRequestType Action { get; } = PlaybackRequestType.Unpause;
+
+        /// <inheritdoc />
+        public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            state.HandleRequest(this, context, state.Type, session, cancellationToken);
+        }
+    }
+}

+ 577 - 0
MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs

@@ -0,0 +1,577 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Queue
+{
+    /// <summary>
+    /// Class PlayQueueManager.
+    /// </summary>
+    public class PlayQueueManager
+    {
+        /// <summary>
+        /// Placeholder index for when no item is playing.
+        /// </summary>
+        /// <value>The no-playing item index.</value>
+        private const int NoPlayingItemIndex = -1;
+
+        /// <summary>
+        /// Random number generator used to shuffle lists.
+        /// </summary>
+        /// <value>The random number generator.</value>
+        private readonly Random _randomNumberGenerator = new Random();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayQueueManager" /> class.
+        /// </summary>
+        public PlayQueueManager()
+        {
+            Reset();
+        }
+
+        /// <summary>
+        /// Gets the playing item index.
+        /// </summary>
+        /// <value>The playing item index.</value>
+        public int PlayingItemIndex { get; private set; }
+
+        /// <summary>
+        /// Gets 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 shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted;
+
+        /// <summary>
+        /// Gets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone;
+
+        /// <summary>
+        /// Gets or sets 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 or sets 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>
+        /// Checks if an item is playing.
+        /// </summary>
+        /// <returns><c>true</c> if an item is playing; <c>false</c> otherwise.</returns>
+        public bool IsItemPlaying()
+        {
+            return PlayingItemIndex != NoPlayingItemIndex;
+        }
+
+        /// <summary>
+        /// Gets the current playlist considering the shuffle mode.
+        /// </summary>
+        /// <returns>The playlist.</returns>
+        public IReadOnlyList<QueueItem> GetPlaylist()
+        {
+            return GetPlaylistInternal();
+        }
+
+        /// <summary>
+        /// Sets a new playlist. Playing item is reset.
+        /// </summary>
+        /// <param name="items">The new items of the playlist.</param>
+        public void SetPlaylist(IReadOnlyList<Guid> items)
+        {
+            SortedPlaylist.Clear();
+            ShuffledPlaylist.Clear();
+
+            SortedPlaylist = CreateQueueItemsFromArray(items);
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+                Shuffle(ShuffledPlaylist);
+            }
+
+            PlayingItemIndex = NoPlayingItemIndex;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Appends new items to the playlist. The specified order is mantained.
+        /// </summary>
+        /// <param name="items">The items to add to the playlist.</param>
+        public void Queue(IReadOnlyList<Guid> items)
+        {
+            var newItems = CreateQueueItemsFromArray(items);
+
+            SortedPlaylist.AddRange(newItems);
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                ShuffledPlaylist.AddRange(newItems);
+            }
+
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled.
+        /// </summary>
+        public void ShufflePlaylist()
+        {
+            if (PlayingItemIndex == NoPlayingItemIndex)
+            {
+                ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+                Shuffle(ShuffledPlaylist);
+            }
+            else if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+            {
+                // First time shuffle.
+                var playingItem = SortedPlaylist[PlayingItemIndex];
+                ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+                ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+                Shuffle(ShuffledPlaylist);
+                ShuffledPlaylist.Insert(0, playingItem);
+                PlayingItemIndex = 0;
+            }
+            else
+            {
+                // Re-shuffle playlist.
+                var playingItem = ShuffledPlaylist[PlayingItemIndex];
+                ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+                Shuffle(ShuffledPlaylist);
+                ShuffledPlaylist.Insert(0, playingItem);
+                PlayingItemIndex = 0;
+            }
+
+            ShuffleMode = GroupShuffleMode.Shuffle;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Resets the playlist to sorted mode. Shuffle mode is changed.
+        /// </summary>
+        public void RestoreSortedPlaylist()
+        {
+            if (PlayingItemIndex != NoPlayingItemIndex)
+            {
+                var playingItem = ShuffledPlaylist[PlayingItemIndex];
+                PlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+            }
+
+            ShuffledPlaylist.Clear();
+
+            ShuffleMode = GroupShuffleMode.Sorted;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Clears the playlist. Shuffle mode is preserved.
+        /// </summary>
+        /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param>
+        public void ClearPlaylist(bool clearPlayingItem)
+        {
+            var playingItem = GetPlayingItem();
+            SortedPlaylist.Clear();
+            ShuffledPlaylist.Clear();
+            LastChange = DateTime.UtcNow;
+
+            if (!clearPlayingItem && playingItem != null)
+            {
+                SortedPlaylist.Add(playingItem);
+                if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+                {
+                    ShuffledPlaylist.Add(playingItem);
+                }
+
+                PlayingItemIndex = 0;
+            }
+            else
+            {
+                PlayingItemIndex = NoPlayingItemIndex;
+            }
+        }
+
+        /// <summary>
+        /// Adds new items to the playlist right after the playing item. The specified order is mantained.
+        /// </summary>
+        /// <param name="items">The items to add to the playlist.</param>
+        public void QueueNext(IReadOnlyList<Guid> items)
+        {
+            var newItems = CreateQueueItemsFromArray(items);
+
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                var playingItem = GetPlayingItem();
+                var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+                // Append items to sorted and shuffled playlist as they are.
+                SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems);
+                ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+            }
+            else
+            {
+                SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+            }
+
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Gets playlist identifier of the playing item, if any.
+        /// </summary>
+        /// <returns>The playlist identifier of the playing item.</returns>
+        public Guid GetPlayingItemPlaylistId()
+        {
+            var playingItem = GetPlayingItem();
+            return playingItem?.PlaylistItemId ?? Guid.Empty;
+        }
+
+        /// <summary>
+        /// Gets the playing item identifier, if any.
+        /// </summary>
+        /// <returns>The playing item identifier.</returns>
+        public Guid GetPlayingItemId()
+        {
+            var playingItem = GetPlayingItem();
+            return playingItem?.ItemId ?? Guid.Empty;
+        }
+
+        /// <summary>
+        /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset.
+        /// </summary>
+        /// <param name="itemId">The new playing item identifier.</param>
+        public void SetPlayingItemById(Guid itemId)
+        {
+            var playlist = GetPlaylistInternal();
+            PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId));
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset.
+        /// </summary>
+        /// <param name="playlistItemId">The new playing item identifier.</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(Guid playlistItemId)
+        {
+            var playlist = GetPlaylistInternal();
+            PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+            LastChange = DateTime.UtcNow;
+
+            return PlayingItemIndex != NoPlayingItemIndex;
+        }
+
+        /// <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 playlist = GetPlaylistInternal();
+            if (playlistIndex < 0 || playlistIndex > playlist.Count)
+            {
+                PlayingItemIndex = NoPlayingItemIndex;
+            }
+            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(IReadOnlyList<Guid> playlistItemIds)
+        {
+            var playingItem = GetPlayingItem();
+
+            SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
+            ShuffledPlaylist.RemoveAll(item => playlistItemIds.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.
+                        // Default to no playing item otherwise.
+                        PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+                    }
+
+                    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(Guid playlistItemId, int newIndex)
+        {
+            var playlist = GetPlaylistInternal();
+            var playingItem = GetPlayingItem();
+
+            var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+            if (oldIndex < 0)
+            {
+                return false;
+            }
+
+            var queueItem = playlist[oldIndex];
+            playlist.RemoveAt(oldIndex);
+            newIndex = Math.Clamp(newIndex, 0, playlist.Count);
+            playlist.Insert(newIndex, queueItem);
+
+            LastChange = DateTime.UtcNow;
+            PlayingItemIndex = playlist.IndexOf(playingItem);
+            return true;
+        }
+
+        /// <summary>
+        /// Resets the playlist to its initial state.
+        /// </summary>
+        public void Reset()
+        {
+            SortedPlaylist.Clear();
+            ShuffledPlaylist.Clear();
+            PlayingItemIndex = NoPlayingItemIndex;
+            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(GroupRepeatMode mode)
+        {
+            RepeatMode = mode;
+            LastChange = DateTime.UtcNow;
+        }
+
+        /// <summary>
+        /// Sets the shuffle mode.
+        /// </summary>
+        /// <param name="mode">The new mode.</param>
+        public void SetShuffleMode(GroupShuffleMode mode)
+        {
+            if (mode.Equals(GroupShuffleMode.Shuffle))
+            {
+                ShufflePlaylist();
+            }
+            else
+            {
+                RestoreSortedPlaylist();
+            }
+        }
+
+        /// <summary>
+        /// Toggles the shuffle mode between sorted and shuffled.
+        /// </summary>
+        public void ToggleShuffleMode()
+        {
+            if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+            {
+                ShufflePlaylist();
+            }
+            else
+            {
+                RestoreSortedPlaylist();
+            }
+        }
+
+        /// <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 = GetPlaylistInternal();
+
+            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 = SortedPlaylist.Count - 1;
+                    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 = 0;
+                    return false;
+                }
+            }
+
+            LastChange = DateTime.UtcNow;
+            return true;
+        }
+
+        /// <summary>
+        /// Shuffles a given list.
+        /// </summary>
+        /// <param name="list">The list to shuffle.</param>
+        private void Shuffle<T>(IList<T> list)
+        {
+            int n = list.Count;
+            while (n > 1)
+            {
+                n--;
+                int k = _randomNumberGenerator.Next(n + 1);
+                T value = list[k];
+                list[k] = list[n];
+                list[n] = value;
+            }
+        }
+
+        /// <summary>
+        /// Creates a list from the array of items. Each item is given an unique playlist identifier.
+        /// </summary>
+        /// <returns>The list of queue items.</returns>
+        private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items)
+        {
+            var list = new List<QueueItem>();
+            foreach (var item in items)
+            {
+                var queueItem = new QueueItem(item);
+                list.Add(queueItem);
+            }
+
+            return list;
+        }
+
+        /// <summary>
+        /// Gets the current playlist considering the shuffle mode.
+        /// </summary>
+        /// <returns>The playlist.</returns>
+        private List<QueueItem> GetPlaylistInternal()
+        {
+            if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                return ShuffledPlaylist;
+            }
+            else
+            {
+                return SortedPlaylist;
+            }
+        }
+
+        /// <summary>
+        /// Gets the current playing item, depending on the shuffle mode.
+        /// </summary>
+        /// <returns>The playing item.</returns>
+        private QueueItem GetPlayingItem()
+        {
+            if (PlayingItemIndex == NoPlayingItemIndex)
+            {
+                return null;
+            }
+            else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+            {
+                return ShuffledPlaylist[PlayingItemIndex];
+            }
+            else
+            {
+                return SortedPlaylist[PlayingItemIndex];
+            }
+        }
+    }
+}

+ 29 - 0
MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs

@@ -0,0 +1,29 @@
+using System;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+    /// <summary>
+    /// Class JoinGroupRequest.
+    /// </summary>
+    public class JoinGroupRequest : ISyncPlayRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="JoinGroupRequest"/> class.
+        /// </summary>
+        /// <param name="groupId">The identifier of the group to join.</param>
+        public JoinGroupRequest(Guid groupId)
+        {
+            GroupId = groupId;
+        }
+
+        /// <summary>
+        /// Gets the group identifier.
+        /// </summary>
+        /// <value>The identifier of the group to join.</value>
+        public Guid GroupId { get; }
+
+        /// <inheritdoc />
+        public RequestType Type { get; } = RequestType.JoinGroup;
+    }
+}

+ 13 - 0
MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs

@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+    /// <summary>
+    /// Class LeaveGroupRequest.
+    /// </summary>
+    public class LeaveGroupRequest : ISyncPlayRequest
+    {
+        /// <inheritdoc />
+        public RequestType Type { get; } = RequestType.LeaveGroup;
+    }
+}

+ 13 - 0
MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs

@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+    /// <summary>
+    /// Class ListGroupsRequest.
+    /// </summary>
+    public class ListGroupsRequest : ISyncPlayRequest
+    {
+        /// <inheritdoc />
+        public RequestType Type { get; } = RequestType.ListGroups;
+    }
+}

+ 28 - 0
MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs

@@ -0,0 +1,28 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+    /// <summary>
+    /// Class NewGroupRequest.
+    /// </summary>
+    public class NewGroupRequest : ISyncPlayRequest
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NewGroupRequest"/> class.
+        /// </summary>
+        /// <param name="groupName">The name of the new group.</param>
+        public NewGroupRequest(string groupName)
+        {
+            GroupName = groupName;
+        }
+
+        /// <summary>
+        /// Gets the group name.
+        /// </summary>
+        /// <value>The name of the new group.</value>
+        public string GroupName { get; }
+
+        /// <inheritdoc />
+        public RequestType Type { get; } = RequestType.NewGroup;
+    }
+}

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

@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class GroupInfoDto.
+    /// </summary>
+    public class GroupInfoDto
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="GroupInfoDto"/> class.
+        /// </summary>
+        /// <param name="groupId">The group identifier.</param>
+        /// <param name="groupName">The group name.</param>
+        /// <param name="state">The group state.</param>
+        /// <param name="participants">The participants.</param>
+        /// <param name="lastUpdatedAt">The date when this DTO has been created.</param>
+        public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList<string> participants, DateTime lastUpdatedAt)
+        {
+            GroupId = groupId;
+            GroupName = groupName;
+            State = state;
+            Participants = participants;
+            LastUpdatedAt = lastUpdatedAt;
+        }
+
+        /// <summary>
+        /// Gets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public Guid GroupId { get; }
+
+        /// <summary>
+        /// Gets the group name.
+        /// </summary>
+        /// <value>The group name.</value>
+        public string GroupName { get; }
+
+        /// <summary>
+        /// Gets the group state.
+        /// </summary>
+        /// <value>The group state.</value>
+        public GroupStateType State { get; }
+
+        /// <summary>
+        /// Gets the participants.
+        /// </summary>
+        /// <value>The participants.</value>
+        public IReadOnlyList<string> Participants { get; }
+
+        /// <summary>
+        /// Gets the date when this DTO has been created.
+        /// </summary>
+        /// <value>The date when this DTO has been created.</value>
+        public DateTime LastUpdatedAt { get; }
+    }
+}

+ 0 - 42
MediaBrowser.Model/SyncPlay/GroupInfoView.cs

@@ -1,42 +0,0 @@
-#nullable disable
-
-using System.Collections.Generic;
-
-namespace MediaBrowser.Model.SyncPlay
-{
-    /// <summary>
-    /// Class GroupInfoView.
-    /// </summary>
-    public class GroupInfoView
-    {
-        /// <summary>
-        /// Gets or sets the group identifier.
-        /// </summary>
-        /// <value>The group identifier.</value>
-        public string GroupId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the playing item id.
-        /// </summary>
-        /// <value>The playing item id.</value>
-        public string PlayingItemId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the playing item name.
-        /// </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; }
-
-        /// <summary>
-        /// Gets or sets the participants.
-        /// </summary>
-        /// <value>The participants.</value>
-        public IReadOnlyList<string> Participants { get; set; }
-    }
-}

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

@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum GroupQueueMode.
+    /// </summary>
+    public enum GroupQueueMode
+    {
+        /// <summary>
+        /// Insert items at the end of the queue.
+        /// </summary>
+        Queue = 0,
+
+        /// <summary>
+        /// Insert items after the currently playing item.
+        /// </summary>
+        QueueNext = 1
+    }
+}

+ 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
+    }
+}

+ 28 - 0
MediaBrowser.Model/SyncPlay/GroupStateType.cs

@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum GroupState.
+    /// </summary>
+    public enum GroupStateType
+    {
+        /// <summary>
+        /// The group is in idle state. No media is playing.
+        /// </summary>
+        Idle = 0,
+
+        /// <summary>
+        /// The group is in wating state. Playback is paused. Will start playing when users are ready.
+        /// </summary>
+        Waiting = 1,
+
+        /// <summary>
+        /// The group is in paused state. Playback is paused. Will resume on play command.
+        /// </summary>
+        Paused = 2,
+
+        /// <summary>
+        /// The group is in playing state. Playback is advancing.
+        /// </summary>
+        Playing = 3
+    }
+}

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

@@ -0,0 +1,31 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class GroupStateUpdate.
+    /// </summary>
+    public class GroupStateUpdate
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="GroupStateUpdate"/> class.
+        /// </summary>
+        /// <param name="state">The state of the group.</param>
+        /// <param name="reason">The reason of the state change.</param>
+        public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason)
+        {
+            State = state;
+            Reason = reason;
+        }
+
+        /// <summary>
+        /// Gets the state of the group.
+        /// </summary>
+        /// <value>The state of the group.</value>
+        public GroupStateType State { get; }
+
+        /// <summary>
+        /// Gets the reason of the state change.
+        /// </summary>
+        /// <value>The reason of the state change.</value>
+        public PlaybackRequestType Reason { get; }
+    }
+}

+ 22 - 8
MediaBrowser.Model/SyncPlay/GroupUpdate.cs

@@ -1,28 +1,42 @@
-#nullable disable
+using System;
 
 namespace MediaBrowser.Model.SyncPlay
 {
     /// <summary>
     /// Class GroupUpdate.
     /// </summary>
+    /// <typeparam name="T">The type of the data of the message.</typeparam>
     public class GroupUpdate<T>
     {
         /// <summary>
-        /// Gets or sets the group identifier.
+        /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class.
+        /// </summary>
+        /// <param name="groupId">The group identifier.</param>
+        /// <param name="type">The update type.</param>
+        /// <param name="data">The update data.</param>
+        public GroupUpdate(Guid groupId, GroupUpdateType type, T data)
+        {
+            GroupId = groupId;
+            Type = type;
+            Data = data;
+        }
+
+        /// <summary>
+        /// Gets the group identifier.
         /// </summary>
         /// <value>The group identifier.</value>
-        public string GroupId { get; set; }
+        public Guid GroupId { get; }
 
         /// <summary>
-        /// Gets or sets the update type.
+        /// Gets the update type.
         /// </summary>
         /// <value>The update type.</value>
-        public GroupUpdateType Type { get; set; }
+        public GroupUpdateType Type { get; }
 
         /// <summary>
-        /// Gets or sets the data.
+        /// Gets the update data.
         /// </summary>
-        /// <value>The data.</value>
-        public T Data { get; set; }
+        /// <value>The update data.</value>
+        public T Data { get; }
     }
 }

+ 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 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.

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

@@ -1,16 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
-    /// <summary>
-    /// Class JoinGroupRequest.
-    /// </summary>
-    public class JoinGroupRequest
-    {
-        /// <summary>
-        /// Gets or sets the Group id.
-        /// </summary>
-        /// <value>The Group id to join.</value>
-        public Guid GroupId { get; set; }
-    }
-}

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

@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class PlayQueueUpdate.
+    /// </summary>
+    public class PlayQueueUpdate
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlayQueueUpdate"/> class.
+        /// </summary>
+        /// <param name="reason">The reason for the update.</param>
+        /// <param name="lastUpdate">The UTC time of the last change to the playing queue.</param>
+        /// <param name="playlist">The playlist.</param>
+        /// <param name="playingItemIndex">The playing item index in the playlist.</param>
+        /// <param name="startPositionTicks">The start position ticks.</param>
+        /// <param name="shuffleMode">The shuffle mode.</param>
+        /// <param name="repeatMode">The repeat mode.</param>
+        public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode)
+        {
+            Reason = reason;
+            LastUpdate = lastUpdate;
+            Playlist = playlist;
+            PlayingItemIndex = playingItemIndex;
+            StartPositionTicks = startPositionTicks;
+            ShuffleMode = shuffleMode;
+            RepeatMode = repeatMode;
+        }
+
+        /// <summary>
+        /// Gets the request type that originated this update.
+        /// </summary>
+        /// <value>The reason for the update.</value>
+        public PlayQueueUpdateReason Reason { get; }
+
+        /// <summary>
+        /// Gets 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 DateTime LastUpdate { get; }
+
+        /// <summary>
+        /// Gets the playlist.
+        /// </summary>
+        /// <value>The playlist.</value>
+        public IReadOnlyList<QueueItem> Playlist { get; }
+
+        /// <summary>
+        /// Gets the playing item index in the playlist.
+        /// </summary>
+        /// <value>The playing item index in the playlist.</value>
+        public int PlayingItemIndex { get; }
+
+        /// <summary>
+        /// Gets the start position ticks.
+        /// </summary>
+        /// <value>The start position ticks.</value>
+        public long StartPositionTicks { get; }
+
+        /// <summary>
+        /// Gets the shuffle mode.
+        /// </summary>
+        /// <value>The shuffle mode.</value>
+        public GroupShuffleMode ShuffleMode { get; }
+
+        /// <summary>
+        /// Gets the repeat mode.
+        /// </summary>
+        /// <value>The repeat mode.</value>
+        public GroupRepeatMode RepeatMode { get; }
+    }
+}

+ 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 adding items the queue.
+        /// </summary>
+        Queue = 4,
+
+        /// <summary>
+        /// A user is adding items to the queue, after the currently playing item.
+        /// </summary>
+        QueueNext = 5,
+
+        /// <summary>
+        /// A user is requesting the next item in queue.
+        /// </summary>
+        NextItem = 6,
+
+        /// <summary>
+        /// A user is requesting the previous item in queue.
+        /// </summary>
+        PreviousItem = 7,
+
+        /// <summary>
+        /// A user is changing repeat mode.
+        /// </summary>
+        RepeatMode = 8,
+
+        /// <summary>
+        /// A user is changing shuffle mode.
+        /// </summary>
+        ShuffleMode = 9
+    }
+}

+ 0 - 34
MediaBrowser.Model/SyncPlay/PlaybackRequest.cs

@@ -1,34 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
-    /// <summary>
-    /// Class PlaybackRequest.
-    /// </summary>
-    public class PlaybackRequest
-    {
-        /// <summary>
-        /// Gets or sets the request type.
-        /// </summary>
-        /// <value>The request type.</value>
-        public PlaybackRequestType Type { get; set; }
-
-        /// <summary>
-        /// Gets or sets when the request has been made by the client.
-        /// </summary>
-        /// <value>The date of the request.</value>
-        public DateTime? When { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        public long? PositionTicks { get; set; }
-
-        /// <summary>
-        /// Gets or sets the ping time.
-        /// </summary>
-        /// <value>The ping time.</value>
-        public long? Ping { get; set; }
-    }
-}

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

@@ -6,33 +6,88 @@ 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 item in playlist.
+        /// </summary>
+        NextItem = 11,
+
+        /// <summary>
+        /// A user is requesting previous item in playlist.
+        /// </summary>
+        PreviousItem = 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
     }
 }

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

@@ -0,0 +1,31 @@
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class QueueItem.
+    /// </summary>
+    public class QueueItem
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="QueueItem"/> class.
+        /// </summary>
+        /// <param name="itemId">The item identifier.</param>
+        public QueueItem(Guid itemId)
+        {
+            ItemId = itemId;
+        }
+
+        /// <summary>
+        /// Gets the item identifier.
+        /// </summary>
+        /// <value>The item identifier.</value>
+        public Guid ItemId { get; }
+
+        /// <summary>
+        /// Gets the playlist identifier of the item.
+        /// </summary>
+        /// <value>The playlist identifier of the item.</value>
+        public Guid PlaylistItemId { get; } = Guid.NewGuid();
+    }
+}

+ 33 - 0
MediaBrowser.Model/SyncPlay/RequestType.cs

@@ -0,0 +1,33 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum RequestType.
+    /// </summary>
+    public enum RequestType
+    {
+        /// <summary>
+        /// A user is requesting to create a new group.
+        /// </summary>
+        NewGroup = 0,
+
+        /// <summary>
+        /// A user is requesting to join a group.
+        /// </summary>
+        JoinGroup = 1,
+
+        /// <summary>
+        /// A user is requesting to leave a group.
+        /// </summary>
+        LeaveGroup = 2,
+
+        /// <summary>
+        /// A user is requesting the list of available groups.
+        /// </summary>
+        ListGroups = 3,
+
+        /// <summary>
+        /// A user is sending a playback command to a group.
+        /// </summary>
+        Playback = 4
+    }
+}

+ 35 - 10
MediaBrowser.Model/SyncPlay/SendCommand.cs

@@ -1,4 +1,4 @@
-#nullable disable
+using System;
 
 namespace MediaBrowser.Model.SyncPlay
 {
@@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay
     public class SendCommand
     {
         /// <summary>
-        /// Gets or sets the group identifier.
+        /// Initializes a new instance of the <see cref="SendCommand"/> class.
+        /// </summary>
+        /// <param name="groupId">The group identifier.</param>
+        /// <param name="playlistItemId">The playlist identifier of the playing item.</param>
+        /// <param name="when">The UTC time when to execute the command.</param>
+        /// <param name="command">The command.</param>
+        /// <param name="positionTicks">The position ticks, for commands that require it.</param>
+        /// <param name="emittedAt">The UTC time when this command has been emitted.</param>
+        public SendCommand(Guid groupId, Guid playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt)
+        {
+            GroupId = groupId;
+            PlaylistItemId = playlistItemId;
+            When = when;
+            Command = command;
+            PositionTicks = positionTicks;
+            EmittedAt = emittedAt;
+        }
+
+        /// <summary>
+        /// Gets the group identifier.
         /// </summary>
         /// <value>The group identifier.</value>
-        public string GroupId { get; set; }
+        public Guid GroupId { get; }
+
+        /// <summary>
+        /// Gets the playlist identifier of the playing item.
+        /// </summary>
+        /// <value>The playlist identifier of the playing item.</value>
+        public Guid PlaylistItemId { get; }
 
         /// <summary>
         /// Gets or sets the UTC time when to execute the command.
         /// </summary>
         /// <value>The UTC time when to execute the command.</value>
-        public string When { get; set; }
+        public DateTime When { get; set; }
 
         /// <summary>
-        /// Gets or sets the position ticks.
+        /// Gets the position ticks.
         /// </summary>
         /// <value>The position ticks.</value>
-        public long? PositionTicks { get; set; }
+        public long? PositionTicks { get; }
 
         /// <summary>
-        /// Gets or sets the command.
+        /// Gets the command.
         /// </summary>
         /// <value>The command.</value>
-        public SendCommandType Command { get; set; }
+        public SendCommandType Command { get; }
 
         /// <summary>
-        /// Gets or sets the UTC time when this command has been emitted.
+        /// Gets the UTC time when this command has been emitted.
         /// </summary>
         /// <value>The UTC time when this command has been emitted.</value>
-        public string EmittedAt { get; set; }
+        public DateTime EmittedAt { get; }
     }
 }

+ 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
     }
 }

+ 28 - 0
MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs

@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Used to filter the sessions of a group.
+    /// </summary>
+    public enum SyncPlayBroadcastType
+    {
+        /// <summary>
+        /// All sessions will receive the message.
+        /// </summary>
+        AllGroup = 0,
+
+        /// <summary>
+        /// Only the specified session will receive the message.
+        /// </summary>
+        CurrentSession = 1,
+
+        /// <summary>
+        /// All sessions, except the current one, will receive the message.
+        /// </summary>
+        AllExceptCurrentSession = 2,
+
+        /// <summary>
+        /// Only sessions that are not buffering will receive the message.
+        /// </summary>
+        AllReady = 3
+    }
+}

+ 16 - 5
MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs

@@ -1,4 +1,4 @@
-#nullable disable
+using System;
 
 namespace MediaBrowser.Model.SyncPlay
 {
@@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay
     public class UtcTimeResponse
     {
         /// <summary>
-        /// Gets or sets the UTC time when request has been received.
+        /// Initializes a new instance of the <see cref="UtcTimeResponse"/> class.
+        /// </summary>
+        /// <param name="requestReceptionTime">The UTC time when request has been received.</param>
+        /// <param name="responseTransmissionTime">The UTC time when response has been sent.</param>
+        public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime)
+        {
+            RequestReceptionTime = requestReceptionTime;
+            ResponseTransmissionTime = responseTransmissionTime;
+        }
+
+        /// <summary>
+        /// Gets the UTC time when request has been received.
         /// </summary>
         /// <value>The UTC time when request has been received.</value>
-        public string RequestReceptionTime { get; set; }
+        public DateTime RequestReceptionTime { get; }
 
         /// <summary>
-        /// Gets or sets the UTC time when response has been sent.
+        /// Gets the UTC time when response has been sent.
         /// </summary>
         /// <value>The UTC time when response has been sent.</value>
-        public string ResponseTransmissionTime { get; set; }
+        public DateTime ResponseTransmissionTime { get; }
     }
 }