Adding W2G basic functionality. Expended the popout modal

This commit is contained in:
WolverinDEV 2020-08-07 13:40:11 +02:00
parent 63895b31b3
commit 31ceb97565
36 changed files with 2267 additions and 227 deletions

View 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 */

View 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"}

View file

@ -35,6 +35,8 @@ import {md5} from "tc-shared/crypto/md5";
import {guid} from "tc-shared/crypto/uid";
import {ServerEventLog} from "tc-shared/ui/frames/log/ServerEventLog";
import {EventType} from "tc-shared/ui/frames/log/Definitions";
import {PluginCmdRegistry} from "tc-shared/connection/PluginCmdHandler";
import {W2GPluginCmdHandler} from "tc-shared/video-viewer/W2GPluginHandler";
export enum DisconnectReason {
HANDLER_DESTROYED,
@ -157,6 +159,8 @@ export class ConnectionHandler {
private _connect_initialize_id: number = 1;
private pluginCmdRegistry: PluginCmdRegistry;
private client_status: LocalClientStatus = {
input_hardware: false,
input_muted: false,
@ -188,6 +192,8 @@ export class ConnectionHandler {
this.fileManager = new FileManager(this);
this.permissions = new PermissionManager(this);
this.pluginCmdRegistry = new PluginCmdRegistry(this);
this.log = new ServerEventLog(this);
this.side_bar = new Frame(this);
this.sound = new SoundManager(this);
@ -217,6 +223,8 @@ export class ConnectionHandler {
this.event_registry.register_handler(this);
this.events().fire("notify_handler_initialized");
this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler());
}
initialize_client_state(source?: ConnectionHandler) {
@ -962,6 +970,9 @@ export class ConnectionHandler {
this.hostbanner && this.hostbanner.destroy();
this.hostbanner = undefined;
this.pluginCmdRegistry && this.pluginCmdRegistry.destroy();
this.pluginCmdRegistry = undefined;
this._local_client && this._local_client.destroy();
this._local_client = undefined;
@ -1031,7 +1042,7 @@ export class ConnectionHandler {
* - Voice bridge hasn't been set upped yet
*/
//TODO: This currently returns false
isSpeakerDisabled() { return false; }
isSpeakerDisabled() : boolean { return false; }
setSubscribeToAllChannels(flag: boolean) {
if(this.client_status.channel_subscribe_all === flag) return;
@ -1043,7 +1054,7 @@ export class ConnectionHandler {
this.event_registry.fire("notify_state_updated", { state: "subscribe" });
}
isSubscribeToAllChannels() { return this.client_status.channel_subscribe_all; }
isSubscribeToAllChannels() : boolean { return this.client_status.channel_subscribe_all; }
setAway(state: boolean | string) {
this.setAway_(state, true);
@ -1084,12 +1095,14 @@ export class ConnectionHandler {
});
}
areQueriesShown() {
areQueriesShown() : boolean {
return this.client_status.queries_visible;
}
hasInputHardware() { return this.client_status.input_hardware; }
hasOutputHardware() { return this.client_status.output_muted; }
hasInputHardware() : boolean { return this.client_status.input_hardware; }
hasOutputHardware() : boolean { return this.client_status.output_muted; }
getPluginCmdRegistry() : PluginCmdRegistry { return this.pluginCmdRegistry; }
}
export type ConnectionStateUpdateType = "microphone" | "speaker" | "away" | "subscribe" | "query";

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

View file

@ -6,7 +6,6 @@ import * as i18n from "./i18n/localize";
import "./proto";
import {spawnVideoPopout} from "tc-shared/video-viewer/Controller";
console.error("Hello World from devel main");
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
@ -22,7 +21,5 @@ loader.register_task(Stage.LOADED, {
name: "invoke",
priority: 10,
function: async () => {
console.error("Spawning video popup");
//spawnVideoPopout();
}
});

View file

@ -2,6 +2,7 @@ import {Registry} from "tc-shared/events";
import * as hex from "tc-shared/crypto/hex";
export const kIPCAvatarChannel = "avatars";
export const kLoadingAvatarImage = "img/loading_image.svg";
export const kDefaultAvatarImage = "img/style/avatar.png";
export type AvatarState = "unset" | "loading" | "errored" | "loaded";

View file

@ -112,15 +112,17 @@ export abstract class BasicIPCHandler {
const data: ChannelMessage = message.data;
let channel_invoked = false;
for(const channel of this._channels)
for(const channel of this._channels) {
if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) {
if(channel.messageHandler)
channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data);
channel_invoked = true;
}
}
if(!channel_invoked) {
debugger;
console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id);
/* Seems like we're not the only web/teaclient instance */
/* console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); */
}
}
}

View file

@ -41,8 +41,7 @@ import "./ui/elements/ContextDivider";
import "./ui/elements/Tab";
import "./connection/CommandHandler";
import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
import {spawnVideoPopout} from "tc-shared/video-viewer/Controller";
import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller"; /* else it might not get bundled because only the backends are accessing it */
import {openVideoViewer} from "tc-shared/video-viewer/Controller";
declare global {
interface Window {
@ -498,7 +497,9 @@ function main() {
modal.close_listener.push(() => settings.changeGlobal(Settings.KEY_USER_IS_NEW, false));
}
(window as any).spawnVideoPopout = spawnVideoPopout;
(window as any).spawnVideoPopout = openVideoViewer;
//spawnVideoPopout(server_connections.active_connection(), "https://www.youtube.com/watch?v=9683D18fyvs");
}
const task_teaweb_starter: loader.Task = {

View file

@ -462,6 +462,12 @@ export class Settings extends StaticSettings {
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 => {
return {
key: "log." + category.toLowerCase() + ".enabled",

View file

@ -8,3 +8,4 @@ export const rendererHTML = new HTMLRenderer(rendererReact);
import "./emoji";
import "./highlight";
import "./youtube";

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

View 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
});

View 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

View file

@ -1,4 +1,4 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import * as React from "react";
import {Slider} from "tc-shared/ui/react-elements/Slider";
import {Button} from "tc-shared/ui/react-elements/Button";
@ -238,7 +238,7 @@ export function spawnClientVolumeChange(client: ClientEntry) {
client.setAudioVolume(event.newValue);
});
const modal = spawnReactModal(class extends Modal {
const modal = spawnReactModal(class extends InternalModal {
constructor() {
super();
}
@ -277,7 +277,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
});
});
const modal = spawnReactModal(class extends Modal {
const modal = spawnReactModal(class extends InternalModal {
constructor() {
super();
}

View file

@ -1,4 +1,4 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
@ -232,27 +232,27 @@ const CreateButton = (props: { events: Registry<GroupCreateModalEvents> }) => {
</Button>
};
class ModalGroupCreate extends Modal {
class ModalGroupCreate extends InternalModal {
readonly target: "server" | "channel";
readonly events = new Registry<GroupCreateModalEvents>();
readonly events: Registry<GroupCreateModalEvents>;
readonly defaultSourceGroup: number;
constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) {
constructor(connection: ConnectionHandler, events: Registry<GroupCreateModalEvents>, target: "server" | "channel", defaultSourceGroup: number) {
super();
this.events.enableDebug("group-create");
this.events = events;
this.defaultSourceGroup = defaultSourceGroup;
this.target = target;
initializeGroupCreateController(connection, this.events, this.target);
}
protected onInitialize() {
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
}
this.events.on(["action_cancel", "action_create"], () => this.modalController().destroy());
protected onDestroy() {
this.events.fire("notify_destroy");
}
renderBody() {
@ -276,8 +276,13 @@ class ModalGroupCreate extends Modal {
}
export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) {
const modal = spawnReactModal(ModalGroupCreate, connection, target, sourceGroup);
const events = new Registry<GroupCreateModalEvents>();
events.enableDebug("group-create");
const modal = spawnReactModal(ModalGroupCreate, connection, events, target, sourceGroup);
modal.show();
events.on(["action_cancel", "action_create"], () => modal.destroy());
}
const stringifyError = error => {

View file

@ -1,4 +1,4 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {useRef, useState} from "react";
@ -11,6 +11,7 @@ import PermissionType from "tc-shared/permission/PermissionType";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {tra} from "tc-shared/i18n/localize";
import {md5} from "tc-shared/crypto/md5";
const cssStyle = require("./ModalGroupPermissionCopy.scss");
@ -119,15 +120,16 @@ const CopyButton = (props: { events: Registry<GroupPermissionCopyModalEvents> })
</Button>
};
class ModalGroupPermissionCopy extends Modal {
readonly events = new Registry<GroupPermissionCopyModalEvents>();
class ModalGroupPermissionCopy extends InternalModal {
readonly events: Registry<GroupPermissionCopyModalEvents>;
readonly defaultSource: 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();
this.events = events;
this.defaultSource = sourceGroup;
this.defaultTarget = targetGroup;
@ -135,12 +137,13 @@ class ModalGroupPermissionCopy extends Modal {
}
protected onInitialize() {
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
}
this.events.on(["action_cancel", "action_copy"], () => this.modalController().destroy());
protected onDestroy() {
this.events.fire("notify_destroy");
this.events.destroy();
}
renderBody() {
@ -162,8 +165,11 @@ class ModalGroupPermissionCopy extends Modal {
}
export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) {
const modal = spawnReactModal(ModalGroupPermissionCopy, connection, target, sourceGroup, targetGroup);
const events = new Registry<GroupPermissionCopyModalEvents>();
const modal = spawnReactModal(ModalGroupPermissionCopy, connection, events, target, sourceGroup, targetGroup);
modal.show();
events.on(["action_cancel", "action_copy"], () => modal.destroy());
}
const stringifyError = error => {

View file

@ -1,4 +1,4 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import * as React from "react";
import {useState} from "react";
@ -267,7 +267,7 @@ const TabSelector = (props: { events: Registry<PermissionModalEvents> }) => {
};
export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number };
class PermissionEditorModal extends Modal {
class PermissionEditorModal extends InternalModal {
readonly modalEvents = new Registry<PermissionModalEvents>();
readonly editorEvents = new Registry<PermissionEditorEvents>();
@ -294,7 +294,6 @@ class PermissionEditorModal extends Modal {
}
protected onInitialize() {
this.modalController().events.on("destroy", () => this.modalEvents.fire("notify_destroy"));
this.modalEvents.fire("query_client_permissions");
this.modalEvents.fire("action_activate_tab", {
tab: this.defaultTab,
@ -304,6 +303,11 @@ class PermissionEditorModal extends Modal {
});
}
protected onDestroy() {
this.modalEvents.fire("notify_destroy");
this.modalEvents.destroy();
}
renderBody() {
return (
<div className={cssStyle.container}>

View file

@ -1,4 +1,4 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import * as React from "react";
import {FileType} from "tc-shared/file/FileManager";
import {Registry} from "tc-shared/events";
@ -179,7 +179,7 @@ export interface FileBrowserEvents {
}
class FileTransferModal extends Modal {
class FileTransferModal extends InternalModal {
readonly remoteBrowseEvents = new Registry<FileBrowserEvents>();
readonly transferInfoEvents = new Registry<TransferInfoEvents>();

View file

@ -1,62 +1,69 @@
import * as React from "react";
import {ClientAvatar} from "tc-shared/file/Avatars";
import {ClientAvatar, kDefaultAvatarImage, kLoadingAvatarImage} from "tc-shared/file/Avatars";
import {useState} from "react";
import * as image_preview from "tc-shared/ui/frames/image_preview";
const ImageStyle = { height: "100%", width: "100%", cursor: "pointer" };
export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar, className?: string, alt?: string }) => {
export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar | "loading" | "default", className?: string, alt?: string }) => {
let [ revision, setRevision ] = useState(0);
let image;
switch (props.avatar?.getState()) {
case "unset":
image = <img
key={"default"}
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;
if(props.avatar === "loading") {
image = <img src={kLoadingAvatarImage} />;
} else if(props.avatar === "default") {
image = <img src={kDefaultAvatarImage} />;
} else {
const imageUrl = props.avatar.getAvatarUrl();
switch (props.avatar.getState()) {
case "unset":
image = <img
key={"default"}
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();
image_preview.preview_image(props.avatar.getAvatarUrl(), undefined);
}}
/>;
break;
event.preventDefault();
image_preview.preview_image(imageUrl, undefined);
}}
/>;
break;
case "loaded":
image = <img
key={"user-" + props.avatar.getAvatarHash()}
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
title={tr("user avatar")}
src={props.avatar.getAvatarUrl()}
style={ImageStyle}
onClick={event => {
if(event.isDefaultPrevented())
return;
case "loaded":
image = <img
key={"user-" + props.avatar.getAvatarHash()}
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
title={tr("user avatar")}
src={imageUrl}
style={ImageStyle}
onClick={event => {
if(event.isDefaultPrevented())
return;
event.preventDefault();
image_preview.preview_image(props.avatar.getAvatarUrl(), undefined);
}}
/>;
break;
event.preventDefault();
image_preview.preview_image(imageUrl, undefined);
}}
/>;
break;
case "errored":
image = <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} />;
break;
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={imageUrl} style={ImageStyle} />;
break;
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} />;
break;
case "loading":
image = <img key={"loading"} alt={typeof props.alt === "string" ? props.alt : tr("loading")} title={tr("loading avatar")} src={kLoadingAvatarImage} style={ImageStyle} />;
break;
case undefined:
break;
case undefined:
break;
}
props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
}
props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
return (
<div className={props.className} style={{ overflow: "hidden" }}>
{image}

View file

@ -9,10 +9,10 @@ export const LoadingDots = (props: { maxDots?: number, speed?: number, textOnly?
const [dots, setDots] = useState(0);
useEffect(() => {
if(!props.enabled)
if(typeof props.enabled === "boolean" && !props.enabled)
return;
const timeout = setTimeout(() => setDots(dots + 1), speed || 500);
const timeout = setTimeout(() => setDots(dots + 1), typeof speed === "number" ? speed : 500);
return () => clearTimeout(timeout);
});

View file

@ -8,6 +8,10 @@ const cssStyle = require("./Modal.scss");
export type ModalType = "error" | "warning" | "info" | "none";
export interface ModalOptions {
destroyOnClose?: boolean;
}
export interface ModalEvents {
"open": {},
"close": {},
@ -22,29 +26,67 @@ export enum ModalState {
DESTROYED
}
export class ModalController<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 modalInstance: InstanceType;
private initializedPromise: Promise<void>;
private domElement: Element;
private refModal: React.RefObject<ModalImpl>;
private refModal: React.RefObject<InternalModalRenderer>;
private modalState_: ModalState = ModalState.HIDDEN;
constructor(instance: InstanceType) {
this.modalInstance = instance;
instance["__modal_controller"] = this;
this.events = new Registry<ModalEvents>();
this.initialize();
}
getOptions(): Readonly<ModalOptions> {
/* FIXME! */
return {};
}
getEvents(): Registry<ModalEvents> {
return this.events;
}
getState() {
return this.modalState_;
}
private initialize() {
this.refModal = React.createRef();
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);
this.initializedPromise = new Promise<void>(resolve => {
ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0));
@ -53,11 +95,7 @@ export class ModalController<InstanceType extends Modal = Modal> {
this.modalInstance["onInitialize"]();
}
modalState() {
return this.modalState_;
}
async show() {
async show() : Promise<void> {
await this.initializedPromise;
if(this.modalState_ === ModalState.DESTROYED)
throw tr("modal has been destroyed");
@ -101,33 +139,9 @@ export class ModalController<InstanceType extends Modal = Modal> {
}
}
export abstract class AbstractModal {
protected constructor() {}
export abstract class InternalModal extends AbstractModal { }
abstract renderBody() : ReactElement;
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 }> {
class InternalModalRenderer extends React.PureComponent<{ controller: InternalModalController }, { show: boolean }> {
private readonly refModal = React.createRef<HTMLDivElement>();
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 Modal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController<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 Modal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController<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 Modal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : ModalController<ModalClass> {
return new ModalController(new modalClass(...args));
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new () => ModalClass) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : InternalModalController<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 InternalModal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : InternalModalController<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 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>;
export function spawnReactModal<ModalClass extends InternalModal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : InternalModalController<ModalClass> {
return new InternalModalController(new modalClass(...args));
}

View file

@ -1,5 +1,7 @@
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import * as ipc from "tc-shared/ipc/BrowserIPC";
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {ChannelMessage} from "tc-shared/ipc/BrowserIPC";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {Registry} from "tc-shared/events";
import {
@ -7,20 +9,28 @@ import {
Popout2ControllerMessages,
PopoutIPCMessage
} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
import {ModalController, ModalEvents, ModalOptions, ModalState} from "tc-shared/ui/react-elements/Modal";
export class ExternalModalController extends EventControllerBase<"controller"> {
readonly modal: string;
readonly userData: any;
export class ExternalModalController extends EventControllerBase<"controller"> implements ModalController {
public readonly modalType: string;
public readonly userData: any;
private modalState: ModalState = ModalState.DESTROYED;
private readonly modalEvents: Registry<ModalEvents>;
private currentWindow: Window;
private callbackWindowInitialized: (error?: string) => void;
private documentQuitListener: () => void;
private readonly documentQuitListener: () => void;
private windowClosedTestInterval: number = 0;
private windowClosedTimeout: number;
constructor(modal: string, localEventRegistry: Registry<any>, userData: any) {
super(localEventRegistry);
this.modal = modal;
this.modalEvents = new Registry<ModalEvents>();
this.modalType = modal;
this.userData = userData;
this.ipcChannel = ipc.getInstance().createChannel();
@ -29,11 +39,23 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
this.documentQuitListener = () => this.currentWindow?.close();
}
private trySpawnWindow() {
getOptions(): Readonly<ModalOptions> {
return {}; /* FIXME! */
}
getEvents(): Registry<ModalEvents> {
return this.modalEvents;
}
getState(): ModalState {
return this.modalState;
}
private trySpawnWindow() : Window | null {
const parameters = {
"loader-target": "manifest",
"chunk": "modal-external",
"modal-target": this.modal,
"modal-target": this.modalType,
"ipc-channel": this.ipcChannel.channelId,
"ipc-address": ipc.getInstance().getLocalAddress(),
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
@ -54,16 +76,21 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
let baseUrl = location.origin + location.pathname + "?";
return window.open(
baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"),
"External Modal",
this.modalType,
Object.keys(features).map(e => e + "=" + features[e]).join(",")
);
}
async open() {
async show() {
if(this.currentWindow) {
this.currentWindow.focus();
return;
}
this.currentWindow = this.trySpawnWindow();
if(!this.currentWindow) {
await new Promise((resolve, reject) => {
spawnYesNo(tr("Would you like to open the popup?"), tra("Would you like to open popup {}?", this.modal), callback => {
spawnYesNo(tr("Would you like to open the popup?"), tra("Would you like to open popup {}?", this.modalType), callback => {
if(!callback) {
reject("user aborted");
return;
@ -71,47 +98,111 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
this.currentWindow = this.trySpawnWindow();
if(this.currentWindow) {
reject("Failed to spawn window");
reject(tr("Failed to spawn window"));
} else {
resolve();
}
}).close_listener.push(() => reject("user aborted"));
}).close_listener.push(() => reject(tr("user aborted")));
})
}
if(!this.currentWindow) {
/* some shitty popup blocker or whatever */
throw "failed to create window";
throw tr("failed to create window");
}
window.addEventListener("unload", this.documentQuitListener);
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.callbackWindowInitialized = undefined;
reject("window haven't called back");
}, 5000);
this.callbackWindowInitialized = error => {
this.callbackWindowInitialized = undefined;
clearTimeout(timeout);
error ? reject(error) : resolve();
};
});
} catch (e) {
this.currentWindow?.close();
this.currentWindow = undefined;
throw e;
}
this.currentWindow.onclose = () => {
/* TODO: General handle */
window.removeEventListener("beforeunload", this.documentQuitListener);
this.currentWindow.onbeforeunload = () => {
clearInterval(this.windowClosedTestInterval);
this.windowClosedTimeout = Date.now() + 5000;
this.windowClosedTestInterval = setInterval(() => {
if(!this.currentWindow) {
clearInterval(this.windowClosedTestInterval);
this.windowClosedTestInterval = 0;
return;
}
if(this.currentWindow.closed || Date.now() > this.windowClosedTimeout) {
window.removeEventListener("unload", this.documentQuitListener);
this.currentWindow = undefined;
this.destroy(); /* TODO: Test if we should do this */
}
}, 100);
};
window.addEventListener("beforeunload", this.documentQuitListener);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.callbackWindowInitialized = undefined;
reject("window haven't called back");
}, 5000);
this.modalState = ModalState.SHOWN;
this.modalEvents.fire("open");
}
this.callbackWindowInitialized = error => {
this.callbackWindowInitialized = undefined;
clearTimeout(timeout);
error ? reject(error) : resolve();
};
});
private destroyPopUp() {
if(this.currentWindow) {
clearInterval(this.windowClosedTestInterval);
this.windowClosedTestInterval = 0;
window.removeEventListener("beforeunload", this.documentQuitListener);
this.currentWindow.close();
this.currentWindow = undefined;
}
}
async hide() {
if(this.modalState == ModalState.DESTROYED || this.modalState === ModalState.HIDDEN)
return;
this.destroyPopUp();
this.modalState = ModalState.HIDDEN;
this.modalEvents.fire("close");
}
destroy() {
if(this.modalState === ModalState.DESTROYED)
return;
this.destroyPopUp();
if(this.ipcChannel)
ipc.getInstance().deleteChannel(this.ipcChannel);
this.destroyIPC();
this.modalState = ModalState.DESTROYED;
this.modalEvents.fire("destroy");
}
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
if(broadcast)
return;
if(this.ipcRemoteId !== remoteId) {
if(this.ipcRemoteId === undefined) {
log.debug(LogCategory.IPC, tr("Remote window connected with id %s"), remoteId);
this.ipcRemoteId = remoteId;
} else if(this.ipcRemoteId !== remoteId) {
console.warn("Remote window got a new id. Maybe reload?");
if(this.windowClosedTestInterval > 0) {
clearInterval(this.windowClosedTestInterval);
this.windowClosedTestInterval = 0;
log.debug(LogCategory.IPC, tr("Remote window got reconnected. Client reloaded it."));
} else {
log.warn(LogCategory.IPC, tr("Remote window got a new id. Maybe a reload?"));
}
this.ipcRemoteId = remoteId;
}
@ -124,7 +215,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
switch (type) {
case "hello-popout": {
const tpayload = payload as PopoutIPCMessage["hello-popout"];
console.log("Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version);
log.trace(LogCategory.IPC, "Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version);
if(tpayload.version !== __build.version) {
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
if(this.callbackWindowInitialized) {
@ -149,7 +240,7 @@ export class ExternalModalController extends EventControllerBase<"controller"> {
break;
default:
console.warn("Received unknown message type from popup window: %s", type);
log.warn(LogCategory.IPC, "Received unknown message type from popup window: %s", type);
return;
}
}

View file

@ -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 = {};
}
}

View file

@ -4,7 +4,6 @@ import * as loader from "tc-loader";
import * as ipc from "../../../ipc/BrowserIPC";
import * as i18n from "../../../i18n/localize";
import "tc-shared/file/RemoteAvatars";
import "tc-shared/proto";
import {Stage} from "tc-loader";

View file

@ -1,7 +1,6 @@
import {Registry} from "tc-shared/events";
import {ExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
export function spawnExternalModal<EventClass>(modal: string, events: Registry<EventClass>, userData: any) : ExternalModalController {
return new ExternalModalController(modal, events as any, userData);
}

View file

@ -3,7 +3,7 @@ import {parseMessageWithArguments} from "tc-shared/ui/frames/chat";
import {cloneElement} from "react";
let instances = [];
export class Translatable extends React.Component<{ children: string, __cacheKey?: string, trIgnore?: boolean }, { translated: string }> {
export class Translatable extends React.Component<{ children: string, __cacheKey?: string, trIgnore?: boolean, enforceTextOnly?: boolean }, { translated: string }> {
constructor(props) {
super(props);

View 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();
};

View file

@ -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();
}

View file

@ -1,6 +1,95 @@
export interface VideoViewerEvents {
"notify_show": {},
interface PlayerStatusPlaying {
status: "playing";
"notify_value": { value: number },
"notify_data_url": { url: string }
timestampPlay: number;
timestampBuffer: number;
}
interface PlayerStatusBuffering {
status: "buffering";
}
interface PlayerStatusStopped {
status: "stopped";
}
interface PlayerStatusPaused {
status: "paused";
}
export type PlayerStatus = PlayerStatusPlaying | PlayerStatusBuffering | PlayerStatusStopped | PlayerStatusPaused;
export interface VideoViewerEvents {
"action_toggle_side_bar": { shown: boolean },
"action_follow": { watcherId: string | undefined },
/* will trigger notify_watcher_info */
"query_watcher_info": {
watcherId: string
},
/* will trigger notify_watcher_status */
"query_watcher_status": {
watcherId: string
},
"query_followers": {
watcherId: string
},
"query_watchers": {},
"query_video": {},
"notify_show": {},
"notify_destroy": {},
"notify_watcher_list": {
watcherIds: string[],
followingWatcher: string | undefined
},
"notify_watcher_status": {
watcherId: string,
status: PlayerStatus
}
"notify_watcher_info": {
watcherId: string,
clientId: number,
clientUniqueId: string,
clientName: string,
isOwnClient: boolean
}
"notify_follower_list": {
watcherId: string,
followerIds: string[]
},
"notify_follower_added": {
watcherId: string,
followerId: string
},
"notify_follower_removed": {
watcherId: string,
followerId: string
},
"notify_following": {
watcherId: string | undefined
},
"notify_following_status": {
status: PlayerStatus
}
"notify_local_status": {
status: PlayerStatus
},
"notify_video": { url: string }
}

View file

@ -1,3 +1,7 @@
@import "../../css/static/mixin";
@import "../../css/static/properties";
$sidebar-width: 20em;
.container {
background: #19191b;
@ -10,6 +14,279 @@
min-height: 10em;
min-width: 20em;
padding-left: 4em;
}
.containerPlayer {
flex-grow: 1;
flex-shrink: 1;
min-height: 100px;
min-width: 100px;
display: flex;
flex-direction: row;
justify-content: stretch;
}
.sidebarButton {
z-index: 10000;
position: absolute;
cursor: pointer;
top: .5em;
right: .5em;
width: 2em;
height: 2em;
border-radius: 2px;
background-color: #373737;
margin-right: 0;
opacity: 1;
@include transition(background-color $button_hover_animation_time ease-in-out, margin-right .25s ease-in-out, opacity .25s ease-in-out);
&:hover {
background-color: #4e4e4e;
}
&.hidden {
margin-right: $sidebar-width;
opacity: 0;
}
svg {
height: 100%;
width: 100%;
}
}
.containerSidebar {
z-index: 10001;
position: absolute;
display: flex;
flex-direction: column;
justify-content: stretch;
background-color: #373737;
right: -$sidebar-width;
top: 0;
bottom: 0;
padding: 1em;
padding-top: 0;
width: $sidebar-width;
@include transition(right .25s ease-in-out);
&.shown {
right: 0;
}
.buttonClose {
font-size: 4em;
cursor: pointer;
position: absolute;
right: 0;
top: 0;
bottom: 0;
opacity: 0.3;
width: .5em;
height: .5em;
margin-right: .1em;
margin-top: .1em;
&:hover {
opacity: 1;
}
@include transition(opacity $button_hover_animation_time ease-in-out);
&:before, &:after {
position: absolute;
left: .25em;
content: ' ';
height: .5em;
width: .05em;
background-color: #666666;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
.header {
height: 3em;
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-bottom: 0.5em;
a {
flex-grow: 1;
flex-shrink: 1;
align-self: flex-end;
font-weight: bold;
color: #e0e0e0;
@include text-dotdotdot();
}
}
.buttons {
flex-grow: 0;
flex-shrink: 0;
margin-top: .5em;
display: flex;
flex-direction: row;
justify-content: space-between;
> :not(:last-of-type) {
margin-right: 1em;
}
}
}
.watcherList {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 6em;
background-color: #28292b;
border: 1px #161616 solid;
border-radius: .2em;
overflow-x: hidden;
overflow-y: auto;
@include chat-scrollbar-vertical();
.watcher {
display: flex;
flex-direction: column;
justify-content: flex-start;
&:first-child {
border-top-right-radius: .2em;
border-top-left-radius: .2em;
}
&:last-child {
border-bottom-right-radius: .2em;
border-bottom-left-radius: .2em;
}
.info {
display: flex;
flex-direction: row;
justify-content: stretch;
border-bottom: 1px solid #313132;
cursor: pointer;
@include transition($button_hover_animation_time ease-in-out);
&:hover {
background-color: hsla(216, 4%, 23%, 1);
}
&.following {
background-color: #28292b;
}
&.ownClient {
background-color: #0d260e;
}
.containerAvatar {
flex-grow: 0;
flex-shrink: 0;
position: relative;
display: inline-block;
margin: 5px 10px 5px 5px;
.avatar {
overflow: hidden;
width: 2em;
height: 2em;
border-radius: 50%;
}
}
.containerDetail {
flex-grow: 1;
flex-shrink: 1;
min-width: 50px;
display: flex;
flex-direction: column;
justify-content: center;
> * {
flex-grow: 0;
flex-shrink: 0;
display: inline-block;
width: 100%;
@include text-dotdotdot();
}
.username {
color: #CCCCCC;
font-weight: bold;
margin-bottom: -.4em;
}
.status {
color: #555353;
display: inline-block;
font-size: .66em;
}
}
&.watcher {}
&.follower {
padding-left: 1em;
}
}
.followerList {
.follower {
border-bottom: 1px solid #313132;
}
}
}
}

View file

@ -1,64 +1,497 @@
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {AbstractModal} from "tc-shared/ui/react-elements/Modal";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {Registry} from "tc-shared/events";
import {VideoViewerEvents} from "./Definitions";
import {PlayerStatus, VideoViewerEvents} from "./Definitions";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import ReactPlayer from 'react-player'
import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer";
import {Button} from "tc-shared/ui/react-elements/Button";
import "tc-shared/file/RemoteAvatars";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars";
import {Settings, settings} from "tc-shared/settings";
const iconNavbar = require("./icon-navbar.svg");
const cssStyle = require("./Renderer.scss");
const kLogPlayerEvents = true;
const PlaytimeRenderer = React.memo((props: { time: number }) => {
const [ revision, setRevision ] = useState(0);
useEffect(() => {
const id = setTimeout(() => setRevision(revision + 1), 950);
return () => clearTimeout(id);
});
let seconds = Math.floor((Date.now() - props.time) / 1000);
let hours = Math.floor(seconds / 3600);
seconds %= 3600;
let minutes = Math.floor(seconds / 60);
seconds %= 60;
let time = ("0" + hours).substr(-2) + ":" + ("0" + minutes).substr(-2) + ":" + ("0" + seconds).substr(-2);
return <>{time}</>;
});
const PlayerStatusRenderer = (props: { status: PlayerStatus | undefined, timestamp: number }) => {
switch (props.status?.status) {
case "paused":
return (<React.Fragment key={"paused"}>
<Translatable>Replay paused</Translatable>
</React.Fragment>);
case "buffering":
return (<React.Fragment key={"buffering"}>
<Translatable>Buffering</Translatable>&nbsp;
<LoadingDots />
</React.Fragment>);
case "stopped":
return (<React.Fragment key={"stopped"}>
<Translatable>Video ended</Translatable>&nbsp;
<LoadingDots />
</React.Fragment>);
case "playing":
return (<React.Fragment key={"playing"}>
<Translatable>Playing</Translatable>&nbsp;
{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>&nbsp;
<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>&nbsp;
<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 {
readonly events: Registry<VideoViewerEvents>;
readonly handlerId: string;
constructor(registry: Registry<VideoViewerEvents>, userData: any) {
super();
this.handlerId = userData.handlerId;
this.events = registry;
this.events.on("notify_show", () => {
console.log("Showed!");
});
this.events.on("notify_data_url", async event => {
console.log(event.url);
console.log(await (await fetch(event.url)).text());
});
}
title(): string | React.ReactElement<Translatable> {
return <>Hello World <LoadingDots textOnly={true} /></>;
return <TitleRenderer events={this.events} />;
}
renderBody(): React.ReactElement {
return <div className={cssStyle.container} >
<ReactPlayer
url={"https://www.youtube.com/watch?v=u_TuibFg-GA"}
height={"100%"}
width={"100%"}
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}
/>
<Sidebar events={this.events} handlerId={this.handlerId} />
<ToggleSidebarButton events={this.events} />
<div className={cssStyle.containerPlayer}>
<PlayerController events={this.events} />
</div>
</div>;
}
}
export = ModalVideoPopout;
console.error("Hello World from video popout");

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

View 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

View file

@ -24,7 +24,8 @@ import {Device} from "tc-shared/audio/player";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
const kAvoidAudioContextWarning = true;
/* lets try without any gestures, maybe the user already clicked the page */
const kAvoidAudioContextWarning = false;
let audioContextRequiredGesture = false;
let audioContextInstance: AudioContext;

35
web/css/static/main.css Normal file
View 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 */

View 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"}