Adding W2G basic functionality. Expended the popout modal
This commit is contained in:
parent
63895b31b3
commit
31ceb97565
36 changed files with 2267 additions and 227 deletions
30
client/css/static/main.css
Normal file
30
client/css/static/main.css
Normal file
|
@ -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 */
|
1
client/css/static/main.css.map
Normal file
1
client/css/static/main.css.map
Normal file
|
@ -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"}
|
|
@ -35,6 +35,8 @@ import {md5} from "tc-shared/crypto/md5";
|
||||||
import {guid} from "tc-shared/crypto/uid";
|
import {guid} from "tc-shared/crypto/uid";
|
||||||
import {ServerEventLog} from "tc-shared/ui/frames/log/ServerEventLog";
|
import {ServerEventLog} from "tc-shared/ui/frames/log/ServerEventLog";
|
||||||
import {EventType} from "tc-shared/ui/frames/log/Definitions";
|
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 {
|
export enum DisconnectReason {
|
||||||
HANDLER_DESTROYED,
|
HANDLER_DESTROYED,
|
||||||
|
@ -157,6 +159,8 @@ export class ConnectionHandler {
|
||||||
|
|
||||||
private _connect_initialize_id: number = 1;
|
private _connect_initialize_id: number = 1;
|
||||||
|
|
||||||
|
private pluginCmdRegistry: PluginCmdRegistry;
|
||||||
|
|
||||||
private client_status: LocalClientStatus = {
|
private client_status: LocalClientStatus = {
|
||||||
input_hardware: false,
|
input_hardware: false,
|
||||||
input_muted: false,
|
input_muted: false,
|
||||||
|
@ -188,6 +192,8 @@ export class ConnectionHandler {
|
||||||
this.fileManager = new FileManager(this);
|
this.fileManager = new FileManager(this);
|
||||||
this.permissions = new PermissionManager(this);
|
this.permissions = new PermissionManager(this);
|
||||||
|
|
||||||
|
this.pluginCmdRegistry = new PluginCmdRegistry(this);
|
||||||
|
|
||||||
this.log = new ServerEventLog(this);
|
this.log = new ServerEventLog(this);
|
||||||
this.side_bar = new Frame(this);
|
this.side_bar = new Frame(this);
|
||||||
this.sound = new SoundManager(this);
|
this.sound = new SoundManager(this);
|
||||||
|
@ -217,6 +223,8 @@ export class ConnectionHandler {
|
||||||
|
|
||||||
this.event_registry.register_handler(this);
|
this.event_registry.register_handler(this);
|
||||||
this.events().fire("notify_handler_initialized");
|
this.events().fire("notify_handler_initialized");
|
||||||
|
|
||||||
|
this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize_client_state(source?: ConnectionHandler) {
|
initialize_client_state(source?: ConnectionHandler) {
|
||||||
|
@ -962,6 +970,9 @@ export class ConnectionHandler {
|
||||||
this.hostbanner && this.hostbanner.destroy();
|
this.hostbanner && this.hostbanner.destroy();
|
||||||
this.hostbanner = undefined;
|
this.hostbanner = undefined;
|
||||||
|
|
||||||
|
this.pluginCmdRegistry && this.pluginCmdRegistry.destroy();
|
||||||
|
this.pluginCmdRegistry = undefined;
|
||||||
|
|
||||||
this._local_client && this._local_client.destroy();
|
this._local_client && this._local_client.destroy();
|
||||||
this._local_client = undefined;
|
this._local_client = undefined;
|
||||||
|
|
||||||
|
@ -1031,7 +1042,7 @@ export class ConnectionHandler {
|
||||||
* - Voice bridge hasn't been set upped yet
|
* - Voice bridge hasn't been set upped yet
|
||||||
*/
|
*/
|
||||||
//TODO: This currently returns false
|
//TODO: This currently returns false
|
||||||
isSpeakerDisabled() { return false; }
|
isSpeakerDisabled() : boolean { return false; }
|
||||||
|
|
||||||
setSubscribeToAllChannels(flag: boolean) {
|
setSubscribeToAllChannels(flag: boolean) {
|
||||||
if(this.client_status.channel_subscribe_all === flag) return;
|
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" });
|
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) {
|
setAway(state: boolean | string) {
|
||||||
this.setAway_(state, true);
|
this.setAway_(state, true);
|
||||||
|
@ -1084,12 +1095,14 @@ export class ConnectionHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
areQueriesShown() {
|
areQueriesShown() : boolean {
|
||||||
return this.client_status.queries_visible;
|
return this.client_status.queries_visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasInputHardware() { return this.client_status.input_hardware; }
|
hasInputHardware() : boolean { return this.client_status.input_hardware; }
|
||||||
hasOutputHardware() { return this.client_status.output_muted; }
|
hasOutputHardware() : boolean { return this.client_status.output_muted; }
|
||||||
|
|
||||||
|
getPluginCmdRegistry() : PluginCmdRegistry { return this.pluginCmdRegistry; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStateUpdateType = "microphone" | "speaker" | "away" | "subscribe" | "query";
|
export type ConnectionStateUpdateType = "microphone" | "speaker" | "away" | "subscribe" | "query";
|
||||||
|
|
118
shared/js/connection/PluginCmdHandler.ts
Normal file
118
shared/js/connection/PluginCmdHandler.ts
Normal file
|
@ -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<CommandResult> {
|
||||||
|
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<T extends PluginCmdHandler>(channel: string) : T | undefined {
|
||||||
|
return this.handlerMap[channel] as T;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,6 @@ import * as i18n from "./i18n/localize";
|
||||||
|
|
||||||
import "./proto";
|
import "./proto";
|
||||||
|
|
||||||
import {spawnVideoPopout} from "tc-shared/video-viewer/Controller";
|
|
||||||
|
|
||||||
console.error("Hello World from devel main");
|
console.error("Hello World from devel main");
|
||||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
|
@ -22,7 +21,5 @@ loader.register_task(Stage.LOADED, {
|
||||||
name: "invoke",
|
name: "invoke",
|
||||||
priority: 10,
|
priority: 10,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
console.error("Spawning video popup");
|
|
||||||
//spawnVideoPopout();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -2,6 +2,7 @@ import {Registry} from "tc-shared/events";
|
||||||
import * as hex from "tc-shared/crypto/hex";
|
import * as hex from "tc-shared/crypto/hex";
|
||||||
|
|
||||||
export const kIPCAvatarChannel = "avatars";
|
export const kIPCAvatarChannel = "avatars";
|
||||||
|
export const kLoadingAvatarImage = "img/loading_image.svg";
|
||||||
export const kDefaultAvatarImage = "img/style/avatar.png";
|
export const kDefaultAvatarImage = "img/style/avatar.png";
|
||||||
export type AvatarState = "unset" | "loading" | "errored" | "loaded";
|
export type AvatarState = "unset" | "loading" | "errored" | "loaded";
|
||||||
|
|
||||||
|
|
|
@ -112,15 +112,17 @@ export abstract class BasicIPCHandler {
|
||||||
const data: ChannelMessage = message.data;
|
const data: ChannelMessage = message.data;
|
||||||
|
|
||||||
let channel_invoked = false;
|
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.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) {
|
||||||
if(channel.messageHandler)
|
if(channel.messageHandler)
|
||||||
channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data);
|
channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data);
|
||||||
channel_invoked = true;
|
channel_invoked = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(!channel_invoked) {
|
if(!channel_invoked) {
|
||||||
debugger;
|
/* Seems like we're not the only web/teaclient instance */
|
||||||
console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id);
|
/* console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,8 +41,7 @@ import "./ui/elements/ContextDivider";
|
||||||
import "./ui/elements/Tab";
|
import "./ui/elements/Tab";
|
||||||
import "./connection/CommandHandler";
|
import "./connection/CommandHandler";
|
||||||
import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
|
import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
|
||||||
import {spawnVideoPopout} from "tc-shared/video-viewer/Controller";
|
import {openVideoViewer} 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 */
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
@ -498,7 +497,9 @@ function main() {
|
||||||
modal.close_listener.push(() => settings.changeGlobal(Settings.KEY_USER_IS_NEW, false));
|
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 = {
|
const task_teaweb_starter: loader.Task = {
|
||||||
|
|
|
@ -462,6 +462,12 @@ export class Settings extends StaticSettings {
|
||||||
valueType: "string"
|
valueType: "string"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly KEY_W2G_SIDEBAR_COLLAPSED: ValuedSettingsKey<boolean> = {
|
||||||
|
key: 'w2g_sidebar_collapsed',
|
||||||
|
defaultValue: false,
|
||||||
|
valueType: "boolean",
|
||||||
|
};
|
||||||
|
|
||||||
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
|
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
|
||||||
return {
|
return {
|
||||||
key: "log." + category.toLowerCase() + ".enabled",
|
key: "log." + category.toLowerCase() + ".enabled",
|
||||||
|
|
|
@ -7,4 +7,5 @@ export const rendererReact = new ReactRenderer();
|
||||||
export const rendererHTML = new HTMLRenderer(rendererReact);
|
export const rendererHTML = new HTMLRenderer(rendererReact);
|
||||||
|
|
||||||
import "./emoji";
|
import "./emoji";
|
||||||
import "./highlight";
|
import "./highlight";
|
||||||
|
import "./youtube";
|
63
shared/js/text/bbcode/youtube.scss
Normal file
63
shared/js/text/bbcode/youtube.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
76
shared/js/text/bbcode/youtube.tsx
Normal file
76
shared/js/text/bbcode/youtube.tsx
Normal file
|
@ -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<TagElement, React.ReactNode> {
|
||||||
|
render(element: TagElement): React.ReactNode {
|
||||||
|
const text = rendererText.render(element);
|
||||||
|
const result = text.match(patternYtVideoId);
|
||||||
|
if(!result || !result[1]) {
|
||||||
|
return <BBCodeRenderer key={"cyt-" + ++reactId} settings={{ convertSingleUrls: false }} message={"[url]" + text + "[/url]"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cssStyle.container}
|
||||||
|
onContextMenu={event => {
|
||||||
|
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"
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img draggable={false} src={"https://img.youtube.com/vi/" + result[1] + "/hqdefault.jpg"} alt={"Video thumbnail"} title={tra("Youtube video {}", result[1])} />
|
||||||
|
<button className={cssStyle.playButton} onClick={() => {
|
||||||
|
openVideoViewer(server_connections.active_connection(), text);
|
||||||
|
}}>
|
||||||
|
<HTMLRenderer purify={false}>{playIcon}</HTMLRenderer>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags(): string | string[] {
|
||||||
|
return ["youtube", "yt"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
});
|
4
shared/js/text/bbcode/yt-play-button.svg
Normal file
4
shared/js/text/bbcode/yt-play-button.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="100%" version="1.1" viewBox="0 0 68 48" width="100%">
|
||||||
|
<path class="ytp-large-play-button-bg" d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z"></path>
|
||||||
|
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 527 B |
|
@ -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 * as React from "react";
|
||||||
import {Slider} from "tc-shared/ui/react-elements/Slider";
|
import {Slider} from "tc-shared/ui/react-elements/Slider";
|
||||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||||
|
@ -238,7 +238,7 @@ export function spawnClientVolumeChange(client: ClientEntry) {
|
||||||
client.setAudioVolume(event.newValue);
|
client.setAudioVolume(event.newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
const modal = spawnReactModal(class extends Modal {
|
const modal = spawnReactModal(class extends InternalModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
|
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
|
||||||
|
@ -232,27 +232,27 @@ const CreateButton = (props: { events: Registry<GroupCreateModalEvents> }) => {
|
||||||
</Button>
|
</Button>
|
||||||
};
|
};
|
||||||
|
|
||||||
class ModalGroupCreate extends Modal {
|
class ModalGroupCreate extends InternalModal {
|
||||||
readonly target: "server" | "channel";
|
readonly target: "server" | "channel";
|
||||||
readonly events = new Registry<GroupCreateModalEvents>();
|
readonly events: Registry<GroupCreateModalEvents>;
|
||||||
readonly defaultSourceGroup: number;
|
readonly defaultSourceGroup: number;
|
||||||
|
|
||||||
constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) {
|
constructor(connection: ConnectionHandler, events: Registry<GroupCreateModalEvents>, target: "server" | "channel", defaultSourceGroup: number) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.events.enableDebug("group-create");
|
this.events = events;
|
||||||
this.defaultSourceGroup = defaultSourceGroup;
|
this.defaultSourceGroup = defaultSourceGroup;
|
||||||
this.target = target;
|
this.target = target;
|
||||||
initializeGroupCreateController(connection, this.events, this.target);
|
initializeGroupCreateController(connection, this.events, this.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInitialize() {
|
protected onInitialize() {
|
||||||
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
|
|
||||||
|
|
||||||
this.events.fire_async("query_available_groups");
|
this.events.fire_async("query_available_groups");
|
||||||
this.events.fire_async("query_client_permissions");
|
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() {
|
renderBody() {
|
||||||
|
@ -276,8 +276,13 @@ class ModalGroupCreate extends Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) {
|
export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) {
|
||||||
const modal = spawnReactModal(ModalGroupCreate, connection, target, sourceGroup);
|
const events = new Registry<GroupCreateModalEvents>();
|
||||||
|
events.enableDebug("group-create");
|
||||||
|
|
||||||
|
const modal = spawnReactModal(ModalGroupCreate, connection, events, target, sourceGroup);
|
||||||
modal.show();
|
modal.show();
|
||||||
|
|
||||||
|
events.on(["action_cancel", "action_create"], () => modal.destroy());
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringifyError = error => {
|
const stringifyError = error => {
|
||||||
|
|
|
@ -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 {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {useRef, useState} from "react";
|
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 {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||||
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
|
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
|
||||||
import {tra} from "tc-shared/i18n/localize";
|
import {tra} from "tc-shared/i18n/localize";
|
||||||
|
import {md5} from "tc-shared/crypto/md5";
|
||||||
|
|
||||||
const cssStyle = require("./ModalGroupPermissionCopy.scss");
|
const cssStyle = require("./ModalGroupPermissionCopy.scss");
|
||||||
|
|
||||||
|
@ -119,15 +120,16 @@ const CopyButton = (props: { events: Registry<GroupPermissionCopyModalEvents> })
|
||||||
</Button>
|
</Button>
|
||||||
};
|
};
|
||||||
|
|
||||||
class ModalGroupPermissionCopy extends Modal {
|
class ModalGroupPermissionCopy extends InternalModal {
|
||||||
readonly events = new Registry<GroupPermissionCopyModalEvents>();
|
readonly events: Registry<GroupPermissionCopyModalEvents>;
|
||||||
|
|
||||||
readonly defaultSource: number;
|
readonly defaultSource: number;
|
||||||
readonly defaultTarget: number;
|
readonly defaultTarget: number;
|
||||||
|
|
||||||
constructor(connection: ConnectionHandler, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) {
|
constructor(connection: ConnectionHandler, events: Registry<GroupPermissionCopyModalEvents>, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.events = events;
|
||||||
this.defaultSource = sourceGroup;
|
this.defaultSource = sourceGroup;
|
||||||
this.defaultTarget = targetGroup;
|
this.defaultTarget = targetGroup;
|
||||||
|
|
||||||
|
@ -135,12 +137,13 @@ class ModalGroupPermissionCopy extends Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInitialize() {
|
protected onInitialize() {
|
||||||
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
|
|
||||||
|
|
||||||
this.events.fire_async("query_available_groups");
|
this.events.fire_async("query_available_groups");
|
||||||
this.events.fire_async("query_client_permissions");
|
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() {
|
renderBody() {
|
||||||
|
@ -162,8 +165,11 @@ class ModalGroupPermissionCopy extends Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) {
|
export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) {
|
||||||
const modal = spawnReactModal(ModalGroupPermissionCopy, connection, target, sourceGroup, targetGroup);
|
const events = new Registry<GroupPermissionCopyModalEvents>();
|
||||||
|
const modal = spawnReactModal(ModalGroupPermissionCopy, connection, events, target, sourceGroup, targetGroup);
|
||||||
modal.show();
|
modal.show();
|
||||||
|
|
||||||
|
events.on(["action_cancel", "action_copy"], () => modal.destroy());
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringifyError = error => {
|
const stringifyError = error => {
|
||||||
|
|
|
@ -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 {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
|
@ -267,7 +267,7 @@ const TabSelector = (props: { events: Registry<PermissionModalEvents> }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number };
|
export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number };
|
||||||
class PermissionEditorModal extends Modal {
|
class PermissionEditorModal extends InternalModal {
|
||||||
readonly modalEvents = new Registry<PermissionModalEvents>();
|
readonly modalEvents = new Registry<PermissionModalEvents>();
|
||||||
readonly editorEvents = new Registry<PermissionEditorEvents>();
|
readonly editorEvents = new Registry<PermissionEditorEvents>();
|
||||||
|
|
||||||
|
@ -294,7 +294,6 @@ class PermissionEditorModal extends Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInitialize() {
|
protected onInitialize() {
|
||||||
this.modalController().events.on("destroy", () => this.modalEvents.fire("notify_destroy"));
|
|
||||||
this.modalEvents.fire("query_client_permissions");
|
this.modalEvents.fire("query_client_permissions");
|
||||||
this.modalEvents.fire("action_activate_tab", {
|
this.modalEvents.fire("action_activate_tab", {
|
||||||
tab: this.defaultTab,
|
tab: this.defaultTab,
|
||||||
|
@ -304,6 +303,11 @@ class PermissionEditorModal extends Modal {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onDestroy() {
|
||||||
|
this.modalEvents.fire("notify_destroy");
|
||||||
|
this.modalEvents.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
renderBody() {
|
renderBody() {
|
||||||
return (
|
return (
|
||||||
<div className={cssStyle.container}>
|
<div className={cssStyle.container}>
|
||||||
|
|
|
@ -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 * as React from "react";
|
||||||
import {FileType} from "tc-shared/file/FileManager";
|
import {FileType} from "tc-shared/file/FileManager";
|
||||||
import {Registry} from "tc-shared/events";
|
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<FileBrowserEvents>();
|
readonly remoteBrowseEvents = new Registry<FileBrowserEvents>();
|
||||||
readonly transferInfoEvents = new Registry<TransferInfoEvents>();
|
readonly transferInfoEvents = new Registry<TransferInfoEvents>();
|
||||||
|
|
||||||
|
|
|
@ -1,62 +1,69 @@
|
||||||
import * as React from "react";
|
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 {useState} from "react";
|
||||||
import * as image_preview from "tc-shared/ui/frames/image_preview";
|
import * as image_preview from "tc-shared/ui/frames/image_preview";
|
||||||
|
|
||||||
const ImageStyle = { height: "100%", width: "100%", cursor: "pointer" };
|
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 [ revision, setRevision ] = useState(0);
|
||||||
|
|
||||||
let image;
|
let image;
|
||||||
switch (props.avatar?.getState()) {
|
if(props.avatar === "loading") {
|
||||||
case "unset":
|
image = <img src={kLoadingAvatarImage} />;
|
||||||
image = <img
|
} else if(props.avatar === "default") {
|
||||||
key={"default"}
|
image = <img src={kDefaultAvatarImage} />;
|
||||||
title={tr("default avatar")}
|
} else {
|
||||||
alt={typeof props.alt === "string" ? props.alt : tr("default avatar")}
|
const imageUrl = props.avatar.getAvatarUrl();
|
||||||
src={props.avatar.getAvatarUrl()}
|
switch (props.avatar.getState()) {
|
||||||
style={ImageStyle}
|
case "unset":
|
||||||
onClick={event => {
|
image = <img
|
||||||
if(event.isDefaultPrevented())
|
key={"default"}
|
||||||
return;
|
title={tr("default avatar")}
|
||||||
|
alt={typeof props.alt === "string" ? props.alt : tr("default avatar")}
|
||||||
|
src={props.avatar.getAvatarUrl()}
|
||||||
|
style={ImageStyle}
|
||||||
|
onClick={event => {
|
||||||
|
if(event.isDefaultPrevented())
|
||||||
|
return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
image_preview.preview_image(props.avatar.getAvatarUrl(), undefined);
|
image_preview.preview_image(imageUrl, undefined);
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "loaded":
|
case "loaded":
|
||||||
image = <img
|
image = <img
|
||||||
key={"user-" + props.avatar.getAvatarHash()}
|
key={"user-" + props.avatar.getAvatarHash()}
|
||||||
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
|
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
|
||||||
title={tr("user avatar")}
|
title={tr("user avatar")}
|
||||||
src={props.avatar.getAvatarUrl()}
|
src={imageUrl}
|
||||||
style={ImageStyle}
|
style={ImageStyle}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
if(event.isDefaultPrevented())
|
if(event.isDefaultPrevented())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
image_preview.preview_image(props.avatar.getAvatarUrl(), undefined);
|
image_preview.preview_image(imageUrl, undefined);
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "errored":
|
case "errored":
|
||||||
image = <img key={"error"} alt={typeof props.alt === "string" ? props.alt : tr("error")} title={tr("avatar failed to load:\n") + props.avatar.getLoadError()} src={props.avatar.getAvatarUrl()} style={ImageStyle} />;
|
image = <img key={"error"} alt={typeof props.alt === "string" ? props.alt : tr("error")} title={tr("avatar failed to load:\n") + props.avatar.getLoadError()} src={imageUrl} style={ImageStyle} />;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "loading":
|
case "loading":
|
||||||
image = <img key={"loading"} alt={typeof props.alt === "string" ? props.alt : tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />;
|
image = <img key={"loading"} alt={typeof props.alt === "string" ? props.alt : tr("loading")} title={tr("loading avatar")} src={kLoadingAvatarImage} style={ImageStyle} />;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case undefined:
|
case undefined:
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className} style={{ overflow: "hidden" }}>
|
<div className={props.className} style={{ overflow: "hidden" }}>
|
||||||
{image}
|
{image}
|
||||||
|
|
|
@ -9,10 +9,10 @@ export const LoadingDots = (props: { maxDots?: number, speed?: number, textOnly?
|
||||||
const [dots, setDots] = useState(0);
|
const [dots, setDots] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!props.enabled)
|
if(typeof props.enabled === "boolean" && !props.enabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const timeout = setTimeout(() => setDots(dots + 1), speed || 500);
|
const timeout = setTimeout(() => setDots(dots + 1), typeof speed === "number" ? speed : 500);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,10 @@ const cssStyle = require("./Modal.scss");
|
||||||
|
|
||||||
export type ModalType = "error" | "warning" | "info" | "none";
|
export type ModalType = "error" | "warning" | "info" | "none";
|
||||||
|
|
||||||
|
export interface ModalOptions {
|
||||||
|
destroyOnClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModalEvents {
|
export interface ModalEvents {
|
||||||
"open": {},
|
"open": {},
|
||||||
"close": {},
|
"close": {},
|
||||||
|
@ -22,29 +26,67 @@ export enum ModalState {
|
||||||
DESTROYED
|
DESTROYED
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModalController<InstanceType extends Modal = Modal> {
|
export interface ModalController {
|
||||||
|
getOptions() : Readonly<ModalOptions>;
|
||||||
|
getEvents() : Registry<ModalEvents>;
|
||||||
|
getState() : ModalState;
|
||||||
|
|
||||||
|
show() : Promise<void>;
|
||||||
|
hide() : Promise<void>;
|
||||||
|
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class AbstractModal {
|
||||||
|
protected constructor() {}
|
||||||
|
|
||||||
|
abstract renderBody() : ReactElement;
|
||||||
|
abstract title() : string | React.ReactElement<Translatable>;
|
||||||
|
|
||||||
|
/* only valid for the "inline" modals */
|
||||||
|
type() : ModalType { return "none"; }
|
||||||
|
|
||||||
|
protected onInitialize() {}
|
||||||
|
protected onDestroy() {}
|
||||||
|
|
||||||
|
protected onClose() {}
|
||||||
|
protected onOpen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InternalModalController<InstanceType extends InternalModal = InternalModal> implements ModalController {
|
||||||
readonly events: Registry<ModalEvents>;
|
readonly events: Registry<ModalEvents>;
|
||||||
readonly modalInstance: InstanceType;
|
readonly modalInstance: InstanceType;
|
||||||
|
|
||||||
private initializedPromise: Promise<void>;
|
private initializedPromise: Promise<void>;
|
||||||
|
|
||||||
private domElement: Element;
|
private domElement: Element;
|
||||||
private refModal: React.RefObject<ModalImpl>;
|
private refModal: React.RefObject<InternalModalRenderer>;
|
||||||
private modalState_: ModalState = ModalState.HIDDEN;
|
private modalState_: ModalState = ModalState.HIDDEN;
|
||||||
|
|
||||||
constructor(instance: InstanceType) {
|
constructor(instance: InstanceType) {
|
||||||
this.modalInstance = instance;
|
this.modalInstance = instance;
|
||||||
instance["__modal_controller"] = this;
|
|
||||||
|
|
||||||
this.events = new Registry<ModalEvents>();
|
this.events = new Registry<ModalEvents>();
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOptions(): Readonly<ModalOptions> {
|
||||||
|
/* FIXME! */
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(): Registry<ModalEvents> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return this.modalState_;
|
||||||
|
}
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
this.refModal = React.createRef();
|
this.refModal = React.createRef();
|
||||||
this.domElement = document.createElement("div");
|
this.domElement = document.createElement("div");
|
||||||
|
|
||||||
const element = <ModalImpl controller={this} ref={this.refModal} />;
|
const element = <InternalModalRenderer controller={this} ref={this.refModal} />;
|
||||||
document.body.appendChild(this.domElement);
|
document.body.appendChild(this.domElement);
|
||||||
this.initializedPromise = new Promise<void>(resolve => {
|
this.initializedPromise = new Promise<void>(resolve => {
|
||||||
ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0));
|
ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0));
|
||||||
|
@ -53,11 +95,7 @@ export class ModalController<InstanceType extends Modal = Modal> {
|
||||||
this.modalInstance["onInitialize"]();
|
this.modalInstance["onInitialize"]();
|
||||||
}
|
}
|
||||||
|
|
||||||
modalState() {
|
async show() : Promise<void> {
|
||||||
return this.modalState_;
|
|
||||||
}
|
|
||||||
|
|
||||||
async show() {
|
|
||||||
await this.initializedPromise;
|
await this.initializedPromise;
|
||||||
if(this.modalState_ === ModalState.DESTROYED)
|
if(this.modalState_ === ModalState.DESTROYED)
|
||||||
throw tr("modal has been destroyed");
|
throw tr("modal has been destroyed");
|
||||||
|
@ -101,33 +139,9 @@ export class ModalController<InstanceType extends Modal = Modal> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AbstractModal {
|
export abstract class InternalModal extends AbstractModal { }
|
||||||
protected constructor() {}
|
|
||||||
|
|
||||||
abstract renderBody() : ReactElement;
|
class InternalModalRenderer extends React.PureComponent<{ controller: InternalModalController }, { show: boolean }> {
|
||||||
abstract title() : string | React.ReactElement<Translatable>;
|
|
||||||
|
|
||||||
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 }> {
|
|
||||||
private readonly refModal = React.createRef<HTMLDivElement>();
|
private readonly refModal = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
@ -175,11 +189,12 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new () => ModalClass) : ModalController<ModalClass>;
|
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new () => ModalClass) : InternalModalController<ModalClass>;
|
||||||
export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController<ModalClass>;
|
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : InternalModalController<ModalClass>;
|
||||||
export function spawnReactModal<ModalClass extends Modal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : ModalController<ModalClass>;
|
export function spawnReactModal<ModalClass extends InternalModal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : InternalModalController<ModalClass>;
|
||||||
export function spawnReactModal<ModalClass extends Modal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController<ModalClass>;
|
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : InternalModalController<ModalClass>;
|
||||||
export function spawnReactModal<ModalClass extends Modal, A1, A2, A3, A4>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : ModalController<ModalClass>;
|
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : InternalModalController<ModalClass>;
|
||||||
export function spawnReactModal<ModalClass extends Modal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : ModalController<ModalClass> {
|
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4, A5>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5) : InternalModalController<ModalClass>;
|
||||||
return new ModalController(new modalClass(...args));
|
export function spawnReactModal<ModalClass extends InternalModal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : InternalModalController<ModalClass> {
|
||||||
|
return new InternalModalController(new modalClass(...args));
|
||||||
}
|
}
|
|
@ -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 * 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 {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {
|
import {
|
||||||
|
@ -7,20 +9,28 @@ import {
|
||||||
Popout2ControllerMessages,
|
Popout2ControllerMessages,
|
||||||
PopoutIPCMessage
|
PopoutIPCMessage
|
||||||
} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
|
} 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"> {
|
export class ExternalModalController extends EventControllerBase<"controller"> implements ModalController {
|
||||||
readonly modal: string;
|
public readonly modalType: string;
|
||||||
readonly userData: any;
|
public readonly userData: any;
|
||||||
|
|
||||||
|
private modalState: ModalState = ModalState.DESTROYED;
|
||||||
|
private readonly modalEvents: Registry<ModalEvents>;
|
||||||
|
|
||||||
private currentWindow: Window;
|
private currentWindow: Window;
|
||||||
private callbackWindowInitialized: (error?: string) => void;
|
private callbackWindowInitialized: (error?: string) => void;
|
||||||
|
|
||||||
private documentQuitListener: () => void;
|
private readonly documentQuitListener: () => void;
|
||||||
|
private windowClosedTestInterval: number = 0;
|
||||||
|
private windowClosedTimeout: number;
|
||||||
|
|
||||||
constructor(modal: string, localEventRegistry: Registry<any>, userData: any) {
|
constructor(modal: string, localEventRegistry: Registry<any>, userData: any) {
|
||||||
super(localEventRegistry);
|
super(localEventRegistry);
|
||||||
|
|
||||||
this.modal = modal;
|
this.modalEvents = new Registry<ModalEvents>();
|
||||||
|
|
||||||
|
this.modalType = modal;
|
||||||
this.userData = userData;
|
this.userData = userData;
|
||||||
|
|
||||||
this.ipcChannel = ipc.getInstance().createChannel();
|
this.ipcChannel = ipc.getInstance().createChannel();
|
||||||
|
@ -29,11 +39,23 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
|
||||||
this.documentQuitListener = () => this.currentWindow?.close();
|
this.documentQuitListener = () => this.currentWindow?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private trySpawnWindow() {
|
getOptions(): Readonly<ModalOptions> {
|
||||||
|
return {}; /* FIXME! */
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(): Registry<ModalEvents> {
|
||||||
|
return this.modalEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): ModalState {
|
||||||
|
return this.modalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trySpawnWindow() : Window | null {
|
||||||
const parameters = {
|
const parameters = {
|
||||||
"loader-target": "manifest",
|
"loader-target": "manifest",
|
||||||
"chunk": "modal-external",
|
"chunk": "modal-external",
|
||||||
"modal-target": this.modal,
|
"modal-target": this.modalType,
|
||||||
"ipc-channel": this.ipcChannel.channelId,
|
"ipc-channel": this.ipcChannel.channelId,
|
||||||
"ipc-address": ipc.getInstance().getLocalAddress(),
|
"ipc-address": ipc.getInstance().getLocalAddress(),
|
||||||
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
|
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
|
||||||
|
@ -54,16 +76,21 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
|
||||||
let baseUrl = location.origin + location.pathname + "?";
|
let baseUrl = location.origin + location.pathname + "?";
|
||||||
return window.open(
|
return window.open(
|
||||||
baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"),
|
baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"),
|
||||||
"External Modal",
|
this.modalType,
|
||||||
Object.keys(features).map(e => e + "=" + features[e]).join(",")
|
Object.keys(features).map(e => e + "=" + features[e]).join(",")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async show() {
|
||||||
|
if(this.currentWindow) {
|
||||||
|
this.currentWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.currentWindow = this.trySpawnWindow();
|
this.currentWindow = this.trySpawnWindow();
|
||||||
if(!this.currentWindow) {
|
if(!this.currentWindow) {
|
||||||
await new Promise((resolve, reject) => {
|
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) {
|
if(!callback) {
|
||||||
reject("user aborted");
|
reject("user aborted");
|
||||||
return;
|
return;
|
||||||
|
@ -71,47 +98,111 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
|
||||||
|
|
||||||
this.currentWindow = this.trySpawnWindow();
|
this.currentWindow = this.trySpawnWindow();
|
||||||
if(this.currentWindow) {
|
if(this.currentWindow) {
|
||||||
reject("Failed to spawn window");
|
reject(tr("Failed to spawn window"));
|
||||||
} else {
|
} else {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
}).close_listener.push(() => reject("user aborted"));
|
}).close_listener.push(() => reject(tr("user aborted")));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!this.currentWindow) {
|
if(!this.currentWindow) {
|
||||||
/* some shitty popup blocker or whatever */
|
/* 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 = () => {
|
this.currentWindow.onbeforeunload = () => {
|
||||||
/* TODO: General handle */
|
clearInterval(this.windowClosedTestInterval);
|
||||||
window.removeEventListener("beforeunload", this.documentQuitListener);
|
|
||||||
|
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) => {
|
this.modalState = ModalState.SHOWN;
|
||||||
const timeout = setTimeout(() => {
|
this.modalEvents.fire("open");
|
||||||
this.callbackWindowInitialized = undefined;
|
}
|
||||||
reject("window haven't called back");
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
this.callbackWindowInitialized = error => {
|
private destroyPopUp() {
|
||||||
this.callbackWindowInitialized = undefined;
|
if(this.currentWindow) {
|
||||||
clearTimeout(timeout);
|
clearInterval(this.windowClosedTestInterval);
|
||||||
error ? reject(error) : resolve();
|
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) {
|
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||||
if(broadcast)
|
if(broadcast)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if(this.ipcRemoteId !== remoteId) {
|
if(this.ipcRemoteId === undefined) {
|
||||||
|
log.debug(LogCategory.IPC, tr("Remote window connected with id %s"), remoteId);
|
||||||
this.ipcRemoteId = remoteId;
|
this.ipcRemoteId = remoteId;
|
||||||
} else if(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;
|
this.ipcRemoteId = remoteId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +215,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "hello-popout": {
|
case "hello-popout": {
|
||||||
const tpayload = payload as PopoutIPCMessage["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) {
|
if(tpayload.version !== __build.version) {
|
||||||
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
|
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
|
||||||
if(this.callbackWindowInitialized) {
|
if(this.callbackWindowInitialized) {
|
||||||
|
@ -149,7 +240,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,4 +110,11 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected destroyIPC() {
|
||||||
|
this.localEventRegistry.disconnectAll(this.localEventReceiver as any);
|
||||||
|
this.ipcChannel = undefined;
|
||||||
|
this.ipcRemoteId = undefined;
|
||||||
|
this.eventFiredListeners = {};
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@ import * as loader from "tc-loader";
|
||||||
import * as ipc from "../../../ipc/BrowserIPC";
|
import * as ipc from "../../../ipc/BrowserIPC";
|
||||||
import * as i18n from "../../../i18n/localize";
|
import * as i18n from "../../../i18n/localize";
|
||||||
|
|
||||||
import "tc-shared/file/RemoteAvatars";
|
|
||||||
import "tc-shared/proto";
|
import "tc-shared/proto";
|
||||||
|
|
||||||
import {Stage} from "tc-loader";
|
import {Stage} from "tc-loader";
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
import {ExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
|
import {ExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
|
||||||
|
|
||||||
|
|
||||||
export function spawnExternalModal<EventClass>(modal: string, events: Registry<EventClass>, userData: any) : ExternalModalController {
|
export function spawnExternalModal<EventClass>(modal: string, events: Registry<EventClass>, userData: any) : ExternalModalController {
|
||||||
return new ExternalModalController(modal, events as any, userData);
|
return new ExternalModalController(modal, events as any, userData);
|
||||||
}
|
}
|
|
@ -3,7 +3,7 @@ import {parseMessageWithArguments} from "tc-shared/ui/frames/chat";
|
||||||
import {cloneElement} from "react";
|
import {cloneElement} from "react";
|
||||||
|
|
||||||
let instances = [];
|
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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
|
331
shared/js/video-viewer/Controller.ts
Normal file
331
shared/js/video-viewer/Controller.ts
Normal file
|
@ -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<VideoViewerEvents>;
|
||||||
|
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<VideoViewerEvents>();
|
||||||
|
this.events.register_handler(this);
|
||||||
|
|
||||||
|
this.plugin = connection.getPluginCmdRegistry().getPluginHandler<W2GPluginCmdHandler>(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<VideoViewerEvents>("query_watchers")
|
||||||
|
private handleQueryWatchers() {
|
||||||
|
this.notifyWatcherList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler<VideoViewerEvents>("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<VideoViewerEvents>("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<VideoViewerEvents>("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<VideoViewerEvents>("query_video")
|
||||||
|
private handleQueryVideo() {
|
||||||
|
this.events.fire_async("notify_video", { url: this.currentVideoUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler<VideoViewerEvents>("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<VideoViewerEvents>("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<VideoViewerEvents>("action_toggle_side_bar")
|
||||||
|
private handleActionToggleSidebar(event: VideoViewerEvents["action_toggle_side_bar"]) {
|
||||||
|
settings.changeGlobal(Settings.KEY_W2G_SIDEBAR_COLLAPSED, !event.shown);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@EventHandler<VideoViewerEvents>("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();
|
||||||
|
};
|
|
@ -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<VideoViewerEvents> }) => {
|
|
||||||
const [ value, setValue ] = useState("unset");
|
|
||||||
|
|
||||||
props.events.reactUse("notify_value", event => setValue(event.value + ""));
|
|
||||||
|
|
||||||
return <>{value}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function spawnVideoPopout() {
|
|
||||||
const registry = new Registry<VideoViewerEvents>();
|
|
||||||
const modalController = spawnExternalModal("video-viewer", registry, {});
|
|
||||||
modalController.open();
|
|
||||||
}
|
|
|
@ -1,6 +1,95 @@
|
||||||
export interface VideoViewerEvents {
|
interface PlayerStatusPlaying {
|
||||||
"notify_show": {},
|
status: "playing";
|
||||||
|
|
||||||
"notify_value": { value: number },
|
timestampPlay: number;
|
||||||
"notify_data_url": { url: string }
|
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 }
|
||||||
}
|
}
|
|
@ -1,3 +1,7 @@
|
||||||
|
@import "../../css/static/mixin";
|
||||||
|
@import "../../css/static/properties";
|
||||||
|
|
||||||
|
$sidebar-width: 20em;
|
||||||
.container {
|
.container {
|
||||||
background: #19191b;
|
background: #19191b;
|
||||||
|
|
||||||
|
@ -10,6 +14,279 @@
|
||||||
|
|
||||||
min-height: 10em;
|
min-height: 10em;
|
||||||
min-width: 20em;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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 {AbstractModal} from "tc-shared/ui/react-elements/Modal";
|
||||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
import {Registry} from "tc-shared/events";
|
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 {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||||
import ReactPlayer from 'react-player'
|
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 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 (<React.Fragment key={"paused"}>
|
||||||
|
<Translatable>Replay paused</Translatable>
|
||||||
|
</React.Fragment>);
|
||||||
|
|
||||||
|
case "buffering":
|
||||||
|
return (<React.Fragment key={"buffering"}>
|
||||||
|
<Translatable>Buffering</Translatable>
|
||||||
|
<LoadingDots />
|
||||||
|
</React.Fragment>);
|
||||||
|
|
||||||
|
case "stopped":
|
||||||
|
return (<React.Fragment key={"stopped"}>
|
||||||
|
<Translatable>Video ended</Translatable>
|
||||||
|
<LoadingDots />
|
||||||
|
</React.Fragment>);
|
||||||
|
|
||||||
|
case "playing":
|
||||||
|
return (<React.Fragment key={"playing"}>
|
||||||
|
<Translatable>Playing</Translatable>
|
||||||
|
{props.timestamp === -1 ? undefined : <>(<PlaytimeRenderer key={"time"} time={props.timestamp - props.status.timestampPlay * 1000} />)</>}
|
||||||
|
</React.Fragment>);
|
||||||
|
|
||||||
|
case undefined:
|
||||||
|
return (<React.Fragment key={"unknown"}>
|
||||||
|
<Translatable>loading</Translatable>
|
||||||
|
<LoadingDots />
|
||||||
|
</React.Fragment>);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (<React.Fragment key={"default"}>
|
||||||
|
<Translatable>unknown player status</Translatable> ({(props as any).status?.status})
|
||||||
|
</React.Fragment>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const WatcherInfo = React.memo((props: { events: Registry<VideoViewerEvents>, 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<PlayerStatus & { timestamp: number }>(() => {
|
||||||
|
props.events.fire("query_watcher_info", { watcherId: props.watcherId });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderedAvatar;
|
||||||
|
if(clientInfo === "loading") {
|
||||||
|
renderedAvatar = <AvatarRenderer avatar={"loading"} key={"loading-avatar"} />;
|
||||||
|
} else {
|
||||||
|
const avatar = getGlobalAvatarManagerFactory().getManager(props.handlerId).resolveClientAvatar({ id: clientInfo.clientId, clientUniqueId: clientInfo.uniqueId });
|
||||||
|
renderedAvatar = <AvatarRenderer avatar={avatar} key={"client-avatar"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderedClientName;
|
||||||
|
if(clientInfo !== "loading") {
|
||||||
|
renderedClientName = <React.Fragment key={"client-name"}>{clientInfo.clientName}</React.Fragment>;
|
||||||
|
} else {
|
||||||
|
renderedClientName = (
|
||||||
|
<React.Fragment key={"client-name-loading"}>
|
||||||
|
<Translatable>loading</Translatable>
|
||||||
|
<LoadingDots />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cssStyle.info + " " + (clientInfo !== "loading" && clientInfo.ownClient ? cssStyle.ownClient : "") + " " + cssStyle[props.type] + " " + (props.isFollowing ? cssStyle.following : "")}
|
||||||
|
onClick={() => {
|
||||||
|
if(clientInfo === "loading")
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(clientInfo.ownClient || props.isFollowing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
props.events.fire("action_follow", { watcherId: props.watcherId });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cssStyle.containerAvatar}>
|
||||||
|
<div className={cssStyle.avatar}>
|
||||||
|
{renderedAvatar}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.containerDetail}>
|
||||||
|
<a className={cssStyle.username}>
|
||||||
|
{renderedClientName}
|
||||||
|
</a>
|
||||||
|
<a className={cssStyle.status}>
|
||||||
|
<PlayerStatusRenderer status={status} timestamp={status?.timestamp} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const WatcherEntry = React.memo((props: { events: Registry<VideoViewerEvents>, watcherId: string, handlerId: string, isFollowing: boolean }) => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.watcher}>
|
||||||
|
<WatcherInfo events={props.events} watcherId={props.watcherId} handlerId={props.handlerId} type={"watcher"} isFollowing={props.isFollowing} />
|
||||||
|
<FollowerList events={props.events} watcherId={props.watcherId} handlerId={props.handlerId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const FollowerList = React.memo((props: { events: Registry<VideoViewerEvents>, watcherId: string, handlerId: string }) => {
|
||||||
|
const [ followers, setFollowers ] = useState<string[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div className={cssStyle.followerList}>
|
||||||
|
{followers.map(followerId => <WatcherInfo key={followerId} events={props.events} watcherId={followerId} handlerId={props.handlerId} type={"follower"} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const WatcherList = (props: { events: Registry<VideoViewerEvents>, handlerId: string }) => {
|
||||||
|
const [ watchers, setWatchers ] = useState<string[]>(() => {
|
||||||
|
props.events.fire("query_watchers");
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ following, setFollowing ] = useState<string | undefined>(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 (
|
||||||
|
<div className={cssStyle.watcherList}>
|
||||||
|
{watchers.map(watcherId => <WatcherEntry key={watcherId} events={props.events} handlerId={props.handlerId} isFollowing={watcherId === following} watcherId={watcherId} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToggleSidebarButton = (props: { events: Registry<VideoViewerEvents> }) => {
|
||||||
|
const [ visible, setVisible ] = useState(settings.global(Settings.KEY_W2G_SIDEBAR_COLLAPSED));
|
||||||
|
|
||||||
|
props.events.reactUse("action_toggle_side_bar", event => setVisible(!event.shown));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.sidebarButton + " " + (visible ? "" : cssStyle.hidden)} onClick={() => props.events.fire("action_toggle_side_bar", { shown: true })}>
|
||||||
|
<HTMLRenderer purify={false}>{iconNavbar}</HTMLRenderer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonUnfollow = (props: { events: Registry<VideoViewerEvents> }) => {
|
||||||
|
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 (
|
||||||
|
<Button color={"red"} type={"small"} disabled={!following} onClick={() => props.events.fire("action_follow", { watcherId: undefined })}>
|
||||||
|
<Translatable>Unfollow</Translatable>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = (props: { events: Registry<VideoViewerEvents>, 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 (
|
||||||
|
<div className={cssStyle.containerSidebar + " " + (visible ? cssStyle.shown : "")}>
|
||||||
|
<div className={cssStyle.buttonClose} onClick={() => props.events.fire("action_toggle_side_bar", { shown: false })} />
|
||||||
|
<div className={cssStyle.header}>
|
||||||
|
<a><Translatable>Watcher list</Translatable></a>
|
||||||
|
</div>
|
||||||
|
<WatcherList events={props.events} handlerId={props.handlerId} />
|
||||||
|
<div className={cssStyle.buttons}>
|
||||||
|
<ButtonUnfollow events={props.events} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayerController = React.memo((props: { events: Registry<VideoViewerEvents> }) => {
|
||||||
|
const player = useRef<ReactPlayer>();
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ReactPlayer
|
||||||
|
ref={player}
|
||||||
|
key={"player-" + mode}
|
||||||
|
|
||||||
|
url={videoUrl}
|
||||||
|
height={"100%"}
|
||||||
|
width={"100%"}
|
||||||
|
|
||||||
|
onError={(error, data, hlsInstance, hlsGlobal) => 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<VideoViewerEvents> }) => {
|
||||||
|
const [ followId, setFollowing ] = useState<string>(undefined);
|
||||||
|
const [ followingName, setFollowingName ] = useState<string>(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 <React.Fragment key={"following"}><Translatable enforceTextOnly={true}>W2G - Following</Translatable> {followingName}</React.Fragment>;
|
||||||
|
} else {
|
||||||
|
return <Translatable key={"watcher"} enforceTextOnly={true}>W2G - Watcher</Translatable>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
class ModalVideoPopout extends AbstractModal {
|
class ModalVideoPopout extends AbstractModal {
|
||||||
readonly events: Registry<VideoViewerEvents>;
|
readonly events: Registry<VideoViewerEvents>;
|
||||||
|
readonly handlerId: string;
|
||||||
|
|
||||||
constructor(registry: Registry<VideoViewerEvents>, userData: any) {
|
constructor(registry: Registry<VideoViewerEvents>, userData: any) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.handlerId = userData.handlerId;
|
||||||
this.events = registry;
|
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<Translatable> {
|
title(): string | React.ReactElement<Translatable> {
|
||||||
return <>Hello World <LoadingDots textOnly={true} /></>;
|
return <TitleRenderer events={this.events} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBody(): React.ReactElement {
|
renderBody(): React.ReactElement {
|
||||||
return <div className={cssStyle.container} >
|
return <div className={cssStyle.container} >
|
||||||
<ReactPlayer
|
<Sidebar events={this.events} handlerId={this.handlerId} />
|
||||||
url={"https://www.youtube.com/watch?v=u_TuibFg-GA"}
|
<ToggleSidebarButton events={this.events} />
|
||||||
height={"100%"}
|
<div className={cssStyle.containerPlayer}>
|
||||||
width={"100%"}
|
<PlayerController events={this.events} />
|
||||||
|
</div>
|
||||||
onError={(error, data, hlsInstance, hlsGlobal) => 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}
|
|
||||||
/>
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export = ModalVideoPopout;
|
export = ModalVideoPopout;
|
||||||
|
|
||||||
console.error("Hello World from video popout");
|
|
442
shared/js/video-viewer/W2GPluginHandler.ts
Normal file
442
shared/js/video-viewer/W2GPluginHandler.ts
Normal file
|
@ -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<W2GWatcherEvents>;
|
||||||
|
public readonly clientId: number;
|
||||||
|
public readonly clientUniqueId: string;
|
||||||
|
|
||||||
|
protected constructor(clientId, clientUniqueId) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.clientUniqueId = clientUniqueId;
|
||||||
|
|
||||||
|
this.events = new Registry<W2GWatcherEvents>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<W2GEvents>;
|
||||||
|
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<W2GEvents>();
|
||||||
|
|
||||||
|
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<T extends keyof W2GCommandPayload>(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<W2GWatcherEvents, "notify_watcher_url_changed" | "notify_watcher_status_changed" | "notify_destroyed">) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
shared/js/video-viewer/icon-navbar.svg
Normal file
5
shared/js/video-viewer/icon-navbar.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
|
<g fill="none" fill-rule="evenodd">
|
||||||
|
<path fill="#FFF" fill-rule="nonzero" d="M14.711 26.432V28H30v-1.568H14.711zM10 19.216v1.568h20v-1.568H10zM14.711 12v1.568H30V12H14.711z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 270 B |
|
@ -24,7 +24,8 @@ import {Device} from "tc-shared/audio/player";
|
||||||
import * as log from "tc-shared/log";
|
import * as log from "tc-shared/log";
|
||||||
import {LogCategory} 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 audioContextRequiredGesture = false;
|
||||||
let audioContextInstance: AudioContext;
|
let audioContextInstance: AudioContext;
|
||||||
|
|
35
web/css/static/main.css
Normal file
35
web/css/static/main.css
Normal file
|
@ -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 */
|
1
web/css/static/main.css.map
Normal file
1
web/css/static/main.css.map
Normal file
|
@ -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"}
|
Loading…
Add table
Reference in a new issue