Ver Fonte

Rewrite syncplay using a state design pattern

gion há 5 anos atrás
pai
commit
e10799e0e8
21 ficheiros alterados com 891 adições e 386 exclusões
  1. 171 0
      Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs
  2. 94 0
      Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs
  3. 92 324
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  4. 2 2
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  5. 23 18
      MediaBrowser.Api/SyncPlay/SyncPlayService.cs
  6. 0 6
      MediaBrowser.Controller/SyncPlay/GroupInfo.cs
  7. 24 0
      MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs
  8. 1 1
      MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
  9. 1 1
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  10. 95 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs
  11. 75 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs
  12. 43 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs
  13. 24 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs
  14. 31 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs
  15. 24 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs
  16. 43 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs
  17. 30 0
      MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs
  18. 65 0
      MediaBrowser.Controller/SyncPlay/SyncPlayAbstractState.cs
  19. 25 0
      MediaBrowser.Model/SyncPlay/GroupState.cs
  20. 0 34
      MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
  21. 28 0
      MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs

+ 171 - 0
Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs

@@ -0,0 +1,171 @@
+using System.Linq;
+using System;
+using System.Threading;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class PausedGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class PausedGroupState : SyncPlayAbstractState
+    {
+        /// <inheritdoc />
+        public override GroupState GetGroupState()
+        {
+            return GroupState.Paused;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state
+            var playingState = new PlayingGroupState();
+            context.SetState(playingState);
+            return playingState.HandleRequest(context, true, request, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            if (newState)
+            {
+                GroupInfo group = context.GetGroup();
+
+                // Pause group and compute the media playback position
+                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 = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+            }
+            else
+            {
+                // Client got lost, sending current state
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+
+            return true;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            GroupInfo group = context.GetGroup();
+
+            // Sanitize PositionTicks
+            var ticks = context.SanitizePositionTicks(request.PositionTicks);
+
+            // Seek
+            group.PositionTicks = ticks;
+            group.LastActivity = DateTime.UtcNow;
+
+            var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+            context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+            return true;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            GroupInfo group = context.GetGroup();
+
+            if (newState)
+            {
+                // Pause group and compute the media playback position
+                var currentTime = DateTime.UtcNow;
+                var elapsedTime = currentTime - group.LastActivity;
+                group.LastActivity = currentTime;
+                group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
+
+                group.SetBuffering(session, true);
+
+                // Send pause command to all non-buffering sessions
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+
+                var updateOthers = context.NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
+                context.SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+            }
+            else
+            {
+                // TODO: no idea?
+                // group.SetBuffering(session, true);
+
+                // Client got lost, sending current state
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+
+            return true;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            GroupInfo group = context.GetGroup();
+
+            group.SetBuffering(session, false);
+
+            var requestTicks = context.SanitizePositionTicks(request.PositionTicks);
+
+            var currentTime = DateTime.UtcNow;
+            var elapsedTime = currentTime - request.When;
+            var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
+            var delay = group.PositionTicks - clientPosition.Ticks;
+
+            if (group.IsBuffering())
+            {
+                // Others are still buffering, tell this client to pause when ready
+                var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+                var pauseAtTime = currentTime.AddMilliseconds(delay);
+                command.When = context.DateToUTCString(pauseAtTime);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+            else
+            {
+                // Let other clients resume as soon as the buffering client catches up
+                if (delay > group.GetHighestPing() * 2)
+                {
+                    // Client that was buffering is recovering, notifying others to resume
+                    group.LastActivity = currentTime.AddMilliseconds(
+                        delay
+                    );
+                    var command = context.NewSyncPlayCommand(SendCommandType.Play);
+                    context.SendCommand(session, SyncPlayBroadcastType.AllExceptCurrentSession, command, cancellationToken);
+                }
+                else
+                {
+                    // Client, that was buffering, resumed playback but did not update others in time
+                    delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing);
+
+                    group.LastActivity = currentTime.AddMilliseconds(
+                        delay
+                    );
+
+                    var command = context.NewSyncPlayCommand(SendCommandType.Play);
+                    context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+                }
+
+                // Change state
+                var playingState = new PlayingGroupState();
+                context.SetState(playingState);
+            }
+
+            return true;
+        }
+    }
+}

+ 94 - 0
Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs

@@ -0,0 +1,94 @@
+using System.Linq;
+using System;
+using System.Threading;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class PlayingGroupState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class PlayingGroupState : SyncPlayAbstractState
+    {
+        /// <inheritdoc />
+        public override GroupState GetGroupState()
+        {
+            return GroupState.Playing;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            GroupInfo group = context.GetGroup();
+
+            if (newState)
+            {
+                // Pick a suitable time that accounts for latency
+                var delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing);
+
+                // Unpause group and set starting point in future
+                // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position)
+                // The added delay does not guarantee, of course, that the command will be received in time
+                // Playback synchronization will mainly happen client side
+                group.LastActivity = DateTime.UtcNow.AddMilliseconds(
+                    delay
+                );
+
+                var command = context.NewSyncPlayCommand(SendCommandType.Play);
+                context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+            }
+            else
+            {
+                // Client got lost, sending current state
+                var command = context.NewSyncPlayCommand(SendCommandType.Play);
+                context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+            }
+
+            return true;
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state
+            var pausedState = new PausedGroupState();
+            context.SetState(pausedState);
+            return pausedState.HandleRequest(context, true, request, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state
+            var pausedState = new PausedGroupState();
+            context.SetState(pausedState);
+            return pausedState.HandleRequest(context, true, request, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Change state
+            var pausedState = new PausedGroupState();
+            context.SetState(pausedState);
+            return pausedState.HandleRequest(context, true, request, session, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            // Group was not waiting, make sure client has latest state
+            var command = context.NewSyncPlayCommand(SendCommandType.Play);
+            context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+            return true;
+        }
+    }
+}

+ 92 - 324
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -8,6 +8,7 @@ using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.SyncPlay;
 using MediaBrowser.Model.Session;
 using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.SyncPlay
 {
@@ -17,34 +18,8 @@ namespace Emby.Server.Implementations.SyncPlay
     /// <remarks>
     /// Class is not thread-safe, external locking is required when accessing methods.
     /// </remarks>
-    public class SyncPlayController : ISyncPlayController
+    public class SyncPlayController : ISyncPlayController, ISyncPlayStateContext
     {
-        /// <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>
@@ -55,22 +30,33 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         private readonly ISyncPlayManager _syncPlayManager;
 
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger _logger;
+
         /// <summary>
         /// The group to manage.
         /// </summary>
         private readonly GroupInfo _group = new GroupInfo();
 
         /// <summary>
-        /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
+        /// Internal group state.
         /// </summary>
-        /// <param name="sessionManager">The session manager.</param>
-        /// <param name="syncPlayManager">The SyncPlay manager.</param>
-        public SyncPlayController(
-            ISessionManager sessionManager,
-            ISyncPlayManager syncPlayManager)
+        /// <value>The group's state.</value>
+        private ISyncPlayState State = new PausedGroupState();
+
+        /// <inheritdoc />
+        public GroupInfo GetGroup()
         {
-            _sessionManager = sessionManager;
-            _syncPlayManager = syncPlayManager;
+            return _group;
+        }
+
+        /// <inheritdoc />
+        public void SetState(ISyncPlayState state)
+        {
+            _logger.LogInformation("SetState: {0} -> {1}.", State.GetGroupState(), state.GetGroupState());
+            this.State = state;
         }
 
         /// <inheritdoc />
@@ -83,13 +69,18 @@ namespace Emby.Server.Implementations.SyncPlay
         public bool IsGroupEmpty() => _group.IsEmpty();
 
         /// <summary>
-        /// Converts DateTime to UTC string.
+        /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
         /// </summary>
-        /// <param name="date">The date to convert.</param>
-        /// <value>The UTC string.</value>
-        private string DateToUTCString(DateTime date)
+        /// <param name="sessionManager">The session manager.</param>
+        /// <param name="syncPlayManager">The SyncPlay manager.</param>
+        public SyncPlayController(
+            ISessionManager sessionManager,
+            ISyncPlayManager syncPlayManager,
+            ILogger logger)
         {
-            return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
+            _sessionManager = sessionManager;
+            _syncPlayManager = syncPlayManager;
+            _logger = logger;
         }
 
         /// <summary>
@@ -98,37 +89,30 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <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)
+        private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type)
         {
             switch (type)
             {
-                case BroadcastType.CurrentSession:
+                case SyncPlayBroadcastType.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);
+                case SyncPlayBroadcastType.AllGroup:
+                    return _group.Participants.Values.Select(
+                        session => session.Session).ToArray();
+                case SyncPlayBroadcastType.AllExceptCurrentSession:
+                    return _group.Participants.Values.Select(
+                        session => session.Session).Where(
+                        session => !session.Id.Equals(from.Id)).ToArray();
+                case SyncPlayBroadcastType.AllReady:
+                    return _group.Participants.Values.Where(
+                        session => !session.IsBuffering).Select(
+                        session => session.Session).ToArray();
                 default:
                     return Array.Empty<SessionInfo>();
             }
         }
 
-        /// <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)
+        /// <inheritdoc />
+        public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
         {
             IEnumerable<Task> GetTasks()
             {
@@ -141,15 +125,8 @@ namespace Emby.Server.Implementations.SyncPlay
             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)
+        /// <inheritdoc />
+        public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken)
         {
             IEnumerable<Task> GetTasks()
             {
@@ -162,12 +139,8 @@ namespace Emby.Server.Implementations.SyncPlay
             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)
+        /// <inheritdoc />
+        public SendCommand NewSyncPlayCommand(SendCommandType type)
         {
             return new SendCommand()
             {
@@ -179,13 +152,8 @@ namespace Emby.Server.Implementations.SyncPlay
             };
         }
 
-        /// <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)
+        /// <inheritdoc />
+        public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
         {
             return new GroupUpdate<T>()
             {
@@ -195,19 +163,45 @@ namespace Emby.Server.Implementations.SyncPlay
             };
         }
 
+        /// <inheritdoc />
+        public string DateToUTCString(DateTime _date)
+        {
+            return _date.ToUniversalTime().ToString("o");
+        }
+
+        /// <inheritdoc />
+        public long SanitizePositionTicks(long? positionTicks)
+        {
+            var ticks = positionTicks ?? 0;
+            ticks = ticks >= 0 ? ticks : 0;
+            if (_group.PlayingItem != null)
+            {
+                var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0;
+                ticks = ticks > runTimeTicks ? runTimeTicks : ticks;
+            }
+
+            return ticks;
+        }
+
         /// <inheritdoc />
         public void CreateGroup(SessionInfo session, CancellationToken cancellationToken)
         {
             _group.AddSession(session);
             _syncPlayManager.AddSessionToGroup(session, this);
 
+            State = new PausedGroupState();
+
             _group.PlayingItem = session.FullNowPlayingItem;
-            _group.IsPaused = session.PlayState.IsPaused;
+            // TODO: looks like new groups should mantain playstate (and not force to pause)
+            // _group.IsPaused = session.PlayState.IsPaused;
             _group.PositionTicks = session.PlayState.PositionTicks ?? 0;
             _group.LastActivity = DateTime.UtcNow;
 
             var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
-            SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
+            SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+            // TODO: looks like new groups should mantain playstate (and not force to pause)
+            var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
+            SendCommand(session, SyncPlayBroadcastType.CurrentSession, pauseCommand, cancellationToken);
         }
 
         /// <inheritdoc />
@@ -219,21 +213,21 @@ namespace Emby.Server.Implementations.SyncPlay
                 _syncPlayManager.AddSessionToGroup(session, this);
 
                 var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
-                SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
+                SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
 
                 var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
-                SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+                SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
 
                 // Syncing will happen client-side
-                if (!_group.IsPaused)
+                if (State.GetGroupState().Equals(GroupState.Playing))
                 {
                     var playCommand = NewSyncPlayCommand(SendCommandType.Play);
-                    SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken);
+                    SendCommand(session, SyncPlayBroadcastType.CurrentSession, playCommand, cancellationToken);
                 }
                 else
                 {
                     var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
-                    SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
+                    SendCommand(session, SyncPlayBroadcastType.CurrentSession, pauseCommand, cancellationToken);
                 }
             }
             else
@@ -244,7 +238,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     StartPositionTicks = _group.PositionTicks
                 };
                 var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
