├── LICENSE ├── doc └── ss.png ├── FodyWeavers.xml ├── PluginConfiguration.cs ├── LogBridge ├── MessageBase.cs ├── MessageIncoming.cs └── Bridge.cs ├── Api ├── PartyAttendeeInfo.cs ├── PartyLeaveService.cs ├── PartyListService.cs ├── BasePartyService.cs ├── RemoteControlSafetyService.cs ├── PartyJoinService.cs ├── PartyStatusService.cs └── WebSocketListener.cs ├── README.md ├── EmbyParty.sln ├── dashboard-ui └── modules │ └── embyparty │ ├── emoji_license.txt │ ├── emoticon.js │ ├── partyapiclient.js │ ├── joinparty.js │ ├── partyheader.css │ └── partyheader.js ├── EntryPoint.cs ├── EmbyParty.csproj ├── Attendee.cs ├── Plugin.cs ├── .gitignore ├── Party.cs └── PartyManager.cs /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Protected/EmbyParty/HEAD/LICENSE -------------------------------------------------------------------------------- /doc/ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Protected/EmbyParty/HEAD/doc/ss.png -------------------------------------------------------------------------------- /FodyWeavers.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | websocket-sharp 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Plugins; 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text; 6 | 7 | namespace EmbyParty 8 | { 9 | public class PluginConfiguration : BasePluginConfiguration 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LogBridge/MessageBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace EmbyParty.LogBridge 6 | { 7 | public class MessageBase 8 | { 9 | public string MessageType { get; set; } 10 | public string Party { get; set; } 11 | public object Data { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LogBridge/MessageIncoming.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace EmbyParty.LogBridge 6 | { 7 | public class MessageIncoming 8 | { 9 | public string MessageType { get; set; } 10 | public string Party { get; set; } 11 | public T Data { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Api/PartyAttendeeInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace EmbyParty.Api 6 | { 7 | public sealed class PartyAttendeeInfo 8 | { 9 | public string UserId { get; set; } 10 | public bool HasPicture { get; set; } 11 | public string Name { get; set; } 12 | public bool IsHosting { get; set; } 13 | public bool IsMe { get; set; } 14 | public bool IsRemoteControlled { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Emby Party is a solution for watching videos with multiple friends through your Emby web client. It requires no additional dependencies - only a device running Emby (presumably with good enough hardware for all the attendees, especially if you're transcoding) and at least one web browser. 2 | 3 | It consists in two halves: A server plugin and a web client module. They *must* be used together! Each half expects the other to be present. 4 | 5 | This is an experimental open source project. Feel free to fork and contribute pull requests and issues (my availability for fixing issues on my own may vary wildly and may be low in the near future). The source code and all known issues can be found here. 6 | 7 | Currently testing in Emby 4.8.10, Windows and Debian Bookworm (Linux), Firefox, Chrome and Edge. 8 | 9 | Readme coming soon! 10 | -------------------------------------------------------------------------------- /Api/PartyLeaveService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common; 2 | using MediaBrowser.Controller.Api; 3 | using MediaBrowser.Controller.Net; 4 | using MediaBrowser.Controller.Session; 5 | using MediaBrowser.Model.Logging; 6 | using MediaBrowser.Model.Services; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Text; 10 | 11 | namespace EmbyParty.Api 12 | { 13 | [Route("/Party/Leave", "POST", Summary = "Leaves the current watch party, if any")] 14 | [Authenticated] 15 | public sealed class PartyLeave : IReturnVoid 16 | { 17 | } 18 | 19 | public class PartyLeaveService : BasePartyService 20 | { 21 | public PartyLeaveService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext) : base(logManager, applicationHost, sessionContext) { } 22 | 23 | public object Post(PartyLeave request) 24 | { 25 | SessionInfo session = GetSession(SessionContext); 26 | 27 | PartyManager.RemoveFromParty(session.Id); 28 | 29 | return null; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /EmbyParty.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.10.35122.118 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbyParty", "EmbyParty.csproj", "{7FFE77B8-2FE1-49E9-B9AD-C0590B7097B0}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {7FFE77B8-2FE1-49E9-B9AD-C0590B7097B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {7FFE77B8-2FE1-49E9-B9AD-C0590B7097B0}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {7FFE77B8-2FE1-49E9-B9AD-C0590B7097B0}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {7FFE77B8-2FE1-49E9-B9AD-C0590B7097B0}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {AA520AB7-2949-46E8-B49F-A4569DB4D6D7} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/emoji_license.txt: -------------------------------------------------------------------------------- 1 | Datasets included under the MIT License 2 | 3 | Emoji shortnames: Copyright (c) 2017-2019 Miles Johnson 4 | Emoticons: Copyright (c) 2019 Federico Ballarini 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /Api/PartyListService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Api; 2 | using MediaBrowser.Controller.Library; 3 | using MediaBrowser.Controller.Net; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Model.Logging; 6 | using MediaBrowser.Model.Services; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Text; 10 | using MediaBrowser.Controller.Session; 11 | using System.IO; 12 | using System.Linq; 13 | using System.Transactions; 14 | using MediaBrowser.Common; 15 | 16 | namespace EmbyParty.Api 17 | { 18 | public sealed class PartyInfo 19 | { 20 | public long Id { get; set; } 21 | public string Name { get; set; } 22 | } 23 | 24 | [Route("/Party/List", "GET", Summary = "Lists existing watch parties")] 25 | [Authenticated] 26 | public sealed class PartyList : IReturn> 27 | { 28 | } 29 | 30 | public class PartyListService : BasePartyService 31 | { 32 | public PartyListService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext) : base(logManager, applicationHost, sessionContext) { } 33 | 34 | public object Get(PartyList request) 35 | { 36 | List results = new List(); 37 | foreach (Party party in PartyManager.Parties) 38 | { 39 | results.Add(new PartyInfo { Id = party.Id, Name = party.Name }); 40 | } 41 | return ToOptimizedResult(results); 42 | } 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Api/BasePartyService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Api; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Model.Dto; 4 | using MediaBrowser.Model.Querying; 5 | using MediaBrowser.Model.Services; 6 | using MediaBrowser.Common.Progress; 7 | using MediaBrowser.Controller.Providers; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Text; 11 | using System.Xml.Linq; 12 | using System.Threading; 13 | using MediaBrowser.Controller.Library; 14 | using MediaBrowser.Controller.Session; 15 | using MediaBrowser.Model.Logging; 16 | using MediaBrowser.Controller.Plugins; 17 | using System.Threading.Tasks; 18 | using Emby.Media.Model.GraphModel; 19 | using System.IO; 20 | using System.Diagnostics; 21 | using MediaBrowser.Common.Plugins; 22 | using MediaBrowser.Common; 23 | using System.Linq; 24 | using MediaBrowser.Controller.Net; 25 | 26 | namespace EmbyParty.Api 27 | { 28 | public abstract class BasePartyService : BaseApiService 29 | { 30 | 31 | private ILogger _logger; 32 | 33 | protected PartyManager PartyManager; 34 | 35 | protected ISessionContext SessionContext; 36 | 37 | public BasePartyService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext) 38 | { 39 | _logger = logManager.GetLogger("Party"); 40 | Plugin plugin = applicationHost.Plugins.OfType().First(); 41 | PartyManager = plugin.PartyManager; 42 | SessionContext = sessionContext; 43 | } 44 | 45 | protected void Log(string message) 46 | { 47 | _logger.Info(message); 48 | } 49 | 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Api/RemoteControlSafetyService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common; 2 | using MediaBrowser.Controller.Net; 3 | using MediaBrowser.Controller.Session; 4 | using MediaBrowser.Model.Logging; 5 | using MediaBrowser.Model.Serialization; 6 | using MediaBrowser.Model.Services; 7 | using MediaBrowser.Model.Session; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | namespace EmbyParty.Api 13 | { 14 | public sealed class RemoteControlSafetyReturn 15 | { 16 | public bool IsSafe { get; set; } 17 | } 18 | 19 | [Route("/Party/RemoteControlSafety", "GET", Summary = "Checks if it's safe to remote control a session")] 20 | [Authenticated] 21 | public sealed class RemoteControlSafety : IReturn 22 | { 23 | public string RemoteControl { get; set; } 24 | } 25 | 26 | public class RemoteControlSafetyService : BasePartyService 27 | { 28 | public RemoteControlSafetyService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext) : base(logManager, applicationHost, sessionContext) { } 29 | 30 | public object Get(RemoteControlSafety request) 31 | { 32 | SessionInfo session = GetSession(SessionContext); 33 | 34 | Party party = PartyManager.GetAttendeeParty(session.Id); 35 | if (party == null) { return new RemoteControlSafetyReturn() { IsSafe = true }; } 36 | 37 | Attendee attendee = party.GetAttendee(session.Id); 38 | 39 | bool result = PartyManager.IsTargetSafe(attendee, request.RemoteControl); 40 | 41 | return new RemoteControlSafetyReturn() { IsSafe = result }; 42 | } 43 | 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /EntryPoint.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common; 2 | using MediaBrowser.Controller; 3 | using MediaBrowser.Controller.Configuration; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Plugins; 6 | using MediaBrowser.Controller.Session; 7 | using MediaBrowser.Model.Logging; 8 | using MediaBrowser.Model.Serialization; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.IO; 12 | using System.Linq; 13 | using System.Reflection; 14 | using System.Text; 15 | 16 | namespace EmbyParty 17 | { 18 | public class EntryPoint : IServerEntryPoint 19 | { 20 | 21 | private ILogger _logger; 22 | private ISessionManager _sessionManager; 23 | private IUserManager _userManager; 24 | private ILibraryManager _libraryManager; 25 | private Plugin _plugin; 26 | 27 | public EntryPoint(ISessionManager sessionManager, IUserManager userManager, ILogManager logManager, IApplicationHost applicationHost, IJsonSerializer jsonSerializer, ILibraryManager libraryManager) 28 | { 29 | _logger = logManager.GetLogger("Party"); 30 | _sessionManager = sessionManager; 31 | _libraryManager = libraryManager; 32 | _userManager = userManager; 33 | _plugin = applicationHost.Plugins.OfType().First(); 34 | _plugin.PartyManager = new PartyManager(_sessionManager, _userManager, jsonSerializer, _libraryManager, _logger); 35 | } 36 | 37 | protected void Log(string message) 38 | { 39 | _logger.Info(message); 40 | } 41 | 42 | public void Run() 43 | { 44 | _plugin.ExtractResources(); 45 | _plugin.PartyManager.Run(); 46 | } 47 | 48 | public void Dispose() 49 | { 50 | _plugin.PartyManager.Dispose(); 51 | } 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/emoticon.js: -------------------------------------------------------------------------------- 1 | define(["exports"],function(_exports){_exports.default={'o/':'1F44B','':'1F604','>.<':'1F621','>:(':'1F620','>:)':'1F608','>:-)':'1F608','>:/':'1F621','>:O':'1F632','>:P':'1F61C','>:[':'1F612','>:\\':'1F621','>;)':'1F608','>_>^':'1F624','^^':'1F60A'};}); -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/partyapiclient.js: -------------------------------------------------------------------------------- 1 | define(["exports", 2 | "./../emby-apiclient/connectionmanager.js" 3 | ], function(_exports, _connectionmanager) { 4 | 5 | function PartyApiClient() { 6 | this._apiClient = _connectionmanager.default.currentApiClient(); 7 | } 8 | 9 | PartyApiClient.prototype.getParties = function(options, signal) { 10 | let query = {...options}; 11 | return this._apiClient.getJSON(this._apiClient.getUrl("Party/List", query), signal); 12 | } 13 | 14 | PartyApiClient.prototype.getPartyStatus = function(options, signal) { 15 | let query = {...options}; 16 | return this._apiClient.getJSON(this._apiClient.getUrl("Party/Status", query), signal); 17 | } 18 | 19 | PartyApiClient.prototype.createParty = function(name, remoteControl) { 20 | let url = this._apiClient.getUrl("Party/Join"); 21 | return this._apiClient.ajax({ 22 | type: "POST", 23 | url: url, 24 | data: JSON.stringify({ 25 | Name: name, 26 | RemoteControl: remoteControl || null 27 | }), 28 | contentType: 'application/json' 29 | }) 30 | .then((result) => result.json()); 31 | } 32 | 33 | PartyApiClient.prototype.joinParty = function(partyId, remoteControl) { 34 | let url = this._apiClient.getUrl("Party/Join"); 35 | return this._apiClient.ajax({ 36 | type: "POST", 37 | url: url, 38 | data: JSON.stringify({ 39 | Id: partyId, 40 | RemoteControl: remoteControl || null 41 | }), 42 | contentType: 'application/json' 43 | }) 44 | .then((result) => result.json()); 45 | } 46 | 47 | PartyApiClient.prototype.leaveParty = function() { 48 | let url = this._apiClient.getUrl("Party/Leave"); 49 | return this._apiClient.ajax({ 50 | type: "POST", 51 | url: url, 52 | data: JSON.stringify({}), 53 | contentType: 'application/json' 54 | }) 55 | .then((result) => { }); 56 | } 57 | 58 | PartyApiClient.prototype.getRemoteControlSafety = function(options, signal) { 59 | let query = {...options}; 60 | return this._apiClient.getJSON(this._apiClient.getUrl("Party/RemoteControlSafety", query), signal); 61 | } 62 | 63 | _exports.default = new PartyApiClient(); 64 | }); -------------------------------------------------------------------------------- /EmbyParty.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | netstandard2.0; 6 | 1.0.0.0 7 | 1.0.0.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | all 33 | runtime; build; native; contentfiles; analyzers; buildtransitive 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/joinparty.js: -------------------------------------------------------------------------------- 1 | define(["exports", 2 | "./partyapiclient.js", 3 | "./../common/globalize.js", 4 | "./../actionsheet/actionsheet.js", 5 | "./../loading/loading.js", 6 | "./../prompt/prompt.js", 7 | "./../common/playback/playbackmanager.js" 8 | ], function(_exports, _partyapiclient, _globalize, _actionsheet, _loading, _prompt, _playbackmanager) { 9 | 10 | function JoinParty() { 11 | } 12 | 13 | JoinParty.prototype.getCurrentTargetSessionId = function() { 14 | return _playbackmanager.default.getPlayerInfo()?.currentSessionId; 15 | }, 16 | 17 | JoinParty.prototype.show = async function(anchor) { 18 | _loading.default.show(); 19 | let parties = await _partyapiclient.default.getParties({}); 20 | _loading.default.hide(); 21 | 22 | let partyList = parties.map(result => ({ 23 | ...result, 24 | icon: "hub", 25 | iconClass: "accentText" 26 | })); 27 | 28 | partyList.push({ 29 | Name: "Create New Party", 30 | id: "new", 31 | icon: "add_box" 32 | }); 33 | 34 | let selection = await _actionsheet.default.show({ 35 | title: "Join Party", 36 | items: partyList, 37 | positionTo: anchor, 38 | positionY: "bottom", 39 | positionX: "right", 40 | transformOrigin: "right top", 41 | resolveOnClick: true, 42 | hasItemIcon: true, 43 | fields: ["Name"], 44 | hasItemSelectionState: true 45 | }); 46 | 47 | if (selection != "new") { 48 | 49 | _loading.default.show(); 50 | let joinresult = await _partyapiclient.default.joinParty(selection, this.getCurrentTargetSessionId()) 51 | _loading.default.hide(); 52 | 53 | return joinresult; 54 | } else { 55 | 56 | let name = await _prompt.default({ 57 | title: "New Party", 58 | label: _globalize.default.translate("LabelName"), 59 | confirmText: _globalize.default.translate("Create") 60 | }); 61 | 62 | _loading.default.show(); 63 | let createresult = await _partyapiclient.default.createParty(name, this.getCurrentTargetSessionId()); 64 | _loading.default.hide(); 65 | 66 | return createresult; 67 | } 68 | } 69 | 70 | _exports.default = new JoinParty(); 71 | }); -------------------------------------------------------------------------------- /Api/PartyJoinService.cs: -------------------------------------------------------------------------------- 1 | using Emby.Web.GenericEdit.Actions; 2 | using MediaBrowser.Common; 3 | using MediaBrowser.Controller.Net; 4 | using MediaBrowser.Controller.Session; 5 | using MediaBrowser.Model.Logging; 6 | using MediaBrowser.Model.Services; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace EmbyParty.Api 13 | { 14 | public sealed class PartyJoinResult 15 | { 16 | public bool Success { get; set; } 17 | public string Reason { get; set; } 18 | } 19 | 20 | [Route("/Party/Join", "POST", Summary = "Joins or creates a party")] 21 | [Authenticated] 22 | public sealed class PartyJoin: IReturn 23 | { 24 | public long? Id { get; set; } 25 | public string Name { get; set; } 26 | public string RemoteControl { get; set; } 27 | } 28 | 29 | public class PartyJoinService : BasePartyService 30 | { 31 | public PartyJoinService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext) : base(logManager, applicationHost, sessionContext) { } 32 | 33 | public async Task Post(PartyJoin request) 34 | { 35 | SessionInfo session = GetSession(SessionContext); 36 | 37 | if (request.Id != null) 38 | { 39 | Party joinedParty = await PartyManager.AddToParty(session.Id, (long)request.Id, session); 40 | if (joinedParty == null) 41 | { 42 | return (object)new PartyJoinResult() { Success = false, Reason = "Party doesn't exist or user doesn't have permission to access media." }; 43 | } 44 | 45 | if (request.RemoteControl != null) 46 | { 47 | joinedParty.SetRemoteControl(session.Id, request.RemoteControl); 48 | } 49 | 50 | return (object)new PartyJoinResult() { Success = true }; 51 | } 52 | else if (request.Name != null && request.Name.Length > 0) 53 | { 54 | string name = request.Name.Substring(0, Math.Min(24, request.Name.Length)); 55 | if (PartyManager.GetPartyByName(name) != null) 56 | { 57 | return (object)new PartyJoinResult() { Success = false, Reason = "There is already a party with that name." }; 58 | } 59 | Party createdParty = await PartyManager.CreateParty(session.Id, name, session); 60 | if (createdParty == null) 61 | { 62 | return (object)new PartyJoinResult() { Success = false, Reason = "Unble to create party with this user." }; 63 | } 64 | 65 | if (request.RemoteControl != null) 66 | { 67 | createdParty.SetRemoteControl(session.Id, request.RemoteControl); 68 | } 69 | 70 | return (object)new PartyJoinResult() { Success = true }; 71 | } 72 | 73 | return (object)new PartyJoinResult() { Success = false, Reason = "Either the party ID or a party name must be provided." }; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /LogBridge/Bridge.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Serialization; 2 | using MediaBrowser.Model.Session; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text; 6 | using System.Timers; 7 | using System.Threading.Tasks; 8 | using WebSocketSharp; 9 | using WebSocketSharp.Server; 10 | 11 | namespace EmbyParty.LogBridge 12 | { 13 | public sealed class ChatExternalMessage 14 | { 15 | public string Name { get; set; } 16 | public string AvatarUrl { get; set; } 17 | public string Message { get; set; } 18 | } 19 | 20 | public class Bridge : WebSocketBehavior 21 | { 22 | private const int PING_INTERVAL = 30000; 23 | 24 | public PartyManager PartyManager { get; set; } 25 | public IJsonSerializer JsonSerializer { get; set; } 26 | 27 | private Timer _pingTimer; 28 | 29 | protected override void OnOpen() 30 | { 31 | PartyManager.Bridges.Add(this); 32 | 33 | _pingTimer = new Timer(PING_INTERVAL); 34 | _pingTimer.Elapsed += TimeToPing; 35 | } 36 | 37 | protected override void OnMessage(MessageEventArgs e) 38 | { 39 | MessageIncoming message = JsonSerializer.DeserializeFromString>(e.Data); 40 | 41 | if (message.MessageType == "Chat") 42 | { 43 | Party party = PartyManager.GetPartyByName(message.Party); 44 | if (party == null) { return; } 45 | 46 | MessageIncoming chat = JsonSerializer.DeserializeFromString>(e.Data); 47 | 48 | string msgtext = chat.Data.Message.Substring(0, Math.Min(chat.Data.Message.Length, 512)); 49 | if (msgtext.Length == 0) { return; } 50 | 51 | GeneralCommand bounceCommand = new GeneralCommand() { Name = "ChatExternal" }; 52 | bounceCommand.Arguments["AvatarUrl"] = chat.Data.AvatarUrl; 53 | bounceCommand.Arguments["Name"] = chat.Data.Name; 54 | bounceCommand.Arguments["Message"] = msgtext; 55 | 56 | party.SendEventToAttendees(null, bounceCommand); 57 | } 58 | 59 | if (message.MessageType == "PartyCheck") 60 | { 61 | Party party = PartyManager.GetPartyByName(message.Party); 62 | SendMessageObject(party != null ? "PartyExists" : "PartyMissing", message.Party, null); 63 | } 64 | 65 | } 66 | 67 | protected override void OnClose(CloseEventArgs e) 68 | { 69 | _pingTimer.Stop(); 70 | _pingTimer.Dispose(); 71 | 72 | PartyManager.Bridges.Remove(this); 73 | } 74 | 75 | private void TimeToPing(object sender, ElapsedEventArgs e) 76 | { 77 | Context.WebSocket.Ping(); 78 | } 79 | 80 | public new void Send(string data) 81 | { 82 | base.Send(data); 83 | } 84 | 85 | public void SendMessageObject(string messageType, string partyName, object data) 86 | { 87 | MessageBase messageObj = new MessageBase() { MessageType = messageType, Party = partyName, Data = data }; 88 | string message = JsonSerializer.SerializeToString(messageObj); 89 | Send(message); 90 | } 91 | 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Api/PartyStatusService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Library; 4 | using MediaBrowser.Controller.Net; 5 | using MediaBrowser.Controller.Session; 6 | using MediaBrowser.Model.Entities; 7 | using MediaBrowser.Model.Logging; 8 | using MediaBrowser.Model.Services; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.Linq; 12 | using System.Text; 13 | 14 | namespace EmbyParty.Api 15 | { 16 | public sealed class PartyStatusReturn 17 | { 18 | public string Id { get; set; } 19 | public PartyInfo CurrentParty { get; set; } 20 | public List Attendees { get; set; } 21 | public long[] CurrentQueue { get; set; } 22 | public int CurrentIndex { get; set; } 23 | public string MediaSourceId { get; set; } 24 | public int? AudioStreamIndex { get; set; } 25 | public int? SubtitleStreamIndex { get; set; } 26 | public bool IsPaused { get; set; } 27 | } 28 | 29 | [Route("/Party/Status", "GET", Summary = "Gets the user's current party status")] 30 | [Authenticated] 31 | public sealed class PartyStatus : IReturn 32 | { 33 | } 34 | 35 | public class PartyStatusService : BasePartyService 36 | { 37 | private IUserManager _userManager; 38 | 39 | public PartyStatusService(ILogManager logManager, IApplicationHost applicationHost, ISessionContext sessionContext, IUserManager userManager) : base(logManager, applicationHost, sessionContext) 40 | { 41 | _userManager = userManager; 42 | } 43 | 44 | public object Get(PartyStatus request) 45 | { 46 | SessionInfo session = GetSession(SessionContext); 47 | 48 | PartyStatusReturn result = new PartyStatusReturn(); 49 | 50 | Party party = PartyManager.GetAttendeeParty(session.Id); 51 | if (party != null) 52 | { 53 | result.Id = party.Id.ToString(); 54 | result.CurrentParty = new PartyInfo() { Name = party.Name, Id = party.Id }; 55 | result.IsPaused = false; 56 | result.Attendees = new List(); 57 | foreach (Attendee attendee in party.Attendees) 58 | { 59 | 60 | User user = _userManager.GetUserById(attendee.UserId); 61 | bool hasPicture = user.GetImages(ImageType.Primary).Count() > 0; 62 | 63 | if (attendee.IsHost && attendee.IsPaused) 64 | { 65 | result.IsPaused = true; 66 | } 67 | 68 | result.Attendees.Add(new PartyAttendeeInfo() 69 | { 70 | UserId = attendee.UserId, 71 | HasPicture = hasPicture, 72 | Name = attendee.DisplayName, 73 | IsHosting = attendee.IsHost, 74 | IsMe = attendee.Id == session.Id, 75 | IsRemoteControlled = attendee.IsBeingRemoteControlled 76 | }); 77 | } 78 | result.CurrentQueue = party.CurrentQueue; 79 | result.CurrentIndex = party.CurrentIndex; 80 | result.MediaSourceId = party.MediaSourceId; 81 | result.SubtitleStreamIndex = party.SubtitleStreamIndex; 82 | result.AudioStreamIndex = party.AudioStreamIndex; 83 | } 84 | 85 | return result; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Attendee.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Model.Logging; 3 | using MediaBrowser.Model.Session; 4 | using MediaBrowser.Model.System; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Text; 8 | using WebSocketSharp; 9 | 10 | namespace EmbyParty 11 | { 12 | public enum AttendeeState 13 | { 14 | Idle, //Not playing anything. 15 | WaitForPlay, //This Guest has been sent a playback order but hasn't reported initiation of playback yet. 16 | WaitForSeek, //This Guest has been sent a seek order but hasn't delivered a time update yet. 17 | Syncing, //This Attendee is ready to continue but one or more Guests aren't yet. 18 | Ready //Synced (stable) playback in progress. 19 | } 20 | 21 | public class Attendee 22 | { 23 | private const long ACCURACY_START = 20000000; //2s 24 | private const long ACCURACY_DECAY = 10000000; //1s 25 | public const long ACCURACY_WORST = 70000000; //7s 26 | 27 | public string Id { get; set; } 28 | 29 | public Party Party { get; set; } 30 | 31 | public string RemoteControl { get; set; } //Session this attendee is controlling 32 | public string TargetId { get => SafeTargetId(new List() {Id}); } 33 | public bool IsBeingRemoteControlled { get; set; } 34 | 35 | public long Ping; //ms 36 | public List PendingPing { get; set; } 37 | 38 | public string UserId { get; set; } 39 | 40 | /* Fields used for client-side display */ 41 | public string UserName { get; set; } 42 | public string DeviceName { get; set; } 43 | public string DeviceType { get; set; } 44 | public string DisplayName { get; set; } 45 | /* --- */ 46 | 47 | private DateTimeOffset _lastStateChange; 48 | private AttendeeState _state; 49 | public AttendeeState State { 50 | get => _state; 51 | set { 52 | _state = value; 53 | _lastStateChange = DateTimeOffset.UtcNow; 54 | } 55 | } 56 | public long MsSinceStateChange { get => (long)Math.Floor((DateTimeOffset.UtcNow - _lastStateChange).TotalMilliseconds); } 57 | 58 | public string ReportedDeviceId { get; set; } 59 | public string PlaySessionId { get; set; } 60 | public long? CurrentMediaItemId { get; set; } 61 | public long RunTimeTicks { get; set; } 62 | 63 | public bool IsPaused { get; set; } 64 | 65 | public bool IsHost { get; set; } 66 | 67 | private long _positionTicks; 68 | private DateTimeOffset _lastUpdate; 69 | public long PositionTicks { 70 | get => _positionTicks; 71 | set { 72 | _positionTicks = value; 73 | _lastUpdate = DateTimeOffset.UtcNow; 74 | } 75 | } 76 | public DateTimeOffset LastUpdate { get => _lastUpdate; } 77 | public long TicksSinceUpdate { get => (long)Math.Floor((DateTimeOffset.UtcNow - _lastUpdate).TotalMilliseconds) * 10000; } 78 | public long EstimatedPositionTicks { get => IsPaused ? PositionTicks : PositionTicks + TicksSinceUpdate; } 79 | 80 | private long _accuracy = ACCURACY_START; 81 | 82 | private HashSet _ignores = new HashSet(); 83 | 84 | public Attendee() 85 | { 86 | State = AttendeeState.Idle; 87 | PendingPing = new List(); 88 | } 89 | 90 | public void IgnoreNext(ProgressEvent command) 91 | { 92 | _ignores.Add(command); 93 | } 94 | 95 | public bool ShouldIgnore(ProgressEvent command) 96 | { 97 | if (_ignores.Contains(command)) 98 | { 99 | _ignores.Remove(command); 100 | return true; 101 | } 102 | return false; 103 | } 104 | 105 | public void ClearIgnores() 106 | { 107 | _ignores.Clear(); 108 | } 109 | 110 | public bool IsOutOfSync(long ticks) 111 | { 112 | return ticks < EstimatedPositionTicks - _accuracy || ticks > EstimatedPositionTicks + _accuracy; 113 | } 114 | 115 | public long UpdatePositionTicksFromEstimate() 116 | { 117 | PositionTicks = EstimatedPositionTicks; 118 | return PositionTicks; 119 | } 120 | 121 | public long LowerAccuracy() 122 | { 123 | if (_accuracy < ACCURACY_WORST) 124 | { 125 | _accuracy += ACCURACY_DECAY; 126 | } 127 | return _accuracy; 128 | } 129 | 130 | public void ResetAccuracy() 131 | { 132 | _accuracy = ACCURACY_START; 133 | } 134 | 135 | public string SafeTargetId(List except) 136 | { 137 | //Prevents endless recursion 138 | if (RemoteControl != null) 139 | { 140 | Attendee target = Party.GetAttendee(RemoteControl); 141 | if (target != null && !except.Contains(target.Id)) 142 | { 143 | except.Add(target.Id); 144 | return target.SafeTargetId(except); 145 | } 146 | return RemoteControl; 147 | } 148 | return Id; 149 | } 150 | 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Plugin.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common.Configuration; 2 | using MediaBrowser.Common.Plugins; 3 | using MediaBrowser.Model.Logging; 4 | using MediaBrowser.Model.Serialization; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Reflection; 10 | using System.Runtime.CompilerServices; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace EmbyParty 15 | { 16 | public class Plugin : BasePlugin 17 | { 18 | 19 | private string[] extractResourcesNames = { 20 | "EmbyParty.dashboard_ui" 21 | }; 22 | 23 | private string _resourcesPath; 24 | private string _pluginDataPath; 25 | private ILogger _logger; 26 | 27 | private List> _partyManagerSetHandlers = new List>(); 28 | 29 | private PartyManager _partyManager; 30 | public PartyManager PartyManager { 31 | get => _partyManager; 32 | set { 33 | _partyManager = value; 34 | foreach (Action action in _partyManagerSetHandlers) 35 | { 36 | action(value); 37 | } 38 | _partyManagerSetHandlers.Clear(); 39 | } 40 | } 41 | 42 | public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogManager logManager) : base(applicationPaths, xmlSerializer) 43 | { 44 | _resourcesPath = applicationPaths.ProgramSystemPath; 45 | _logger = logManager.GetLogger("Party"); 46 | _pluginDataPath = Path.Combine(applicationPaths.DataPath, "EmbyParty"); 47 | } 48 | 49 | public override string Name => "Emby Party"; 50 | 51 | public override string Description => "Easy to use solution for synchronizing user playback experiences"; 52 | 53 | public override Guid Id => new Guid("A7572F2B-B3E5-4055-945D-640A57D2C09B"); 54 | 55 | public void OnPartyManagerSet(Action action) 56 | { 57 | if (PartyManager != null) 58 | { 59 | action(PartyManager); 60 | } 61 | else 62 | { 63 | _partyManagerSetHandlers.Add(action); 64 | } 65 | } 66 | 67 | public override void OnUninstalling() 68 | { 69 | base.OnUninstalling(); 70 | 71 | CleanupResources(true); 72 | } 73 | 74 | public void ExtractResources() 75 | { 76 | Assembly assembly = Assembly.GetAssembly(typeof(Plugin)); 77 | bool update = ReadResourceVersion() != Version.ToString(); 78 | 79 | foreach (string resourceName in assembly.GetManifestResourceNames()) 80 | { 81 | if (extractResourcesNames.Where(prefix => resourceName.StartsWith(prefix)).Count() == 0) { continue; } 82 | 83 | string outputPath = Path.Combine(_resourcesPath, ConvertResourceNameToPath(assembly, resourceName)); 84 | 85 | if (!update && File.Exists(outputPath)) { continue; } 86 | 87 | _logger.Info("Extracting resource " + resourceName); 88 | 89 | Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); 90 | 91 | using (Stream resourceStream = assembly.GetManifestResourceStream(resourceName)) 92 | using (FileStream fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) 93 | { 94 | resourceStream?.CopyTo(fileStream); 95 | } 96 | } 97 | 98 | if (update) { WriteResourceVersion(); } 99 | } 100 | 101 | private void CleanupResources(bool andDirectory) 102 | { 103 | Assembly assembly = Assembly.GetAssembly(typeof(Plugin)); 104 | foreach (string resourceName in assembly.GetManifestResourceNames()) 105 | { 106 | if (extractResourcesNames.Where(prefix => resourceName.StartsWith(prefix)).Count() == 0) { continue; } 107 | 108 | string outputPath = Path.Combine(_resourcesPath, ConvertResourceNameToPath(assembly, resourceName)); 109 | 110 | _logger.Info("Deleting resource " + resourceName); 111 | 112 | File.Delete(outputPath); 113 | 114 | string dir = Path.GetDirectoryName(outputPath); 115 | if (andDirectory && Directory.GetFileSystemEntries(dir).Length == 0) 116 | { 117 | Directory.Delete(dir); 118 | } 119 | } 120 | } 121 | 122 | private string ConvertResourceNameToPath(Assembly assembly, string resourceName) 123 | { 124 | string assemblyNamespace = assembly.GetName().Name; 125 | string relativePath = resourceName.Replace(assemblyNamespace + ".", ""); 126 | 127 | int lastDotIndex = relativePath.LastIndexOf('.'); 128 | if (lastDotIndex > -1) 129 | { 130 | relativePath = relativePath.Substring(0, lastDotIndex) 131 | .Replace('.', Path.DirectorySeparatorChar) 132 | .Replace('_', '-') 133 | + relativePath.Substring(lastDotIndex); 134 | } 135 | 136 | return relativePath; 137 | } 138 | 139 | private void WriteResourceVersion() 140 | { 141 | Directory.CreateDirectory(_pluginDataPath); 142 | File.WriteAllText(Path.Combine(_pluginDataPath, "rversion.txt"), Version.ToString()); 143 | } 144 | 145 | private string ReadResourceVersion() 146 | { 147 | try 148 | { 149 | return File.ReadAllText(Path.Combine(_pluginDataPath, "rversion.txt")); 150 | } 151 | catch 152 | { 153 | return ""; 154 | } 155 | } 156 | 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Api/WebSocketListener.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common; 2 | using MediaBrowser.Controller.Net; 3 | using MediaBrowser.Controller.Plugins; 4 | using MediaBrowser.Controller.Session; 5 | using MediaBrowser.Model.Logging; 6 | using MediaBrowser.Model.Serialization; 7 | using MediaBrowser.Model.Services; 8 | using MediaBrowser.Model.Session; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.IO; 12 | using System.Linq; 13 | using System.Text; 14 | using System.Threading.Tasks; 15 | 16 | namespace EmbyParty.Api 17 | { 18 | 19 | public sealed class ChatMessage 20 | { 21 | public string Message { get; set; } 22 | } 23 | 24 | public sealed class NameMessage 25 | { 26 | public string Name { get; set; } 27 | } 28 | 29 | public sealed class RemoteControlMessage 30 | { 31 | public string RemoteControl { get; set; } 32 | } 33 | 34 | public sealed class PingMessage 35 | { 36 | public long ts { get; set; } 37 | } 38 | 39 | public class WebSocketListener : IWebSocketListener 40 | { 41 | private ILogger _logger; 42 | private Plugin _plugin; 43 | private IJsonSerializer _jsonSerializer; 44 | private ISessionManager _sessionManager; 45 | 46 | protected PartyManager PartyManager; 47 | 48 | public WebSocketListener(ILogManager logManager, IApplicationHost applicationHost, IJsonSerializer jsonSerializer, ISessionManager sessionManager) 49 | { 50 | _logger = logManager.GetLogger("Party"); 51 | _plugin = applicationHost.Plugins.OfType().First(); 52 | _jsonSerializer = jsonSerializer; 53 | _sessionManager = sessionManager; 54 | _plugin.OnPartyManagerSet(partyManager => { PartyManager = partyManager; }); 55 | } 56 | 57 | protected void Log(string message) 58 | { 59 | _logger.Info(message); 60 | } 61 | 62 | protected void Debug(string message) 63 | { 64 | _logger.Debug(message); 65 | } 66 | 67 | public Task ProcessMessage(WebSocketMessageInfo message) 68 | { 69 | SessionInfo session = _sessionManager.GetSessionByAuthenticationToken(message.Connection.QueryString["api_key"], message.Connection.QueryString["deviceId"], message.Connection.RemoteAddress, null); 70 | if (session == null) { return Task.CompletedTask; } 71 | 72 | Debug("Websocket message: " + session.Id + " > " + message.MessageType + " > " + message.Data); 73 | 74 | if (message.MessageType == "Chat") 75 | { 76 | ChatMessage chat = _jsonSerializer.DeserializeFromString(message.Data); 77 | 78 | Party party = PartyManager.GetAttendeeParty(session.Id); 79 | if (party == null) { return Task.CompletedTask; } 80 | 81 | Attendee attendee = party.GetAttendee(session.Id); 82 | 83 | string msgtext = chat.Message.Substring(0, Math.Min(chat.Message.Length, 512)); 84 | if (msgtext.Length == 0) { return Task.CompletedTask; } 85 | 86 | GeneralCommand bounceCommand = new GeneralCommand() { Name = "ChatBroadcast" }; 87 | bounceCommand.Arguments["UserId"] = attendee.UserId; 88 | bounceCommand.Arguments["Name"] = attendee.DisplayName; 89 | bounceCommand.Arguments["Message"] = msgtext; 90 | 91 | party.SendEventToAttendees(null, bounceCommand); 92 | PartyManager.BroadcastToBridges("GeneralCommand", party.Name, bounceCommand); 93 | } 94 | 95 | //Keep track of remote control target 96 | if (message.MessageType == "PartyUpdateRemoteControl") 97 | { 98 | RemoteControlMessage updateRemoteControl = _jsonSerializer.DeserializeFromString(message.Data); 99 | 100 | Party party = PartyManager.GetAttendeeParty(session.Id); 101 | if (party == null) { return Task.CompletedTask; } 102 | 103 | party.SetRemoteControl(session.Id, updateRemoteControl.RemoteControl); 104 | } 105 | 106 | //Keep alive 107 | if (message.MessageType == "PartyPing") 108 | { 109 | Party party = PartyManager.GetAttendeeParty(session.Id); 110 | if (party == null) { return Task.CompletedTask; } 111 | Attendee attendee = party.GetAttendee(session.Id); 112 | party.SendEventToSingleAttendee(attendee, new GeneralCommand() { Name = "PartyPong" }); 113 | } 114 | if (message.MessageType == "PartyPong") 115 | { 116 | DateTimeOffset now = DateTimeOffset.UtcNow; 117 | 118 | Party party = PartyManager.GetAttendeeParty(session.Id); 119 | if (party == null) { return Task.CompletedTask; } 120 | Attendee attendee = party.GetAttendee(session.Id); 121 | 122 | PingMessage ping = _jsonSerializer.DeserializeFromString(message.Data); 123 | if (attendee.PendingPing.Remove(ping.ts)) { 124 | attendee.Ping = (now.ToUnixTimeMilliseconds() - ping.ts) / 2; 125 | } 126 | } 127 | 128 | //User refreshed/reopened the page 129 | if (message.MessageType == "PartyRefresh") 130 | { 131 | Party party = PartyManager.GetAttendeeParty(session.Id); 132 | if (party == null) { return Task.CompletedTask; } 133 | 134 | if (party.CurrentQueue != null) 135 | { 136 | Attendee attendee = party.GetAttendee(session.Id); 137 | 138 | if (!attendee.IsHost) 139 | { 140 | PartyManager.LateJoiner(party, attendee); 141 | } 142 | else 143 | { 144 | PartyManager.RemoveFromParty(attendee.Id); 145 | } 146 | 147 | _sessionManager.SendGeneralCommand(null, attendee.Id, new GeneralCommand() { Name = "PartyRefreshDone" }, new System.Threading.CancellationToken()); 148 | } 149 | } 150 | 151 | //Host tools for when guest doesn't respond 152 | if (message.MessageType == "PartyAttendeePlay" || message.MessageType == "PartyAttendeeKick") 153 | { 154 | NameMessage nameMessage = _jsonSerializer.DeserializeFromString(message.Data); 155 | 156 | Party party = PartyManager.GetAttendeeParty(session.Id); 157 | if (party == null) { return Task.CompletedTask; } 158 | 159 | Attendee attendee = party.GetAttendee(session.Id); 160 | if (!attendee.IsHost) { return Task.CompletedTask; } 161 | 162 | Attendee target = party.GetAttendeeByDisplayName(nameMessage.Name); 163 | if (target == null || target.State != AttendeeState.WaitForPlay && target.State != AttendeeState.WaitForSeek || target.MsSinceStateChange < 20000) { return Task.CompletedTask; } 164 | 165 | Debug("-----Party attendee action"); 166 | 167 | if (message.MessageType == "PartyAttendeePlay") 168 | { 169 | PartyManager.SyncPartyPlay(party, new Attendee[] { target }, target.State, PlayCommand.PlayNow); 170 | } 171 | 172 | if (message.MessageType == "PartyAttendeeKick") 173 | { 174 | PartyManager.RemoveFromParty(target.Id); 175 | 176 | GeneralCommand notification = new GeneralCommand() { Name = "PartyLeave" }; 177 | notification.Arguments["Name"] = target.DisplayName; 178 | notification.Arguments["IsHosting"] = "false"; 179 | party.SendEventToSingleAttendee(target, notification); 180 | } 181 | } 182 | 183 | return Task.CompletedTask; 184 | } 185 | 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/partyheader.css: -------------------------------------------------------------------------------- 1 | /* Header */ 2 | 3 | .ef-partyheader { 4 | display: none; 5 | } 6 | 7 | .ef-partyheader.inparty { 8 | background-color: hsla(var(--theme-primary-color-hue),var(--theme-primary-color-saturation),var(--theme-primary-color-lightness),1); 9 | color: white; 10 | border-radius: 0.6em; 11 | } 12 | 13 | .ef-partyheader .headerPartyName { 14 | font-weight: bold; 15 | vertical-align: middle; 16 | } 17 | 18 | 19 | /* Emby style mods and embedded elements */ 20 | 21 | .videoOsdHeader .ef-partyheader .headerButton { 22 | display: inline-block !important; 23 | } 24 | 25 | .videoOsd-belowtransportbuttons .videoOsd-sync { 26 | display: none; 27 | animation: syncspin 4s linear infinite; 28 | } 29 | .videoOsd-belowtransportbuttons .videoOsd-ready { 30 | display: none; 31 | } 32 | .nowPlayingBar .syncPlaceholder { 33 | animation: syncspin 4s linear infinite; 34 | } 35 | @keyframes syncspin { 36 | 100% { 37 | transform:rotate(-360deg); 38 | } 39 | } 40 | 41 | .appcontainer { 42 | position: absolute; 43 | top: 0; bottom: 0; 44 | right: 0; 45 | left: 0; 46 | } 47 | 48 | .appcontainer > .skinBody { 49 | position: relative; 50 | overflow: hidden; 51 | height: 100%; 52 | width: 100%; 53 | } 54 | 55 | .appcontainer > .skinBody .skinHeader, 56 | .appcontainer > .skinBody .appfooter { 57 | position: absolute; 58 | } 59 | 60 | body { 61 | background-color: hsl(var(--background-hue),var(--background-saturation),var(--background-lightness)); 62 | --party-sidebar-width: 0px; 63 | } 64 | 65 | 66 | /* Sidebar */ 67 | 68 | .party-sidebar { 69 | position: fixed; 70 | top: 0; bottom: 0; 71 | right: 0; 72 | width: 0; 73 | border-left: 1px solid black; 74 | cursor: default; 75 | z-index: 2002; 76 | --party-alert-hue: 39; 77 | --party-alert-saturation: 100%; 78 | --party-alert-lightness: 50%; 79 | --party-color-alert: hsl(var(--party-alert-hue),var(--party-alert-saturation),var(--party-alert-lightness)); 80 | --party-color-kick: rgb(255,105,0); 81 | --party-box-normal: hsla(var(--card-background-hue),var(--card-background-saturation),var(--card-background-lightness),var(--card-background-alpha)); 82 | --party-box-extra: hsla(var(--card-background-hue),var(--card-background-saturation),calc(var(--card-background-lightness)*1.3),var(--card-background-alpha)); 83 | --party-box-head: hsla(var(--card-background-hue),var(--card-background-saturation),calc(var(--card-background-lightness)*1.7),calc(var(--card-background-alpha) * 0.8)); 84 | --party-box-chat: hsla(var(--card-background-hue),var(--card-background-saturation),calc(var(--card-background-lightness)*2),calc(var(--card-background-alpha) + 0.1)); 85 | --party-box-externalchat: rgba(120, 120, 140, calc(var(--card-background-alpha) + 0.1)); 86 | } 87 | .theme-light .party-sidebar { 88 | --party-box-normal: hsla(var(--card-background-hue),calc(var(--card-background-saturation)*0.8),calc(var(--card-background-lightness)*0.8),var(--card-background-alpha)); 89 | --party-box-extra: hsla(var(--card-background-hue),var(--card-background-saturation),calc(var(--card-background-lightness)*0.77),var(--card-background-alpha)); 90 | --party-box-head: hsla(var(--card-background-hue),calc(var(--card-background-saturation)*0.5),calc(var(--card-background-lightness)*0.7),calc(var(--card-background-alpha) * 0.8)); 91 | --party-box-chat: hsla(var(--card-background-hue),calc(var(--card-background-saturation)*0.5),calc(var(--card-background-lightness)*0.92),calc(var(--card-background-alpha) + 0.1)); 92 | --party-box-externalchat: rgba(220, 220, 255, calc(var(--card-background-alpha) + 0.1)); 93 | } 94 | .party-sidebar:not(.undocked) { 95 | background-color: hsl(var(--background-hue),var(--background-saturation),var(--background-lightness)); 96 | } 97 | .party-sidebar::after { 98 | content: ''; 99 | position: absolute; 100 | left: 0; 101 | width: 5px; 102 | height: 100%; 103 | cursor: ew-resize; 104 | } 105 | 106 | .mouseIdle .party-sidebar button, 107 | .mouseIdle .party-sidebar textarea { 108 | cursor: inherit !important; 109 | } 110 | 111 | .party-name button { 112 | position: absolute; 113 | scale: 0.5; 114 | z-index: 10; 115 | } 116 | .party-name .btnPartyDock, 117 | .party-name .btnPartyUndock { 118 | transform-origin: top right; 119 | right: 1px; top: 2px; 120 | } 121 | .party-name .btnPartyWipe { 122 | transform-origin: bottom right; 123 | right: 1px; bottom: 2px; 124 | } 125 | 126 | .party-sidebar .profilepic { 127 | display: inline-block; 128 | width: 40px; height: 40px; 129 | font-size: 1.66956521739130434em; 130 | line-height: 1.3; 131 | text-align: center; 132 | background-color: hsla(var(--button-background-hue),var(--button-background-saturation),var(--button-background-lightness),var(--button-background-alpha)); 133 | flex-grow: 0; 134 | } 135 | 136 | .party-sidebarflex { 137 | display: flex; 138 | flex-direction: column; 139 | position: absolute; 140 | top: 0; 141 | bottom: 0; 142 | left: 0; right: 0; 143 | row-gap: 5px; 144 | background: var(--docked-drawer-background); 145 | } 146 | 147 | .party-sidebar .party-name { 148 | text-align: center; 149 | background-color: var(--party-box-head); 150 | overflow: hidden; 151 | height: 80px; 152 | position: relative; 153 | flex-shrink: 0; 154 | } 155 | 156 | .party-sidebar .party-name > h3 { 157 | margin: 0; 158 | position: absolute; 159 | transform: translateY(-50%); 160 | top: 50%; 161 | left: 0; right: 0; 162 | padding: 0 10px; 163 | } 164 | 165 | .party-attendees { 166 | margin: 5px 5px 10px 5px; 167 | border-bottom: 1px solid hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-secondary-text-color-alpha)); 168 | padding-top: 2px; 169 | padding-bottom: 5px; 170 | flex-shrink: 0; 171 | max-height: 40%; 172 | overflow-y: hidden; 173 | } 174 | .party-attendees:hover { 175 | overflow-y: auto; 176 | } 177 | 178 | .party-attendees > div { 179 | margin: 5px 0; 180 | display: flex; 181 | column-gap: 8px; 182 | height: 40px; 183 | overflow: hidden; 184 | border-radius: 20px; 185 | padding: 1px; 186 | } 187 | .party-attendees > .meSeeks { 188 | background-color: hsla(var(--party-alert-hue),var(--party-alert-saturation),calc(var(--party-alert-lightness)), 0.1); 189 | } 190 | .theme-light .party-attendees > .meSeeks{ 191 | background-color: hsla(var(--party-alert-hue),var(--party-alert-saturation),calc(var(--party-alert-lightness)), 0.2); 192 | } 193 | .undocked .party-attendees > div { 194 | background-color: var(--party-box-normal); 195 | } 196 | .undocked .party-attendees > .meSeeks { 197 | background-color: hsla(var(--party-alert-hue),var(--party-alert-saturation),calc(var(--party-alert-lightness)*0.6), 0.5); 198 | } 199 | 200 | .party-attendees > .party-ishost { 201 | color: var(--theme-accent-text-color); 202 | } 203 | 204 | .party-attendees .profilepic { 205 | border-radius: 1000px; 206 | position: relative; 207 | } 208 | .party-attendees .party-isme .profilepic { 209 | outline: 2px solid hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-text-color-alpha)); 210 | outline-offset: -1px; 211 | } 212 | .party-attendees .party-isme.meSeeks .profilepic { 213 | outline-color: var(--party-color-alert); 214 | } 215 | .party-attendees .name { 216 | font-weight: bold; 217 | flex-grow: 1; 218 | position: relative; 219 | } 220 | .party-attendees .name > div { 221 | position: absolute; 222 | top: 50%; 223 | transform: translateY(-50%); 224 | max-height: 40px; 225 | line-height: 1.2; 226 | } 227 | 228 | .party-attendees .name .actualname { 229 | margin-right: 3px; 230 | } 231 | .party-attendees .name .md-icon { 232 | margin: 0 3px 2px 0; 233 | } 234 | .party-attendees .name .isWaiting, 235 | .party-attendees .name .isNotResponding { 236 | animation: syncspin 4s linear infinite; 237 | } 238 | .party-attendees .name .isSyncing { 239 | color: var(--theme-accent-text-color); 240 | } 241 | .party-attendees .name .isNotResponding { 242 | color: var(--party-color-alert); 243 | } 244 | .party-attendees .name .btnSendAttendeeKick { 245 | color: var(--party-color-kick); 246 | } 247 | .party-attendees .name button { 248 | width: 1.2em; 249 | height: 1.2em; 250 | font-size: inherit; 251 | padding: 0; 252 | } 253 | 254 | .htmlVideoPlayerContainer { 255 | right: var(--party-sidebar-width); 256 | } 257 | 258 | .party-logcontainer { 259 | flex-grow: 1; 260 | overflow-y: hidden; 261 | scrollbar-gutter: stable; 262 | } 263 | .party-logcontainer:hover { 264 | overflow-y: auto; 265 | } 266 | 267 | .party-log { 268 | display: flex; 269 | justify-content: end; 270 | flex-direction: column; 271 | row-gap: 8px; 272 | padding: 0 5px; 273 | min-height: 100%; 274 | } 275 | 276 | .chatseparator { 277 | border-bottom: 1px solid var(--line-background); 278 | margin: 5px auto; 279 | width: 80%; 280 | } 281 | 282 | .chatmessage { 283 | border-radius: 10px; 284 | display: flex; 285 | column-gap: 4px; 286 | } 287 | .undocked .chatmessage { 288 | background-color: var(--party-box-chat); 289 | } 290 | .externalmessage.chatmessage { 291 | background-color: var(--party-box-externalchat); 292 | } 293 | .undocked .generic.chatmessage { 294 | background-color: var(--party-box-normal); 295 | } 296 | 297 | .chatmessage .profilepic { 298 | border-radius: 10px; 299 | margin: 3px; 300 | position: relative; 301 | min-width: 40px; 302 | } 303 | .party-attendees .profilepic > i, 304 | .chatmessage .profilepic > i { 305 | position: absolute; 306 | top: 50%; 307 | left: 50%; 308 | transform: translate(-50%, -50%); 309 | } 310 | 311 | .chatmessage > div { 312 | margin-top: 2px; 313 | flex-grow: 1; 314 | position: relative; 315 | overflow: hidden; 316 | } 317 | .generic.chatmessage > div { 318 | padding: 1px 5px; 319 | } 320 | 321 | .generic.chatmessage .message { 322 | color: hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-secondary-text-color-alpha)); 323 | font-size: 0.9em; 324 | padding-right: 42px; 325 | } 326 | 327 | .generic.chatmessage .message b { 328 | display: inline-block; 329 | background-color: var(--party-box-extra); 330 | color: hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-secondary-text-color-alpha)); 331 | border-radius: 5px; 332 | padding: 1px 3px; 333 | margin: 0 2px 2px 0; 334 | } 335 | .generic.chatmessage .message b > .md-icon { 336 | display: inline-block; 337 | margin-right: 2px; 338 | width: 1em; 339 | vertical-align: -2px; 340 | } 341 | 342 | .chatmessage .name { 343 | max-height: 40px; 344 | line-height: 1.2; 345 | font-weight: bold; 346 | padding-right: 42px; 347 | overflow: hidden; 348 | } 349 | 350 | .chatmessage .timestamp { 351 | position: absolute; 352 | top: 0; 353 | right: 5px; 354 | font-size: 0.8em; 355 | color: hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-secondary-text-color-alpha)); 356 | } 357 | 358 | .chatmessage .spoiler { 359 | background-color: hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness)); 360 | border-radius: 3px; 361 | } 362 | .chatmessage .spoiler:hover { 363 | background-color: inherit; 364 | } 365 | .chatmessage .spoiler:click { 366 | background-color: inherit; 367 | } 368 | 369 | .chatmessage pre { 370 | margin: 0; 371 | padding: 5px 3px; 372 | white-space: pre-wrap; 373 | display: inline; 374 | } 375 | 376 | .party-send { 377 | padding-left: 8px; 378 | } 379 | .party-send .chatwrap { 380 | width: calc(100% - 70px); 381 | position: relative; 382 | height: 60px; 383 | display: inline-block; 384 | } 385 | .party-send .chatwrap > textarea { 386 | resize: none; 387 | position: absolute; 388 | top: 0; bottom: 0; 389 | left: 0; right: 0; 390 | font-family: noto sans, sans serif; 391 | font-size: 10pt; 392 | background: hsla(var(--input-background-hue),var(--input-background-saturation),var(--input-background-lightness),var(--button-background-alpha)); 393 | border: 0; 394 | border-radius: 3px; 395 | color: hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness),var(--theme-text-color-alpha)); 396 | outline: 1px solid var(--line-background); 397 | } 398 | .party-send .chatwrap > textarea:focus { 399 | outline: 2px solid hsla(var(--theme-text-color-hue),var(--theme-text-color-saturation),var(--theme-text-color-lightness)); 400 | } 401 | .theme-light .party-send .chatwrap > textarea:focus { 402 | outline: 2px solid var(--theme-accent-text-color-lightbg); 403 | } 404 | .party-send button.btnChatSend { 405 | margin-top: -38px !important; 406 | background-color: hsla(var(--button-background-hue),var(--button-background-saturation),var(--button-background-lightness),var(--button-background-alpha)); 407 | } 408 | -------------------------------------------------------------------------------- /Party.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Controller.Library; 3 | using MediaBrowser.Controller.Session; 4 | using MediaBrowser.Model.Entities; 5 | using MediaBrowser.Model.Logging; 6 | using MediaBrowser.Model.Services; 7 | using MediaBrowser.Model.Session; 8 | using MediaBrowser.Model.System; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.IO; 12 | using System.Linq; 13 | using System.Text; 14 | using System.Timers; 15 | 16 | namespace EmbyParty 17 | { 18 | public class Party 19 | { 20 | private PartyManager _partyManager; 21 | private ISessionManager _sessionManager; 22 | private IUserManager _userManager; 23 | private ILogger _logger; 24 | 25 | private long _id; 26 | private Dictionary _attendees = new Dictionary(); 27 | 28 | private Dictionary _attendeeTargetMap = new Dictionary(); //Map target to remote controller 29 | 30 | private Random _tagCharacterRng = new Random(); 31 | 32 | public long Id { get => _id; } 33 | 34 | public string Name { get; set; } 35 | 36 | public long[] CurrentQueue { get; set; } 37 | public int CurrentIndex { get; set; } 38 | public string MediaSourceId { get; set; } 39 | public int? AudioStreamIndex { get; set; } 40 | public int? SubtitleStreamIndex { get; set; } 41 | public long? PreviousItem { get; set; } 42 | 43 | public bool NoUnpauseAfterSync { get; set; } //Store whether host sync is taking place from a starting paused or unpaused state 44 | public bool InSyncConclusion { get; set; } //True between the final guest reporting playback and the end of the sync process (host pending unpause), prevents out of order pausing 45 | public bool GuestPauseAction { get; set; } 46 | public Timer LateClearHost { get; set; } 47 | 48 | public Attendee Host { get => _attendees.Values.FirstOrDefault(attendee => attendee.IsHost); } 49 | 50 | public Dictionary.ValueCollection Attendees { get => _attendees.Values; } 51 | 52 | public IEnumerable AttendeePlayers { get => _attendees.Values.Where(attendee => !attendee.IsBeingRemoteControlled); } 53 | 54 | public Dictionary.KeyCollection AttendeeTargets { get => _attendeeTargetMap.Keys; } 55 | 56 | public IEnumerable AttendeesPaused { get => _attendees.Values.Where(attendee => attendee.IsPaused); } 57 | 58 | public Party(long id, PartyManager partyManager, ISessionManager sessionManager, IUserManager userManager, ILogger logger) 59 | { 60 | _partyManager = partyManager; 61 | _sessionManager = sessionManager; 62 | _userManager = userManager; 63 | _logger = logger; 64 | _id = id; 65 | } 66 | 67 | public void Log(string message) 68 | { 69 | _logger.Info(message); 70 | } 71 | 72 | protected void LogWarning(string message) 73 | { 74 | _logger.Warn(message); 75 | } 76 | 77 | private string randomTag(int length) 78 | { 79 | string pool = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 80 | string result = ""; 81 | for (int i = 0; i < length; i++) 82 | { 83 | result += pool.Substring(_tagCharacterRng.Next(0, pool.Length), 1); 84 | } 85 | return result; 86 | } 87 | 88 | private string[] GenerateAlternativeNames(Attendee attendee) 89 | { 90 | return new string[] { 91 | attendee.UserName + " - " + attendee.DeviceName, 92 | attendee.UserName + " " + randomTag(2) 93 | }; 94 | } 95 | 96 | public Attendee AttendeeJoin(string sessionId, SessionInfo session) 97 | { 98 | if (_attendees.ContainsKey(sessionId)) { return null; } 99 | 100 | Attendee newAttendee = new Attendee() 101 | { 102 | Id = sessionId, 103 | Party = this, 104 | UserId = session.UserId, 105 | UserName = session.UserName, 106 | DeviceName = session.DeviceName, 107 | DeviceType = session.DeviceType, 108 | DisplayName = session.UserName 109 | }; 110 | 111 | //Resolve name collisions 112 | 113 | string[] altNames = GenerateAlternativeNames(newAttendee); 114 | int altNameIndex = 0; 115 | Attendee currentDuplicate = null; 116 | Attendee duplicate = GetAttendeeByDisplayName(newAttendee.DisplayName); 117 | string oldDuplicateName = null; 118 | while (duplicate != null) 119 | { 120 | newAttendee.DisplayName = altNames[altNameIndex]; 121 | string[] duplicateAlts = GenerateAlternativeNames(duplicate); 122 | currentDuplicate = duplicate; 123 | if (oldDuplicateName == null) { oldDuplicateName = currentDuplicate.DisplayName; } 124 | currentDuplicate.DisplayName = duplicateAlts[altNameIndex]; 125 | duplicate = GetAttendeeByDisplayName(newAttendee.DisplayName); 126 | if (altNameIndex < altNames.Length - 1) { altNameIndex++; } 127 | } 128 | 129 | if (oldDuplicateName != null) 130 | { 131 | GeneralCommand renameCommand = new GeneralCommand() { Name = "PartyUpdateName" }; 132 | renameCommand.Arguments["OldName"] = oldDuplicateName; 133 | renameCommand.Arguments["NewName"] = currentDuplicate.DisplayName; 134 | SendEventToAttendees(null, renameCommand); 135 | } 136 | 137 | _attendees.Add(sessionId, newAttendee); 138 | 139 | //Remote control map 140 | 141 | if (_attendeeTargetMap.ContainsKey(newAttendee.Id)) 142 | { 143 | newAttendee.IsBeingRemoteControlled = true; 144 | } 145 | 146 | //Other stuff 147 | 148 | User user = _userManager.GetUserById(session.UserId); 149 | bool hasPicture = user.GetImages(ImageType.Primary).Count() > 0; 150 | 151 | GeneralCommand command = new GeneralCommand() { Name = "PartyJoin" }; 152 | command.Arguments["UserId"] = newAttendee.UserId; 153 | command.Arguments["HasPicture"] = hasPicture ? "true" : "false"; 154 | command.Arguments["Name"] = newAttendee.DisplayName; 155 | command.Arguments["IsRemoteControlled"] = newAttendee.IsBeingRemoteControlled ? "true" : "false"; 156 | SendEventToAttendees(sessionId, command); 157 | 158 | return newAttendee; 159 | } 160 | 161 | public bool SetRemoteControl(string sessionId, string remoteControl) 162 | { 163 | if (sessionId == null) { return false; } 164 | if (sessionId == remoteControl) { remoteControl = null; } 165 | Attendee attendee = GetAttendee(sessionId); 166 | if (attendee == null) { return false; } 167 | 168 | Log("Request set remote control in " + Name + ": " + sessionId + " to control " + remoteControl); 169 | 170 | if (attendee.RemoteControl == remoteControl) 171 | { 172 | //sessionId is already controlling remoteControl 173 | return true; 174 | } 175 | 176 | if (attendee.RemoteControl != null) 177 | { 178 | //Attendee loses control over its former RemoteControl session, since we're replacing it 179 | Attendee formerTarget = GetAttendee(attendee.RemoteControl); 180 | if (formerTarget != null) 181 | { 182 | formerTarget.IsBeingRemoteControlled = false; 183 | Log("Former target " + formerTarget.Id + " is now marked as not remote controlled."); 184 | 185 | GeneralCommand updateRemoteControlled = new GeneralCommand() { Name = "PartyUpdateRemoteControlled" }; 186 | updateRemoteControlled.Arguments["Name"] = formerTarget.DisplayName; 187 | updateRemoteControlled.Arguments["Status"] = "false"; 188 | SendEventToAttendees(null, updateRemoteControlled); 189 | } 190 | if (_attendeeTargetMap.ContainsKey(attendee.RemoteControl)) 191 | { 192 | _attendeeTargetMap.Remove(attendee.RemoteControl); 193 | } 194 | } 195 | 196 | Attendee newTarget = null; 197 | if (remoteControl != null) 198 | { 199 | //Attendee will control remoteControl 200 | newTarget = GetAttendee(remoteControl); 201 | if (newTarget != null) 202 | { 203 | newTarget.IsBeingRemoteControlled = true; 204 | Log("New target " + newTarget.Id + " is now marked as remote controlled."); 205 | 206 | GeneralCommand updateRemoteControlled = new GeneralCommand() { Name = "PartyUpdateRemoteControlled" }; 207 | updateRemoteControlled.Arguments["Name"] = newTarget.DisplayName; 208 | updateRemoteControlled.Arguments["Status"] = "true"; 209 | SendEventToAttendees(null, updateRemoteControlled); 210 | } 211 | _attendeeTargetMap.Add(remoteControl, attendee); 212 | } 213 | 214 | attendee.RemoteControl = remoteControl; 215 | 216 | //Log("Target map after operation: " + ControlMapForDisplay()); 217 | return true; 218 | } 219 | 220 | public Attendee AttendeePart(string sessionId) 221 | { 222 | Attendee attendee = _attendees[sessionId]; 223 | bool result = _attendees.Remove(sessionId); 224 | 225 | foreach (string eachId in _attendeeTargetMap.Keys) 226 | { 227 | if (_attendeeTargetMap[eachId].Id == sessionId) 228 | { 229 | _attendeeTargetMap.Remove(eachId); 230 | } 231 | } 232 | 233 | GeneralCommand command = new GeneralCommand() { Name = "PartyLeave" }; 234 | command.Arguments["Name"] = attendee.DisplayName; 235 | command.Arguments["IsHosting"] = attendee.IsHost ? "true" : "false"; 236 | SendEventToAttendees(sessionId, command); 237 | 238 | if (attendee.IsHost && _attendees.Count > 0) 239 | { 240 | _attendees.First().Value.IsHost = true; 241 | 242 | GeneralCommand hostnameCommand = new GeneralCommand() { Name = "PartyUpdateHost" }; 243 | hostnameCommand.Arguments["Host"] = _attendees.First().Value.DisplayName; 244 | SendEventToAttendees(null, hostnameCommand); 245 | } 246 | return result ? attendee : null; 247 | } 248 | 249 | public Attendee GetAttendee(string sessionId) 250 | { 251 | return _attendees.ContainsKey(sessionId) ? _attendees[sessionId] : null; 252 | } 253 | 254 | public string ControlMapForDisplay() 255 | { 256 | List items = new List(); 257 | foreach (string key in _attendeeTargetMap.Keys) 258 | { 259 | items.Add(key + " => " + _attendeeTargetMap[key].Id); 260 | } 261 | return String.Join(", ", items.ToArray()); 262 | } 263 | 264 | public Attendee GetAttendeeByTarget(string sessionId) 265 | { 266 | while (_attendeeTargetMap.ContainsKey(sessionId)) 267 | { 268 | sessionId = _attendeeTargetMap[sessionId].Id; 269 | } 270 | return GetAttendee(sessionId); 271 | } 272 | 273 | public Attendee GetAttendeeByDisplayName(string displayName) 274 | { 275 | foreach (Attendee attendee in _attendees.Values) 276 | { 277 | if (attendee.DisplayName.ToLower() == displayName.ToLower()) 278 | { 279 | return attendee; 280 | } 281 | } 282 | return null; 283 | } 284 | 285 | public List GetAttendingUsers() 286 | { 287 | HashSet userIds = new HashSet(); 288 | foreach (Attendee attendee in _attendees.Values) 289 | { 290 | userIds.Add(attendee.UserId); 291 | } 292 | return userIds.ToList().Select(userId => _userManager.GetUserById(userId)).ToList(); 293 | } 294 | 295 | public void SetStates(AttendeeState state) 296 | { 297 | foreach (Attendee attendee in _attendees.Values) 298 | { 299 | attendee.State = state; 300 | } 301 | } 302 | 303 | public void SetPlayerStates(AttendeeState? stateForPlayers, AttendeeState? stateForRemoteControlled) 304 | { 305 | foreach (Attendee attendee in _attendees.Values) 306 | { 307 | if (attendee.IsHost) 308 | { 309 | continue; 310 | } 311 | else if (!attendee.IsBeingRemoteControlled) 312 | { 313 | if (stateForPlayers != null) 314 | { 315 | attendee.State = (AttendeeState)stateForPlayers; 316 | } 317 | } 318 | else if (stateForRemoteControlled != null) 319 | { 320 | attendee.State = (AttendeeState)stateForRemoteControlled; 321 | } 322 | } 323 | } 324 | 325 | public bool AreAllStates(AttendeeState state) 326 | { 327 | foreach (Attendee attendee in _attendees.Values) 328 | { 329 | if (attendee.State != state) 330 | { 331 | return false; 332 | } 333 | } 334 | return true; 335 | } 336 | 337 | public bool AreAllInSync(Attendee reference) 338 | { 339 | foreach (Attendee attendee in _attendees.Values) 340 | { 341 | if (attendee.Id == reference.Id) { continue; } 342 | if (attendee.IsOutOfSync(reference.EstimatedPositionTicks)) 343 | { 344 | return false; 345 | } 346 | } 347 | return true; 348 | } 349 | 350 | public void ResetPositionTicks(long? positionTicks) 351 | { 352 | foreach (Attendee attendee in _attendees.Values) 353 | { 354 | attendee.PositionTicks = (long)positionTicks; 355 | } 356 | } 357 | 358 | public void IgnoreNext(ProgressEvent evt) 359 | { 360 | foreach (Attendee attendee in AttendeePlayers) 361 | { 362 | attendee.IgnoreNext(evt); 363 | } 364 | } 365 | 366 | public void ClearIgnores() 367 | { 368 | foreach (Attendee attendee in _attendees.Values) 369 | { 370 | attendee.ClearIgnores(); 371 | attendee.IsPaused = false; 372 | } 373 | } 374 | 375 | public void ClearOngoingInformation() 376 | { 377 | CurrentQueue = null; 378 | CurrentIndex = 0; 379 | MediaSourceId = null; 380 | AudioStreamIndex = null; 381 | SubtitleStreamIndex = null; 382 | } 383 | 384 | public void SetHost(Attendee attendee, bool state) 385 | { 386 | if (LateClearHost != null) 387 | { 388 | LateClearHost.Dispose(); 389 | LateClearHost = null; 390 | } 391 | 392 | if (attendee.IsHost != state) 393 | { 394 | attendee.IsHost = state; 395 | 396 | GeneralCommand hostnameCommand = new GeneralCommand() { Name = "PartyUpdateHost" }; 397 | hostnameCommand.Arguments["Host"] = state ? attendee.DisplayName : ""; 398 | SendEventToAttendees(null, hostnameCommand); 399 | } 400 | } 401 | 402 | public void ClearHost() 403 | { 404 | Attendee host = Host; 405 | if (host == null) { return; } 406 | 407 | SetHost(host, false); 408 | PreviousItem = null; 409 | 410 | ClearOngoingInformation(); 411 | ClearIgnores(); 412 | 413 | //Just to be sure 414 | PlaystateRequest stop = new PlaystateRequest() { Command = PlaystateCommand.Stop }; 415 | foreach (Attendee eachAttendee in Attendees) 416 | { 417 | eachAttendee.State = AttendeeState.Idle; 418 | eachAttendee.ResetAccuracy(); 419 | eachAttendee.CurrentMediaItemId = null; 420 | if (eachAttendee.Id == host.Id) { continue; } 421 | _partyManager.SendPlaystateCommandToAttendeeTarget(eachAttendee, stop); 422 | } 423 | } 424 | 425 | public void SendEventToAttendees(string except, GeneralCommand request) 426 | { 427 | foreach (Attendee attendee in Attendees) 428 | { 429 | if (except != null && attendee.Id == except) { continue; } 430 | try 431 | { 432 | _sessionManager.SendGeneralCommand(null, attendee.Id, request, new System.Threading.CancellationToken()); 433 | } 434 | catch 435 | { 436 | LogWarning("Failed to send General command (event) to " + attendee.TargetId + ": " + request.Name); 437 | } 438 | } 439 | } 440 | 441 | public void SendEventToSingleAttendee(Attendee attendee, GeneralCommand request) 442 | { 443 | if (attendee == null || attendee.Party != this) { return; } 444 | try 445 | { 446 | _sessionManager.SendGeneralCommand(null, attendee.Id, request, new System.Threading.CancellationToken()); 447 | } 448 | catch 449 | { 450 | LogWarning("Failed to send General command (event) to " + attendee.TargetId + ": " + request.Name); 451 | } 452 | } 453 | 454 | public void SendSyncStart() 455 | { 456 | SendEventToAttendees(null, new GeneralCommand() { Name = "PartySyncStart" }); 457 | } 458 | 459 | public void SendSyncWaiting(Attendee attendee) 460 | { 461 | GeneralCommand waiting = new GeneralCommand() { Name = "PartySyncWaiting" }; 462 | waiting.Arguments["Name"] = attendee.DisplayName; 463 | SendEventToAttendees(null, waiting); 464 | } 465 | 466 | public void SendSyncReset(Attendee attendee) 467 | { 468 | GeneralCommand reset = new GeneralCommand() { Name = "PartySyncReset" }; 469 | reset.Arguments["Name"] = attendee.DisplayName; 470 | SendEventToAttendees(null, reset); 471 | } 472 | 473 | public void SendSyncEnd() 474 | { 475 | SendEventToAttendees(null, new GeneralCommand() { Name = "PartySyncEnd" }); 476 | } 477 | 478 | public void SendLogMessageToAttendees(string type, string subject) 479 | { 480 | GeneralCommand logMessage = new GeneralCommand() { Name = "PartyLogMessage" }; 481 | logMessage.Arguments["Type"] = type; 482 | logMessage.Arguments["Subject"] = subject; 483 | SendEventToAttendees(null, logMessage); 484 | _partyManager.BroadcastToBridges("GeneralCommand", Name, logMessage); 485 | } 486 | 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /PartyManager.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Plugins; 2 | using MediaBrowser.Controller.Session; 3 | using MediaBrowser.Controller.Library; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Collections.Concurrent; 7 | using System.Text; 8 | using MediaBrowser.Model.Session; 9 | using MediaBrowser.Model.Services; 10 | using MediaBrowser.Model.Logging; 11 | using System.Threading.Tasks; 12 | using System.IO; 13 | using System.Linq; 14 | using MediaBrowser.Controller.Security; 15 | using System.Net; 16 | using MediaBrowser.Model.Serialization; 17 | using System.Timers; 18 | using MediaBrowser.Controller.Entities; 19 | using MediaBrowser.Controller.Api; 20 | using WebSocketSharp.Server; 21 | using System.Net.Http.Headers; 22 | using EmbyParty.LogBridge; 23 | using System.Diagnostics; 24 | 25 | namespace EmbyParty 26 | { 27 | public class PartyManager 28 | { 29 | 30 | private const long ONE_SECOND_TICKS = 10000000; 31 | private const long EXACT_START_TICKS = ONE_SECOND_TICKS; 32 | 33 | //Ping timer interval 34 | private const int TIMER_INTERVAL = 29000; //29s 35 | 36 | //How long after host stops playback until hosting ends. Hosting will continue if there's another playback request. 37 | private const int TIMER_HOSTCLEAR_INTERVAL = 7000; //7s (>= Attendee.ACCURACY_WORST) 38 | 39 | //Time at the end of the video during which manual pause requests should be ignored 40 | private const long NO_PAUSE_AT_THE_END = 50000000; //5s 41 | 42 | //Time at the end of the video during which stray guests aren't returned to the video, but instead stopped to wait for host to end the video. 43 | //If the guest is in the next video, they're placed in the syncing state to streamline the next playback. 44 | private const long NO_RETURN_AT_THE_END = 100000000; //10s (> Attendee.ACCURACY_WORST) 45 | 46 | //How long guest playback can be deferred in order to wait for the previous video to end, in case guest is offset from host (includes ping) 47 | private const int MAX_DELAY_AT_THE_END = 10000; //10s 48 | 49 | //Time at the end of the video during which we help the host along if it runs into the Emby autoplay bug, caused by ffmpeg failing to transcode the final segments 50 | private const long ASSUMED_FINISHED_INTERVAL = 50000000; //5s 51 | 52 | //Bridge sidebar chat with external application 53 | private const string BRIDGE_HOST = "ws://localhost:8196"; 54 | private const string BRIDGE_PATH = "/bridge"; 55 | 56 | private long _nextId = 1; 57 | private ConcurrentDictionary _parties = new ConcurrentDictionary(); 58 | private ConcurrentDictionary _attendees = new ConcurrentDictionary(); 59 | 60 | public ICollection Parties { get => _parties.Values; } 61 | 62 | private ILogger _logger; 63 | private ISessionManager _sessionManager; 64 | private IUserManager _userManager; 65 | private IJsonSerializer _jsonSerializer; 66 | private ILibraryManager _libraryManager; 67 | 68 | private Timer _partyUpdates; 69 | 70 | private WebSocketServer _bridgeServer; 71 | public List Bridges = new List(); 72 | 73 | public PartyManager(ISessionManager sessionManager, IUserManager userManager, IJsonSerializer jsonSerializer, ILibraryManager libraryManager, ILogger logger) 74 | { 75 | _logger = logger; 76 | _jsonSerializer = jsonSerializer; 77 | _sessionManager = sessionManager; 78 | _userManager = userManager; 79 | _libraryManager = libraryManager; 80 | } 81 | 82 | protected void Log(string message) 83 | { 84 | _logger.Info(message); 85 | } 86 | 87 | protected void Debug(string message) 88 | { 89 | _logger.Debug(message); 90 | } 91 | 92 | protected void LogWarning(string message) 93 | { 94 | _logger.Warn(message); 95 | } 96 | 97 | public void Run() 98 | { 99 | _sessionManager.PlaybackStart += PlaybackStart; 100 | _sessionManager.PlaybackProgress += PlaybackProgress; 101 | _sessionManager.PlaybackStopped += PlaybackStopped; 102 | _sessionManager.SessionEnded += SessionEnded; 103 | 104 | _partyUpdates = new Timer(TIMER_INTERVAL); 105 | _partyUpdates.Elapsed += OnHeartbeat; 106 | _partyUpdates.AutoReset = true; 107 | _partyUpdates.Start(); 108 | 109 | _bridgeServer = new WebSocketServer(BRIDGE_HOST); 110 | _bridgeServer.AddWebSocketService(BRIDGE_PATH, (bridge) => { 111 | bridge.PartyManager = this; 112 | bridge.JsonSerializer = _jsonSerializer; 113 | }); 114 | _bridgeServer.Start(); 115 | } 116 | 117 | public void Dispose() 118 | { 119 | _bridgeServer.Stop(); 120 | 121 | _partyUpdates.Stop(); 122 | _partyUpdates.Dispose(); 123 | 124 | _sessionManager.PlaybackStart -= PlaybackStart; 125 | _sessionManager.PlaybackProgress -= PlaybackProgress; 126 | _sessionManager.PlaybackStopped -= PlaybackStopped; 127 | _sessionManager.SessionEnded -= SessionEnded; 128 | } 129 | 130 | private void PlaybackStart(object sender, PlaybackProgressEventArgs e) 131 | { 132 | Party party = FindAttendeePartyByTarget(e.Session.Id); 133 | if (party == null) { return; } 134 | 135 | Attendee host = party.Host; 136 | Attendee attendee = party.GetAttendeeByTarget(e.Session.Id); 137 | 138 | if (attendee == null) { return; } //Hopefully impossible 139 | 140 | Log(">> Playback event from " + e.Session.Id + " (attendee " + attendee.Id + "). At this time host is set to " + host?.Id); 141 | 142 | attendee.ResetAccuracy(); 143 | attendee.PlaySessionId = e.PlaySessionId; 144 | attendee.ReportedDeviceId = e.DeviceId; 145 | attendee.CurrentMediaItemId = e.Item.InternalId; 146 | attendee.RunTimeTicks = (long)e.Item.RunTimeTicks; 147 | 148 | if (host != null && host.Id != attendee.Id) { 149 | GuestStart(party, host, attendee, e); 150 | } 151 | else 152 | { 153 | HostStart(party, attendee, e); 154 | } 155 | } 156 | 157 | private async void HostStart(Party party, Attendee attendee, PlaybackProgressEventArgs e) 158 | { 159 | Log("Party " + party.Id + " member " + attendee.Id + " initiated playback and becomes the host. Syncing with " + party.AttendeePlayers.Count() + " attendees."); 160 | 161 | Debug("Playlist (" + e.Session.PlaylistIndex + ")" + String.Join(",", e.Session.NowPlayingQueue.Select(QueueItem => QueueItem.Id.ToString()))); 162 | Debug("Attendee map: [" + String.Join("] [", party.Attendees.Select(eachAttendee => eachAttendee.Id + " = " + eachAttendee.DisplayName + " (" + eachAttendee.State + ")")) + "]"); 163 | 164 | bool canPlay = await InitiatePartyPlay(attendee, party, e.Session); 165 | if (!canPlay) 166 | { 167 | await SendPlaystateCommandToAttendeeTarget(attendee, new PlaystateRequest() { Command = PlaystateCommand.Stop }); 168 | party.SendLogMessageToAttendees("Reject", "Failed to start video player."); 169 | } 170 | } 171 | 172 | private async void PlayNextVideoOrStop(Attendee host, Party party) 173 | { 174 | if (party.CurrentIndex + 1 < party.CurrentQueue.Length) 175 | { 176 | PlayRequest request = new PlayRequest(); 177 | request.ItemIds = party.CurrentQueue; 178 | //request.MediaSourceId = party.MediaSourceId; 179 | request.StartIndex = party.CurrentIndex + 1; 180 | request.StartPositionTicks = EXACT_START_TICKS; 181 | request.PlayCommand = PlayCommand.PlayNow; 182 | 183 | await DelayedAttendeePartyPlay(host, host, AttendeeState.Ready, request, 0); 184 | } 185 | else 186 | { 187 | await SendPlaystateCommandToAttendeeTarget(host, new PlaystateRequest() { Command = PlaystateCommand.Stop }); 188 | } 189 | } 190 | 191 | private async void GuestStart(Party party, Attendee host, Attendee attendee, PlaybackProgressEventArgs e) 192 | { 193 | if (party.CurrentQueue != null && e.Item != null && party.CurrentQueue[party.CurrentIndex] != e.Item.InternalId) 194 | { 195 | if (attendee.State == AttendeeState.WaitForPlay) 196 | { 197 | //Ignore wrong video start event during playback sync (assume stray event from multiple successive playback requests) 198 | } 199 | else if (e.Item.RunTimeTicks < NO_RETURN_AT_THE_END || host.EstimatedPositionTicks > e.Item.RunTimeTicks - NO_RETURN_AT_THE_END) 200 | { 201 | //Stop guest to wait for host 202 | Log("Host is about to end item " + party.CurrentQueue[party.CurrentIndex] + " but guest is in item " + e.Item.InternalId + " so stop guest to wait for host."); 203 | 204 | if (party.CurrentIndex + 1 < party.CurrentQueue.Length && party.CurrentQueue[party.CurrentIndex + 1] == e.Item.InternalId) 205 | { 206 | //Guest is ready in advance to play next item, let's use that 207 | attendee.State = AttendeeState.Syncing; 208 | party.SendSyncWaiting(attendee); 209 | } 210 | 211 | attendee.IgnoreNext(ProgressEvent.Pause); 212 | await SendPlaystateCommandToAttendeeTarget(attendee, new PlaystateRequest() { Command = PlaystateCommand.Pause }); 213 | } 214 | else 215 | { 216 | //Return guest to the correct video 217 | Log("Host is in item " + party.CurrentQueue[party.CurrentIndex] + " but guest is in item " + e.Item.InternalId + " so bring guest back to right item."); 218 | SyncPartyPlay(party, new Attendee[] { attendee }, attendee.State == AttendeeState.WaitForPlay ? AttendeeState.WaitForPlay : AttendeeState.Ready, PlayCommand.PlayNow); 219 | } 220 | } 221 | else if (attendee.State == AttendeeState.WaitForPlay || attendee.State == AttendeeState.WaitForSeek) 222 | { 223 | //During sync, unpause everyone if all guests are playing, otherwise pause guest 224 | //WaitForSeek can be handled here because of when guests join late or reopen video player during sync. 225 | attendee.State = AttendeeState.Syncing; 226 | if (!CheckAndCompleteSync(party, host)) 227 | { 228 | ReadyAndContinueSync(party, attendee); 229 | } 230 | } 231 | else if (host.State != AttendeeState.Syncing && host.IsPaused) 232 | { 233 | //Host is regular paused 234 | attendee.IgnoreNext(ProgressEvent.Pause); 235 | await SendPlaystateCommandToAttendeeTarget(attendee, new PlaystateRequest() { Command = PlaystateCommand.Pause }); 236 | } 237 | } 238 | 239 | private async void PlaybackProgress(object sender, PlaybackProgressEventArgs e) 240 | { 241 | Party party = FindAttendeePartyByTarget(e.Session.Id); 242 | if (party == null) { return; } 243 | 244 | Attendee attendee = party.GetAttendeeByTarget(e.Session.Id); 245 | 246 | Log(">> Progress event from " + e.Session.Id + (e.Session.Id != attendee.Id ? " (attendee " + attendee.Id + ")" : "") + ": " + e.EventName); 247 | 248 | if (e.Item.InternalId != attendee.CurrentMediaItemId) 249 | { 250 | Debug("Discarded out of order event."); 251 | return; 252 | } 253 | 254 | try 255 | { 256 | if (e.EventName == ProgressEvent.TimeUpdate) 257 | { 258 | if (e.PlaybackPositionTicks != attendee.PositionTicks && (e.PlaybackPositionTicks - attendee.PositionTicks) % ONE_SECOND_TICKS == 0) 259 | { 260 | //Discard bogus updates 261 | return; 262 | } 263 | if (e.PlaySessionId != attendee.PlaySessionId) 264 | { 265 | //Playsession changes if audio streams are changed mid-video 266 | attendee.PlaySessionId = e.PlaySessionId; 267 | } 268 | } 269 | 270 | Debug("Attendee states: [" + String.Join("] [", party.Attendees.Select(eachAttendee => eachAttendee.Id + " = " + eachAttendee.State)) + "]"); 271 | 272 | if (e.EventName == ProgressEvent.Pause) 273 | { 274 | if (attendee.IsPaused) { return; } 275 | attendee.PositionTicks = (long)e.PlaybackPositionTicks; 276 | attendee.IsPaused = true; 277 | } 278 | 279 | if (e.EventName == ProgressEvent.Unpause) 280 | { 281 | if (!attendee.IsPaused) { return; } 282 | attendee.PositionTicks = (long)e.PlaybackPositionTicks; 283 | attendee.IsPaused = false; 284 | } 285 | 286 | Attendee host = party.Host; 287 | if (host == null) { return; } 288 | 289 | if (host.Id == attendee.Id) 290 | { 291 | HostProgress(party, host, e); 292 | } 293 | else 294 | { 295 | await GuestProgress(party, host, attendee, e); 296 | } 297 | } 298 | catch (Exception ex) 299 | { 300 | LogWarning("Exception in playback progress! " + ex.Message); 301 | LogWarning(new System.Diagnostics.StackTrace().ToString()); 302 | } 303 | } 304 | 305 | private void HostProgress(Party party, Attendee host, PlaybackProgressEventArgs e) 306 | { 307 | if (party.InSyncConclusion && e.EventName == ProgressEvent.Unpause) 308 | { 309 | party.InSyncConclusion = false; 310 | } 311 | 312 | if (host.ShouldIgnore(e.EventName)) { return; } 313 | 314 | Log("Party " + party.Id + " host " + host.Id + " progress update " + e.EventName); 315 | 316 | if (e.EventName == ProgressEvent.TimeUpdate && e.PlaybackPositionTicks != EXACT_START_TICKS) 317 | { 318 | //Keep track of host position and detect manual seek 319 | 320 | if (host.PositionTicks > host.RunTimeTicks - ASSUMED_FINISHED_INTERVAL && host.PositionTicks == e.PlaybackPositionTicks) 321 | { 322 | //The host is not making progress and is at the end of the video; play next video 323 | 324 | Log("Stalled; Forcibly ending current video."); 325 | 326 | PlayNextVideoOrStop(host, party); 327 | 328 | } 329 | else if (host.State == AttendeeState.Ready && host.IsOutOfSync((long)e.PlaybackPositionTicks)) 330 | { 331 | Log("Host is out of sync with itself, " + e.PlaybackPositionTicks + " is too far from saved pos " + host.EstimatedPositionTicks + " (LU: " + host.LastUpdate.Ticks + " TSU: " + host.TicksSinceUpdate + " NOW:" + DateTimeOffset.UtcNow.Ticks + ") "); 332 | 333 | if (party.AttendeePlayers.Count() > 1) 334 | { 335 | if (host.State == AttendeeState.Syncing) 336 | { 337 | //Use ongoing sync for seeking 338 | 339 | foreach (Attendee attendee in party.AttendeePlayers) 340 | { 341 | if (attendee.State == AttendeeState.Syncing) { 342 | attendee.State = AttendeeState.WaitForSeek; 343 | party.SendSyncReset(attendee); 344 | } 345 | } 346 | 347 | SyncPlaystateByState(party, host.Id, AttendeeState.WaitForSeek, PlaystateCommand.Seek, e.PlaybackPositionTicks); 348 | } 349 | else 350 | { 351 | //Start sync for seeking 352 | 353 | party.SendSyncStart(); 354 | 355 | if (!host.IsPaused) 356 | { 357 | //Pause the host while waiting for guests 358 | 359 | PlaystateRequest pauseForSeek = new PlaystateRequest(); 360 | pauseForSeek.Command = PlaystateCommand.Pause; 361 | pauseForSeek.SeekPositionTicks = e.PlaybackPositionTicks; 362 | 363 | host.IgnoreNext(ProgressEvent.Pause); 364 | SendPlaystateCommandToAttendeeTarget(host, pauseForSeek); 365 | } 366 | else 367 | { 368 | party.NoUnpauseAfterSync = true; 369 | } 370 | 371 | party.SetPlayerStates(AttendeeState.WaitForSeek, AttendeeState.Syncing); 372 | host.State = AttendeeState.Syncing; 373 | 374 | Log("Host began syncing sequence."); 375 | 376 | //Seek guests 377 | 378 | SyncPlaystate(party, host.Id, PlaystateCommand.Seek, e.PlaybackPositionTicks); 379 | } 380 | } 381 | } 382 | 383 | host.PositionTicks = (long)e.PlaybackPositionTicks; 384 | Debug("Host updated position to " + host.PositionTicks + " at " + host.LastUpdate.Ticks); 385 | } 386 | 387 | if (e.EventName == ProgressEvent.Pause) { 388 | if (host.RunTimeTicks > NO_PAUSE_AT_THE_END && e.PlaybackPositionTicks < host.RunTimeTicks - NO_PAUSE_AT_THE_END) 389 | { 390 | if (!party.GuestPauseAction) { party.SendLogMessageToAttendees("Pause", host.DisplayName); } 391 | party.GuestPauseAction = false; 392 | SyncPlaystate(party, host.Id, PlaystateCommand.Pause, e.PlaybackPositionTicks); 393 | } 394 | } 395 | if (e.EventName == ProgressEvent.Unpause) { 396 | if (!party.GuestPauseAction) { party.SendLogMessageToAttendees("Unpause", host.DisplayName); } 397 | party.GuestPauseAction = false; 398 | SyncPlaystate(party, host.Id, PlaystateCommand.Unpause, e.PlaybackPositionTicks); 399 | } 400 | 401 | if (e.EventName == ProgressEvent.AudioTrackChange) { 402 | SyncTrack(party, host.Id, nameof(GeneralCommandType.SetAudioStreamIndex), e.PlaySession.PlayState.AudioStreamIndex); 403 | } 404 | if (e.EventName == ProgressEvent.SubtitleTrackChange) { 405 | SyncTrack(party, host.Id, nameof(GeneralCommandType.SetSubtitleStreamIndex), e.PlaySession.PlayState.SubtitleStreamIndex); 406 | } 407 | } 408 | 409 | private async Task GuestProgress(Party party, Attendee host, Attendee attendee, PlaybackProgressEventArgs e) 410 | { 411 | if (attendee.ShouldIgnore(e.EventName)) { return; } 412 | 413 | if (e.EventName == ProgressEvent.TimeUpdate) 414 | { 415 | if (attendee.State == AttendeeState.WaitForSeek) 416 | { 417 | //Unpause after seek 418 | attendee.State = AttendeeState.Syncing; 419 | if (!CheckAndCompleteSync(party, host)) 420 | { 421 | ReadyAndContinueSync(party, attendee); 422 | } 423 | } 424 | else if (attendee.State == AttendeeState.Ready && attendee.CurrentMediaItemId == host.CurrentMediaItemId) 425 | { 426 | //Keep track of guest position and automatically seek if it's off 427 | attendee.PositionTicks = (long)e.PlaybackPositionTicks; 428 | 429 | if (attendee.IsOutOfSync(host.EstimatedPositionTicks)) 430 | { 431 | long newacc = attendee.LowerAccuracy(); 432 | Log("Guest needs seek because pos " + attendee.PositionTicks + " (" + attendee.Ping + "ms) too far from estimated host pos " + host.EstimatedPositionTicks + " (" + host.Ping + "ms). Accuracy lowered to " + newacc); 433 | 434 | PlaystateRequest seek = new PlaystateRequest(); 435 | seek.Command = PlaystateCommand.Seek; 436 | seek.SeekPositionTicks = host.EstimatedPositionTicks + attendee.Ping; 437 | 438 | attendee.PositionTicks = host.EstimatedPositionTicks; 439 | await SendPlaystateCommandToAttendeeTarget(attendee, seek); 440 | } 441 | 442 | //Pause the guest if the host is paused. This happens if a playback command was sent to the guest while the host was paused (late joiner, auto return). 443 | if (host.IsPaused && !attendee.IsPaused && !party.InSyncConclusion) 444 | { 445 | attendee.IgnoreNext(ProgressEvent.Pause); 446 | await SendPlaystateCommandToAttendeeTarget(attendee, new PlaystateRequest() { Command = PlaystateCommand.Pause }); 447 | } 448 | } 449 | } 450 | 451 | if (e.EventName == ProgressEvent.Pause && !host.IsPaused && attendee.CurrentMediaItemId == host.CurrentMediaItemId) 452 | { 453 | Log("Detected manual pause request, propagating."); 454 | party.SendLogMessageToAttendees("Pause", attendee.DisplayName); 455 | party.GuestPauseAction = true; 456 | await SendPlaystateCommandToAttendeeTarget(host, new PlaystateRequest() { Command = PlaystateCommand.Pause }); 457 | } 458 | 459 | if (e.EventName == ProgressEvent.Unpause && host.IsPaused && attendee.CurrentMediaItemId == host.CurrentMediaItemId) 460 | { 461 | Log("Detected manual unpause request, propagating."); 462 | party.SendLogMessageToAttendees("Unpause", attendee.DisplayName); 463 | party.GuestPauseAction = true; 464 | await SendPlaystateCommandToAttendeeTarget(host, new PlaystateRequest() { Command = PlaystateCommand.Unpause }); 465 | } 466 | } 467 | 468 | private bool CheckAndCompleteSync(Party party, Attendee host) 469 | { 470 | if (party.AreAllStates(AttendeeState.Syncing)) 471 | { 472 | Log("All guests have synced with the host."); 473 | if (!party.NoUnpauseAfterSync) 474 | { 475 | Log("The host wasn't paused before sync, therefore unpausing."); 476 | party.IgnoreNext(ProgressEvent.Unpause); 477 | SyncPlaystate(party, null, PlaystateCommand.Unpause, host.PositionTicks); 478 | } 479 | 480 | party.NoUnpauseAfterSync = false; 481 | party.InSyncConclusion = true; 482 | party.SetStates(AttendeeState.Ready); 483 | 484 | party.SendSyncEnd(); 485 | 486 | return true; 487 | } 488 | else 489 | { 490 | Debug("Failed sync check."); 491 | } 492 | return false; 493 | } 494 | 495 | private async void ReadyAndContinueSync(Party party, Attendee attendee) 496 | { 497 | attendee.IgnoreNext(ProgressEvent.Pause); 498 | await SendPlaystateCommandToAttendeeTarget(attendee, new PlaystateRequest() { Command = PlaystateCommand.Pause }); 499 | 500 | party.SendSyncWaiting(attendee); 501 | } 502 | 503 | private void PlaybackStopped(object sender, PlaybackStopEventArgs e) 504 | { 505 | Party party = FindAttendeePartyByTarget(e.Session.Id); 506 | if (party == null) { return; } 507 | 508 | Attendee attendee = party.GetAttendeeByTarget(e.Session.Id); 509 | 510 | Log(">> Stop event from " + e.Session.Id + " (attendee " + attendee.Id + ") with play session "+ attendee.PlaySessionId); 511 | 512 | if (attendee.PlaySessionId != e.PlaySessionId) { return; } //PlaySession has already been replaced 513 | 514 | attendee.PlaySessionId = null; 515 | 516 | Attendee host = party.Host; 517 | if (host == null || host.Id != attendee.Id) { return; } 518 | 519 | Log("Party " + party.Id + " host " + host.Id + " ended playback and relinquished role as host."); 520 | 521 | party.PreviousItem = null; 522 | if (party.CurrentQueue != null) 523 | { 524 | party.PreviousItem = party.CurrentQueue[party.CurrentIndex]; 525 | } 526 | 527 | if (party.CurrentQueue.Length > 1) 528 | { 529 | //Prevent party from losing and re-gaining host on every video change 530 | party.LateClearHost = new Timer(TIMER_HOSTCLEAR_INTERVAL); 531 | party.LateClearHost.Elapsed += (source, eea) => party.ClearHost(); 532 | party.LateClearHost.Start(); 533 | } 534 | else 535 | { 536 | party.ClearHost(); 537 | } 538 | 539 | } 540 | 541 | private async Task InitiatePartyPlay(Attendee host, Party party, SessionInfo session) 542 | { 543 | if (session == null || session.PlayState == null || session.PlaylistIndex < 0 || session.PlaylistIndex >= session.NowPlayingQueue.Length) { return false; } 544 | 545 | long[] itemIds = session.NowPlayingQueue.Select(queueItem => queueItem.Id).ToArray(); 546 | List attendingUsers = party.GetAttendingUsers(); 547 | 548 | //Check if item is playable by all attendees 549 | 550 | BaseItem item = null; 551 | if (itemIds != null) 552 | { 553 | item = _libraryManager.GetItemById(itemIds[session.PlaylistIndex]); 554 | } 555 | if (item == null) { return false; } 556 | 557 | foreach (User user in attendingUsers) 558 | { 559 | if (!item.IsVisible(user)) { 560 | party.SendLogMessageToAttendees("Reject", item.Name); 561 | return false; 562 | } 563 | } 564 | 565 | //Sort out previous item 566 | 567 | BaseItem previousItem = null; 568 | if (host.State != AttendeeState.Syncing) 569 | { 570 | if (party.CurrentQueue != null && party.CurrentIndex < party.CurrentQueue.Length) 571 | { 572 | //Take previous item from queue if Play is called without a Stop 573 | Debug("Taking previous item from queue: " + party.CurrentQueue[party.CurrentIndex]); 574 | previousItem = _libraryManager.GetItemById(party.CurrentQueue[party.CurrentIndex]); 575 | } 576 | else if (party.PreviousItem != null) 577 | { 578 | //Use previous item stored by stop handler if Play was called after Stop 579 | Debug("Taking previous item from stop: " + party.PreviousItem); 580 | previousItem = _libraryManager.GetItemById((long)party.PreviousItem); 581 | } 582 | } 583 | 584 | //Update party status 585 | 586 | party.ClearIgnores(); 587 | 588 | party.SetHost(host, true); 589 | bool hostWasAlreadySyncing = (host.State == AttendeeState.Syncing); 590 | host.State = AttendeeState.Syncing; 591 | party.ResetPositionTicks(1); //(1 tick) Emby starts at exactly 1s. Don't reset to exactly 1s offset from that or first update is discarded. 592 | host.PositionTicks = (long)session.PlayState.PositionTicks; 593 | 594 | party.CurrentQueue = itemIds; 595 | party.CurrentIndex = session.PlaylistIndex; 596 | party.MediaSourceId = session.PlayState.MediaSourceId; 597 | party.AudioStreamIndex = session.PlayState.AudioStreamIndex; 598 | party.SubtitleStreamIndex = session.PlayState.SubtitleStreamIndex; 599 | 600 | party.SendLogMessageToAttendees("Now Playing", item.Name); 601 | 602 | Debug("Initiate party play: " + party.CurrentQueue[party.CurrentIndex] + " AI:" + party.AudioStreamIndex + " SI:" + party.SubtitleStreamIndex + " Pos:" + host.PositionTicks + " Midsync:" + hostWasAlreadySyncing + " Att:" + party.Attendees.Count()); 603 | 604 | if (party.AttendeePlayers.Count() > 1) 605 | { 606 | party.SendSyncStart(); 607 | 608 | //Pause the host while waiting for guests 609 | 610 | if (!hostWasAlreadySyncing) 611 | { 612 | PlaystateRequest startPaused = new PlaystateRequest(); 613 | startPaused.Command = PlaystateCommand.Pause; 614 | startPaused.SeekPositionTicks = host.PositionTicks; 615 | 616 | host.IgnoreNext(ProgressEvent.Pause); 617 | await SendPlaystateCommandToAttendeeTarget(host, startPaused); 618 | } 619 | 620 | //Play on guests 621 | 622 | party.SetPlayerStates(null, AttendeeState.Syncing); 623 | SyncPartyPlay(party, party.AttendeePlayers.ToArray(), AttendeeState.WaitForPlay, PlayCommand.PlayNow, previousItem); 624 | 625 | } 626 | 627 | return true; 628 | } 629 | 630 | public void SyncPartyPlay(Party party, Attendee[] attendees, AttendeeState initialState, PlayCommand playCommand) 631 | { 632 | SyncPartyPlay(party, attendees, initialState, playCommand, null); 633 | } 634 | public async void SyncPartyPlay(Party party, Attendee[] attendees, AttendeeState initialState, PlayCommand playCommand, BaseItem previousItem) 635 | { 636 | Attendee host = party.Host; 637 | 638 | PlayRequest request = new PlayRequest(); 639 | request.ItemIds = party.CurrentQueue; 640 | //request.MediaSourceId = party.MediaSourceId; 641 | request.StartIndex = party.CurrentIndex; 642 | request.StartPositionTicks = host.PositionTicks; 643 | request.PlayCommand = playCommand; 644 | request.AudioStreamIndex = party.AudioStreamIndex; 645 | request.SubtitleStreamIndex = party.SubtitleStreamIndex; 646 | 647 | Debug("Assembled party play request:" + _jsonSerializer.SerializeToString(request)); 648 | 649 | List> partyPlays = new List>(); 650 | 651 | foreach (Attendee attendee in attendees) 652 | { 653 | if (attendee.Id == host.Id || attendee.IsBeingRemoteControlled) { continue; } 654 | 655 | //Debug("Previous item:" + (previousItem != null ? "true" : "false") + ", Attendee state:" + attendee.State + ", Attendee paused:" + (attendee.IsPaused ? "true" : "false") + ", EPT:" + attendee.EstimatedPositionTicks + ", RPT:" + (previousItem != null ? previousItem.RunTimeTicks.ToString() : "")); 656 | 657 | int delay = 0; 658 | if (previousItem != null && !attendee.IsPaused 659 | && attendee.EstimatedPositionTicks > previousItem.RunTimeTicks - Attendee.ACCURACY_WORST && attendee.EstimatedPositionTicks < previousItem.RunTimeTicks) 660 | { 661 | //Delay playback of next item in queue to account for offset between guest and host 662 | delay = (int)(previousItem.RunTimeTicks - attendee.EstimatedPositionTicks) / 10000; 663 | } 664 | if (attendee.Ping > 0) { delay += (int)attendee.Ping; } 665 | if (delay > MAX_DELAY_AT_THE_END) delay = MAX_DELAY_AT_THE_END; 666 | 667 | Log("Sync party play to " + attendee.Id + " at position ticks " + host.PositionTicks + " after " + delay + "ms"); 668 | partyPlays.Add(DelayedAttendeePartyPlay(host, attendee, initialState, request, delay)); 669 | } 670 | 671 | bool[] results = await Task.WhenAll(partyPlays); 672 | 673 | if (results.ToList().Where(result => result).Count() == 0) 674 | { 675 | //None of the party play commands fired, so all guests should be ready to go 676 | CheckAndCompleteSync(party, host); 677 | } 678 | } 679 | 680 | private async Task DelayedAttendeePartyPlay(Attendee host, Attendee attendee, AttendeeState initialState, PlayRequest request, int delay) 681 | { 682 | if (delay >= 100) { await Task.Delay(delay); } 683 | 684 | if (attendee.CurrentMediaItemId != null && (long)attendee.CurrentMediaItemId == request.ItemIds[request.StartIndex ?? 0] && attendee.State == AttendeeState.Syncing) { 685 | //PlaybackStart already arrived for guest, so this isn't necessary 686 | return false; 687 | } 688 | 689 | attendee.State = initialState; 690 | 691 | attendee.PositionTicks = host.PositionTicks; 692 | attendee.IsPaused = false; 693 | try 694 | { 695 | Debug("----- Sending Play command to " + attendee.TargetId); 696 | await _sessionManager.SendPlayCommand(null, attendee.TargetId, request, new System.Threading.CancellationToken()); 697 | } 698 | catch 699 | { 700 | LogWarning("Failed to send Play command to " + attendee.TargetId); 701 | } 702 | 703 | return true; 704 | } 705 | 706 | public void SyncPlaystate(Party party, string fromId, PlaystateCommand command, long? positionTicks) 707 | { 708 | SyncPlaystateByState(party, fromId, null, command, positionTicks); 709 | } 710 | public void SyncPlaystateByState(Party party, string fromId, AttendeeState? state, PlaystateCommand command, long? positionTicks) 711 | { 712 | Debug("----- Sync playstate request: " + command + (fromId != null ? " from " + fromId : "")); 713 | 714 | PlaystateRequest request = new PlaystateRequest(); 715 | request.SeekPositionTicks = positionTicks; 716 | request.Command = command; 717 | 718 | foreach (Attendee attendee in party.AttendeePlayers) 719 | { 720 | if (attendee.Id == fromId) { continue; } 721 | if (state != null && attendee.State != state) { continue; } 722 | SendPlaystateCommandToAttendeeTarget(attendee, request); 723 | } 724 | } 725 | 726 | public void SyncTrack(Party party, string fromId, string command, int? index) 727 | { 728 | GeneralCommand request = new GeneralCommand(); 729 | request.Name = command; 730 | request.Arguments["Index"] = index.ToString(); 731 | 732 | foreach (Attendee attendee in party.AttendeePlayers) 733 | { 734 | if (attendee.Id == fromId) { continue; } 735 | try 736 | { 737 | _sessionManager.SendGeneralCommand(null, attendee.TargetId, request, new System.Threading.CancellationToken()); 738 | } 739 | catch 740 | { 741 | LogWarning("Failed to send General command to " + attendee.TargetId + ": " + request.Name); 742 | } 743 | } 744 | } 745 | 746 | private void SessionEnded(object sender, SessionEventArgs e) 747 | { 748 | RemoveFromParty(e.SessionInfo.Id); 749 | } 750 | 751 | public async Task CreateParty(string sessionId, string name, SessionInfo session) 752 | { 753 | Log("Creating new party."); 754 | Party party = new Party(_nextId++, this, _sessionManager, _userManager, _logger); 755 | party.Name = name; 756 | _parties.TryAdd(party.Id, party); 757 | 758 | Party withAttendee = await AddToParty(sessionId, party.Id, session); 759 | if (withAttendee != party) 760 | { 761 | _parties.TryRemove(party.Id, out _); 762 | Log("Party creation failed, rolled back for " + party.Id); 763 | return null; 764 | } 765 | 766 | Log("Created party " + party.Id + " (" + name + ") with " + sessionId); 767 | return party; 768 | } 769 | 770 | public async Task AddToParty(string sessionId, long partyId, SessionInfo session) 771 | { 772 | Party party = GetParty(partyId); 773 | if (party == null) { return null; } 774 | if (party.CurrentQueue != null) 775 | { 776 | BaseItem item = _libraryManager.GetItemById(party.CurrentQueue[party.CurrentIndex]); 777 | User user = _userManager.GetUserById(session.UserId); 778 | if (!item.IsVisible(user)) { return null; } 779 | } 780 | 781 | RemoveFromParty(sessionId); 782 | Attendee newAttendee = party.AttendeeJoin(sessionId, session); 783 | if (newAttendee == null) { 784 | Log("Attendee " + sessionId + " was already in party " + partyId); 785 | return party; 786 | } 787 | _attendees.TryAdd(sessionId, party); 788 | 789 | Log("Added " + sessionId + " to party " + partyId); 790 | 791 | if (party.CurrentQueue != null) 792 | { 793 | LateJoiner(party, newAttendee); 794 | } 795 | else if (session.NowPlayingItem != null) 796 | { 797 | //Playing something but no one else is, so become host 798 | 799 | newAttendee.ResetAccuracy(); 800 | newAttendee.ReportedDeviceId = session.DeviceId; 801 | newAttendee.CurrentMediaItemId = long.Parse(session.NowPlayingItem.Id); 802 | newAttendee.RunTimeTicks = (long)session.NowPlayingItem.RunTimeTicks; 803 | 804 | bool canPlay = await InitiatePartyPlay(newAttendee, party, session); 805 | if (!canPlay) 806 | { 807 | await SendPlaystateCommandToAttendeeTarget(newAttendee, new PlaystateRequest() { Command = PlaystateCommand.Stop }); 808 | party.SendLogMessageToAttendees("Reject", "Failed to host on previous video player."); 809 | } 810 | } 811 | 812 | return party; 813 | } 814 | 815 | public void LateJoiner(Party party, Attendee attendee) 816 | { 817 | Attendee host = party.Host; 818 | SyncPartyPlay(party, new Attendee[] { attendee }, host.State == AttendeeState.Syncing ? AttendeeState.WaitForSeek : AttendeeState.Ready, PlayCommand.PlayNow); 819 | } 820 | 821 | public bool RemoveFromParty(string sessionId) 822 | { 823 | Party party = GetAttendeeParty(sessionId); 824 | if (party == null) { return false; } 825 | Attendee formerAttendee = party.AttendeePart(sessionId); 826 | 827 | if (party.Attendees.Count == 0) 828 | { 829 | _parties.TryRemove(party.Id, out _); 830 | Log("Discarding empty party " + party.Id); 831 | BroadcastToBridges("PartyEnded", party.Name, null); 832 | } 833 | else if (formerAttendee.State == AttendeeState.WaitForPlay || formerAttendee.State == AttendeeState.WaitForSeek) 834 | { 835 | CheckAndCompleteSync(party, party.Host); 836 | } 837 | 838 | _attendees.TryRemove(sessionId, out _); 839 | 840 | Log("Removed " + sessionId + " from party " + party.Id); 841 | 842 | return true; 843 | } 844 | 845 | public Task SendPlaystateCommandToAttendeeTarget(Attendee attendee, PlaystateRequest request) 846 | { 847 | try 848 | { 849 | return _sessionManager.SendPlaystateCommand(null, attendee.TargetId, request, new System.Threading.CancellationToken()); 850 | } 851 | catch 852 | { 853 | LogWarning("Failed to send Playstate command to " + attendee.TargetId + ": " + request.Command); 854 | } 855 | return Task.CompletedTask; 856 | } 857 | 858 | public Party GetParty(long partyId) 859 | { 860 | Party party; 861 | if (_parties.TryGetValue(partyId, out party)) 862 | { 863 | return party; 864 | } 865 | return null; 866 | } 867 | 868 | public Party GetPartyByName(string name) 869 | { 870 | return Parties.Where(party => party.Name == name).FirstOrDefault(); 871 | } 872 | 873 | public Party GetAttendeeParty(string sessionId) { 874 | Party party; 875 | if (_attendees.TryGetValue(sessionId, out party)) 876 | { 877 | return party; 878 | } 879 | return null; 880 | } 881 | 882 | public Party FindAttendeePartyByTarget(string sessionId) 883 | { 884 | foreach (Party party in _parties.Values) 885 | { 886 | if (party.AttendeeTargets.Contains(sessionId)) 887 | { 888 | return party; 889 | } 890 | } 891 | return GetAttendeeParty(sessionId); 892 | } 893 | 894 | private void OnHeartbeat(Object source, ElapsedEventArgs e) 895 | { 896 | long nowms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 897 | GeneralCommand ping = new GeneralCommand() { Name = "PartyPing" }; 898 | ping.Arguments["ts"] = nowms.ToString(); 899 | 900 | foreach (Party party in Parties) 901 | { 902 | //Emby Session ping 903 | foreach (Attendee paused in party.AttendeesPaused) 904 | { 905 | if (paused.PlaySessionId == null) { continue; } 906 | Debug("Pinging paused user: " + paused.Id); 907 | _sessionManager.PingSession(paused.ReportedDeviceId, paused.PlaySessionId); 908 | } 909 | 910 | //Our ping 911 | foreach (Attendee attendee in party.Attendees) 912 | { 913 | if (attendee.PendingPing.Count > 1) //Can miss 2 pings 914 | { 915 | Log("Ping timeout: " + attendee.Id); 916 | RemoveFromParty(attendee.Id); 917 | continue; 918 | } 919 | 920 | attendee.PendingPing.Add(nowms); 921 | party.SendEventToSingleAttendee(attendee, ping); 922 | } 923 | 924 | } 925 | } 926 | 927 | public void BroadcastToBridges(string messageType, string partyName, object data) 928 | { 929 | MessageBase messageObj = new MessageBase() { MessageType = messageType, Party = partyName, Data = data}; 930 | string message = _jsonSerializer.SerializeToString(messageObj); 931 | foreach (Bridge bridge in Bridges) 932 | { 933 | bridge.Send(message); 934 | } 935 | } 936 | 937 | 938 | public bool IsTargetSafe(Attendee attendee, string remoteCandidate) 939 | { 940 | //Prevent remote control loops for sessions we're tracking. Better than nothing. 941 | return IsTargetSafeInternal(remoteCandidate, new List() { attendee.Id, remoteCandidate }); 942 | } 943 | private bool IsTargetSafeInternal(string check, List trace) 944 | { 945 | if (check == null) { return true; } 946 | Party party = GetAttendeeParty(check); 947 | if (party == null) { return true; } 948 | Attendee attendee = party.GetAttendee(check); 949 | if (attendee.RemoteControl == null) { return true; } 950 | if (trace.Contains(attendee.RemoteControl)) { return false; } 951 | trace.Add(attendee.RemoteControl); 952 | return IsTargetSafeInternal(attendee.RemoteControl, trace); 953 | } 954 | 955 | } 956 | } 957 | -------------------------------------------------------------------------------- /dashboard-ui/modules/embyparty/partyheader.js: -------------------------------------------------------------------------------- 1 | define(["exports", 2 | "./../emby-apiclient/connectionmanager.js", 3 | "./partyapiclient.js", 4 | "./../emby-apiclient/events.js", 5 | "./joinparty.js", 6 | "./../toast/toast.js", 7 | "./../common/dialogs/confirm.js", 8 | "./../common/dialogs/alert.js", 9 | "./../focusmanager.js", 10 | "./emoji.js", 11 | "./emoticon.js", 12 | "./../common/input/api.js", 13 | "./../common/playback/playbackmanager.js" 14 | ], function(_exports, _connectionmanager, _partyapiclient, _events, _joinParty, _toast, _confirm, _alert, _focusmanager, _emoji, _emoticon, _api, _playbackmanager) { 15 | 16 | require(["material-icons", "css!modules/embyparty/partyheader.css"]); 17 | 18 | const LS_SIDEBAR_STATE = "party-sidebar"; 19 | const LS_SIDEBAR_WIDTH = "party-sidebar-width"; 20 | const LS_DOCK_MODE = "party-sidebar-undock"; 21 | const LS_HAS_REMOTE_TARGET = "party-hasremote"; 22 | 23 | const PING_INTERVAL = 29000; 24 | const RECONNECT_DELAYS = [5, 5, 10, 20, 30, 60]; 25 | 26 | const CHAT_SEPARATOR_DELAY = 3600000; 27 | 28 | function PartyHeader() { 29 | this._focusOnChat = false; 30 | this._sidebarMinWidth = 250; 31 | this._sidebarMinWidthPct = 0.1; 32 | this._sidebarMaxWidthPct = 0.25; 33 | 34 | this._undocked = localStorage.getItem(LS_DOCK_MODE) == "true"; 35 | 36 | this.partyStatus = null; 37 | 38 | this._meseeks = null; //Timer for blinking seek warning in attendee list 39 | this._expireSync = null; //Timer for changing syncing party attendee to expired in the list 40 | 41 | this._hasVideoPlayer = false; 42 | this._videoOsds = []; 43 | this._nextOsdTag = 1; 44 | 45 | this._loggedIn = false; 46 | this._pingTimer = null; 47 | this._reconnectAttempt = 0; 48 | this._reconnectTimer = null; 49 | 50 | this._lastLogEntry = null; 51 | 52 | window.t_ph = this; 53 | } 54 | 55 | PartyHeader.prototype.getSidebarMinWidth = function() { 56 | return Math.min(this._sidebarMinWidth, this._sidebarMinWidthPct * window.innerWidth); 57 | } 58 | 59 | PartyHeader.prototype.getSidebarMaxWidth = function() { 60 | return this._sidebarMaxWidthPct * window.innerWidth; 61 | } 62 | 63 | PartyHeader.prototype.getPartyStatus = async function() { 64 | this.partyStatus = await _partyapiclient.default.getPartyStatus(); 65 | if (!this.partyStatus?.CurrentParty) { 66 | this.partyStatus = null; 67 | } 68 | } 69 | 70 | // ##### Initialize party header and attach it to an anchor ##### 71 | 72 | PartyHeader.prototype.show = async function(anchor) { 73 | let apiClient = _connectionmanager.default.currentApiClient(); 74 | this.partyStatus = null; 75 | 76 | setTimeout(() => { //Delay DOM modifications until after page has loaded 77 | 78 | let body = document.body, 79 | bodyElements = [...body.children]; 80 | 81 | //Wrap entire vanilla app/make it squeezable 82 | 83 | let mainWrapper = document.createElement('div'), 84 | innerWrapper = document.createElement('div'); 85 | 86 | mainWrapper.className = 'appcontainer'; 87 | innerWrapper.className = body.className; 88 | 89 | mainWrapper.append(innerWrapper); 90 | body.append(mainWrapper); 91 | body.className = ""; 92 | for (let bodyElement of bodyElements) { 93 | innerWrapper.append(bodyElement); 94 | } 95 | 96 | //Add sidebar 97 | 98 | let sidebar = document.createElement('div'); 99 | sidebar.className = 'party-sidebar focuscontainer'; 100 | sidebar.innerHTML = ` 101 |
102 |
103 | 104 | 105 | 106 |

107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 |
116 |
117 | ` 118 | body.append(sidebar); 119 | 120 | let m_pos; 121 | let resize = (e) => { 122 | const dx = m_pos - e.x; 123 | m_pos = e.x; 124 | this.showSidebar(parseInt(getComputedStyle(sidebar, '').width) + dx, true); 125 | } 126 | 127 | //Sidebar events 128 | 129 | sidebar.addEventListener("mousedown", (e) => { 130 | if (e.offsetX < 5) { 131 | m_pos = e.x; 132 | document.addEventListener("mousemove", resize, false); 133 | } else { 134 | this._focusOnChat = false; 135 | } 136 | }, false); 137 | 138 | document.addEventListener("mouseup", () => { 139 | document.removeEventListener("mousemove", resize, false); 140 | }, false); 141 | 142 | sidebar.querySelector(".btnPartyDock").addEventListener("click", () => { 143 | this.setDocked(); 144 | }); 145 | 146 | sidebar.querySelector(".btnPartyUndock").addEventListener("click", () => { 147 | this.setUndocked(); 148 | }); 149 | 150 | sidebar.querySelector(".btnPartyWipe").addEventListener("click", () => { 151 | this.clearMessageLog(); 152 | }); 153 | 154 | let textarea = sidebar.querySelector(".party-send textarea"); 155 | 156 | textarea.addEventListener("keydown", (e) => { 157 | e.stopPropagation(); 158 | if (e.key == "Enter") { 159 | this.sendMessageFromChatbox(); 160 | e.preventDefault(); 161 | } 162 | }); 163 | 164 | textarea.addEventListener("mousedown", (e) => { 165 | e.stopPropagation(); 166 | }); 167 | 168 | textarea.addEventListener("focus", (e) => { 169 | this._focusOnChat = true; 170 | }); 171 | 172 | textarea.addEventListener("blur", (e) => { 173 | setTimeout(() => { 174 | if (this._focusOnChat) { 175 | if (!document.activeElement.classList.contains('btnOption')) { 176 | this.focusOnChatSend(); 177 | } 178 | } 179 | }, 1); 180 | }); 181 | 182 | sidebar.querySelector(".btnChatSend").addEventListener("click", () => { 183 | this.sendMessageFromChatbox(); 184 | this.focusOnChatSend(); 185 | }); 186 | 187 | sidebar.addEventListener("focus", (e) => { 188 | e.stopPropagation(); 189 | }); 190 | 191 | sidebar.addEventListener("mouseenter", (e) => { 192 | e.stopPropagation(); 193 | }); 194 | 195 | sidebar.addEventListener("pointermove", (e) => { 196 | e.stopPropagation(); 197 | }); 198 | 199 | let bodyFix = new MutationObserver((mutations) => { 200 | //Move late additions to the DOM into the wrapper 201 | for (let mutation of mutations) { 202 | for (let newElement of Array.from(mutation.addedNodes).filter(node => node.nodeType == 1)) { 203 | if (newElement.classList.contains("htmlVideoPlayerContainer") || newElement.classList.contains("dialogContainer")) continue; 204 | innerWrapper.append(newElement); 205 | } 206 | } 207 | }); 208 | bodyFix.observe(body, {childList: true}); 209 | 210 | let bodyCheck = new MutationObserver((mutations) => { 211 | for (let mutation of mutations) { 212 | 213 | let newElements = Array.from(mutation.addedNodes).filter(node => node.nodeType == 1); 214 | if (newElements.length > 0) { 215 | 216 | //Catch new videoosd views 217 | for (let newElement of newElements) { 218 | if (!newElement.classList.contains("view")) continue; 219 | 220 | let temp = new MutationObserver((mutations) => { 221 | if (newElement.classList.contains("view-videoosd-videoosd")) { 222 | this._videoOsds.push(newElement); 223 | newElement.setAttribute("osdtag", this._nextOsdTag++); 224 | } 225 | temp.disconnect(newElement); 226 | }); 227 | temp.observe(newElement, {attributes: true, attributeFilter: ["class"]}); 228 | } 229 | 230 | //Refocus on chat when an auto-focused element is added to the DOM (such as video osd controls) 231 | if (this._focusOnChat) { 232 | setTimeout(() => { 233 | this.focusOnChatSend(); 234 | }, 1); 235 | } 236 | 237 | } 238 | 239 | //Remove videoosd views 240 | let oldElements = Array.from(mutation.removedNodes).filter(node => node.nodeType == 1); 241 | for (let oldElement of oldElements) { 242 | if (oldElement.classList.contains("view-videoosd-videoosd")) { 243 | this._videoOsds = this._videoOsds.filter(videoosd => videoosd != oldElement); 244 | } 245 | } 246 | } 247 | }); 248 | bodyCheck.observe(innerWrapper, {childList: true}); 249 | 250 | window.addEventListener("keydown", (e) => { 251 | if (!this._focusOnChat) { 252 | if (this.getSidebarWidth() > 0) { 253 | //Prevent accidentally nuking the server with pause commands when someone is trying to type in chat 254 | e.stopPropagation(); 255 | } else if (this.isASyncPlaceholderVisible()) { 256 | //Manual shenanigans during synchronization are discouraged 257 | e.stopPropagation(); 258 | } 259 | } 260 | }, {capture: true}); 261 | 262 | }, 1); 263 | 264 | //Header (access point) 265 | 266 | let partyHeader = document.createElement('div'); 267 | partyHeader.className = 'ef-partyheader'; 268 | 269 | partyHeader.innerHTML = ` 270 | 271 | 272 | 273 | 274 | `; 275 | 276 | let partyButton = partyHeader.querySelector(".headerPartyButton"); 277 | 278 | partyButton.addEventListener("click", () => { 279 | if (this.partyStatus) { 280 | _confirm.default({ 281 | title: "Leave " + (this.partyStatus?.CurrentParty?.Name || "party") + "?", 282 | confirmText: "Leave", 283 | primary: "submit", 284 | autoFocus: 1 285 | }) 286 | .then(() => _partyapiclient.default.leaveParty()) 287 | .then(() => { 288 | this.partyStatus = null; 289 | this.updatePartyStatusDisplay(); 290 | }) 291 | .catch(() => {}) 292 | ; 293 | let confirmButton = document.querySelector(".confirmDialog button"); 294 | setTimeout(() => confirmButton && _focusmanager.default.focus(confirmButton), 10); 295 | } else { 296 | _joinParty.default.show(partyButton) 297 | .then(async (result) => { 298 | 299 | if (!result?.Success) { 300 | _alert.default({ 301 | title: "Unable to join party", 302 | text: result?.Reason || undefined, 303 | primary: "submit", 304 | autoFocus: 1 305 | }); 306 | let confirmButton = document.querySelector(".alertDialog button"); 307 | setTimeout(() => confirmButton && _focusmanager.default.focus(confirmButton), 10); 308 | return; 309 | } 310 | 311 | await this.getPartyStatus(); 312 | this.updatePartyStatusDisplay(); 313 | 314 | if (!this.partyStatus) { 315 | return; 316 | } 317 | 318 | for (let attendee of (this.partyStatus.Attendees || [])) { 319 | this.addAttendeeToList(attendee); 320 | } 321 | 322 | if (localStorage.getItem(LS_SIDEBAR_STATE) == "true") { 323 | this.showSidebar(localStorage.getItem(LS_SIDEBAR_WIDTH)); 324 | } 325 | 326 | let player = _playbackmanager.default.getCurrentPlayer(); 327 | if (player != null) { 328 | let remoteControlSafety = await _partyapiclient.default.getRemoteControlSafety({RemoteControl: player.currentSessionId}); 329 | if (remoteControlSafety && !remoteControlSafety.IsSafe) { 330 | _playbackmanager.default.setDefaultPlayerActive(); 331 | _toast.default({text: "Remote control disabled because it would create a loop.", icon: "warning"}); 332 | } 333 | } 334 | }) 335 | .catch(() => {}); 336 | 337 | } 338 | }); 339 | 340 | partyHeader.querySelector(".headerPartyReturnButton").addEventListener("click", () => { 341 | this.openCurrentVideoPlayer(); 342 | }); 343 | 344 | partyHeader.querySelector(".headerPartySidebarButton").addEventListener("click", () => { 345 | if (this.getSidebarWidth() > 0) { 346 | this.hideSidebar(); 347 | } else { 348 | this.showSidebar(Math.min(Math.max(localStorage.getItem(LS_SIDEBAR_WIDTH) || this.getSidebarMinWidth(), this.getSidebarMinWidth()), this.getSidebarMaxWidth())); 349 | } 350 | }); 351 | 352 | anchor.prepend(partyHeader); 353 | 354 | //Initialize things 355 | 356 | try { 357 | await this.getPartyStatus(); 358 | this.updatePartyStatusDisplay(); 359 | } catch (e) { } 360 | 361 | let returning = () => { 362 | _events.default.off(apiClient, "websocketopen", returning); 363 | if (this.partyStatus?.CurrentQueue) { 364 | this.sendRefreshNotification(); 365 | } 366 | if (this.partyStatus && localStorage.getItem(LS_HAS_REMOTE_TARGET) == this.partyStatus.Id) { 367 | this.setRemoteControlInParty(null); 368 | } 369 | }; 370 | _events.default.on(apiClient, "websocketopen", returning); 371 | 372 | setTimeout(() => { 373 | //Things that use both the sidebar and party should run after mounting sidebar and after party status retrieval 374 | //These sequence bump timers can be promisified later 375 | 376 | for (let attendee of (this.partyStatus?.Attendees || [])) { 377 | this.addAttendeeToList(attendee); 378 | } 379 | 380 | if (this.partyStatus) { 381 | if (localStorage.getItem(LS_SIDEBAR_STATE) == "true") { 382 | this.showSidebar(localStorage.getItem(LS_SIDEBAR_WIDTH)); 383 | } 384 | } 385 | }, 2); 386 | 387 | if (apiClient.getCurrentUserId()) { 388 | partyHeader.style.display = 'block'; 389 | } 390 | 391 | //Event handlers 392 | 393 | let setUpReconnectWebSocket = (attempt) => { 394 | this._reconnectAttempt = attempt; 395 | this._reconnectTimer = setTimeout(() => reconnectWebSocket(), RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)] * 1000); 396 | } 397 | 398 | let reconnectWebSocket = () => { 399 | this._reconnectTimer = null; 400 | if (!this.partyStatus) return; 401 | if (!apiClient || apiClient.isWebSocketOpenOrConnecting()) return; 402 | 403 | console.log("Attempting to reconnect to websocket."); 404 | apiClient.ensureWebSocket(); 405 | 406 | setUpReconnectWebSocket(this._reconnectAttempt + 1); 407 | } 408 | 409 | let abortReconnectWebSocket = () => { 410 | if (this._reconnectTimer) { 411 | clearTimeout(this._reconnectTimer); 412 | } 413 | this._reconnectTimer = null; 414 | } 415 | 416 | _events.default.on(apiClient, "websocketopen", async () => { 417 | if (this._reconnectAttempt) { 418 | await this.getPartyStatus(); 419 | this.updatePartyStatusDisplay(); 420 | this.addGenericMessageToLog("Reconnect", "Connection to server re-established" + (this._reconnectAttempt > 1 ? ` after ${this._reconnectAttempt} attempts` : ""), "redo"); 421 | } 422 | }); 423 | _events.default.on(apiClient, "websocketclose", () => { 424 | setUpReconnectWebSocket(0); 425 | }); 426 | 427 | _events.default.on(apiClient, "message", (...args) => this.webSocketMessageHandler.apply(this, args)); 428 | 429 | _events.default.on(_playbackmanager.default, "playerchange", (...args) => this.playerChangeHandler.apply(this, args)); 430 | 431 | _events.default.on(_connectionmanager.default, "localusersignedin", (e, serverId, userId) => { 432 | partyHeader.style.display = 'block'; 433 | this._loggedIn = true; 434 | }); 435 | 436 | _events.default.on(_connectionmanager.default, "localusersignedout", (e) => { 437 | partyHeader.style.display = 'none'; 438 | this._loggedIn = false; 439 | abortReconnectWebSocket(); 440 | this.partyStatus = null; 441 | this.updatePartyStatusDisplay(); 442 | }); 443 | 444 | _events.default.on(_api.default, "UserUpdated", (e, apiClient, data) => { partyHeader.style.display = (data ? 'block' : 'none'); }); 445 | 446 | let captureClick = (e) => { 447 | if (this.isASyncPlaceholderVisible()) { 448 | //Manual pause/pause during synchronization is discouraged 449 | e.stopPropagation(); 450 | } 451 | } 452 | 453 | _events.default.on(_playbackmanager.default, "playbackstart", () => { 454 | for (let videoOsd of this._videoOsds) { 455 | videoOsd.addEventListener("click", captureClick, {capture: true}); 456 | } 457 | }); 458 | _events.default.on(_playbackmanager.default, "playbackstop", () => { 459 | for (let videoOsd of this._videoOsds) { 460 | videoOsd.removeEventListener("click", captureClick, {capture: true}); 461 | } 462 | }); 463 | 464 | } 465 | 466 | PartyHeader.prototype.updatePartyStatusDisplay = function() { 467 | let skinHeader = document.querySelector(".skinHeader"); 468 | let partyHeader = document.querySelector('.ef-partyheader'); 469 | let partyButton = partyHeader.querySelector('.headerPartyButton'); 470 | let partySidebarButton = partyHeader.querySelector('.headerPartySidebarButton'); 471 | let partySidebar = document.querySelector('.party-sidebar'); 472 | 473 | if (this.partyStatus) { 474 | let partyname = this.partyStatus.CurrentParty.Name + " (" + this.partyStatus.Attendees.length + ")" 475 | partyHeader.querySelector('.headerPartyName').textContent = partyname; 476 | if (partySidebar) partySidebar.querySelector('.party-name > h3').textContent = partyname; 477 | 478 | partyHeader.classList.add('inparty'); 479 | partyButton.title = "Leave this party"; 480 | 481 | if (this.partyStatus.Attendees.length > 1) { 482 | skinHeader.classList.add("guestsAreIn"); 483 | } else { 484 | skinHeader.classList.remove("guestsAreIn"); 485 | } 486 | 487 | let host = this.partyStatus.Attendees.find(attendee => attendee.IsHosting); 488 | if (host?.IsMe) { 489 | skinHeader.classList.add("hostIsMe"); 490 | } else { 491 | skinHeader.classList.remove("hostIsMe"); 492 | } 493 | 494 | partySidebarButton.classList.remove('hide'); 495 | 496 | } else { 497 | this.hideSidebar(true); 498 | this.clearAttendeeList(); 499 | this.clearMessageLog(); 500 | 501 | partyHeader.querySelector('.headerPartyName').textContent = ""; 502 | 503 | partyHeader.classList.remove('inparty'); 504 | partyButton.title = "Join a party"; 505 | 506 | skinHeader.classList.remove("guestsAreIn"); 507 | skinHeader.classList.remove("hostIsMe"); 508 | 509 | partySidebarButton.classList.add('hide'); 510 | } 511 | 512 | this.updatePartyReturnButtonDisplay(); 513 | } 514 | 515 | PartyHeader.prototype.updatePartyReturnButtonDisplay = function() { 516 | let partyHeader = document.querySelector('.ef-partyheader'); 517 | let partyReturnButton = partyHeader.querySelector('.headerPartyReturnButton'); 518 | if (this.partyStatus) { 519 | if (!this._hasVideoPlayer && this.partyStatus.CurrentQueue) { 520 | partyReturnButton.classList.remove('hide'); 521 | } else { 522 | partyReturnButton.classList.add('hide'); 523 | } 524 | } else { 525 | partyReturnButton.classList.add('hide'); 526 | } 527 | } 528 | 529 | PartyHeader.prototype.openCurrentVideoPlayer = function() { 530 | if (!this.partyStatus) return false; 531 | let apiClient = _connectionmanager.default.currentApiClient(); 532 | 533 | return _playbackmanager.default.play({ 534 | serverId: apiClient.serverId(), 535 | mediaSourceId: this.partyStatus.MediaSourceId, 536 | ids: this.partyStatus.CurrentQueue, 537 | startIndex: this.partyStatus.CurrentIndex, 538 | audioStreamIndex: this.partyStatus.AudioStreamIndex, 539 | subtitleStreamIndex: this.partyStatus.SubtitleStreamIndex, 540 | startPositionTicks: 0 541 | }); 542 | } 543 | 544 | // ##### Web socket (our GeneralCommands) ##### 545 | 546 | PartyHeader.prototype.webSocketMessageHandler = async function(e, message) { 547 | let data = message.Data; 548 | 549 | //console.log("##### " + message.MessageType + ":", data); 550 | 551 | //Blink circle icon when receiving a seek order 552 | if (message.MessageType == "Playstate" && data.Command == "Seek") { 553 | let isWaiting = document.querySelector('.party-attendees > .party-isme .isWaiting'); 554 | if (!isWaiting || isWaiting.classList.contains('hide')) { 555 | let me = document.querySelector('.party-attendees > .party-isme'); 556 | me.classList.add('meSeeks'); 557 | me.title = "Position adjusted by party"; 558 | if (this._meseeks) clearTimeout(this._meseeks); 559 | this._meseeks = setTimeout(() => { 560 | me.classList.remove('meSeeks'); 561 | me.title = "This is me!"; 562 | this._meseeks = null; 563 | }, 2000); 564 | } 565 | } 566 | 567 | //Infer party status from play orders 568 | 569 | if (message.MessageType == "Play" && data.PlayCommand == "PlayNow" && this.partyStatus) { 570 | this.partyStatus.CurrentQueue = data.ItemIds.slice(); 571 | this.partyStatus.CurrentIndex = data.StartIndex; 572 | this.partyStatus.MediaSourceId = data.MediaSourceId; 573 | this.partyStatus.AudioStreamIndex = data.AudioStreamIndex; 574 | this.partyStatus.SubtitleStreamIndex = data.SubtitleStreamIndex; 575 | } 576 | 577 | if (message.MessageType == "Playstate" && data.Command == "Stop") { 578 | this.partyStatus.CurrentQueue = null; 579 | this.partyStatus.CurrentIndex = 0; 580 | this.clearAllAttendeeIndicators(); 581 | } 582 | 583 | if (message.MessageType != "GeneralCommand") { return; } 584 | 585 | let skinHeader = document.querySelector(".skinHeader"); 586 | 587 | //Custom GeneralCommands 588 | 589 | if (data.Name == "PartyJoin") { 590 | if (this.partyStatus?.Attendees) { 591 | let attendee = {Name: data.Arguments.Name, UserId: data.Arguments.UserId, IsHosting: false, HasPicture: data.Arguments.HasPicture && data.Arguments.HasPicture != "false", IsRemoteControlled: data.Arguments.IsRemoteControlled == "true"}; 592 | this.partyStatus.Attendees.push(attendee); 593 | this.updatePartyStatusDisplay(); 594 | this.addAttendeeToList(attendee); 595 | 596 | this.addGenericMessageToLog("Join", data.Arguments.Name, "login"); 597 | if (this.getSidebarWidth() < 1) { 598 | _toast.default({text: data.Arguments.Name + " joined " + this.partyStatus.CurrentParty.Name + ".", icon: "login"}); 599 | } 600 | } 601 | } 602 | if (data.Name == "PartyLeave") { 603 | if (this.partyStatus?.Attendees) { 604 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == data.Arguments.Name); 605 | this.partyStatus.Attendees = this.partyStatus.Attendees.filter(attendee => attendee.Name != data.Arguments.Name); 606 | this.updatePartyStatusDisplay(); 607 | this.removeAttendeeFromList(data.Arguments.Name); 608 | 609 | if (target?.IsMe) { 610 | await this.getPartyStatus(); 611 | this.updatePartyStatusDisplay(); 612 | this.placeholdersHidden(); 613 | return; 614 | } 615 | 616 | this.addGenericMessageToLog("Part", data.Arguments.Name, "logout"); 617 | if (this.getSidebarWidth() < 1) { 618 | _toast.default({text: data.Arguments.Name + " left " + this.partyStatus.CurrentParty.Name, icon: "logout"}); 619 | } 620 | } 621 | } 622 | 623 | if (data.Name == "PartyUpdateName") { 624 | if (this.partyStatus?.Attendees) { 625 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == data.Arguments.OldName); 626 | if (target) { 627 | this.renameAttendeeItem(target.Name, data.Arguments.NewName); 628 | target.Name = data.Arguments.NewName; 629 | this.addGenericMessageToLog("Name change", data.Arguments.OldName + " is now " + data.Arguments.NewName, "id_card"); 630 | } 631 | } 632 | } 633 | 634 | if (data.Name == "PartyUpdateHost") { 635 | if (this.partyStatus?.Attendees) { 636 | let oldHost = this.partyStatus.Attendees.find(attendee => attendee.IsHosting); 637 | if (oldHost) { 638 | oldHost.IsHosting = false; 639 | this.setAttendeeItemHost(oldHost.Name, false); 640 | } 641 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == data.Arguments.Host); 642 | if (target) { 643 | target.IsHosting = true; 644 | this.setAttendeeItemHost(target.Name, true); 645 | if (target.IsMe) { 646 | skinHeader.classList.add("hostIsMe"); 647 | } else { 648 | skinHeader.classList.remove("hostIsMe"); 649 | } 650 | 651 | this.addGenericMessageToLog("Host", target.Name, "hub"); 652 | if (this.getSidebarWidth() < 1) { 653 | _toast.default({text: target.Name + " is now hosting.", icon: "hub"}); 654 | } 655 | } else if (oldHost) { 656 | this.addGenericMessageToLog("Host", "" + oldHost.Name + "", "hub"); 657 | this.partyStatus.CurrentQueue = null; 658 | this.clearAllAttendeeIndicators(); 659 | this.updatePartyReturnButtonDisplay(); 660 | if (this.getSidebarWidth() < 1) { 661 | _toast.default({text: oldHost.Name + " is no longer hosting.", icon: "hub"}); 662 | } 663 | } 664 | } 665 | } 666 | 667 | if (data.Name == "ChatBroadcast") { 668 | this.addChatMessageToLog(data.Arguments); 669 | } 670 | 671 | if (data.Name == "ChatExternal") { 672 | this.addChatExternalMessageToLog(data.Arguments); 673 | } 674 | 675 | if (data.Name == "PartyLogMessage") { 676 | let type = data.Arguments.Type; 677 | let subject = data.Arguments.Subject; 678 | let icon = null; 679 | 680 | if (this.getSidebarWidth() < 1) { 681 | if (type == "Pause") { 682 | _toast.default({text: data.Arguments.Subject + " paused the playback.", icon: "pause"}); 683 | } 684 | if (type == "Unpause") { 685 | _toast.default({text: data.Arguments.Subject + " unpaused the playback.", icon: "resume"}); 686 | } 687 | } 688 | 689 | if (type == "Now Playing") icon = "play_arrow"; 690 | if (type == "Pause") icon = "pause"; 691 | if (type == "Unpause") icon = "resume"; 692 | if (type == "Reject") { icon = "do_not_disturb_on"; subject = "Not all attendees have permission to access this item."; } 693 | this.addGenericMessageToLog(type, subject, icon); 694 | } 695 | 696 | let setupExpireSync = () => { 697 | if (this._expireSync != null) clearTimeout(this._expireSync); 698 | this._expireSync = setTimeout(() => { 699 | let attendees = document.querySelectorAll('.party-attendees > :not(.party-ishost):not(.party-isremotecontrolled)'); 700 | for (let attendee of attendees) { 701 | if (!attendee.querySelector('.isWaiting').classList.contains("hide")) { 702 | attendee.querySelector('.isWaiting').classList.add("hide"); 703 | attendee.querySelector('.isNotResponding').classList.remove("hide"); 704 | if (skinHeader.classList.contains("hostIsMe")) { 705 | attendee.querySelector('.btnSendAttendeePlay').classList.remove("hide"); 706 | attendee.querySelector('.btnSendAttendeeKick').classList.remove("hide"); 707 | } 708 | } 709 | } 710 | }, 21000); 711 | } 712 | 713 | if (data.Name == "PartySyncStart") { 714 | this.placeholdersSyncing(); 715 | 716 | for (let attendee of document.querySelectorAll('.party-attendees > :not(.party-ishost):not(.party-isremotecontrolled)')) { 717 | if (!attendee.querySelector('.isSyncing').classList.contains("hide")) continue; //Guest started before host 718 | attendee.querySelector('.isWaiting').classList.remove("hide"); 719 | } 720 | 721 | setupExpireSync(); 722 | } 723 | 724 | if (data.Name == "PartySyncWaiting") { 725 | 726 | let attendee = this.findAttendeeListItem(data.Arguments.Name); 727 | if (attendee) { 728 | attendee.querySelector('.isWaiting').classList.add("hide"); 729 | attendee.querySelector('.isNotResponding').classList.add("hide"); 730 | attendee.querySelector('.btnSendAttendeePlay').classList.add("hide"); 731 | attendee.querySelector('.btnSendAttendeeKick').classList.add("hide"); 732 | attendee.querySelector('.isSyncing').classList.remove("hide"); 733 | } 734 | 735 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == data.Arguments.Name); 736 | if (target?.IsMe) { 737 | this.placeholdersReady(); 738 | } 739 | } 740 | 741 | if (data.Name == "PartySyncReset") { 742 | 743 | let attendee = this.findAttendeeListItem(data.Arguments.Name); 744 | if (attendee) { 745 | attendee.querySelector('.isSyncing').classList.add("hide"); 746 | attendee.querySelector('.isNotResponding').classList.add("hide"); 747 | attendee.querySelector('.btnSendAttendeePlay').classList.add("hide"); 748 | attendee.querySelector('.btnSendAttendeeKick').classList.add("hide"); 749 | attendee.querySelector('.isWaiting').classList.remove("hide"); 750 | } 751 | 752 | setupExpireSync(); 753 | 754 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == data.Arguments.Name); 755 | if (target?.IsMe) { 756 | this.placeholdersSyncing(); 757 | } 758 | } 759 | 760 | if (data.Name == "PartySyncEnd") { 761 | if (this._expireSync != null) { clearTimeout(this._expireSync); this._expireSync = null; } 762 | this.placeholdersHidden(); 763 | this.clearAllAttendeeIndicators(); 764 | } 765 | 766 | if (data.Name == "PartyUpdateRemoteControlled") { 767 | let attendees = document.querySelectorAll('.party-attendees > :not(.party-ishost)'); 768 | for (let attendee of attendees) { 769 | if (attendee.querySelector('.actualname').textContent == data.Arguments.Name) { 770 | if (data.Arguments.Status == "true") { 771 | attendee.classList.add("party-isremotecontrolled"); 772 | attendee.querySelector('.isRemoteControlled').classList.remove("hide"); 773 | } else { 774 | attendee.classList.remove("party-isremotecontrolled"); 775 | attendee.querySelector('.isRemoteControlled').classList.add("hide"); 776 | } 777 | break; 778 | } 779 | } 780 | } 781 | 782 | if (data.Name == "PartyRefreshDone") { 783 | await this.getPartyStatus(); 784 | this.updatePartyStatusDisplay(); 785 | let video = document.querySelector('video.htmlvideoplayer'); 786 | if (!video) { 787 | return; 788 | } 789 | 790 | if (!this.partyStatus) { 791 | video.stop(); 792 | return; 793 | } 794 | 795 | let host = this.partyStatus.Attendees.find(attendee => attendee.IsHosting); 796 | if (!host?.IsMe && video.paused) { 797 | //Possibly in the future pause play session in this case 798 | } 799 | } 800 | 801 | if (data.Name == "PartyPing") { 802 | this.partyPong(data.Arguments.ts); 803 | } 804 | } 805 | 806 | PartyHeader.prototype.startPartyPing = function() { 807 | if (this._pingTimer) clearinterval(this._pingTimer); 808 | 809 | let apiClient = _connectionmanager.default.currentApiClient(); 810 | this._pingTimer = setInterval(() => { 811 | if (this.partyStatus) { 812 | apiClient.sendWebSocketMessage("PartyPing", JSON.stringify({Id: this.partyStatus?.CurrentParty?.Id})); 813 | } 814 | }, PING_INTERVAL); 815 | } 816 | 817 | PartyHeader.prototype.stopPartyPing = function() { 818 | if (this._pingTimer) { 819 | clearinterval(this._pingTimer); 820 | this._pingTimer = null; 821 | } 822 | } 823 | 824 | PartyHeader.prototype.partyPong = function(ts) { 825 | let apiClient = _connectionmanager.default.currentApiClient(); 826 | apiClient.sendWebSocketMessage("PartyPong", JSON.stringify({ts: ts})); 827 | } 828 | 829 | // ##### Player button placeholders and changes ##### 830 | 831 | PartyHeader.prototype.getSyncPlaceholder = function(videoOsd) { 832 | let sync = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-sync'); 833 | if (!sync) { 834 | let playpause = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-btnPause'); 835 | sync = document.createElement('button'); 836 | sync.className = "osdIconButton videoOsd-sync autofocus paper-icon-button-light videoOsd-btnPause-autolayout"; 837 | sync.innerHTML = ``; 838 | sync.title = "Synchronizing..."; 839 | if (playpause) playpause.after(sync); 840 | } 841 | return sync; 842 | } 843 | 844 | PartyHeader.prototype.getSyncReadyPlaceholder = function(videoOsd) { 845 | let ready = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-ready'); 846 | if (!ready) { 847 | let playpause = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-btnPause'); 848 | ready = document.createElement('button'); 849 | ready.className = "osdIconButton videoOsd-ready autofocus paper-icon-button-light videoOsd-btnPause-autolayout"; 850 | ready.innerHTML = ``; 851 | ready.title = "Ready, please wait for host"; 852 | if (playpause) playpause.after(ready); 853 | } 854 | return ready; 855 | } 856 | 857 | PartyHeader.prototype.getSyncNowPlaying = function() { 858 | let sync = document.querySelector('.nowPlayingBar .syncPlaceholder'); 859 | if (!sync) { 860 | let playpause = document.querySelector('.nowPlayingBar .playPauseButton'); 861 | sync = document.createElement('button'); 862 | sync.className = "nowPlayingBar-hidetv syncPlaceholder mediaButton md-icon md-icon-fill paper-icon-button-light"; 863 | sync.innerHTML = ``; 864 | sync.title = "Synchronizing..."; 865 | if (playpause) playpause.after(sync); 866 | } 867 | return sync; 868 | } 869 | 870 | PartyHeader.prototype.getSyncReadyNowPlaying = function() { 871 | let ready = document.querySelector('.nowPlayingBar .readyPlaceholder'); 872 | if (!ready) { 873 | let playpause = document.querySelector('.nowPlayingBar .playPauseButton'); 874 | ready = document.createElement('button'); 875 | ready.className = "nowPlayingBar-hidetv readyPlaceholder mediaButton md-icon md-icon-fill paper-icon-button-light"; 876 | ready.innerHTML = ``; 877 | ready.title = "Ready, please wait for host"; 878 | if (playpause) playpause.after(ready); 879 | } 880 | return ready; 881 | } 882 | 883 | PartyHeader.prototype.placeholdersHidden = function() { 884 | let pause = document.querySelector('.nowPlayingBar .playPauseButton'); 885 | if (pause) { 886 | pause.style.display = "block"; 887 | this.getSyncNowPlaying().style.display = "none"; 888 | this.getSyncReadyNowPlaying().style.display = "none"; 889 | } 890 | 891 | for (let videoOsd of this._videoOsds) { 892 | pause = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-btnPause'); 893 | if (pause) { 894 | pause.style.display = "flex"; 895 | this.getSyncPlaceholder(videoOsd).style.display = "none"; 896 | this.getSyncReadyPlaceholder(videoOsd).style.display = "none"; 897 | } 898 | } 899 | } 900 | 901 | PartyHeader.prototype.placeholdersSyncing = function() { 902 | let pause = document.querySelector('.nowPlayingBar .playPauseButton'); 903 | if (pause) { 904 | pause.style.display = "none"; 905 | this.getSyncNowPlaying().style.display = "block"; 906 | this.getSyncReadyNowPlaying().style.display = "none"; 907 | } 908 | 909 | for (let videoOsd of this._videoOsds) { 910 | pause = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-btnPause'); 911 | if (pause) { 912 | pause.style.display = "none"; 913 | this.getSyncPlaceholder(videoOsd).style.display = "flex"; 914 | this.getSyncReadyPlaceholder(videoOsd).style.display = "none"; 915 | } 916 | } 917 | } 918 | 919 | PartyHeader.prototype.placeholdersReady = function() { 920 | let pause = document.querySelector('.nowPlayingBar .playPauseButton'); 921 | if (pause) { 922 | pause.style.display = "none"; 923 | this.getSyncNowPlaying().style.display = "none"; 924 | this.getSyncReadyNowPlaying().style.display = "block"; 925 | } 926 | 927 | for (let videoOsd of this._videoOsds) { 928 | pause = videoOsd.querySelector('.videoOsd-belowtransportbuttons .videoOsd-btnPause'); 929 | if (pause) { 930 | pause.style.display = "none"; 931 | this.getSyncPlaceholder(videoOsd).style.display = "none"; 932 | this.getSyncReadyPlaceholder(videoOsd).style.display = "flex"; 933 | } 934 | } 935 | } 936 | 937 | PartyHeader.prototype.isASyncPlaceholderVisible = function() { 938 | for (let videoOsd of this._videoOsds) { 939 | if (this.getSyncPlaceholder(videoOsd).style.display == "flex" || this.getSyncReadyPlaceholder(videoOsd).style.display == "flex") { 940 | return true; 941 | } 942 | } 943 | return false; 944 | } 945 | 946 | // ##### Remote control support ##### 947 | 948 | PartyHeader.prototype.getCurrentTargetSessionId = function() { 949 | return _playbackmanager.default.getPlayerInfo()?.currentSessionId; 950 | } 951 | 952 | PartyHeader.prototype.setRemoteControlInParty = function(targetId) { 953 | let apiClient = _connectionmanager.default.currentApiClient(); 954 | localStorage.setItem(LS_HAS_REMOTE_TARGET, targetId != null ? this.partyStatus.Id : null); 955 | apiClient.sendWebSocketMessage("PartyUpdateRemoteControl", JSON.stringify({RemoteControl: targetId})); 956 | } 957 | 958 | PartyHeader.prototype.playerChangeHandler = async function(e, newPlayer, newTarget, previousPlayer) { 959 | //console.log("Player change handler:", newPlayer, newTarget, previousPlayer); 960 | 961 | if (newPlayer && !newPlayer.isLocalPlayer) { 962 | let remoteControlSafety = await _partyapiclient.default.getRemoteControlSafety({RemoteControl: newPlayer.currentSessionId}); 963 | if (remoteControlSafety && !remoteControlSafety.IsSafe) { 964 | _playbackmanager.default.setDefaultPlayerActive(); 965 | newPlayer = null; 966 | _toast.default({text: "Remote control denied because it would create a loop.", icon: "warning"}); 967 | } 968 | } 969 | 970 | if (newPlayer && this.partyStatus) { 971 | this.setRemoteControlInParty(newPlayer?.currentSessionId || null); 972 | } 973 | 974 | this._hasVideoPlayer = !!newPlayer; 975 | 976 | setTimeout(async () => { 977 | this.updatePartyReturnButtonDisplay(); 978 | 979 | if (previousPlayer && !previousPlayer.isLocalPlayer && !this._hasVideoPlayer) { 980 | //Cease to be a remote controller 981 | await this.getPartyStatus(); //Former remote controller is missing playback queue 982 | if (this.partyStatus?.CurrentQueue) { 983 | this.openCurrentVideoPlayer(); 984 | } 985 | } 986 | }, 100); 987 | } 988 | 989 | // ##### Sidebar log (chat) ##### 990 | 991 | PartyHeader.prototype.getProfilePictureHTML = function(userId) { 992 | let userAttendee = (this.partyStatus?.Attendees || []).find(attendee => attendee.UserId == userId); 993 | if (userAttendee?.HasPicture) { 994 | return ``; 995 | } else { 996 | return `
`; 997 | } 998 | } 999 | 1000 | PartyHeader.prototype.getExternalProfileHTML = function(avatarUrl) { 1001 | if (avatarUrl) { 1002 | return ``; 1003 | } else { 1004 | return `
`; 1005 | } 1006 | } 1007 | 1008 | PartyHeader.prototype.sendMessageFromChatbox = function() { 1009 | let textarea = document.querySelector(".party-send textarea"); 1010 | this.sendChatMessage(textarea.value); 1011 | textarea.value = ""; 1012 | } 1013 | 1014 | PartyHeader.prototype.sendChatMessage = function(message) { 1015 | if (!message) return; 1016 | let apiClient = _connectionmanager.default.currentApiClient(); 1017 | 1018 | let escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1019 | let codepointsToEmoji = (str) => str.split("-").map(codepoint => String.fromCodePoint(parseInt(codepoint, 16))).join(""); 1020 | 1021 | message = message.replaceAll(/\>\!(.*?)\!\ { 1024 | if (_emoji.default[shortname]) { 1025 | return codepointsToEmoji(_emoji.default[shortname]); 1026 | } 1027 | return all; 1028 | }); 1029 | 1030 | message = message.replaceAll(new RegExp('(^|(?<=\\W))(' + Object.keys(_emoticon.default).map(ei => escapeRegex(ei)).join("|") + ')((?=\\W)|$)', 'g'), (all, d, ei) => { 1031 | if (_emoticon.default[ei]) { 1032 | return codepointsToEmoji(_emoticon.default[ei]); 1033 | } 1034 | return all; 1035 | }); 1036 | 1037 | message = String.fromCodePoint(0x200B) + message; //Straighten out Emby's encoding detection 1038 | 1039 | apiClient.sendWebSocketMessage("Chat", JSON.stringify({Message: message})); 1040 | } 1041 | 1042 | PartyHeader.prototype.sendRefreshNotification = function() { 1043 | let apiClient = _connectionmanager.default.currentApiClient(); 1044 | apiClient.sendWebSocketMessage("PartyRefresh", JSON.stringify({})); 1045 | } 1046 | 1047 | PartyHeader.prototype.isNameMe = function(name) { 1048 | let target = this.partyStatus.Attendees.find(attendee => attendee.Name == name); 1049 | return target && target.IsMe; 1050 | } 1051 | 1052 | PartyHeader.prototype.addChatMessageToLog = function(messageData) { 1053 | return this.addChatToLog( 1054 | messageData.Name, 1055 | messageData.Message, 1056 | this.getProfilePictureHTML(messageData.UserId), 1057 | null, 1058 | this.isNameMe(messageData.Name) 1059 | ); 1060 | } 1061 | 1062 | PartyHeader.prototype.addChatExternalMessageToLog = function(externalMessageData) { 1063 | return this.addChatToLog( 1064 | externalMessageData.Name, 1065 | externalMessageData.Message, 1066 | this.getExternalProfileHTML(externalMessageData.AvatarUrl), 1067 | "externalmessage", 1068 | false 1069 | ); 1070 | } 1071 | 1072 | PartyHeader.prototype.addChatToLog = function(name, message, avatarHTML, extraClasses, forceScroll) { 1073 | let newEntry = document.createElement('div'); 1074 | newEntry.className = "chatmessage" + (extraClasses ? " " + extraClasses : ""); 1075 | 1076 | message = message 1077 | .replaceAll(/\*\*(.*?)\*\*/g,'$1') 1078 | .replaceAll(/\*(.*?)\*/g,'$1') 1079 | .replaceAll(/\_\_(.*?)\_\_/g,'$1') 1080 | .replaceAll(/\_(.*?)\_/g,'$1') 1081 | .replaceAll(/~~(.*?)~~/g,'$1') 1082 | .replaceAll(/`(.*?)`/g,'
$1
') 1083 | .replaceAll(/\|\|(.*?)\|\|/g,'$1') 1084 | .replaceAll(/\>\!(.*?)\!\$1') 1085 | ; 1086 | 1087 | newEntry.innerHTML = ` 1088 | ${avatarHTML || ""} 1089 |
1090 |
${name}
1091 |
${message}
1092 |
${this.getCurrentTimestamp()}
1093 |
1094 | `; 1095 | 1096 | if (this._lastLogEntry !== null && Date.now() - this._lastLogEntry > CHAT_SEPARATOR_DELAY) { 1097 | this.addSeparatorToLog(); 1098 | } 1099 | 1100 | return this.addMessageElementToLog(newEntry, forceScroll); 1101 | } 1102 | 1103 | PartyHeader.prototype.addGenericMessageToLog = function(type, subject, icon) { 1104 | let newEntry = document.createElement('div'); 1105 | newEntry.className = "chatmessage generic"; 1106 | 1107 | let iconpart = icon ? `${icon}` : ""; 1108 | 1109 | newEntry.innerHTML = ` 1110 |
1111 |
${iconpart}${type} ${subject}
1112 |
${this.getCurrentTimestamp()}
1113 |
1114 | `; 1115 | 1116 | if (this._lastLogEntry !== null && Date.now() - this._lastLogEntry > CHAT_SEPARATOR_DELAY) { 1117 | this.addSeparatorToLog(); 1118 | } 1119 | 1120 | return this.addMessageElementToLog(newEntry); 1121 | } 1122 | 1123 | PartyHeader.prototype.addSeparatorToLog = function() { 1124 | let newEntry = document.createElement('div'); 1125 | newEntry.className = "chatseparator"; 1126 | return this.addMessageElementToLog(newEntry); 1127 | } 1128 | 1129 | PartyHeader.prototype.getCurrentTimestamp = function(now) { 1130 | if (!now) now = new Date(); 1131 | return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; 1132 | } 1133 | 1134 | PartyHeader.prototype.addMessageElementToLog = function(element, forceScroll) { 1135 | let container = document.querySelector('.party-sidebar .party-logcontainer'); 1136 | let messageLog = document.querySelector('.party-sidebar .party-log'); 1137 | 1138 | let atBottom = container.scrollTop > (container.scrollHeight - container.clientHeight) - 30; 1139 | 1140 | messageLog.append(element); 1141 | 1142 | let oldest = [...messageLog.children].slice(0, -100); 1143 | for (let child of oldest) { 1144 | child.remove(); 1145 | } 1146 | 1147 | if (atBottom || forceScroll) { 1148 | container.scrollTop = container.scrollHeight; 1149 | } 1150 | 1151 | this._lastLogEntry = Date.now(); 1152 | } 1153 | 1154 | PartyHeader.prototype.clearMessageLog = function() { 1155 | let messageLog = document.querySelector('.party-sidebar .party-log'); 1156 | messageLog.innerHTML = ""; 1157 | } 1158 | 1159 | // ##### Other sidebar control methods ##### 1160 | 1161 | PartyHeader.prototype.getSidebarWidth = function() { 1162 | return parseInt(getComputedStyle(document.querySelector(".party-sidebar"), '').width); 1163 | } 1164 | 1165 | PartyHeader.prototype.showSidebar = function(width, saveWidth) { 1166 | if (width < this.getSidebarMinWidth()) width = this.getSidebarMinWidth(); 1167 | if (width > this.getSidebarMaxWidth()) width = this.getSidebarMaxWidth(); 1168 | let sidebar = document.querySelector(".party-sidebar"); 1169 | let appcontainer = document.querySelector(".appcontainer"); 1170 | let currentWidth = this.getSidebarWidth(); 1171 | let offset = width - currentWidth; 1172 | sidebar.style.display = "block"; 1173 | sidebar.style.width = `${width}px`; 1174 | appcontainer.style.right = sidebar.style.width; 1175 | if (this._undocked) { 1176 | this.setUndocked(); 1177 | } else { 1178 | this.setDocked(); 1179 | } 1180 | document.body.style.setProperty('--party-sidebar-width', this._undocked ? 0 : sidebar.style.width); 1181 | localStorage.setItem(LS_SIDEBAR_STATE, true); 1182 | if (saveWidth) { 1183 | localStorage.setItem(LS_SIDEBAR_WIDTH, width); 1184 | } 1185 | } 1186 | 1187 | PartyHeader.prototype.focusOnChatSend = function() { 1188 | _focusmanager.default.focus(document.querySelector('.party-send textarea')); 1189 | } 1190 | 1191 | PartyHeader.prototype.hideSidebar = function(transient) { 1192 | let sidebar = document.querySelector(".party-sidebar"); 1193 | if (!sidebar) return; 1194 | sidebar.style.width = 0; 1195 | sidebar.style.display = "none"; 1196 | document.querySelector(".appcontainer").style.right = 0; 1197 | document.body.style.setProperty('--party-sidebar-width', 0); 1198 | if (!transient) { 1199 | localStorage.setItem(LS_SIDEBAR_STATE, false); 1200 | } 1201 | this._focusOnChat = false; 1202 | this._lastLogEntry = null; 1203 | } 1204 | 1205 | PartyHeader.prototype.addAttendeeToList = function(attendee) { 1206 | let container = document.querySelector('.party-sidebar .party-logcontainer'); 1207 | let attendeeList = document.querySelector(".party-attendees"); 1208 | 1209 | let atBottom = container.scrollTop > (container.scrollHeight - container.clientHeight) - 30; 1210 | 1211 | let newAttendee = document.createElement('div'); 1212 | newAttendee.innerHTML = ` 1213 | ${this.getProfilePictureHTML(attendee.UserId)} 1214 |
1215 | 1216 | ${attendee.Name} 1217 | 1218 | 1219 | 1220 | 1221 | 1222 | 1223 |
1224 | `; 1225 | if (attendee.IsMe) { 1226 | newAttendee.title = "This is me!"; 1227 | newAttendee.classList.add("party-isme"); 1228 | } 1229 | if (attendee.IsHosting) newAttendee.classList.add("party-ishost"); 1230 | if (attendee.IsRemoteControlled) newAttendee.classList.add("party-isremotecontrolled"); 1231 | attendeeList.append(newAttendee); 1232 | 1233 | newAttendee.querySelector(".btnSendAttendeePlay").addEventListener("click", () => { 1234 | this.sendAttendeePlay(attendee.Name); 1235 | }); 1236 | 1237 | newAttendee.querySelector(".btnSendAttendeeKick").addEventListener("click", () => { 1238 | this.sendAttendeeKick(attendee.Name); 1239 | }); 1240 | 1241 | if (atBottom) { 1242 | container.scrollTop = container.scrollHeight; 1243 | } 1244 | } 1245 | 1246 | PartyHeader.prototype.sendAttendeePlay = function(attendeename) { 1247 | let apiClient = _connectionmanager.default.currentApiClient(); 1248 | apiClient.sendWebSocketMessage("PartyAttendeePlay", JSON.stringify({Name: attendeename})); 1249 | } 1250 | 1251 | PartyHeader.prototype.sendAttendeeKick = function(attendeename) { 1252 | let apiClient = _connectionmanager.default.currentApiClient(); 1253 | apiClient.sendWebSocketMessage("PartyAttendeeKick", JSON.stringify({Name: attendeename})); 1254 | } 1255 | 1256 | PartyHeader.prototype.findAttendeeListItem = function(name) { 1257 | return [...document.querySelector(".party-attendees").children].find(child => child.querySelector('.actualname').innerText == name); 1258 | } 1259 | 1260 | PartyHeader.prototype.clearAllAttendeeIndicators = function() { 1261 | for (let indicator of document.querySelectorAll('.party-attendees .isWaiting, .party-attendees .isSyncing, .party-attendees .isNotResponding, .party-attendees .btnSendAttendeePlay, .party-attendees .btnSendAttendeeKick')) { 1262 | indicator.classList.add("hide"); 1263 | } 1264 | } 1265 | 1266 | PartyHeader.prototype.removeAttendeeFromList = function(name) { 1267 | let item = this.findAttendeeListItem(name); 1268 | if (item) { 1269 | item.remove(); 1270 | } 1271 | } 1272 | 1273 | PartyHeader.prototype.renameAttendeeItem = function(name, newName) { 1274 | let item = this.findAttendeeListItem(name); 1275 | if (item) { 1276 | item.querySelector('.actualname').innerText = newName; 1277 | } 1278 | } 1279 | 1280 | PartyHeader.prototype.setAttendeeItemHost = function(name, state) { 1281 | let item = this.findAttendeeListItem(name); 1282 | if (item) { 1283 | if (state) { 1284 | item.classList.add("party-ishost"); 1285 | item.querySelector(".isHost").classList.remove("hide"); 1286 | for (let indicator of item.querySelectorAll('.isWaiting, .isSyncing, .isNotResponding, .btnSendAttendeePlay, .btnSendAttendeeKick')) { 1287 | indicator.classList.add("hide"); 1288 | } 1289 | } else { 1290 | item.classList.remove("party-ishost"); 1291 | item.querySelector(".isHost").classList.add("hide"); 1292 | } 1293 | } 1294 | } 1295 | 1296 | PartyHeader.prototype.clearAttendeeList = function () { 1297 | let attendeeList = document.querySelector(".party-attendees"); 1298 | attendeeList.innerHTML = ""; 1299 | } 1300 | 1301 | PartyHeader.prototype.setUndocked = function() { 1302 | this._undocked = true; 1303 | localStorage.setItem(LS_DOCK_MODE, true); 1304 | let sidebar = document.querySelector('.party-sidebar'); 1305 | sidebar.querySelector(".btnPartyDock").classList.remove('hide'); 1306 | sidebar.querySelector(".btnPartyUndock").classList.add('hide'); 1307 | sidebar.classList.add("undocked"); 1308 | document.body.style.setProperty('--party-sidebar-width', 0); 1309 | } 1310 | 1311 | PartyHeader.prototype.setDocked = function() { 1312 | this._undocked = false; 1313 | localStorage.setItem(LS_DOCK_MODE, false); 1314 | let sidebar = document.querySelector('.party-sidebar'); 1315 | sidebar.querySelector(".btnPartyDock").classList.add('hide'); 1316 | sidebar.querySelector(".btnPartyUndock").classList.remove('hide'); 1317 | sidebar.classList.remove("undocked"); 1318 | document.body.style.setProperty('--party-sidebar-width', sidebar.style.width); 1319 | } 1320 | 1321 | 1322 | _exports.default = PartyHeader; 1323 | }); --------------------------------------------------------------------------------