diff --git a/client/css/static/main.css b/client/css/static/main.css new file mode 100644 index 00000000..356a89e4 --- /dev/null +++ b/client/css/static/main.css @@ -0,0 +1,30 @@ +html, body { + border: 0; + margin: 0; +} + +.app-container { + right: 0; + left: 0; + top: 0; + bottom: 0; + width: 100%; + height: 100%; + position: absolute; + display: flex; + justify-content: stretch; +} +.app-container .app { + width: 100%; + height: 100%; + margin: 0; + display: flex; + flex-direction: column; + resize: both; +} + +footer { + display: none !important; +} + +/*# sourceMappingURL=main.css.map */ diff --git a/client/css/static/main.css.map b/client/css/static/main.css.map new file mode 100644 index 00000000..eb78f7c0 --- /dev/null +++ b/client/css/static/main.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACC;EACA;;;AAGD;EACC;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;;AAEA;EACC;EACA;EACA;EAEA;EAAe;EAAwB;;;AAIzC;EACC","file":"main.css"} \ No newline at end of file diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 7f8450f0..31e50fe3 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -35,6 +35,8 @@ import {md5} from "tc-shared/crypto/md5"; import {guid} from "tc-shared/crypto/uid"; import {ServerEventLog} from "tc-shared/ui/frames/log/ServerEventLog"; import {EventType} from "tc-shared/ui/frames/log/Definitions"; +import {PluginCmdRegistry} from "tc-shared/connection/PluginCmdHandler"; +import {W2GPluginCmdHandler} from "tc-shared/video-viewer/W2GPluginHandler"; export enum DisconnectReason { HANDLER_DESTROYED, @@ -157,6 +159,8 @@ export class ConnectionHandler { private _connect_initialize_id: number = 1; + private pluginCmdRegistry: PluginCmdRegistry; + private client_status: LocalClientStatus = { input_hardware: false, input_muted: false, @@ -188,6 +192,8 @@ export class ConnectionHandler { this.fileManager = new FileManager(this); this.permissions = new PermissionManager(this); + this.pluginCmdRegistry = new PluginCmdRegistry(this); + this.log = new ServerEventLog(this); this.side_bar = new Frame(this); this.sound = new SoundManager(this); @@ -217,6 +223,8 @@ export class ConnectionHandler { this.event_registry.register_handler(this); this.events().fire("notify_handler_initialized"); + + this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler()); } initialize_client_state(source?: ConnectionHandler) { @@ -962,6 +970,9 @@ export class ConnectionHandler { this.hostbanner && this.hostbanner.destroy(); this.hostbanner = undefined; + this.pluginCmdRegistry && this.pluginCmdRegistry.destroy(); + this.pluginCmdRegistry = undefined; + this._local_client && this._local_client.destroy(); this._local_client = undefined; @@ -1031,7 +1042,7 @@ export class ConnectionHandler { * - Voice bridge hasn't been set upped yet */ //TODO: This currently returns false - isSpeakerDisabled() { return false; } + isSpeakerDisabled() : boolean { return false; } setSubscribeToAllChannels(flag: boolean) { if(this.client_status.channel_subscribe_all === flag) return; @@ -1043,7 +1054,7 @@ export class ConnectionHandler { this.event_registry.fire("notify_state_updated", { state: "subscribe" }); } - isSubscribeToAllChannels() { return this.client_status.channel_subscribe_all; } + isSubscribeToAllChannels() : boolean { return this.client_status.channel_subscribe_all; } setAway(state: boolean | string) { this.setAway_(state, true); @@ -1084,12 +1095,14 @@ export class ConnectionHandler { }); } - areQueriesShown() { + areQueriesShown() : boolean { return this.client_status.queries_visible; } - hasInputHardware() { return this.client_status.input_hardware; } - hasOutputHardware() { return this.client_status.output_muted; } + hasInputHardware() : boolean { return this.client_status.input_hardware; } + hasOutputHardware() : boolean { return this.client_status.output_muted; } + + getPluginCmdRegistry() : PluginCmdRegistry { return this.pluginCmdRegistry; } } export type ConnectionStateUpdateType = "microphone" | "speaker" | "away" | "subscribe" | "query"; diff --git a/shared/js/connection/PluginCmdHandler.ts b/shared/js/connection/PluginCmdHandler.ts new file mode 100644 index 00000000..ef9eb460 --- /dev/null +++ b/shared/js/connection/PluginCmdHandler.ts @@ -0,0 +1,118 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; +import {AbstractServerConnection, ServerCommand} from "tc-shared/connection/ConnectionBase"; + +export interface PluginCommandInvoker { + clientId: number; + clientUniqueId: string; + clientName: string; +} + +export abstract class PluginCmdHandler { + protected readonly channel: string; + protected currentServerConnection: AbstractServerConnection; + + protected constructor(channel: string) { + this.channel = channel; + } + + handleHandlerUnregistered() {} + handleHandlerRegistered() {} + + getChannel() { return this.channel; } + + abstract handlePluginCommand(data: string, invoker: PluginCommandInvoker); + + protected sendPluginCommand(data: string, mode: "server" | "view" | "channel" | "private", clientId?: number) : Promise { + if(!this.currentServerConnection) + throw "plugin command handler not registered"; + + return this.currentServerConnection.send_command("plugincmd", { + data: data, + name: this.channel, + targetmode: mode === "server" ? 1 : + mode === "view" ? 3 : + mode === "channel" ? 0 : + 2, + target: clientId + }); + } +} + +class PluginCmdRegistryCommandHandler extends AbstractCommandHandler { + private readonly callback: (channel: string, data: string, invoker: PluginCommandInvoker) => void; + + constructor(connection, callback) { + super(connection); + this.callback = callback; + } + + handle_command(command: ServerCommand): boolean { + if(command.command !== "notifyplugincmd") + return false; + + const channel = command.arguments[0]["name"]; + const payload = command.arguments[0]["data"]; + const invoker = { + clientId: parseInt(command.arguments[0]["invokerid"]), + clientUniqueId: command.arguments[0]["invokeruid"], + clientName: command.arguments[0]["invokername"] + } as PluginCommandInvoker; + + this.callback(channel, payload, invoker); + + return false; + } +} + +export class PluginCmdRegistry { + readonly connection: ConnectionHandler; + private readonly handler: PluginCmdRegistryCommandHandler; + + private handlerMap: {[key: string]: PluginCmdHandler} = {}; + + constructor(connection: ConnectionHandler) { + this.connection = connection; + + this.handler = new PluginCmdRegistryCommandHandler(connection.serverConnection, this.handlePluginCommand.bind(this)); + this.connection.serverConnection.command_handler_boss().register_handler(this.handler); + } + + destroy() { + this.connection.serverConnection.command_handler_boss().unregister_handler(this.handler); + + Object.keys(this.handlerMap).map(e => this.handlerMap[e]).forEach(handler => { + handler["currentServerConnection"] = undefined; + handler.handleHandlerUnregistered(); + }); + this.handlerMap = {}; + } + + registerHandler(handler: PluginCmdHandler) { + if(this.handlerMap[handler.getChannel()] !== undefined) + throw tra("A handler for channel {} already exists", handler.getChannel()); + + this.handlerMap[handler.getChannel()] = handler; + handler["currentServerConnection"] = this.connection.serverConnection; + handler.handleHandlerRegistered(); + } + + unregisterHandler(handler: PluginCmdHandler) { + if(this.handlerMap[handler.getChannel()] !== handler) + return; + + handler["currentServerConnection"] = undefined; + handler.handleHandlerUnregistered(); + delete this.handlerMap[handler.getChannel()]; + } + + private handlePluginCommand(channel: string, payload: string, invoker: PluginCommandInvoker) { + const handler = this.handlerMap[channel] as PluginCmdHandler; + handler?.handlePluginCommand(payload, invoker); + } + + getPluginHandler(channel: string) : T | undefined { + return this.handlerMap[channel] as T; + } +} \ No newline at end of file diff --git a/shared/js/devel_main.ts b/shared/js/devel_main.ts index b41792c3..c90f9ba2 100644 --- a/shared/js/devel_main.ts +++ b/shared/js/devel_main.ts @@ -6,7 +6,6 @@ import * as i18n from "./i18n/localize"; import "./proto"; -import {spawnVideoPopout} from "tc-shared/video-viewer/Controller"; console.error("Hello World from devel main"); loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { @@ -22,7 +21,5 @@ loader.register_task(Stage.LOADED, { name: "invoke", priority: 10, function: async () => { - console.error("Spawning video popup"); - //spawnVideoPopout(); } }); \ No newline at end of file diff --git a/shared/js/file/Avatars.ts b/shared/js/file/Avatars.ts index 2fb03210..cddeffe9 100644 --- a/shared/js/file/Avatars.ts +++ b/shared/js/file/Avatars.ts @@ -2,6 +2,7 @@ import {Registry} from "tc-shared/events"; import * as hex from "tc-shared/crypto/hex"; export const kIPCAvatarChannel = "avatars"; +export const kLoadingAvatarImage = "img/loading_image.svg"; export const kDefaultAvatarImage = "img/style/avatar.png"; export type AvatarState = "unset" | "loading" | "errored" | "loaded"; diff --git a/shared/js/ipc/BrowserIPC.ts b/shared/js/ipc/BrowserIPC.ts index 41b87909..833d8326 100644 --- a/shared/js/ipc/BrowserIPC.ts +++ b/shared/js/ipc/BrowserIPC.ts @@ -112,15 +112,17 @@ export abstract class BasicIPCHandler { const data: ChannelMessage = message.data; let channel_invoked = false; - for(const channel of this._channels) + for(const channel of this._channels) { if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) { if(channel.messageHandler) channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data); channel_invoked = true; } + } + if(!channel_invoked) { - debugger; - console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); + /* Seems like we're not the only web/teaclient instance */ + /* console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); */ } } } diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 174a8a7a..70ba1df4 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -41,8 +41,7 @@ import "./ui/elements/ContextDivider"; import "./ui/elements/Tab"; import "./connection/CommandHandler"; import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler"; -import {spawnVideoPopout} from "tc-shared/video-viewer/Controller"; -import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller"; /* else it might not get bundled because only the backends are accessing it */ +import {openVideoViewer} from "tc-shared/video-viewer/Controller"; declare global { interface Window { @@ -498,7 +497,9 @@ function main() { modal.close_listener.push(() => settings.changeGlobal(Settings.KEY_USER_IS_NEW, false)); } - (window as any).spawnVideoPopout = spawnVideoPopout; + (window as any).spawnVideoPopout = openVideoViewer; + + //spawnVideoPopout(server_connections.active_connection(), "https://www.youtube.com/watch?v=9683D18fyvs"); } const task_teaweb_starter: loader.Task = { diff --git a/shared/js/settings.ts b/shared/js/settings.ts index a9500da4..64f01cd1 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -462,6 +462,12 @@ export class Settings extends StaticSettings { valueType: "string" }; + static readonly KEY_W2G_SIDEBAR_COLLAPSED: ValuedSettingsKey = { + key: 'w2g_sidebar_collapsed', + defaultValue: false, + valueType: "boolean", + }; + static readonly FN_LOG_ENABLED: (category: string) => SettingsKey = category => { return { key: "log." + category.toLowerCase() + ".enabled", diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts index 8e492a4a..cb913483 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.ts @@ -7,4 +7,5 @@ export const rendererReact = new ReactRenderer(); export const rendererHTML = new HTMLRenderer(rendererReact); import "./emoji"; -import "./highlight"; \ No newline at end of file +import "./highlight"; +import "./youtube"; \ No newline at end of file diff --git a/shared/js/text/bbcode/youtube.scss b/shared/js/text/bbcode/youtube.scss new file mode 100644 index 00000000..4250a03f --- /dev/null +++ b/shared/js/text/bbcode/youtube.scss @@ -0,0 +1,63 @@ +.container { + width: 20em; + height: 10em; + + display: flex; + flex-direction: column; + justify-content: center; + + margin: .5em; + border-radius: .1em; + + overflow: hidden; + user-select: none; + + img { + max-width: 100%; + } + + .playButton { + position: absolute; + + cursor: pointer; + + left: 50%; + top: 50%; + + width: 68px; + height: 48px; + + margin-left: -34px; + margin-top: -24px; + + border: none; + background-color: transparent; + padding: 0; + + svg { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + + :global { + .ytp-large-play-button-bg { + fill: #212121; + fill-opacity: .8; + + -moz-transition: fill .1s cubic-bezier(0.0,0.0,0.2,1),fill-opacity .1s cubic-bezier(0.0,0.0,0.2,1); + -webkit-transition: fill .1s cubic-bezier(0.0,0.0,0.2,1),fill-opacity .1s cubic-bezier(0.0,0.0,0.2,1); + transition: fill .1s cubic-bezier(0.0,0.0,0.2,1),fill-opacity .1s cubic-bezier(0.0,0.0,0.2,1); + } + } + + &:hover { + :global(.ytp-large-play-button-bg) { + fill: #f00; + fill-opacity: 1; + } + } + } +} \ No newline at end of file diff --git a/shared/js/text/bbcode/youtube.tsx b/shared/js/text/bbcode/youtube.tsx new file mode 100644 index 00000000..72377007 --- /dev/null +++ b/shared/js/text/bbcode/youtube.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import * as loader from "tc-loader"; +import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import {ElementRenderer} from "vendor/xbbcode/renderer/base"; +import {TagElement} from "vendor/xbbcode/elements"; +import {BBCodeRenderer} from "tc-shared/text/bbcode"; +import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer"; +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import {openVideoViewer} from "tc-shared/video-viewer/Controller"; +import {server_connections} from "tc-shared/ui/frames/connection_handlers"; + +const playIcon = require("./yt-play-button.svg"); +const cssStyle = require("./youtube.scss"); + +loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { + name: "XBBCode code tag init", + function: async () => { + let reactId = 0; + + const patternYtVideoId = /^(?:http(?:s)?:\/\/)?(?:www\.)?(?:m\.)?(?:youtu\.be\/|youtube\.com\/(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/))([^?&"'>]{10,11})$/; + rendererReact.registerCustomRenderer(new class extends ElementRenderer { + render(element: TagElement): React.ReactNode { + const text = rendererText.render(element); + const result = text.match(patternYtVideoId); + if(!result || !result[1]) { + return ; + } + + return ( +
{ + event.preventDefault(); + + spawn_context_menu(event.pageX, event.pageY, { + callback: () => { + openVideoViewer(server_connections.active_connection(), text); + }, + name: tr("Watch video"), + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "" + }, { + callback: () => { + const win = window.open(text, '_blank'); + win.focus(); + }, + name: tr("Open video URL"), + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-browse-addon-online" + }, contextmenu.Entry.HR(), { + callback: () => copy_to_clipboard(text), + name: tr("Copy video URL to clipboard"), + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-copy" + }) + }} + > + {"Video + +
+ ); + } + + tags(): string | string[] { + return ["youtube", "yt"]; + } + }); + }, + priority: 10 +}); \ No newline at end of file diff --git a/shared/js/text/bbcode/yt-play-button.svg b/shared/js/text/bbcode/yt-play-button.svg new file mode 100644 index 00000000..57947f14 --- /dev/null +++ b/shared/js/text/bbcode/yt-play-button.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/shared/js/ui/modal/ModalChangeVolumeNew.tsx b/shared/js/ui/modal/ModalChangeVolumeNew.tsx index 56b698fb..c2f9dd3e 100644 --- a/shared/js/ui/modal/ModalChangeVolumeNew.tsx +++ b/shared/js/ui/modal/ModalChangeVolumeNew.tsx @@ -1,4 +1,4 @@ -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import * as React from "react"; import {Slider} from "tc-shared/ui/react-elements/Slider"; import {Button} from "tc-shared/ui/react-elements/Button"; @@ -238,7 +238,7 @@ export function spawnClientVolumeChange(client: ClientEntry) { client.setAudioVolume(event.newValue); }); - const modal = spawnReactModal(class extends Modal { + const modal = spawnReactModal(class extends InternalModal { constructor() { super(); } @@ -277,7 +277,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu }); }); - const modal = spawnReactModal(class extends Modal { + const modal = spawnReactModal(class extends InternalModal { constructor() { super(); } diff --git a/shared/js/ui/modal/ModalGroupCreate.tsx b/shared/js/ui/modal/ModalGroupCreate.tsx index d4f1c6a7..133b3ccc 100644 --- a/shared/js/ui/modal/ModalGroupCreate.tsx +++ b/shared/js/ui/modal/ModalGroupCreate.tsx @@ -1,4 +1,4 @@ -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {Registry} from "tc-shared/events"; import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField"; @@ -232,27 +232,27 @@ const CreateButton = (props: { events: Registry }) => { }; -class ModalGroupCreate extends Modal { +class ModalGroupCreate extends InternalModal { readonly target: "server" | "channel"; - readonly events = new Registry(); + readonly events: Registry; readonly defaultSourceGroup: number; - constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) { + constructor(connection: ConnectionHandler, events: Registry, target: "server" | "channel", defaultSourceGroup: number) { super(); - this.events.enableDebug("group-create"); + this.events = events; this.defaultSourceGroup = defaultSourceGroup; this.target = target; initializeGroupCreateController(connection, this.events, this.target); } protected onInitialize() { - this.modalController().events.on("destroy", () => this.events.fire("notify_destroy")); - this.events.fire_async("query_available_groups"); this.events.fire_async("query_client_permissions"); + } - this.events.on(["action_cancel", "action_create"], () => this.modalController().destroy()); + protected onDestroy() { + this.events.fire("notify_destroy"); } renderBody() { @@ -276,8 +276,13 @@ class ModalGroupCreate extends Modal { } export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) { - const modal = spawnReactModal(ModalGroupCreate, connection, target, sourceGroup); + const events = new Registry(); + events.enableDebug("group-create"); + + const modal = spawnReactModal(ModalGroupCreate, connection, events, target, sourceGroup); modal.show(); + + events.on(["action_cancel", "action_create"], () => modal.destroy()); } const stringifyError = error => { diff --git a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx index f7c25ed4..dd5bdd08 100644 --- a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx +++ b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx @@ -1,4 +1,4 @@ -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {Registry} from "tc-shared/events"; import {useRef, useState} from "react"; @@ -11,6 +11,7 @@ import PermissionType from "tc-shared/permission/PermissionType"; import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; import {tra} from "tc-shared/i18n/localize"; +import {md5} from "tc-shared/crypto/md5"; const cssStyle = require("./ModalGroupPermissionCopy.scss"); @@ -119,15 +120,16 @@ const CopyButton = (props: { events: Registry }) }; -class ModalGroupPermissionCopy extends Modal { - readonly events = new Registry(); +class ModalGroupPermissionCopy extends InternalModal { + readonly events: Registry; readonly defaultSource: number; readonly defaultTarget: number; - constructor(connection: ConnectionHandler, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) { + constructor(connection: ConnectionHandler, events: Registry, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) { super(); + this.events = events; this.defaultSource = sourceGroup; this.defaultTarget = targetGroup; @@ -135,12 +137,13 @@ class ModalGroupPermissionCopy extends Modal { } protected onInitialize() { - this.modalController().events.on("destroy", () => this.events.fire("notify_destroy")); - this.events.fire_async("query_available_groups"); this.events.fire_async("query_client_permissions"); + } - this.events.on(["action_cancel", "action_copy"], () => this.modalController().destroy()); + protected onDestroy() { + this.events.fire("notify_destroy"); + this.events.destroy(); } renderBody() { @@ -162,8 +165,11 @@ class ModalGroupPermissionCopy extends Modal { } export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) { - const modal = spawnReactModal(ModalGroupPermissionCopy, connection, target, sourceGroup, targetGroup); + const events = new Registry(); + const modal = spawnReactModal(ModalGroupPermissionCopy, connection, events, target, sourceGroup, targetGroup); modal.show(); + + events.on(["action_cancel", "action_copy"], () => modal.destroy()); } const stringifyError = error => { diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index d516eefc..88abba9d 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -1,4 +1,4 @@ -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import * as React from "react"; import {useState} from "react"; @@ -267,7 +267,7 @@ const TabSelector = (props: { events: Registry }) => { }; export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number }; -class PermissionEditorModal extends Modal { +class PermissionEditorModal extends InternalModal { readonly modalEvents = new Registry(); readonly editorEvents = new Registry(); @@ -294,7 +294,6 @@ class PermissionEditorModal extends Modal { } protected onInitialize() { - this.modalController().events.on("destroy", () => this.modalEvents.fire("notify_destroy")); this.modalEvents.fire("query_client_permissions"); this.modalEvents.fire("action_activate_tab", { tab: this.defaultTab, @@ -304,6 +303,11 @@ class PermissionEditorModal extends Modal { }); } + protected onDestroy() { + this.modalEvents.fire("notify_destroy"); + this.modalEvents.destroy(); + } + renderBody() { return (
diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx index 75bd33c1..f4e0f4ec 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -1,4 +1,4 @@ -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import * as React from "react"; import {FileType} from "tc-shared/file/FileManager"; import {Registry} from "tc-shared/events"; @@ -179,7 +179,7 @@ export interface FileBrowserEvents { } -class FileTransferModal extends Modal { +class FileTransferModal extends InternalModal { readonly remoteBrowseEvents = new Registry(); readonly transferInfoEvents = new Registry(); diff --git a/shared/js/ui/react-elements/Avatar.tsx b/shared/js/ui/react-elements/Avatar.tsx index 8ca115b3..d66a5f88 100644 --- a/shared/js/ui/react-elements/Avatar.tsx +++ b/shared/js/ui/react-elements/Avatar.tsx @@ -1,62 +1,69 @@ import * as React from "react"; -import {ClientAvatar} from "tc-shared/file/Avatars"; +import {ClientAvatar, kDefaultAvatarImage, kLoadingAvatarImage} from "tc-shared/file/Avatars"; import {useState} from "react"; import * as image_preview from "tc-shared/ui/frames/image_preview"; const ImageStyle = { height: "100%", width: "100%", cursor: "pointer" }; -export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar, className?: string, alt?: string }) => { +export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar | "loading" | "default", className?: string, alt?: string }) => { let [ revision, setRevision ] = useState(0); let image; - switch (props.avatar?.getState()) { - case "unset": - image = {typeof { - if(event.isDefaultPrevented()) - return; + if(props.avatar === "loading") { + image = ; + } else if(props.avatar === "default") { + image = ; + } else { + const imageUrl = props.avatar.getAvatarUrl(); + switch (props.avatar.getState()) { + case "unset": + image = {typeof { + if(event.isDefaultPrevented()) + return; - event.preventDefault(); - image_preview.preview_image(props.avatar.getAvatarUrl(), undefined); - }} - />; - break; + event.preventDefault(); + image_preview.preview_image(imageUrl, undefined); + }} + />; + break; - case "loaded": - image = {typeof { - if(event.isDefaultPrevented()) - return; + case "loaded": + image = {typeof { + if(event.isDefaultPrevented()) + return; - event.preventDefault(); - image_preview.preview_image(props.avatar.getAvatarUrl(), undefined); - }} - />; - break; + event.preventDefault(); + image_preview.preview_image(imageUrl, undefined); + }} + />; + break; - case "errored": - image = {typeof; - break; + case "errored": + image = {typeof; + break; - case "loading": - image = {typeof; - break; + case "loading": + image = {typeof; + break; - case undefined: - break; + case undefined: + break; + } + + props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1)); } - props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1)); - return (
{image} diff --git a/shared/js/ui/react-elements/LoadingDots.tsx b/shared/js/ui/react-elements/LoadingDots.tsx index 67c933b5..9e2eca29 100644 --- a/shared/js/ui/react-elements/LoadingDots.tsx +++ b/shared/js/ui/react-elements/LoadingDots.tsx @@ -9,10 +9,10 @@ export const LoadingDots = (props: { maxDots?: number, speed?: number, textOnly? const [dots, setDots] = useState(0); useEffect(() => { - if(!props.enabled) + if(typeof props.enabled === "boolean" && !props.enabled) return; - const timeout = setTimeout(() => setDots(dots + 1), speed || 500); + const timeout = setTimeout(() => setDots(dots + 1), typeof speed === "number" ? speed : 500); return () => clearTimeout(timeout); }); diff --git a/shared/js/ui/react-elements/Modal.tsx b/shared/js/ui/react-elements/Modal.tsx index 7bb985d2..d0a526b7 100644 --- a/shared/js/ui/react-elements/Modal.tsx +++ b/shared/js/ui/react-elements/Modal.tsx @@ -8,6 +8,10 @@ const cssStyle = require("./Modal.scss"); export type ModalType = "error" | "warning" | "info" | "none"; +export interface ModalOptions { + destroyOnClose?: boolean; +} + export interface ModalEvents { "open": {}, "close": {}, @@ -22,29 +26,67 @@ export enum ModalState { DESTROYED } -export class ModalController { +export interface ModalController { + getOptions() : Readonly; + getEvents() : Registry; + getState() : ModalState; + + show() : Promise; + hide() : Promise; + + destroy(); +} + +export abstract class AbstractModal { + protected constructor() {} + + abstract renderBody() : ReactElement; + abstract title() : string | React.ReactElement; + + /* only valid for the "inline" modals */ + type() : ModalType { return "none"; } + + protected onInitialize() {} + protected onDestroy() {} + + protected onClose() {} + protected onOpen() {} +} + +export class InternalModalController implements ModalController { readonly events: Registry; readonly modalInstance: InstanceType; private initializedPromise: Promise; private domElement: Element; - private refModal: React.RefObject; + private refModal: React.RefObject; private modalState_: ModalState = ModalState.HIDDEN; constructor(instance: InstanceType) { this.modalInstance = instance; - instance["__modal_controller"] = this; - this.events = new Registry(); this.initialize(); } + getOptions(): Readonly { + /* FIXME! */ + return {}; + } + + getEvents(): Registry { + return this.events; + } + + getState() { + return this.modalState_; + } + private initialize() { this.refModal = React.createRef(); this.domElement = document.createElement("div"); - const element = ; + const element = ; document.body.appendChild(this.domElement); this.initializedPromise = new Promise(resolve => { ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0)); @@ -53,11 +95,7 @@ export class ModalController { this.modalInstance["onInitialize"](); } - modalState() { - return this.modalState_; - } - - async show() { + async show() : Promise { await this.initializedPromise; if(this.modalState_ === ModalState.DESTROYED) throw tr("modal has been destroyed"); @@ -101,33 +139,9 @@ export class ModalController { } } -export abstract class AbstractModal { - protected constructor() {} +export abstract class InternalModal extends AbstractModal { } - abstract renderBody() : ReactElement; - abstract title() : string | React.ReactElement; - - protected onInitialize() {} - protected onDestroy() {} - - protected onClose() {} - protected onOpen() {} -} - -export abstract class Modal extends AbstractModal { - private __modal_controller: ModalController; - - type() : ModalType { return "none"; } - - /** - * Will only return a modal controller when the modal has not been destroyed - */ - modalController() : ModalController | undefined { - return this.__modal_controller; - } -} - -class ModalImpl extends React.PureComponent<{ controller: ModalController }, { show: boolean }> { +class InternalModalRenderer extends React.PureComponent<{ controller: InternalModalController }, { show: boolean }> { private readonly refModal = React.createRef(); constructor(props) { @@ -175,11 +189,12 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, { } } -export function spawnReactModal(modalClass: new () => ModalClass) : ModalController; -export function spawnReactModal(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController; -export function spawnReactModal(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : ModalController; -export function spawnReactModal(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController; -export function spawnReactModal(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : ModalController; -export function spawnReactModal(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : ModalController { - return new ModalController(new modalClass(...args)); +export function spawnReactModal(modalClass: new () => ModalClass) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5) : InternalModalController; +export function spawnReactModal(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : InternalModalController { + return new InternalModalController(new modalClass(...args)); } \ No newline at end of file diff --git a/shared/js/ui/react-elements/external-modal/Controller.ts b/shared/js/ui/react-elements/external-modal/Controller.ts index bda2fdf3..7ea45739 100644 --- a/shared/js/ui/react-elements/external-modal/Controller.ts +++ b/shared/js/ui/react-elements/external-modal/Controller.ts @@ -1,5 +1,7 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; import * as ipc from "tc-shared/ipc/BrowserIPC"; -import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC"; +import {ChannelMessage} from "tc-shared/ipc/BrowserIPC"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {Registry} from "tc-shared/events"; import { @@ -7,20 +9,28 @@ import { Popout2ControllerMessages, PopoutIPCMessage } from "tc-shared/ui/react-elements/external-modal/IPCMessage"; +import {ModalController, ModalEvents, ModalOptions, ModalState} from "tc-shared/ui/react-elements/Modal"; -export class ExternalModalController extends EventControllerBase<"controller"> { - readonly modal: string; - readonly userData: any; +export class ExternalModalController extends EventControllerBase<"controller"> implements ModalController { + public readonly modalType: string; + public readonly userData: any; + + private modalState: ModalState = ModalState.DESTROYED; + private readonly modalEvents: Registry; private currentWindow: Window; private callbackWindowInitialized: (error?: string) => void; - private documentQuitListener: () => void; + private readonly documentQuitListener: () => void; + private windowClosedTestInterval: number = 0; + private windowClosedTimeout: number; constructor(modal: string, localEventRegistry: Registry, userData: any) { super(localEventRegistry); - this.modal = modal; + this.modalEvents = new Registry(); + + this.modalType = modal; this.userData = userData; this.ipcChannel = ipc.getInstance().createChannel(); @@ -29,11 +39,23 @@ export class ExternalModalController extends EventControllerBase<"controller"> { this.documentQuitListener = () => this.currentWindow?.close(); } - private trySpawnWindow() { + getOptions(): Readonly { + return {}; /* FIXME! */ + } + + getEvents(): Registry { + return this.modalEvents; + } + + getState(): ModalState { + return this.modalState; + } + + private trySpawnWindow() : Window | null { const parameters = { "loader-target": "manifest", "chunk": "modal-external", - "modal-target": this.modal, + "modal-target": this.modalType, "ipc-channel": this.ipcChannel.channelId, "ipc-address": ipc.getInstance().getLocalAddress(), "disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0, @@ -54,16 +76,21 @@ export class ExternalModalController extends EventControllerBase<"controller"> { let baseUrl = location.origin + location.pathname + "?"; return window.open( baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"), - "External Modal", + this.modalType, Object.keys(features).map(e => e + "=" + features[e]).join(",") ); } - async open() { + async show() { + if(this.currentWindow) { + this.currentWindow.focus(); + return; + } + this.currentWindow = this.trySpawnWindow(); if(!this.currentWindow) { await new Promise((resolve, reject) => { - spawnYesNo(tr("Would you like to open the popup?"), tra("Would you like to open popup {}?", this.modal), callback => { + spawnYesNo(tr("Would you like to open the popup?"), tra("Would you like to open popup {}?", this.modalType), callback => { if(!callback) { reject("user aborted"); return; @@ -71,47 +98,111 @@ export class ExternalModalController extends EventControllerBase<"controller"> { this.currentWindow = this.trySpawnWindow(); if(this.currentWindow) { - reject("Failed to spawn window"); + reject(tr("Failed to spawn window")); } else { resolve(); } - }).close_listener.push(() => reject("user aborted")); + }).close_listener.push(() => reject(tr("user aborted"))); }) } if(!this.currentWindow) { /* some shitty popup blocker or whatever */ - throw "failed to create window"; + throw tr("failed to create window"); + } + window.addEventListener("unload", this.documentQuitListener); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.callbackWindowInitialized = undefined; + reject("window haven't called back"); + }, 5000); + + this.callbackWindowInitialized = error => { + this.callbackWindowInitialized = undefined; + clearTimeout(timeout); + error ? reject(error) : resolve(); + }; + }); + } catch (e) { + this.currentWindow?.close(); + this.currentWindow = undefined; + throw e; } - this.currentWindow.onclose = () => { - /* TODO: General handle */ - window.removeEventListener("beforeunload", this.documentQuitListener); + this.currentWindow.onbeforeunload = () => { + clearInterval(this.windowClosedTestInterval); + + this.windowClosedTimeout = Date.now() + 5000; + this.windowClosedTestInterval = setInterval(() => { + if(!this.currentWindow) { + clearInterval(this.windowClosedTestInterval); + this.windowClosedTestInterval = 0; + return; + } + + if(this.currentWindow.closed || Date.now() > this.windowClosedTimeout) { + window.removeEventListener("unload", this.documentQuitListener); + this.currentWindow = undefined; + this.destroy(); /* TODO: Test if we should do this */ + } + }, 100); }; - window.addEventListener("beforeunload", this.documentQuitListener); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.callbackWindowInitialized = undefined; - reject("window haven't called back"); - }, 5000); + this.modalState = ModalState.SHOWN; + this.modalEvents.fire("open"); + } - this.callbackWindowInitialized = error => { - this.callbackWindowInitialized = undefined; - clearTimeout(timeout); - error ? reject(error) : resolve(); - }; - }); + private destroyPopUp() { + if(this.currentWindow) { + clearInterval(this.windowClosedTestInterval); + this.windowClosedTestInterval = 0; + + window.removeEventListener("beforeunload", this.documentQuitListener); + this.currentWindow.close(); + this.currentWindow = undefined; + } + } + + async hide() { + if(this.modalState == ModalState.DESTROYED || this.modalState === ModalState.HIDDEN) + return; + + this.destroyPopUp(); + this.modalState = ModalState.HIDDEN; + this.modalEvents.fire("close"); + } + + destroy() { + if(this.modalState === ModalState.DESTROYED) + return; + + this.destroyPopUp(); + if(this.ipcChannel) + ipc.getInstance().deleteChannel(this.ipcChannel); + + this.destroyIPC(); + this.modalState = ModalState.DESTROYED; + this.modalEvents.fire("destroy"); } protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) { if(broadcast) return; - if(this.ipcRemoteId !== remoteId) { + if(this.ipcRemoteId === undefined) { + log.debug(LogCategory.IPC, tr("Remote window connected with id %s"), remoteId); this.ipcRemoteId = remoteId; } else if(this.ipcRemoteId !== remoteId) { - console.warn("Remote window got a new id. Maybe reload?"); + if(this.windowClosedTestInterval > 0) { + clearInterval(this.windowClosedTestInterval); + this.windowClosedTestInterval = 0; + + log.debug(LogCategory.IPC, tr("Remote window got reconnected. Client reloaded it.")); + } else { + log.warn(LogCategory.IPC, tr("Remote window got a new id. Maybe a reload?")); + } this.ipcRemoteId = remoteId; } @@ -124,7 +215,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> { switch (type) { case "hello-popout": { const tpayload = payload as PopoutIPCMessage["hello-popout"]; - console.log("Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version); + log.trace(LogCategory.IPC, "Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version); if(tpayload.version !== __build.version) { this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") }); if(this.callbackWindowInitialized) { @@ -149,7 +240,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> { break; default: - console.warn("Received unknown message type from popup window: %s", type); + log.warn(LogCategory.IPC, "Received unknown message type from popup window: %s", type); return; } } diff --git a/shared/js/ui/react-elements/external-modal/IPCMessage.ts b/shared/js/ui/react-elements/external-modal/IPCMessage.ts index 014fa6fe..58036614 100644 --- a/shared/js/ui/react-elements/external-modal/IPCMessage.ts +++ b/shared/js/ui/react-elements/external-modal/IPCMessage.ts @@ -110,4 +110,11 @@ export abstract class EventControllerBase } } } + + protected destroyIPC() { + this.localEventRegistry.disconnectAll(this.localEventReceiver as any); + this.ipcChannel = undefined; + this.ipcRemoteId = undefined; + this.eventFiredListeners = {}; + } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts b/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts index cb249931..5d301aeb 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts +++ b/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts @@ -4,7 +4,6 @@ import * as loader from "tc-loader"; import * as ipc from "../../../ipc/BrowserIPC"; import * as i18n from "../../../i18n/localize"; -import "tc-shared/file/RemoteAvatars"; import "tc-shared/proto"; import {Stage} from "tc-loader"; diff --git a/shared/js/ui/react-elements/external-modal/index.ts b/shared/js/ui/react-elements/external-modal/index.ts index 5273d42e..c9c06c02 100644 --- a/shared/js/ui/react-elements/external-modal/index.ts +++ b/shared/js/ui/react-elements/external-modal/index.ts @@ -1,7 +1,6 @@ import {Registry} from "tc-shared/events"; import {ExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller"; - export function spawnExternalModal(modal: string, events: Registry, userData: any) : ExternalModalController { return new ExternalModalController(modal, events as any, userData); } \ No newline at end of file diff --git a/shared/js/ui/react-elements/i18n/index.tsx b/shared/js/ui/react-elements/i18n/index.tsx index 8519936f..ab77d642 100644 --- a/shared/js/ui/react-elements/i18n/index.tsx +++ b/shared/js/ui/react-elements/i18n/index.tsx @@ -3,7 +3,7 @@ import {parseMessageWithArguments} from "tc-shared/ui/frames/chat"; import {cloneElement} from "react"; let instances = []; -export class Translatable extends React.Component<{ children: string, __cacheKey?: string, trIgnore?: boolean }, { translated: string }> { +export class Translatable extends React.Component<{ children: string, __cacheKey?: string, trIgnore?: boolean, enforceTextOnly?: boolean }, { translated: string }> { constructor(props) { super(props); diff --git a/shared/js/video-viewer/Controller.ts b/shared/js/video-viewer/Controller.ts new file mode 100644 index 00000000..5de8c685 --- /dev/null +++ b/shared/js/video-viewer/Controller.ts @@ -0,0 +1,331 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal"; +import {EventHandler, Registry} from "tc-shared/events"; +import {VideoViewerEvents} from "./Definitions"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {W2GPluginCmdHandler, W2GWatcher, W2GWatcherFollower} from "tc-shared/video-viewer/W2GPluginHandler"; +import {ModalController} from "tc-shared/ui/react-elements/Modal"; +import {settings, Settings} from "tc-shared/settings"; + +const parseWatcherId = (id: string): { clientId: number, clientUniqueId: string} => { + const [ clientIdString, clientUniqueId ] = id.split(" - "); + return { clientId: parseInt(clientIdString), clientUniqueId: clientUniqueId }; +}; + +const followerWatcherId = (follower: W2GWatcherFollower) => follower.clientId + " - " + follower.clientUniqueId; + +class VideoViewer { + public readonly connection: ConnectionHandler; + public readonly events: Registry; + public readonly modal: ModalController; + + private readonly plugin: W2GPluginCmdHandler; + private currentVideoUrl: string; + + private unregisterCallbacks = []; + private destroyCalled = false; + + constructor(connection: ConnectionHandler, initialUrl: string) { + this.connection = connection; + + this.events = new Registry(); + this.events.register_handler(this); + + this.plugin = connection.getPluginCmdRegistry().getPluginHandler(W2GPluginCmdHandler.kPluginChannel); + if(!this.plugin) { + throw tr("Missing video viewer plugin"); + } + + this.modal = spawnExternalModal("video-viewer", this.events, { handlerId: connection.handlerId, url: initialUrl }); + this.setWatchingVideo(initialUrl); + + this.registerPluginListeners(); + this.plugin.getCurrentWatchers().forEach(watcher => this.registerWatcherEvents(watcher)); + + this.modal.getEvents().on("destroy", () => this.destroy()); + } + + destroy() { + if(this.destroyCalled) + return; + + this.destroyCalled = true; + this.plugin.setLocalPlayerClosed(); + + this.events.fire("notify_destroy"); + this.events.unregister_handler(this); + + this.modal.destroy(); + this.events.destroy(); + } + + setWatchingVideo(url: string) { + if(this.currentVideoUrl === url) + return; + + this.events.fire_async("notify_following", { watcherId: undefined }); + this.events.fire_async("notify_video", { url: url }); + this.modal.show(); + } + + async open() { + await this.modal.show(); + } + + private notifyWatcherList() { + const watchers = this.plugin.getCurrentWatchers().filter(e => e.getCurrentVideo() === this.currentVideoUrl); + this.events.fire_async("notify_watcher_list", { + followingWatcher: this.plugin.getLocalFollowingWatcher() ? this.plugin.getLocalFollowingWatcher().clientId + " - " + this.plugin.getLocalFollowingWatcher().clientUniqueId : undefined, + watcherIds: watchers.map(e => e.clientId + " - " + e.clientUniqueId) + }) + }; + + private registerPluginListeners() { + this.events.on("notify_destroy", this.plugin.events.on("notify_watcher_add", event => { + this.registerWatcherEvents(event.watcher); + this.notifyWatcherList(); + })); + + this.events.on("notify_destroy", this.plugin.events.on("notify_watcher_remove", event => { + if(event.watcher.getCurrentVideo() !== this.currentVideoUrl) + return; + + this.notifyWatcherList(); + })); + + this.events.on("notify_destroy", this.plugin.events.on("notify_following_changed", event => { + this.events.fire("notify_following", { watcherId: event.newWatcher ? event.newWatcher.clientId + " - " + event.newWatcher.clientUniqueId : undefined }); + })); + + this.events.on("notify_destroy", this.plugin.events.on("notify_following_url", () => { + /* TODO! */ + })); + + this.events.on("notify_destroy", this.plugin.events.on("notify_following_watcher_status", event => { + this.events.fire("notify_following_status", { + status: event.newStatus + }); + })); + } + + private registerWatcherEvents(watcher: W2GWatcher) { + let watcherUnregisterCallbacks = []; + + const watcherId = watcher.clientId + " - " + watcher.clientUniqueId; + watcherUnregisterCallbacks.push(watcher.events.on("notify_destroyed", () => { + watcherUnregisterCallbacks.forEach(e => { + this.unregisterCallbacks.remove(e); + e(); + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_url_changed", event => { + if(event.oldVideo !== this.currentVideoUrl && event.newVideo !== this.currentVideoUrl) + return; + + /* remove own watcher from the list */ + this.notifyWatcherList(); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_nickname_changed", () => { + this.events.fire_async("notify_watcher_info", { + watcherId: watcherId, + + clientId: watcher.clientId, + clientUniqueId: watcher.clientUniqueId, + + clientName: watcher.getWatcherName(), + isOwnClient: this.connection.getClientId() === watcher.clientId + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_status_changed", event => { + this.events.fire_async("notify_watcher_status", { + watcherId: watcherId, + status: event.newStatus + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_added", event => { + this.events.fire_async("notify_follower_added", { + watcherId: watcherId, + followerId: followerWatcherId(event.follower) + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_nickname_changed", event => { + this.events.fire_async("notify_watcher_info", { + watcherId: followerWatcherId(event.follower), + + clientId: event.follower.clientId, + clientUniqueId: event.follower.clientUniqueId, + + clientName: event.follower.clientNickname, + isOwnClient: event.follower.clientId === this.connection.getClientId() + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_status_changed", event => { + this.events.fire_async("notify_watcher_status", { + watcherId: followerWatcherId(event.follower), + status: event.newStatus + }); + })); + + watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_removed", event => { + this.events.fire_async("notify_follower_removed", { + watcherId: watcherId, + followerId: followerWatcherId(event.follower) + }); + })); + } + + @EventHandler("query_watchers") + private handleQueryWatchers() { + this.notifyWatcherList(); + } + + @EventHandler("query_watcher_status") + private handleQueryWatcherStatus(event: VideoViewerEvents["query_watcher_status"]) { + const info = parseWatcherId(event.watcherId); + for(const watcher of this.plugin.getCurrentWatchers()) { + if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) { + this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: watcher.getWatcherStatus() }); + return; + } + + for(const follower of watcher.getFollowers()) { + if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) { + this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: follower.status }); + return; + } + } + } + + log.warn(LogCategory.GENERAL, tr("Video viewer queried the watcher status of an unknown client: %s (%o)"), event.watcherId, info); + } + + @EventHandler("query_watcher_info") + private handleQueryWatcherInfo(event: VideoViewerEvents["query_watcher_info"]) { + const info = parseWatcherId(event.watcherId); + for(const watcher of this.plugin.getCurrentWatchers()) { + if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) { + this.events.fire_async("notify_watcher_info", { + watcherId: event.watcherId, + clientName: watcher.getWatcherName(), + clientUniqueId: watcher.clientUniqueId, + clientId: watcher.clientId, + isOwnClient: watcher.clientId === this.connection.getClientId() + }); + return; + } + + for(const follower of watcher.getFollowers()) { + if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) { + this.events.fire_async("notify_watcher_info", { + watcherId: event.watcherId, + clientName: follower.clientNickname, + clientUniqueId: follower.clientUniqueId, + clientId: follower.clientId, + isOwnClient: follower.clientId === this.connection.getClientId() + }); + return; + } + } + } + + log.warn(LogCategory.GENERAL, tr("Video viewer queried the watcher info of an unknown client: %s (%o)"), event.watcherId, info); + } + + @EventHandler("query_followers") + private handleQueryFollowers(event: VideoViewerEvents["query_followers"]) { + const info = parseWatcherId(event.watcherId); + for(const watcher of this.plugin.getCurrentWatchers()) { + if(watcher.clientId !== info.clientId || watcher.clientUniqueId !== info.clientUniqueId) + continue; + + this.events.fire_async("notify_follower_list", { + followerIds: watcher.getFollowers().map(e => followerWatcherId(e)), + watcherId: event.watcherId + }); + return; + } + + log.warn(LogCategory.GENERAL, tr("Video viewer queried the watcher followers of an unknown client: %s (%o)"), event.watcherId, info); + } + + @EventHandler("query_video") + private handleQueryVideo() { + this.events.fire_async("notify_video", { url: this.currentVideoUrl }); + } + + @EventHandler("notify_local_status") + private handleLocalStatus(event: VideoViewerEvents["notify_local_status"]) { + const following = this.plugin.getLocalFollowingWatcher(); + if(following) + this.plugin.setLocalFollowing(following, event.status); + else + this.plugin.setLocalWatcherStatus(this.currentVideoUrl, event.status); + } + + @EventHandler("action_follow") + private handleActionFollow(event: VideoViewerEvents["action_follow"]) { + if(event.watcherId) { + const info = parseWatcherId(event.watcherId); + for(const watcher of this.plugin.getCurrentWatchers()) { + if(watcher.clientId !== info.clientId || watcher.clientUniqueId !== info.clientUniqueId) + continue; + + this.plugin.setLocalFollowing(watcher, { status: "paused" }); + return; + } + + log.warn(LogCategory.GENERAL, tr("Video viewer tried to follow an unknown client: %s (%o)"), event.watcherId, info); + } else { + this.plugin.setLocalWatcherStatus(this.currentVideoUrl, { status: "paused" }); + } + } + + @EventHandler("action_toggle_side_bar") + private handleActionToggleSidebar(event: VideoViewerEvents["action_toggle_side_bar"]) { + settings.changeGlobal(Settings.KEY_W2G_SIDEBAR_COLLAPSED, !event.shown); + } + + + + @EventHandler("notify_video") + private handleVideo(event: VideoViewerEvents["notify_video"]) { + if(this.currentVideoUrl === event.url) + return; + + this.currentVideoUrl = event.url; + const following = this.plugin.getLocalFollowingWatcher(); + if(following) + this.plugin.setLocalFollowing(following, { status: "paused" }); + else + this.plugin.setLocalWatcherStatus(this.currentVideoUrl, { status: "paused" }); + } +} + +let currentVideoViewer: VideoViewer; + +export function openVideoViewer(connection: ConnectionHandler, url: string) { + if(currentVideoViewer?.connection === connection) { + currentVideoViewer.setWatchingVideo(url); + return; + } else if(currentVideoViewer) { + currentVideoViewer.destroy(); + currentVideoViewer = undefined; + } + + currentVideoViewer = new VideoViewer(connection, url); + currentVideoViewer.events.on("notify_destroy", () => { + currentVideoViewer = undefined; + }); + currentVideoViewer.open(); +} + +window.onunload = () => { + currentVideoViewer?.destroy(); +}; \ No newline at end of file diff --git a/shared/js/video-viewer/Controller.tsx b/shared/js/video-viewer/Controller.tsx deleted file mode 100644 index 62547b95..00000000 --- a/shared/js/video-viewer/Controller.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal"; -import {Registry} from "tc-shared/events"; -import {VideoViewerEvents} from "./Definitions"; -import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import * as React from "react"; -import {useState} from "react"; - -const NumberRenderer = (props: { events: Registry }) => { - const [ value, setValue ] = useState("unset"); - - props.events.reactUse("notify_value", event => setValue(event.value + "")); - - return <>{value}; -}; - -export function spawnVideoPopout() { - const registry = new Registry(); - const modalController = spawnExternalModal("video-viewer", registry, {}); - modalController.open(); -} \ No newline at end of file diff --git a/shared/js/video-viewer/Definitions.ts b/shared/js/video-viewer/Definitions.ts index b159e6bc..4dde3b4b 100644 --- a/shared/js/video-viewer/Definitions.ts +++ b/shared/js/video-viewer/Definitions.ts @@ -1,6 +1,95 @@ -export interface VideoViewerEvents { - "notify_show": {}, +interface PlayerStatusPlaying { + status: "playing"; - "notify_value": { value: number }, - "notify_data_url": { url: string } + timestampPlay: number; + timestampBuffer: number; +} + +interface PlayerStatusBuffering { + status: "buffering"; +} + +interface PlayerStatusStopped { + status: "stopped"; +} + +interface PlayerStatusPaused { + status: "paused"; +} + +export type PlayerStatus = PlayerStatusPlaying | PlayerStatusBuffering | PlayerStatusStopped | PlayerStatusPaused; + +export interface VideoViewerEvents { + "action_toggle_side_bar": { shown: boolean }, + "action_follow": { watcherId: string | undefined }, + + /* will trigger notify_watcher_info */ + "query_watcher_info": { + watcherId: string + }, + + /* will trigger notify_watcher_status */ + "query_watcher_status": { + watcherId: string + }, + + "query_followers": { + watcherId: string + }, + + "query_watchers": {}, + "query_video": {}, + + "notify_show": {}, + "notify_destroy": {}, + + "notify_watcher_list": { + watcherIds: string[], + + followingWatcher: string | undefined + }, + + "notify_watcher_status": { + watcherId: string, + status: PlayerStatus + } + + "notify_watcher_info": { + watcherId: string, + + clientId: number, + clientUniqueId: string, + clientName: string, + + isOwnClient: boolean + } + + "notify_follower_list": { + watcherId: string, + followerIds: string[] + }, + + "notify_follower_added": { + watcherId: string, + followerId: string + }, + + "notify_follower_removed": { + watcherId: string, + followerId: string + }, + + "notify_following": { + watcherId: string | undefined + }, + + "notify_following_status": { + status: PlayerStatus + } + + "notify_local_status": { + status: PlayerStatus + }, + + "notify_video": { url: string } } \ No newline at end of file diff --git a/shared/js/video-viewer/Renderer.scss b/shared/js/video-viewer/Renderer.scss index 73e68206..ac061296 100644 --- a/shared/js/video-viewer/Renderer.scss +++ b/shared/js/video-viewer/Renderer.scss @@ -1,3 +1,7 @@ +@import "../../css/static/mixin"; +@import "../../css/static/properties"; + +$sidebar-width: 20em; .container { background: #19191b; @@ -10,6 +14,279 @@ min-height: 10em; min-width: 20em; +} - padding-left: 4em; +.containerPlayer { + flex-grow: 1; + flex-shrink: 1; + + min-height: 100px; + min-width: 100px; + + display: flex; + flex-direction: row; + justify-content: stretch; +} + +.sidebarButton { + z-index: 10000; + position: absolute; + + cursor: pointer; + + top: .5em; + right: .5em; + + width: 2em; + height: 2em; + + border-radius: 2px; + background-color: #373737; + + margin-right: 0; + opacity: 1; + + @include transition(background-color $button_hover_animation_time ease-in-out, margin-right .25s ease-in-out, opacity .25s ease-in-out); + + &:hover { + background-color: #4e4e4e; + } + + &.hidden { + margin-right: $sidebar-width; + opacity: 0; + } + + svg { + height: 100%; + width: 100%; + } +} + +.containerSidebar { + z-index: 10001; + position: absolute; + + display: flex; + flex-direction: column; + justify-content: stretch; + + background-color: #373737; + + right: -$sidebar-width; + top: 0; + bottom: 0; + + padding: 1em; + padding-top: 0; + + width: $sidebar-width; + + @include transition(right .25s ease-in-out); + &.shown { + right: 0; + } + + .buttonClose { + font-size: 4em; + + cursor: pointer; + + position: absolute; + right: 0; + top: 0; + bottom: 0; + + opacity: 0.3; + + width: .5em; + height: .5em; + + margin-right: .1em; + margin-top: .1em; + + &:hover { + opacity: 1; + } + @include transition(opacity $button_hover_animation_time ease-in-out); + + &:before, &:after { + position: absolute; + left: .25em; + content: ' '; + height: .5em; + width: .05em; + background-color: #666666; + } + + &:before { + transform: rotate(45deg); + } + + &:after { + transform: rotate(-45deg); + } + } + + .header { + height: 3em; + + flex-grow: 0; + flex-shrink: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + padding-bottom: 0.5em; + + a { + flex-grow: 1; + flex-shrink: 1; + + align-self: flex-end; + font-weight: bold; + + color: #e0e0e0; + + @include text-dotdotdot(); + } + } + + .buttons { + flex-grow: 0; + flex-shrink: 0; + + margin-top: .5em; + + display: flex; + flex-direction: row; + justify-content: space-between; + + > :not(:last-of-type) { + margin-right: 1em; + } + } +} + +.watcherList { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 6em; + + background-color: #28292b; + border: 1px #161616 solid; + border-radius: .2em; + + overflow-x: hidden; + overflow-y: auto; + @include chat-scrollbar-vertical(); + + .watcher { + display: flex; + flex-direction: column; + justify-content: flex-start; + + &:first-child { + border-top-right-radius: .2em; + border-top-left-radius: .2em; + } + + &:last-child { + border-bottom-right-radius: .2em; + border-bottom-left-radius: .2em; + } + + .info { + display: flex; + flex-direction: row; + justify-content: stretch; + + border-bottom: 1px solid #313132; + + cursor: pointer; + + @include transition($button_hover_animation_time ease-in-out); + + &:hover { + background-color: hsla(216, 4%, 23%, 1); + } + + &.following { + background-color: #28292b; + } + + &.ownClient { + background-color: #0d260e; + } + + .containerAvatar { + flex-grow: 0; + flex-shrink: 0; + + position: relative; + display: inline-block; + margin: 5px 10px 5px 5px; + + .avatar { + overflow: hidden; + + width: 2em; + height: 2em; + + border-radius: 50%; + } + } + + .containerDetail { + flex-grow: 1; + flex-shrink: 1; + min-width: 50px; + + display: flex; + flex-direction: column; + justify-content: center; + + > * { + flex-grow: 0; + flex-shrink: 0; + + display: inline-block; + width: 100%; + + @include text-dotdotdot(); + } + + .username { + color: #CCCCCC; + font-weight: bold; + margin-bottom: -.4em; + } + + .status { + color: #555353; + display: inline-block; + font-size: .66em; + } + } + + &.watcher {} + &.follower { + padding-left: 1em; + } + } + + .followerList { + + .follower { + + border-bottom: 1px solid #313132; + } + } + } } \ No newline at end of file diff --git a/shared/js/video-viewer/Renderer.tsx b/shared/js/video-viewer/Renderer.tsx index a91e9e4d..9ee5bc0a 100644 --- a/shared/js/video-viewer/Renderer.tsx +++ b/shared/js/video-viewer/Renderer.tsx @@ -1,64 +1,497 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; import {AbstractModal} from "tc-shared/ui/react-elements/Modal"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import * as React from "react"; +import {useEffect, useRef, useState} from "react"; import {Registry} from "tc-shared/events"; -import {VideoViewerEvents} from "./Definitions"; +import {PlayerStatus, VideoViewerEvents} from "./Definitions"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import ReactPlayer from 'react-player' +import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import "tc-shared/file/RemoteAvatars"; +import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar"; +import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; +import {Settings, settings} from "tc-shared/settings"; + +const iconNavbar = require("./icon-navbar.svg"); const cssStyle = require("./Renderer.scss"); +const kLogPlayerEvents = true; + +const PlaytimeRenderer = React.memo((props: { time: number }) => { + const [ revision, setRevision ] = useState(0); + useEffect(() => { + const id = setTimeout(() => setRevision(revision + 1), 950); + return () => clearTimeout(id); + }); + + let seconds = Math.floor((Date.now() - props.time) / 1000); + + let hours = Math.floor(seconds / 3600); + seconds %= 3600; + + let minutes = Math.floor(seconds / 60); + seconds %= 60; + + let time = ("0" + hours).substr(-2) + ":" + ("0" + minutes).substr(-2) + ":" + ("0" + seconds).substr(-2); + return <>{time}; +}); + +const PlayerStatusRenderer = (props: { status: PlayerStatus | undefined, timestamp: number }) => { + switch (props.status?.status) { + case "paused": + return ( + Replay paused + ); + + case "buffering": + return ( + Buffering  + + ); + + case "stopped": + return ( + Video ended  + + ); + + case "playing": + return ( + Playing  + {props.timestamp === -1 ? undefined : <>()} + ); + + case undefined: + return ( + loading  + + ); + + default: + return ( + unknown player status ({(props as any).status?.status}) + ); + } +}; + +const WatcherInfo = React.memo((props: { events: Registry, watcherId: string, handlerId: string, isFollowing?: boolean, type: "watcher" | "follower" }) => { + const [ clientInfo, setClientInfo ] = useState<"loading" | { uniqueId: string, clientId: number, clientName: string, ownClient: boolean }>(() => { + props.events.fire("query_watcher_status", { watcherId: props.watcherId }); + return "loading"; + }); + + const [ status, setStatus ] = useState(() => { + props.events.fire("query_watcher_info", { watcherId: props.watcherId }); + return undefined; + }); + + let renderedAvatar; + if(clientInfo === "loading") { + renderedAvatar = ; + } else { + const avatar = getGlobalAvatarManagerFactory().getManager(props.handlerId).resolveClientAvatar({ id: clientInfo.clientId, clientUniqueId: clientInfo.uniqueId }); + renderedAvatar = ; + } + + let renderedClientName; + if(clientInfo !== "loading") { + renderedClientName = {clientInfo.clientName}; + } else { + renderedClientName = ( + + loading  + + + ); + } + + props.events.reactUse("notify_watcher_info", event => { + if(event.watcherId !== props.watcherId) + return; + + setClientInfo({ uniqueId: event.clientUniqueId, clientId: event.clientId, clientName: event.clientName, ownClient: event.isOwnClient }); + }); + + props.events.reactUse("notify_watcher_status", event => { + if(event.watcherId !== props.watcherId) + return; + + if(status?.status === "playing" && event.status.status === "playing") { + const expectedPlaytime = (Date.now() - status.timestamp) / 1000 + status.timestampPlay; + const currentPlaytime = event.status.timestampPlay; + + if(Math.abs(expectedPlaytime - currentPlaytime) > 2) { + setStatus(Object.assign({ timestamp: Date.now() }, event.status)); + } else { + /* keep the last value, its still close enought */ + setStatus({ + status: "playing", + timestamp: status.timestamp, + timestampBuffer: 0, + timestampPlay: status.timestampPlay + }); + } + } else { + setStatus(Object.assign({ timestamp: Date.now() }, event.status)); + } + }); + + return ( +
{ + if(clientInfo === "loading") + return; + + if(clientInfo.ownClient || props.isFollowing) + return; + + props.events.fire("action_follow", { watcherId: props.watcherId }); + }} + > +
+
+ {renderedAvatar} +
+
+ +
+ ); +}); + +const WatcherEntry = React.memo((props: { events: Registry, watcherId: string, handlerId: string, isFollowing: boolean }) => { + return ( +
+ + +
+ ); +}); + +const FollowerList = React.memo((props: { events: Registry, watcherId: string, handlerId: string }) => { + const [ followers, setFollowers ] = useState(() => { + props.events.fire("query_followers", { watcherId: props.watcherId }); + return []; + }); + + const [ followerRevision, setFollowerRevision ] = useState(0); + + props.events.reactUse("notify_follower_list", event => { + if(event.watcherId !== props.watcherId) + return; + + setFollowers(event.followerIds.slice(0)); + }); + + props.events.reactUse("notify_follower_added", event => { + if(event.watcherId !== props.watcherId) + return; + + if(followers.indexOf(event.followerId) !== -1) + return; + + console.error("Added follower"); + followers.push(event.followerId); + setFollowerRevision(followerRevision + 1); + }); + + props.events.reactUse("notify_follower_removed", event => { + if(event.watcherId !== props.watcherId) + return; + + const index = followers.indexOf(event.followerId); + if(index === -1) + return; + + console.error("Removed follower"); + followers.splice(index, 1); + setFollowerRevision(followerRevision + 1); + }); + + return ( +
+ {followers.map(followerId => )} +
+ ); +}); + +const WatcherList = (props: { events: Registry, handlerId: string }) => { + const [ watchers, setWatchers ] = useState(() => { + props.events.fire("query_watchers"); + return []; + }); + + const [ following, setFollowing ] = useState(undefined); + + props.events.reactUse("notify_watcher_list", event => { + setWatchers(event.watcherIds.slice(0)); + setFollowing(event.followingWatcher); + }); + + props.events.reactUse("notify_following", event => setFollowing(event.watcherId)); + + return ( +
+ {watchers.map(watcherId => )} +
+ ); +}; + +const ToggleSidebarButton = (props: { events: Registry }) => { + const [ visible, setVisible ] = useState(settings.global(Settings.KEY_W2G_SIDEBAR_COLLAPSED)); + + props.events.reactUse("action_toggle_side_bar", event => setVisible(!event.shown)); + + return ( +
props.events.fire("action_toggle_side_bar", { shown: true })}> + {iconNavbar} +
+ ); +}; + +const ButtonUnfollow = (props: { events: Registry }) => { + const [ following, setFollowing ] = useState(false); + + props.events.reactUse("notify_following", event => setFollowing(event.watcherId !== undefined)); + props.events.reactUse("notify_watcher_list", event => setFollowing(event.followingWatcher !== undefined)); + + return ( + + ); +}; + +const Sidebar = (props: { events: Registry, handlerId: string }) => { + const [ visible, setVisible ] = useState(!settings.global(Settings.KEY_W2G_SIDEBAR_COLLAPSED)); + + props.events.reactUse("action_toggle_side_bar", event => setVisible(event.shown)); + + return ( +
+
props.events.fire("action_toggle_side_bar", { shown: false })} /> + + +
+ +
+
+ ) +}; + +const PlayerController = React.memo((props: { events: Registry }) => { + const player = useRef(); + + const [ mode, setMode ] = useState<"watcher" | "follower">("watcher"); + const [ videoUrl, setVideoUrl ] = useState<"querying" | string>(() => { + props.events.fire_async("query_video"); + return "querying"; + }); + + const playerState = useRef<"playing" | "buffering" | "paused" | "stopped">("paused"); + const currentTime = useRef<{ play: number, buffer: number }>({ play: -1, buffer: -1 }); + + const [ masterPlayerState, setWatcherPlayerState ] = useState<"playing" | "buffering" | "paused" | "stopped">("stopped"); + const watcherTimestamp = useRef(); + + const [ forcePause, setForcePause ] = useState(false); + + props.events.reactUse("notify_following", event => setMode(event.watcherId === undefined ? "watcher" : "follower")); + props.events.reactUse("notify_watcher_list", event => setMode(event.followingWatcher === undefined ? "watcher" : "follower")); + + props.events.reactUse("notify_following_status", event => { + if(mode !== "follower") + return; + + setWatcherPlayerState(event.status.status); + if(event.status.status === "playing" && player.current) { + const distance = Math.abs(player.current.getCurrentTime() - event.status.timestampPlay); + const doSeek = distance > 7; + + log.trace(LogCategory.GENERAL, tr("Follower sync. Remote timestamp %d, Local timestamp: %d. Difference: %d, Do seek: %o"), + player.current.getCurrentTime(), + event.status.timestampPlay, + distance, + doSeek + ); + + if(doSeek) { + player.current.seekTo(event.status.timestampPlay, "seconds"); + } + + watcherTimestamp.current = Date.now() - event.status.timestampPlay * 1000; + } + }); + + props.events.reactUse("notify_video", event => setVideoUrl(event.url)); + + useEffect(() => { + if(forcePause) + setForcePause(false); + }); + + /* TODO: Some kind of overlay if the video url is loading? */ + return ( + console.log("onError(%o, %o, %o, %o)", error, data, hlsInstance, hlsGlobal)} + onBuffer={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onBuffer()")); + playerState.current = "buffering"; + props.events.fire("notify_local_status", { status: { status: "buffering" } }); + }} + + onBufferEnd={() => { + if(playerState.current === "buffering") + playerState.current = "playing"; + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onBufferEnd()")); + }} + + onDisablePIP={() => { /* console.log("onDisabledPIP()") */ }} + onEnablePIP={() => { /* console.log("onEnablePIP()") */ }} + + onDuration={duration => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onDuration(%d)"), duration); + }} + + onEnded={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onEnded()")); + playerState.current = "stopped"; + props.events.fire("notify_local_status", { status: { status: "stopped" } }); + }} + + onPause={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onPause()")); + playerState.current = "paused"; + props.events.fire("notify_local_status", { status: { status: "paused" } }); + }} + + onPlay={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onPlay()")); + + if(mode === "follower") { + if(masterPlayerState !== "playing") { + setForcePause(true); + return; + } + + const currentSeconds = player.current.getCurrentTime(); + const expectedSeconds = (Date.now() - watcherTimestamp.current) / 1000; + const doSync = Math.abs(currentSeconds - expectedSeconds) > 5; + + log.debug(LogCategory.GENERAL, tr("Player started, at second %d. Watcher is at %s. So sync: %o"), currentSeconds, expectedSeconds, doSync); + doSync && player.current.seekTo(expectedSeconds, "seconds"); + } + playerState.current = "playing"; + props.events.fire("notify_local_status", { status: { status: "playing", timestampBuffer: currentTime.current.buffer, timestampPlay: currentTime.current.play } }); + }} + + onProgress={state => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onProgress %d seconds played, %d seconds buffered. Player state: %s"), state.playedSeconds, state.loadedSeconds, playerState.current); + + currentTime.current = { buffer: state.loadedSeconds, play: state.playedSeconds }; + if(playerState.current !== "playing") + return; + + props.events.fire("notify_local_status", { + status: { + status: "playing", + timestampBuffer: Math.floor(state.loadedSeconds), + timestampPlay: Math.floor(state.playedSeconds) + } + }) + }} + + onReady={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onReady()")); + }} + + onSeek={seconds => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onSeek(%d)"), seconds); + }} + + onStart={() => { + kLogPlayerEvents && log.trace(LogCategory.GENERAL, tr("ReactPlayer::onStart()")); + }} + + controls={true} + + loop={false} + light={false} + + playing={mode === "watcher" ? undefined : masterPlayerState === "playing" || forcePause} + /> + ); +}); + +const TitleRenderer = (props: { events: Registry }) => { + const [ followId, setFollowing ] = useState(undefined); + const [ followingName, setFollowingName ] = useState(undefined); + + props.events.reactUse("notify_following", event => setFollowing(event.watcherId)); + props.events.reactUse("notify_watcher_list", event => setFollowing(event.followingWatcher)); + props.events.reactUse("notify_watcher_info", event => { + if(event.watcherId !== followId) + return; + + setFollowingName(event.clientName); + }); + + useEffect(() => { + if(followingName === undefined && followId) + props.events.fire("query_watcher_info", { watcherId: followId }); + }); + + if(followId && followingName) { + return W2G - Following {followingName}; + } else { + return W2G - Watcher; + } +}; + class ModalVideoPopout extends AbstractModal { readonly events: Registry; + readonly handlerId: string; constructor(registry: Registry, userData: any) { super(); + this.handlerId = userData.handlerId; this.events = registry; - this.events.on("notify_show", () => { - console.log("Showed!"); - }); - - this.events.on("notify_data_url", async event => { - console.log(event.url); - console.log(await (await fetch(event.url)).text()); - }); } title(): string | React.ReactElement { - return <>Hello World ; + return ; } renderBody(): React.ReactElement { return
- console.log("onError(%o, %o, %o, %o)", error, data, hlsInstance, hlsGlobal)} - onBuffer={() => console.log("onBuffer()")} - onBufferEnd={() => console.log("onBufferEnd()")} - onDisablePIP={() => console.log("onDisabledPIP()")} - onEnablePIP={() => console.log("onEnablePIP()")} - onDuration={duration => console.log("onDuration(%o)", duration)} - onEnded={() => console.log("onEnded()")} - onPause={() => console.log("onPause()")} - onPlay={() => console.log("onPlay()")} - onProgress={state => console.log("onProgress(%o)", state)} - onReady={() => console.log("onReady()")} - onSeek={seconds => console.log("onSeek(%o)", seconds)} - onStart={() => console.log("onStart()")} - - controls={true} - - loop={false} - light={false} - /> + + +
+ +
; } } -export = ModalVideoPopout; - -console.error("Hello World from video popout"); \ No newline at end of file +export = ModalVideoPopout; \ No newline at end of file diff --git a/shared/js/video-viewer/W2GPluginHandler.ts b/shared/js/video-viewer/W2GPluginHandler.ts new file mode 100644 index 00000000..9b4ff79b --- /dev/null +++ b/shared/js/video-viewer/W2GPluginHandler.ts @@ -0,0 +1,442 @@ +import {PluginCmdHandler, PluginCommandInvoker} from "tc-shared/connection/PluginCmdHandler"; +import {Event, Registry} from "tc-shared/events"; +import {PlayerStatus} from "tc-shared/video-viewer/Definitions"; + +export interface W2GEvents { + notify_watcher_add: { watcher: W2GWatcher }, + notify_watcher_remove: { watcher: W2GWatcher }, + + notify_following_changed: { oldWatcher: W2GWatcher | undefined, newWatcher: W2GWatcher | undefined } + notify_following_watcher_status: { newStatus: PlayerStatus }, + notify_following_url: { newUrl: string } +} + +export interface W2GWatcherEvents { + notify_follower_added: { follower: W2GWatcherFollower }, + notify_follower_removed: { follower: W2GWatcherFollower }, + notify_follower_status_changed: { follower: W2GWatcherFollower, newStatus: PlayerStatus }, + notify_follower_nickname_changed: { follower: W2GWatcherFollower, newName: string }, + + notify_watcher_status_changed: { newStatus: PlayerStatus }, + notify_watcher_nickname_changed: { newName: string }, + notify_watcher_url_changed: { oldVideo: string, newVideo: string }, + + notify_destroyed: {} +} + +export interface W2GWatcherFollower { + clientId: number; + clientUniqueId: string; + + clientNickname: string; + status: PlayerStatus; +} + +export abstract class W2GWatcher { + public readonly events: Registry; + public readonly clientId: number; + public readonly clientUniqueId: string; + + protected constructor(clientId, clientUniqueId) { + this.clientId = clientId; + this.clientUniqueId = clientUniqueId; + + this.events = new Registry(); + } + + public abstract getWatcherName() : string; + public abstract getWatcherStatus() : PlayerStatus; + + public abstract getCurrentVideo() : string; + public abstract getFollowers() : W2GWatcherFollower[]; +} + +interface InternalW2GWatcherFollower extends W2GWatcherFollower { + jsonStatus: string; + + statusTimeoutId: number; +} + +class InternalW2GWatcher extends W2GWatcher { + public watcherName: string; + + public watcherStatus: PlayerStatus; + public watcherJsonStatus: string; + + public currentVideo: string; + public watcherStatusReceived = false; + + public statusTimeoutId: number; + public followers: InternalW2GWatcherFollower[] = []; + + constructor(clientId, clientUniqueId) { + super(clientId, clientUniqueId); + } + + getCurrentVideo(): string { + return this.currentVideo; + } + + getFollowers(): W2GWatcherFollower[] { + return this.followers; + } + + getWatcherName(): string { + return this.watcherName; + } + + getWatcherStatus(): PlayerStatus { + return this.watcherStatus; + } + + destroy() { + this.events.fire("notify_destroyed"); + } + + updateWatcher(client: PluginCommandInvoker, url: string, status: PlayerStatus) { + this.watcherStatusReceived = true; + + if(this.watcherName !== client.clientName) { + this.watcherName = client.clientName; + this.events.fire("notify_watcher_nickname_changed", { newName: client.clientName }); + } + + if(this.currentVideo !== url) { + const oldVideo = this.currentVideo; + this.currentVideo = url; + this.events.fire("notify_watcher_url_changed", { oldVideo: oldVideo, newVideo: url }); + } + + const jsonStatus = JSON.stringify(status); + if(this.watcherJsonStatus !== jsonStatus) { + this.watcherJsonStatus = jsonStatus; + this.watcherStatus = status; + this.events.fire("notify_watcher_status_changed", { newStatus: status }) + } + } + + findFollower(client: PluginCommandInvoker) : InternalW2GWatcherFollower { + return this.followers.find(e => e.clientId === client.clientId && e.clientUniqueId == client.clientUniqueId); + } + + removeFollower(client: PluginCommandInvoker) { + const follower = this.findFollower(client); + if(!follower) return; + + this.doRemoveFollower(follower); + } + + updateFollower(client: PluginCommandInvoker, status: PlayerStatus) { + let follower = this.findFollower(client); + if(!follower) { + /* yeah a new follower */ + follower = { + status: status, + jsonStatus: JSON.stringify(status), + + clientNickname: client.clientName, + clientUniqueId: client.clientUniqueId, + clientId: client.clientId, + + statusTimeoutId: 0 + }; + this.followers.push(follower); + this.events.fire("notify_follower_added", { follower: follower }); + } else { + if(follower.clientNickname !== client.clientName) { + follower.clientNickname = client.clientName; + this.events.fire("notify_follower_nickname_changed", { follower: follower, newName: client.clientName }); + } + + let jsonStatus = JSON.stringify(status); + if(follower.jsonStatus !== jsonStatus) { + follower.jsonStatus = jsonStatus; + follower.status = status; + this.events.fire("notify_follower_status_changed", { follower: follower, newStatus: status }); + } + } + + clearTimeout(follower.statusTimeoutId); + follower.statusTimeoutId = setTimeout(() => this.doRemoveFollower(follower), W2GPluginCmdHandler.kStatusUpdateTimeout); + } + + private doRemoveFollower(follower: InternalW2GWatcherFollower) { + const index = this.followers.indexOf(follower); + if(index === -1) return; + + this.followers.splice(index, 1); + clearTimeout(follower.statusTimeoutId); + + this.events.fire("notify_follower_removed", { follower: follower }); + } +} + +interface W2GCommand { + type: keyof W2GCommandPayload; + payload: W2GCommandPayload[keyof W2GCommandPayload]; +} + +interface W2GCommandPayload { + "update-status": { + videoUrl: string; + + followingId: number; + followingUniqueId: string; + + status: PlayerStatus; + }, + + "player-closed": {} +} + +export class W2GPluginCmdHandler extends PluginCmdHandler { + static readonly kPluginChannel = "teaspeak-w2g"; + static readonly kStatusUpdateInterval = 5000; + static readonly kStatusUpdateTimeout = 10000; + + readonly events: Registry; + private currentWatchers: InternalW2GWatcher[] = []; + + private localPlayerStatus: PlayerStatus; + private localVideoUrl: string; + private localFollowing: InternalW2GWatcher | undefined; + private localStatusUpdateTimer: number; + + private callbackWatcherEvents; + + constructor() { + super(W2GPluginCmdHandler.kPluginChannel); + this.events = new Registry(); + + this.callbackWatcherEvents = this.handleLocalWatcherEvent.bind(this); + } + + handleHandlerRegistered() { + console.error("REGISTER!"); + this.localStatusUpdateTimer = setInterval(() => this.notifyLocalStatus(), W2GPluginCmdHandler.kStatusUpdateInterval); + this.setLocalWatcherStatus("https://www.youtube.com/watch?v=9683D18fyvs", { status: "paused" }); + } + + handleHandlerUnregistered() { + clearInterval(this.localStatusUpdateTimer); + this.localStatusUpdateTimer = undefined; + } + + handlePluginCommand(data: string, invoker: PluginCommandInvoker) { + if(invoker.clientId === this.currentServerConnection.client.getClientId()) + return; + + let command: W2GCommand; + try { + command = JSON.parse(data); + } catch (e) { + return; + } + + if(command.type === "update-status") { + this.handleStatusUpdate(command.payload as any, invoker); + } else if(command.type === "player-closed") { + this.handlePlayerClosed(invoker); + } + } + + private sendCommand(command: T, payload: W2GCommandPayload[T]) { + this.sendPluginCommand(JSON.stringify({ + type: command, + payload: payload + } as W2GCommand), "server"); + } + + getCurrentWatchers() : W2GWatcher[] { + return this.currentWatchers.filter(e => e.watcherStatusReceived); + } + + private findWatcher(client: PluginCommandInvoker) : InternalW2GWatcher { + return this.currentWatchers.find(e => e.clientUniqueId === client.clientUniqueId && e.clientId == client.clientId); + } + + private destroyWatcher(watcher: InternalW2GWatcher) { + this.currentWatchers.remove(watcher); + this.events.fire("notify_watcher_remove", { watcher: watcher }); + watcher.destroy(); + } + + private removeClientFromWatchers(client: PluginCommandInvoker) { + const watcher = this.findWatcher(client); + if(!watcher) return; + + this.destroyWatcher(watcher); + } + + private removeClientFromFollowers(client: PluginCommandInvoker) { + this.currentWatchers.forEach(watcher => watcher.removeFollower(client)); + } + + private handlePlayerClosed(client: PluginCommandInvoker) { + this.removeClientFromWatchers(client); + this.removeClientFromFollowers(client); + } + + private handleStatusUpdate(command: W2GCommandPayload["update-status"], client: PluginCommandInvoker) { + if(command.followingId !== 0) { + this.removeClientFromWatchers(client); + + let watcher = this.currentWatchers.find(e => e.clientId === command.followingId && e.clientUniqueId === command.followingUniqueId); + if(!watcher) { + /* Seems like a following client was faster with notifying than the watcher itself. So lets create him. */ + this.currentWatchers.push(watcher = new InternalW2GWatcher(command.followingId, command.followingUniqueId)); + } + + watcher.updateFollower(client, command.status); + } else { + this.removeClientFromFollowers(client); + + let watcher = this.findWatcher(client); + let isNewWatcher; + if(!watcher) { + isNewWatcher = true; + this.currentWatchers.push(watcher = new InternalW2GWatcher(client.clientId, client.clientUniqueId)); + } else { + isNewWatcher = !watcher.watcherStatusReceived; + } + + watcher.updateWatcher(client, command.videoUrl, command.status); + if(isNewWatcher) + this.events.fire("notify_watcher_add", { watcher: watcher }); + + clearTimeout(watcher.statusTimeoutId); + watcher.statusTimeoutId = setTimeout(() => this.watcherStatusTimeout(watcher), W2GPluginCmdHandler.kStatusUpdateTimeout); + } + } + + private watcherStatusTimeout(watcher: InternalW2GWatcher) { + const index = this.currentWatchers.indexOf(watcher); + if(index === -1) return; + + this.destroyWatcher(watcher); + } + + private notifyLocalStatus() { + let statusUpdate: W2GCommandPayload["update-status"]; + if(this.localFollowing) { + statusUpdate = { + status: this.localPlayerStatus, + videoUrl: this.localVideoUrl, + followingUniqueId: this.localFollowing.clientUniqueId, + followingId: this.localFollowing.clientId + }; + } else if(this.localVideoUrl) { + statusUpdate = { + status: this.localPlayerStatus, + videoUrl: this.localVideoUrl, + + followingId: 0, + followingUniqueId: "" + }; + } + + if(statusUpdate) { + if(this.currentServerConnection.connected()) + this.sendCommand("update-status", statusUpdate); + + const ownClient = this.currentServerConnection.client.getClient(); + this.handleStatusUpdate(statusUpdate, { + clientId: ownClient.clientId(), + clientUniqueId: ownClient.clientUid(), + clientName: ownClient.clientNickName() + }); + } + } + + setLocalPlayerClosed() { + if(this.localVideoUrl === undefined && this.localFollowing === undefined) + return; + + this.localVideoUrl = undefined; + this.localFollowing = undefined; + + this.sendCommand("player-closed", {}); + + const ownClient = this.currentServerConnection.client.getClient(); + this.handlePlayerClosed({ + clientId: ownClient.clientId(), + clientUniqueId: ownClient.clientUid(), + clientName: ownClient.clientNickName() + }); + } + + setLocalWatcherStatus(videoUrl: string, status: PlayerStatus) { + let forceUpdate = false; + + if(this.localFollowing) { + this.localFollowing.events.off(this.callbackWatcherEvents); + this.localFollowing = undefined; + forceUpdate = true; + } + + if(this.localVideoUrl !== videoUrl) { + this.localVideoUrl = videoUrl; + forceUpdate = true; + } + + forceUpdate = forceUpdate || this.localPlayerStatus?.status !== status.status; + this.localPlayerStatus = status; + if(forceUpdate) + this.notifyLocalStatus(); + } + + setLocalFollowing(target: W2GWatcher | undefined, status?: PlayerStatus) { + let forceUpdate = false; + + if(!(target instanceof InternalW2GWatcher)) + throw tr("invalid target watcher"); + + if(this.localFollowing !== target) { + if(target && target.clientId === this.currentServerConnection.client.getClientId()) + throw tr("You can't follow your self"); + + const oldWatcher = this.localFollowing; + oldWatcher?.events.off(this.callbackWatcherEvents); + + this.localFollowing = target; + this.localFollowing?.events.on("notify_watcher_status_changed", this.callbackWatcherEvents); + this.localFollowing?.events.on("notify_watcher_url_changed", this.callbackWatcherEvents); + this.localFollowing?.events.on("notify_destroyed", this.callbackWatcherEvents); + + this.events.fire("notify_following_changed", { oldWatcher: oldWatcher, newWatcher: target }); + forceUpdate = true; + } + + if(target) { + if(typeof status !== "object") + throw tr("missing w2g status"); + + forceUpdate = forceUpdate || this.localPlayerStatus?.status !== status.status; + this.localPlayerStatus = status; + } + + if(forceUpdate) + this.notifyLocalStatus(); + } + + getLocalFollowingWatcher() : W2GWatcher | undefined { return this.localFollowing; } + + private handleLocalWatcherEvent(event: Event) { + switch (event.type) { + case "notify_watcher_url_changed": + this.events.fire("notify_following_url", { newUrl: event.as<"notify_watcher_url_changed">().newVideo }); + break; + + case "notify_watcher_status_changed": + this.events.fire("notify_following_watcher_status", { newStatus: event.as<"notify_watcher_status_changed">().newStatus }); + break; + + case "notify_destroyed": + const oldWatcher = this.localFollowing; + this.localFollowing = undefined; + this.events.fire_async("notify_following_changed", { newWatcher: undefined, oldWatcher: oldWatcher }); + this.notifyLocalStatus(); + break; + } + } +} \ No newline at end of file diff --git a/shared/js/video-viewer/icon-navbar.svg b/shared/js/video-viewer/icon-navbar.svg new file mode 100644 index 00000000..b6af42b1 --- /dev/null +++ b/shared/js/video-viewer/icon-navbar.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/app/audio/player.ts b/web/app/audio/player.ts index 59d1839c..684524b2 100644 --- a/web/app/audio/player.ts +++ b/web/app/audio/player.ts @@ -24,7 +24,8 @@ import {Device} from "tc-shared/audio/player"; import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; -const kAvoidAudioContextWarning = true; +/* lets try without any gestures, maybe the user already clicked the page */ +const kAvoidAudioContextWarning = false; let audioContextRequiredGesture = false; let audioContextInstance: AudioContext; diff --git a/web/css/static/main.css b/web/css/static/main.css new file mode 100644 index 00000000..fd4e4c5b --- /dev/null +++ b/web/css/static/main.css @@ -0,0 +1,35 @@ +html, body { + overflow-y: hidden; + height: 100%; + width: 100%; + position: fixed; +} + +.app-container { + display: flex; + justify-content: stretch; + position: absolute; + top: 1.5em !important; + bottom: 0; + transition: all 0.5s linear; +} +.app-container .app { + width: 100%; + height: 100%; + margin: 0; + display: flex; + flex-direction: column; + resize: both; +} + +@media only screen and (max-width: 650px) { + html, body { + padding: 0 !important; + } + + .app-container { + bottom: 0; + } +} + +/*# sourceMappingURL=main.css.map */ diff --git a/web/css/static/main.css.map b/web/css/static/main.css.map new file mode 100644 index 00000000..daab01e0 --- /dev/null +++ b/web/css/static/main.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACC;EAEG;EACA;EACA;;;AAGJ;EACC;EACA;EACA;EAEA;EACG;EAEA;;AAEH;EACC;EACA;EACA;EAEA;EAAe;EAAwB;;;AAKzC;EACC;IACC;;;EAGD;IACC","file":"main.css"} \ No newline at end of file