-                SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
+                SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken);
             }
         }
 
@@ -255,247 +249,21 @@ namespace Emby.Server.Implementations.SyncPlay
             _syncPlayManager.RemoveSessionFromGroup(session, this);
 
             var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks);
-            SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
+            SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
 
             var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
-            SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+            SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
         }
 
         /// <inheritdoc />
-        public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+        public void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken)
         {
             // The server's job is to maintain a consistent state for clients to reference
             // and notify clients of state changes. The actual syncing of media playback
             // happens client side. Clients are aware of the server's time and use it to sync.
-            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);
+            _logger.LogInformation("HandleRequest: {0}:{1}.", request.GetType(), State.GetGroupState());
+            _ = request.Apply(this, State, session, cancellationToken);
+            // TODO: do something with returned value
         }
 
         /// <inheritdoc />

+ 2 - 2
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -186,7 +186,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     LeaveGroup(session, cancellationToken);
                 }
 
-                var group = new SyncPlayController(_sessionManager, this);
+                var group = new SyncPlayController(_sessionManager, this, _logger);
                 _groups[group.GetGroupId()] = group;
 
                 group.CreateGroup(session, cancellationToken);
@@ -312,7 +312,7 @@ namespace Emby.Server.Implementations.SyncPlay
         }
 
         /// <inheritdoc />
-        public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+        public void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken)
         {
             var user = _userManager.GetUserById(session.UserId);
 

+ 23 - 18
MediaBrowser.Api/SyncPlay/SyncPlayService.cs

@@ -172,7 +172,7 @@ namespace MediaBrowser.Api.SyncPlay
         /// </summary>
         /// <param name="request">The request.</param>
         /// <value>The requested list of groups.</value>
-        public List<GroupInfoDto> Get(SyncPlayListGroups request)
+        public List<GroupInfoDto> Get(SyncPlayList request)
         {
             var currentSession = GetSession(_sessionContext);
             var filterItemId = Guid.Empty;
@@ -192,10 +192,7 @@ namespace MediaBrowser.Api.SyncPlay
         public void Post(SyncPlayPlay request)
         {
             var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = PlaybackRequestType.Play
-            };
+            var syncPlayRequest = new PlayGroupRequest();
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
         }
 
@@ -206,10 +203,7 @@ namespace MediaBrowser.Api.SyncPlay
         public void Post(SyncPlayPause request)
         {
             var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlaybackRequest()
-            {
-                Type = PlaybackRequestType.Pause
-            };
+            var syncPlayRequest = new PauseGroupRequest();
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
         }
 
@@ -220,9 +214,8 @@ namespace MediaBrowser.Api.SyncPlay
         public void Post(SyncPlaySeek request)
         {
             var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlaybackRequest()
+            var syncPlayRequest = new SeekGroupRequest()
             {
-                Type = PlaybackRequestType.Seek,
                 PositionTicks = request.PositionTicks
             };
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
@@ -235,12 +228,25 @@ namespace MediaBrowser.Api.SyncPlay
         public void Post(SyncPlayBuffering request)
         {
             var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlaybackRequest()
+
+            IPlaybackGroupRequest syncPlayRequest;
+            if (!request.BufferingDone)
             {
-                Type = request.BufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer,
-                When = DateTime.Parse(request.When),
-                PositionTicks = request.PositionTicks
-            };
+                syncPlayRequest = new BufferGroupRequest()
+                {
+                    When = DateTime.Parse(request.When),
+                    PositionTicks = request.PositionTicks
+                };
+            }
+            else
+            {
+                syncPlayRequest = new ReadyGroupRequest()
+                {
+                    When = DateTime.Parse(request.When),
+                    PositionTicks = request.PositionTicks
+                };
+            }
+
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
         }
 
@@ -251,9 +257,8 @@ namespace MediaBrowser.Api.SyncPlay
         public void Post(SyncPlayPing request)
         {
             var currentSession = GetSession(_sessionContext);
-            var syncPlayRequest = new PlaybackRequest()
+            var syncPlayRequest = new PingGroupRequest()
             {
-                Type = PlaybackRequestType.Ping,
                 Ping = Convert.ToInt64(request.Ping)
             };
             _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);

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

@@ -30,12 +30,6 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <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>

+ 24 - 0
MediaBrowser.Controller/SyncPlay/IPlaybackGroupRequest.cs

@@ -0,0 +1,24 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface IPlaybackGroupRequest.
+    /// </summary>
+    public interface IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets the playback request type.
+        /// </summary>
+        /// <value>The playback request type.</value>
+        PlaybackRequestType Type();
+
+        /// <summary>
+        /// Applies the request to a group.
+        /// </summary>
+        /// <value>The operation completion status.</value>
+        bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken);
+    }
+}

+ 1 - 1
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs

@@ -56,7 +56,7 @@ namespace MediaBrowser.Controller.SyncPlay
         /// <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);
+        void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// Gets the info about the group for the clients.

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

@@ -48,7 +48,7 @@ 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);
+        void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken);
 
         /// <summary>
         /// Maps a session to a group.

+ 95 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayState.cs

@@ -0,0 +1,95 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface ISyncPlayState.
+    /// </summary>
+    public interface ISyncPlayState
+    {
+        /// <summary>
+        /// Gets the group state.
+        /// </summary>
+        /// <value>The group state.</value>
+        GroupState GetGroupState();
+
+        /// <summary>
+        /// Generic handle. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The play action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a play action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The play action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a pause action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The pause action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a seek action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The seek action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a buffering action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The buffering action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles a buffering-done action requested by a session. Context's state can change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The buffering-done action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Updates ping of a session. Context's state should not change.
+        /// </summary>
+        /// <param name="context">The context of the state.</param>
+        /// <param name="newState">Whether the state has been just set.</param>
+        /// <param name="request">The buffering-done action.</param>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <value>The operation completion status.</value>
+        bool HandleRequest(ISyncPlayStateContext context, bool newState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken);
+    }
+}

+ 75 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayStateContext.cs

@@ -0,0 +1,75 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface ISyncPlayStateContext.
+    /// </summary>
+    public interface ISyncPlayStateContext
+    {
+        /// <summary>
+        /// Gets the context's group.
+        /// </summary>
+        /// <value>The group.</value>
+        GroupInfo GetGroup();
+
+        /// <summary>
+        /// Sets a new state.
+        /// </summary>
+        /// <param name="state">The new state.</param>
+        void SetState(ISyncPlayState state);
+
+        /// <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>
+        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>
+        /// <value>The task.</value>
+        Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Builds a new playback command with some default values.
+        /// </summary>
+        /// <param name="type">The command type.</param>
+        /// <value>The SendCommand.</value>
+        SendCommand NewSyncPlayCommand(SendCommandType type);
+
+        /// <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>
+        GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data);
+
+        /// <summary>
+        /// Converts DateTime to UTC string.
+        /// </summary>
+        /// <param name="date">The date to convert.</param>
+        /// <value>The UTC string.</value>
+        string DateToUTCString(DateTime date);
+
+        /// <summary>
+        /// Sanitizes the PositionTicks, considers the current playing item when available.
+        /// </summary>
+        /// <param name="positionTicks">The PositionTicks.</param>
+        /// <value>The sanitized PositionTicks.</value>
+        long SanitizePositionTicks(long? positionTicks);
+    }
+}

+ 43 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/BufferGroupRequest.cs

@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class BufferingGroupRequest.
+    /// </summary>
+    public class BufferGroupRequest : IPlaybackGroupRequest
+    {
+        /// <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 playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        public Guid PlayingItemId { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Buffer;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

+ 24 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/PauseGroupRequest.cs

@@ -0,0 +1,24 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class PauseGroupRequest.
+    /// </summary>
+    public class PauseGroupRequest : IPlaybackGroupRequest
+    {
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Pause;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

+ 31 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/PingGroupRequest.cs

@@ -0,0 +1,31 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+// FIXME: not really group related, can be moved up to SyncPlayController maybe?
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class UpdatePingGroupRequest.
+    /// </summary>
+    public class PingGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the ping time.
+        /// </summary>
+        /// <value>The ping time.</value>
+        public long Ping { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Ping;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

+ 24 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/PlayGroupRequest.cs

@@ -0,0 +1,24 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class PlayGroupRequest.
+    /// </summary>
+    public class PlayGroupRequest : IPlaybackGroupRequest
+    {
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Play;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

+ 43 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/ReadyGroupRequest.cs

@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class BufferingDoneGroupRequest.
+    /// </summary>
+    public class ReadyGroupRequest : IPlaybackGroupRequest
+    {
+        /// <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 playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        public Guid PlayingItemId { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Ready;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

+ 30 - 0
MediaBrowser.Controller/SyncPlay/PlaybackRequest/SeekGroupRequest.cs

@@ -0,0 +1,30 @@
+using System.Threading;
+using MediaBrowser.Model.SyncPlay;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class SeekGroupRequest.
+    /// </summary>
+    public class SeekGroupRequest : IPlaybackGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <inheritdoc />
+        public PlaybackRequestType Type()
+        {
+            return PlaybackRequestType.Seek;
+        }
+
+        /// <inheritdoc />
+        public bool Apply(ISyncPlayStateContext context, ISyncPlayState state, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return state.HandleRequest(context, false, this, session, cancellationToken);
+        }
+    }
+}

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

@@ -0,0 +1,65 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class SyncPlayAbstractState.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public abstract class SyncPlayAbstractState : ISyncPlayState
+    {
+        /// <inheritdoc />
+        public abstract GroupState GetGroupState();
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            return true;
+        }
+
+        /// <inheritdoc />
+        public virtual bool HandleRequest(ISyncPlayStateContext context, bool newState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken)
+        {
+            GroupInfo group = context.GetGroup();
+
+            // Collected pings are used to account for network latency when unpausing playback
+            group.UpdatePing(session, request.Ping);
+
+            return true;
+        }
+    }
+}

+ 25 - 0
MediaBrowser.Model/SyncPlay/GroupState.cs

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

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

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