Adding the new video spotlight mode
parent
ea79d9d6a4
commit
4394d36383
|
@ -1,4 +1,8 @@
|
||||||
# Changelog:
|
# Changelog:
|
||||||
|
* **12.03.21**
|
||||||
|
- Added a new video spotlight mode which allows showing multiple videos at the same time as well as
|
||||||
|
dragging and resizing them
|
||||||
|
|
||||||
* **20.02.21**
|
* **20.02.21**
|
||||||
- Improved the browser IPC module
|
- Improved the browser IPC module
|
||||||
- Added support for client invite links
|
- Added support for client invite links
|
||||||
|
|
|
@ -1529,6 +1529,14 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/react-grid-layout": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bvPkITzwGGOZKjp01nVSgPrdfGm/uTa5t8Odd8vQRXJsLj7uZLZXSXgWr+TiXBAkUsmHPxhsyswXQCiFeDuZnQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/react-transition-group": {
|
"@types/react-transition-group": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
|
||||||
|
@ -4111,6 +4119,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"classnames": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
|
||||||
|
},
|
||||||
"clean-css": {
|
"clean-css": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
|
||||||
|
@ -9138,6 +9151,11 @@
|
||||||
"integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=",
|
"integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
|
||||||
|
},
|
||||||
"lodash.memoize": {
|
"lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
|
@ -11456,11 +11474,32 @@
|
||||||
"scheduler": "^0.19.1"
|
"scheduler": "^0.19.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-draggable": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==",
|
||||||
|
"requires": {
|
||||||
|
"classnames": "^2.2.5",
|
||||||
|
"prop-types": "^15.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-fast-compare": {
|
"react-fast-compare": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
|
||||||
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||||
},
|
},
|
||||||
|
"react-grid-layout": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-i5/xPkyi0llA6PCEW2B26e7pTY+JLp0zPvs6MYbbEXWaO1FWG0xr6Q/FqPrh6K86glV5ZRU7DEbvnedZVdFglg==",
|
||||||
|
"requires": {
|
||||||
|
"classnames": "2.x",
|
||||||
|
"lodash.isequal": "^4.0.0",
|
||||||
|
"prop-types": "^15.0.0",
|
||||||
|
"react-draggable": "^4.0.0",
|
||||||
|
"react-resizable": "^1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-is": {
|
"react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
@ -11478,6 +11517,15 @@
|
||||||
"react-fast-compare": "^3.0.1"
|
"react-fast-compare": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-resizable": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-S70gbLaAYqjuAd49utRHibtHLrHXInh7GuOR+6OO6RO6uleQfuBnWmZjRABfqNEx3C3Z6VPLg0/0uOYFrkfu9Q==",
|
||||||
|
"requires": {
|
||||||
|
"prop-types": "15.x",
|
||||||
|
"react-draggable": "^4.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-transition-group": {
|
"react-transition-group": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
||||||
|
|
|
@ -91,6 +91,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://www.teaspeak.de",
|
"homepage": "https://www.teaspeak.de",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-grid-layout": "^1.1.1",
|
||||||
"@types/react-transition-group": "^4.4.0",
|
"@types/react-transition-group": "^4.4.0",
|
||||||
"broadcastchannel-polyfill": "^1.0.1",
|
"broadcastchannel-polyfill": "^1.0.1",
|
||||||
"detect-browser": "^5.2.0",
|
"detect-browser": "^5.2.0",
|
||||||
|
@ -104,6 +105,7 @@
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
"react-grid-layout": "^1.2.2",
|
||||||
"react-player": "^2.5.0",
|
"react-player": "^2.5.0",
|
||||||
"react-transition-group": "^4.4.1",
|
"react-transition-group": "^4.4.1",
|
||||||
"remarkable": "^2.0.1",
|
"remarkable": "^2.0.1",
|
||||||
|
|
|
@ -19,7 +19,19 @@ declare global {
|
||||||
last?(): T;
|
last?(): T;
|
||||||
|
|
||||||
pop_front(): T | undefined;
|
pop_front(): T | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param entry The entry to toggle
|
||||||
|
* @returns `true` if the entry has been inserted and false if the entry has been deleted
|
||||||
|
*/
|
||||||
toggle(entry: T) : boolean;
|
toggle(entry: T) : boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param entry The entry to toggle
|
||||||
|
* @param insert Whatever the entry should be in the array or not
|
||||||
|
* @returns `true` if the array has been modified
|
||||||
|
*/
|
||||||
|
toggle(entry: T, insert: boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JSON {
|
interface JSON {
|
||||||
|
@ -173,14 +185,16 @@ if (!Array.prototype.pop_front) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.prototype.toggle) {
|
if (!Array.prototype.toggle) {
|
||||||
Array.prototype.toggle = function<T>(element: T): boolean {
|
Array.prototype.toggle = function<T>(element: T, insert?: boolean): boolean {
|
||||||
const index = this.findIndex(e => e === element);
|
const index = this.findIndex(e => e === element);
|
||||||
if(index === -1) {
|
if((index !== -1) === insert) {
|
||||||
|
return false;
|
||||||
|
} else if(index === -1) {
|
||||||
this.push(element);
|
this.push(element);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
this.splice(index, 1);
|
this.splice(index, 1);
|
||||||
return false;
|
return typeof insert === "boolean";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -758,6 +758,13 @@ export class Settings {
|
||||||
valueType: "boolean",
|
valueType: "boolean",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly KEY_VIDEO_SPOTLIGHT_MODE: ValuedRegistryKey<number> = {
|
||||||
|
key: "video_spotlight_mode",
|
||||||
|
defaultValue: 1,
|
||||||
|
description: "Select the video spotlight mode.\n0: Single video\n1: Video grid",
|
||||||
|
valueType: "number",
|
||||||
|
};
|
||||||
|
|
||||||
static readonly KEY_INVITE_SHORT_URL: ValuedRegistryKey<boolean> = {
|
static readonly KEY_INVITE_SHORT_URL: ValuedRegistryKey<boolean> = {
|
||||||
key: "invite_short_url",
|
key: "invite_short_url",
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
|
|
@ -21,39 +21,6 @@ import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
|
||||||
|
|
||||||
const cssStyle = require("./AppRenderer.scss");
|
const cssStyle = require("./AppRenderer.scss");
|
||||||
|
|
||||||
/*
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="app">
|
|
||||||
<!-- navigation bar -->
|
|
||||||
<div class="container-control-bar">
|
|
||||||
<div id="control_bar" class="control_bar">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container-connection-handlers" id="connection-handler-list"></div>
|
|
||||||
<div class="container-app-main">
|
|
||||||
<div class="container-channel-video" id="channel-video"></div>
|
|
||||||
<div class="container-channel-chat">
|
|
||||||
<!-- Channel tree -->
|
|
||||||
<div class="container-channel-tree">
|
|
||||||
<div class="hostbanner" id="hostbanner"></div>
|
|
||||||
<div class="channel-tree" id="channelTree"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container-seperator vertical" seperator-id="seperator-channel-chat"></div>
|
|
||||||
<!-- Chat window -->
|
|
||||||
<div class="container-chat" id="chat"></div>
|
|
||||||
</div>
|
|
||||||
<div class="container-seperator horizontal" seperator-id="seperator-main-log"></div>
|
|
||||||
<div class="container-bottom">
|
|
||||||
<div class="container-server-log" id="server-log"></div>
|
|
||||||
<div class="container-footer" id="container-footer">
|
|
||||||
</div>
|
|
||||||
</div> <!-- Selection info -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
*/
|
|
||||||
|
|
||||||
const VideoFrame = React.memo((props: { events: Registry<AppUiEvents> }) => {
|
const VideoFrame = React.memo((props: { events: Registry<AppUiEvents> }) => {
|
||||||
const refElement = React.useRef<HTMLDivElement>();
|
const refElement = React.useRef<HTMLDivElement>();
|
||||||
const [ container, setContainer ] = useState<HTMLDivElement | undefined>(() => {
|
const [ container, setContainer ] = useState<HTMLDivElement | undefined>(() => {
|
||||||
|
|
|
@ -354,7 +354,7 @@ class ChannelVideoController {
|
||||||
private localVideoController: LocalVideoController;
|
private localVideoController: LocalVideoController;
|
||||||
private clientVideos: {[key: number]: RemoteClientVideoController} = {};
|
private clientVideos: {[key: number]: RemoteClientVideoController} = {};
|
||||||
|
|
||||||
private currentSpotlight: string;
|
private currentSpotlights: string[];
|
||||||
|
|
||||||
constructor(events: Registry<ChannelVideoEvents>, connection: ConnectionHandler) {
|
constructor(events: Registry<ChannelVideoEvents>, connection: ConnectionHandler) {
|
||||||
this.events = events;
|
this.events = events;
|
||||||
|
@ -366,6 +366,8 @@ class ChannelVideoController {
|
||||||
this.localVideoController = new LocalVideoController(connection.getClient(), this.events);
|
this.localVideoController = new LocalVideoController(connection.getClient(), this.events);
|
||||||
this.localVideoController.callbackBroadcastStateChanged = () => this.notifyVideoList();
|
this.localVideoController.callbackBroadcastStateChanged = () => this.notifyVideoList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.currentSpotlights = [];
|
||||||
this.currentlyVisible = false;
|
this.currentlyVisible = false;
|
||||||
this.expended = false;
|
this.expended = false;
|
||||||
}
|
}
|
||||||
|
@ -395,8 +397,8 @@ class ChannelVideoController {
|
||||||
this.events.fire_react("notify_expended", { expended: this.expended });
|
this.events.fire_react("notify_expended", { expended: this.expended });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.events.on("action_set_spotlight", event => {
|
this.events.on("action_toggle_spotlight", event => {
|
||||||
this.setSpotlight(event.videoId);
|
this.toggleSpotlight(event.videoIds, event.enabled);
|
||||||
if(!this.isExpended()) {
|
if(!this.isExpended()) {
|
||||||
this.events.fire("action_toggle_expended", { expended: true });
|
this.events.fire("action_toggle_expended", { expended: true });
|
||||||
}
|
}
|
||||||
|
@ -554,13 +556,13 @@ class ChannelVideoController {
|
||||||
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList()));
|
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpotlight(videoId: string | undefined) {
|
toggleSpotlight(videoId: string[], enabled: boolean) {
|
||||||
if(this.currentSpotlight === videoId) { return; }
|
const updated = videoId.map(entry => this.currentSpotlights.toggle(entry, enabled)).find(updated => updated);
|
||||||
|
if(!updated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* TODO: test if the video event exists? */
|
this.notifySpotlight();
|
||||||
|
|
||||||
this.currentSpotlight = videoId;
|
|
||||||
this.notifySpotlight()
|
|
||||||
this.notifyVideoList();
|
this.notifyVideoList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -597,7 +599,7 @@ class ChannelVideoController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetClientVideos() {
|
private resetClientVideos() {
|
||||||
this.currentSpotlight = undefined;
|
this.currentSpotlights = [];
|
||||||
for(const clientId of Object.keys(this.clientVideos)) {
|
for(const clientId of Object.keys(this.clientVideos)) {
|
||||||
this.destroyClientVideo(parseInt(clientId));
|
this.destroyClientVideo(parseInt(clientId));
|
||||||
}
|
}
|
||||||
|
@ -614,10 +616,7 @@ class ChannelVideoController {
|
||||||
video.destroy();
|
video.destroy();
|
||||||
delete this.clientVideos[clientId];
|
delete this.clientVideos[clientId];
|
||||||
|
|
||||||
if(video.videoId === this.currentSpotlight) {
|
this.toggleSpotlight([ video.videoId ], false);
|
||||||
this.currentSpotlight = undefined;
|
|
||||||
this.notifySpotlight();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
|
@ -635,7 +634,7 @@ class ChannelVideoController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifySpotlight() {
|
private notifySpotlight() {
|
||||||
this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlight });
|
this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlights });
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyVideoList() {
|
private notifyVideoList() {
|
||||||
|
@ -677,7 +676,7 @@ class ChannelVideoController {
|
||||||
|
|
||||||
this.updateVisibility(videoStreamingCount !== 0);
|
this.updateVisibility(videoStreamingCount !== 0);
|
||||||
if(this.expended) {
|
if(this.expended) {
|
||||||
videoIds.remove(this.currentSpotlight);
|
this.currentSpotlights.forEach(entry => videoIds.remove(entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.fire_react("notify_videos", {
|
this.events.fire_react("notify_videos", {
|
||||||
|
|
|
@ -69,7 +69,11 @@ export type LocalVideoState = "muted" | "unset" | "empty";
|
||||||
export interface ChannelVideoEvents {
|
export interface ChannelVideoEvents {
|
||||||
action_toggle_expended: { expended: boolean },
|
action_toggle_expended: { expended: boolean },
|
||||||
action_video_scroll: { direction: "left" | "right" },
|
action_video_scroll: { direction: "left" | "right" },
|
||||||
action_set_spotlight: { videoId: string | undefined, expend: boolean },
|
action_toggle_spotlight: {
|
||||||
|
videoIds: string[],
|
||||||
|
enabled: boolean,
|
||||||
|
expend: boolean
|
||||||
|
},
|
||||||
action_focus_spotlight: {},
|
action_focus_spotlight: {},
|
||||||
action_set_fullscreen: { videoId: string | undefined },
|
action_set_fullscreen: { videoId: string | undefined },
|
||||||
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
|
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
|
||||||
|
@ -108,7 +112,7 @@ export interface ChannelVideoEvents {
|
||||||
right: boolean
|
right: boolean
|
||||||
},
|
},
|
||||||
notify_spotlight: {
|
notify_spotlight: {
|
||||||
videoId: string | undefined
|
videoId: string[]
|
||||||
},
|
},
|
||||||
notify_video_statistics: {
|
notify_video_statistics: {
|
||||||
videoId: string | undefined,
|
videoId: string | undefined,
|
||||||
|
|
|
@ -24,12 +24,22 @@ $small_height: 10em;
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
height: 0;
|
height: 0;
|
||||||
|
transition: none; /* else the whole spotlight will be triggered N times */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heightProvider {
|
||||||
|
height: 100%; /* the footer size (version etc) */
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.expended {
|
&.expended {
|
||||||
.panel {
|
.panel {
|
||||||
height: calc(100% - 1.5em); /* the footer size (version etc) */
|
height: 100%; /* the footer size (version etc) */
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
}
|
}
|
||||||
|
@ -62,6 +72,24 @@ $small_height: 10em;
|
||||||
@include transition(all .3s ease-in-out);
|
@include transition(all .3s ease-in-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heightProvider {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
height: $small_height;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.expendArrow {
|
.expendArrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
|
@ -122,8 +150,15 @@ $small_height: 10em;
|
||||||
height: ($small_height - 1em);
|
height: ($small_height - 1em);
|
||||||
width: ($small_height * 16 / 9);
|
width: ($small_height * 16 / 9);
|
||||||
|
|
||||||
|
margin-top: .5em;
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
|
&:not(:last-of-type) {
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,11 +216,14 @@ $small_height: 10em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
min-height: 5em;
|
min-height: 5em;
|
||||||
min-width: 5em;
|
min-width: 5em;
|
||||||
|
|
||||||
margin-left: .5em;
|
margin-left: .5em;
|
||||||
margin-right: .5em;
|
margin-right: .5em;
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -198,20 +236,21 @@ $small_height: 10em;
|
||||||
max-width: 25% !important;
|
max-width: 25% !important;
|
||||||
max-height: 25%!important;
|
max-height: 25%!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.grid {
|
||||||
|
/* if we're in grid mode we don't need any margins (will already be applied via the grid itself) */
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoContainer {
|
.videoContainer, :global(.react-grid-item.react-grid-placeholder) {
|
||||||
|
/* Note: don't use margin here since it might */
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
margin-top: .5em;
|
|
||||||
margin-bottom: .5em;
|
|
||||||
|
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
background-color: #2e2e2e;
|
|
||||||
box-shadow: inset 0 0 5px #00000040;
|
|
||||||
|
|
||||||
border-radius: .2em;
|
border-radius: .2em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@ -219,8 +258,19 @@ $small_height: 10em;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:not(:last-of-type) {
|
&.outlined, &:global(.react-grid-item.react-grid-placeholder) {
|
||||||
margin-right: .5em;
|
background-color: #2e2e2e;
|
||||||
|
box-shadow: inset 0 0 5px #00000040;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:global(.react-grid-item.react-grid-placeholder) {
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global .react-resizable-handle {
|
||||||
|
&::after {
|
||||||
|
border-color: #66666666;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video {
|
.video {
|
||||||
|
|
|
@ -11,24 +11,29 @@ import {
|
||||||
makeVideoAutoplay,
|
makeVideoAutoplay,
|
||||||
VideoStreamState,
|
VideoStreamState,
|
||||||
VideoSubscribeInfo
|
VideoSubscribeInfo
|
||||||
} from "tc-shared/ui/frames/video/Definitions";
|
} from "./Definitions";
|
||||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||||
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
||||||
import ResizeObserver from "resize-observer-polyfill";
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
import {LogCategory, logWarn} from "tc-shared/log";
|
import {LogCategory, logTrace, logWarn} from "tc-shared/log";
|
||||||
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
||||||
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
||||||
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
|
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
|
||||||
import {useTr} from "tc-shared/ui/react-elements/Helper";
|
import {useTr} from "tc-shared/ui/react-elements/Helper";
|
||||||
|
import {Spotlight, SpotlightDimensions, SpotlightDimensionsContext} from "./RendererSpotlight";
|
||||||
|
import * as _ from "lodash";
|
||||||
|
|
||||||
|
|
||||||
const SubscribeContext = React.createContext<VideoSubscribeInfo>(undefined);
|
const SubscribeContext = React.createContext<VideoSubscribeInfo>(undefined);
|
||||||
const EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
|
const EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
|
||||||
const HandlerIdContext = React.createContext<string>(undefined);
|
const HandlerIdContext = React.createContext<string>(undefined);
|
||||||
|
|
||||||
|
export const RendererVideoEventContext = EventContext;
|
||||||
|
|
||||||
const cssStyle = require("./Renderer.scss");
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
const ExpendArrow = () => {
|
const ExpendArrow = React.memo(() => {
|
||||||
const events = useContext(EventContext);
|
const events = useContext(EventContext);
|
||||||
|
|
||||||
const [ expended, setExpended ] = useState(() => {
|
const [ expended, setExpended ] = useState(() => {
|
||||||
|
@ -43,7 +48,7 @@ const ExpendArrow = () => {
|
||||||
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
|
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
};
|
});
|
||||||
|
|
||||||
const VideoInfo = React.memo((props: { videoId: string }) => {
|
const VideoInfo = React.memo((props: { videoId: string }) => {
|
||||||
const events = useContext(EventContext);
|
const events = useContext(EventContext);
|
||||||
|
@ -368,7 +373,7 @@ const VideoControlButtons = React.memo((props: {
|
||||||
if(props.isSpotlight) {
|
if(props.isSpotlight) {
|
||||||
events.fire("action_set_fullscreen", { videoId: props.fullscreenMode === "set" ? undefined : props.videoId });
|
events.fire("action_set_fullscreen", { videoId: props.fullscreenMode === "set" ? undefined : props.videoId });
|
||||||
} else {
|
} else {
|
||||||
events.fire("action_set_spotlight", { videoId: props.videoId, expend: true });
|
events.fire("action_toggle_spotlight", { videoIds: [ props.videoId ], expend: true, enabled: true });
|
||||||
events.fire("action_focus_spotlight", { });
|
events.fire("action_focus_spotlight", { });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -380,7 +385,7 @@ const VideoControlButtons = React.memo((props: {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolean }) => {
|
export const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolean }) => {
|
||||||
const events = useContext(EventContext);
|
const events = useContext(EventContext);
|
||||||
const refContainer = useRef<HTMLDivElement>();
|
const refContainer = useRef<HTMLDivElement>();
|
||||||
const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype;
|
const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype;
|
||||||
|
@ -438,14 +443,14 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cssStyle.videoContainer}
|
className={cssStyle.videoContainer + " " + cssStyle.outlined}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
if(isFullscreen) {
|
if(isFullscreen) {
|
||||||
events.fire("action_set_fullscreen", { videoId: undefined });
|
events.fire("action_set_fullscreen", { videoId: undefined });
|
||||||
} else if(props.isSpotlight) {
|
} else if(props.isSpotlight) {
|
||||||
events.fire("action_set_fullscreen", { videoId: props.videoId });
|
events.fire("action_set_fullscreen", { videoId: props.videoId });
|
||||||
} else {
|
} else {
|
||||||
events.fire("action_set_spotlight", { videoId: props.videoId, expend: true });
|
events.fire("action_toggle_spotlight", { videoIds: [ props.videoId ], expend: true, enabled: true });
|
||||||
events.fire("action_focus_spotlight", { });
|
events.fire("action_focus_spotlight", { });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -479,7 +484,7 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
|
||||||
label: props.isSpotlight ? tr("Release spotlight") : tr("Put client in spotlight"),
|
label: props.isSpotlight ? tr("Release spotlight") : tr("Put client in spotlight"),
|
||||||
icon: ClientIcon.Fullscreen,
|
icon: ClientIcon.Fullscreen,
|
||||||
click: () => {
|
click: () => {
|
||||||
events.fire("action_set_spotlight", { videoId: props.isSpotlight ? undefined : props.videoId, expend: true });
|
events.fire("action_toggle_spotlight", { videoIds: [ props.videoId ], expend: true, enabled: !props.isSpotlight });
|
||||||
events.fire("action_focus_spotlight", { });
|
events.fire("action_focus_spotlight", { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -514,7 +519,7 @@ const VideoBarArrow = React.memo((props: { direction: "left" | "right", containe
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const VideoBar = () => {
|
const VideoBar = React.memo(() => {
|
||||||
const events = useContext(EventContext);
|
const events = useContext(EventContext);
|
||||||
const refVideos = useRef<HTMLDivElement>();
|
const refVideos = useRef<HTMLDivElement>();
|
||||||
const refArrowRight = useRef<HTMLDivElement>();
|
const refArrowRight = useRef<HTMLDivElement>();
|
||||||
|
@ -594,51 +599,53 @@ const VideoBar = () => {
|
||||||
<VideoBarArrow direction={"right"} containerRef={refArrowRight} />
|
<VideoBarArrow direction={"right"} containerRef={refArrowRight} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
};
|
});
|
||||||
|
|
||||||
const Spotlight = () => {
|
|
||||||
const events = useContext(EventContext);
|
|
||||||
const refContainer = useRef<HTMLDivElement>();
|
|
||||||
|
|
||||||
const [ videoId, setVideoId ] = useState<string>(() => {
|
const PanelContainer = (props: { children }) => {
|
||||||
events.fire("query_spotlight");
|
const refSpotlightContainer = useRef<HTMLDivElement>();
|
||||||
return undefined;
|
const [ spotlightDimensions, setSpotlightDimensions ] = useState<SpotlightDimensions>({ width: 1200, height: 900 });
|
||||||
});
|
|
||||||
events.reactUse("notify_spotlight", event => setVideoId(event.videoId), undefined, []);
|
|
||||||
events.reactUse("action_focus_spotlight", () => refContainer.current?.focus(), undefined, []);
|
|
||||||
|
|
||||||
let body;
|
useEffect(() => {
|
||||||
if(videoId) {
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
body = <VideoContainer videoId={videoId} key={"video-" + videoId} isSpotlight={true} />;
|
const entry = entries.last();
|
||||||
} else {
|
const newDimensions = { height: entry.contentRect.height, width: entry.contentRect.width };
|
||||||
body = (
|
|
||||||
<div className={cssStyle.videoContainer} key={"no-video"}>
|
if(newDimensions.width === 0) {
|
||||||
<div className={cssStyle.text}><Translatable>No spotlight selected</Translatable></div>
|
/* div most likely got removed or something idk... */
|
||||||
</div>
|
return;
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
if(_.isEqual(newDimensions, spotlightDimensions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpotlightDimensions(newDimensions);
|
||||||
|
logTrace(LogCategory.VIDEO, tr("New spotlight dimensions: %o"), entry.contentRect);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(refSpotlightContainer.current);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SpotlightDimensionsContext.Provider value={spotlightDimensions}>
|
||||||
className={cssStyle.spotlight}
|
<div className={cssStyle.panel}>
|
||||||
onKeyDown={event => {
|
{props.children}
|
||||||
if(event.key === "Escape") {
|
</div>
|
||||||
events.fire("action_set_spotlight", { videoId: undefined, expend: false });
|
<div className={cssStyle.heightProvider}>
|
||||||
}
|
<div className={cssStyle.videoBar} />
|
||||||
}}
|
<div className={cssStyle.spotlight} ref={refSpotlightContainer} />
|
||||||
tabIndex={0}
|
</div>
|
||||||
ref={refContainer}
|
</SpotlightDimensionsContext.Provider>
|
||||||
>
|
);
|
||||||
{body}
|
}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
|
export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
|
||||||
return (
|
return (
|
||||||
<EventContext.Provider value={props.events}>
|
<EventContext.Provider value={props.events}>
|
||||||
<HandlerIdContext.Provider value={props.handlerId}>
|
<HandlerIdContext.Provider value={props.handlerId}>
|
||||||
<div className={cssStyle.panel}>
|
<PanelContainer>
|
||||||
<VideoSubscribeContextProvider>
|
<VideoSubscribeContextProvider>
|
||||||
<VideoBar />
|
<VideoBar />
|
||||||
<ExpendArrow />
|
<ExpendArrow />
|
||||||
|
@ -646,7 +653,7 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr
|
||||||
<Spotlight />
|
<Spotlight />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</VideoSubscribeContextProvider>
|
</VideoSubscribeContextProvider>
|
||||||
</div>
|
</PanelContainer>
|
||||||
</HandlerIdContext.Provider>
|
</HandlerIdContext.Provider>
|
||||||
</EventContext.Provider>
|
</EventContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,359 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {useContext, useMemo, useRef, useState} from "react";
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {Layout} from "react-grid-layout";
|
||||||
|
import * as GridLayout from "react-grid-layout";
|
||||||
|
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
|
||||||
|
import * as _ from "lodash";
|
||||||
|
import {FontSizeObserver} from "tc-shared/ui/react-elements/FontSize";
|
||||||
|
import {RendererVideoEventContext, VideoContainer} from "tc-shared/ui/frames/video/Renderer";
|
||||||
|
|
||||||
|
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!react-resizable/css/styles.css";
|
||||||
|
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!react-grid-layout/css/styles.css";
|
||||||
|
import {useGlobalSetting} from "tc-shared/ui/react-elements/Helper";
|
||||||
|
import {Settings} from "tc-shared/settings";
|
||||||
|
|
||||||
|
export type SpotlightDimensions = { width: number, height: number };
|
||||||
|
export const SpotlightDimensionsContext = React.createContext<SpotlightDimensions>(undefined);
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
const SpotlightSingle = () => {
|
||||||
|
const events = useContext(RendererVideoEventContext);
|
||||||
|
const refContainer = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
const [ videoId, setVideoId ] = useState<string>(() => {
|
||||||
|
events.fire("query_spotlight");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
events.reactUse("notify_spotlight", event => {
|
||||||
|
setVideoId(event.videoId.last());
|
||||||
|
const dropped = event.videoId.slice(0, event.videoId.length - 1);
|
||||||
|
if(dropped.length > 0) {
|
||||||
|
events.fire("action_toggle_spotlight", { expend: false, enabled: false, videoIds: dropped });
|
||||||
|
}
|
||||||
|
}, undefined, []);
|
||||||
|
events.reactUse("action_focus_spotlight", () => refContainer.current?.focus(), undefined, []);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if(videoId) {
|
||||||
|
body = <VideoContainer videoId={videoId} key={"video-" + videoId} isSpotlight={true} />;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className={cssStyle.videoContainer + " " + cssStyle.outlined} key={"no-video"}>
|
||||||
|
<div className={cssStyle.text}><Translatable>No spotlight selected</Translatable></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cssStyle.spotlight}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if(event.key === "Escape") {
|
||||||
|
events.fire("action_toggle_spotlight", { videoIds: [ videoId ], expend: false, enabled: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
ref={refContainer}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
type Rectangle = { x: number, y: number, w: number, h: number };
|
||||||
|
const largestRectangleInHistogram = (histogram: number[]) : Rectangle => {
|
||||||
|
const len = histogram.length;
|
||||||
|
const stack = [];
|
||||||
|
let max = 0, maxH, maxW, maxX;
|
||||||
|
let h, w;
|
||||||
|
|
||||||
|
for (let i = 0; i <= len; i++) {
|
||||||
|
while (stack.length && (i === len || histogram[i] <= histogram[stack[stack.length - 1]])) {
|
||||||
|
h = histogram[stack.pop()];
|
||||||
|
w = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
|
||||||
|
|
||||||
|
if(h * w > max) {
|
||||||
|
maxH = h;
|
||||||
|
maxW = w;
|
||||||
|
max = h * w;
|
||||||
|
maxX = i;
|
||||||
|
|
||||||
|
while(maxX > 0 && histogram[maxX - 1] >= h) {
|
||||||
|
maxX --;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: maxX, y: maxH, h: maxH, w: maxW };
|
||||||
|
};
|
||||||
|
|
||||||
|
const largestRectangle = (matrix: boolean[][]) : Rectangle | undefined => {
|
||||||
|
let result: Rectangle & { area: number } = { w: -1, h: -1, x: 0, y: 0, area: -1 };
|
||||||
|
|
||||||
|
let histogram = [];
|
||||||
|
for(const _ of matrix[0]) { histogram.push(0); }
|
||||||
|
|
||||||
|
for(let row = 0; row < matrix.length; row++) {
|
||||||
|
for(let column = 0; column < matrix[row].length; column++) {
|
||||||
|
if(matrix[row][column]) {
|
||||||
|
histogram[column] += 1;
|
||||||
|
} else {
|
||||||
|
histogram[column] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectangle = largestRectangleInHistogram(histogram);
|
||||||
|
const area = rectangle.w * rectangle.h;
|
||||||
|
if(area > result.area) {
|
||||||
|
result = {
|
||||||
|
area,
|
||||||
|
w: rectangle.w,
|
||||||
|
h: rectangle.h,
|
||||||
|
x: rectangle.x,
|
||||||
|
y: row - rectangle.y + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.area === -1 ? undefined : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numbers given in em units, its a 16:9 ratio */
|
||||||
|
const kVideoMinWidth = 17.77778;
|
||||||
|
const kVideoMinHeight = 9;
|
||||||
|
|
||||||
|
const kGridScaleFactor = 16;
|
||||||
|
|
||||||
|
class SpotlightGridController extends React.PureComponent<{ fontSize: number }, { layout: Layout[] }> {
|
||||||
|
private currentLayout: Layout[] = [];
|
||||||
|
private columnCount: number;
|
||||||
|
private rowCount: number;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = { layout: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<RendererVideoEventContext.Consumer>
|
||||||
|
{events => (
|
||||||
|
<SpotlightDimensionsContext.Consumer>
|
||||||
|
{containerDimensions => {
|
||||||
|
this.columnCount = Math.floor(Math.max(containerDimensions.width / (kVideoMinWidth * this.props.fontSize), 1)) * kGridScaleFactor;
|
||||||
|
this.rowCount = Math.floor(Math.max(containerDimensions.height / (kVideoMinHeight * this.props.fontSize), 1)) * kGridScaleFactor;
|
||||||
|
|
||||||
|
this.currentLayout.forEach(entry => {
|
||||||
|
entry.minW = kGridScaleFactor;
|
||||||
|
entry.minH = kGridScaleFactor;
|
||||||
|
});
|
||||||
|
|
||||||
|
//error("Column count: %o, Row count: %o, Layout: %o", this.columnCount, this.rowCount, this.state.layout);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cssStyle.spotlight + " " + cssStyle.grid}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<GridLayout
|
||||||
|
maxRows={this.rowCount}
|
||||||
|
rowHeight={(containerDimensions.height - this.rowCount * this.props.fontSize * .5) / this.rowCount}
|
||||||
|
|
||||||
|
cols={this.columnCount}
|
||||||
|
width={containerDimensions.width}
|
||||||
|
|
||||||
|
onLayoutChange={newLayout => {
|
||||||
|
this.currentLayout = newLayout;
|
||||||
|
events.fire("action_toggle_spotlight", {
|
||||||
|
videoIds: newLayout.filter(entry => entry.x >= this.columnCount || entry.y >= this.rowCount).map(entry => entry.i),
|
||||||
|
enabled: false,
|
||||||
|
expend: false
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
|
||||||
|
layout={this.state.layout}
|
||||||
|
|
||||||
|
margin={[this.props.fontSize * .5, this.props.fontSize * .5]}
|
||||||
|
autoSize={false}
|
||||||
|
compactType={"vertical"}
|
||||||
|
|
||||||
|
resizeHandles={["ne", "nw", "se", "sw"]}
|
||||||
|
>
|
||||||
|
{this.state.layout.map(entry => (
|
||||||
|
<div className={cssStyle.videoContainer} key={entry.i}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<VideoContainer videoId={entry.i} key={entry.i} isSpotlight={true} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</SpotlightDimensionsContext.Consumer>
|
||||||
|
)}
|
||||||
|
</RendererVideoEventContext.Consumer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBoxes(keys: string[]) {
|
||||||
|
const deletedKeys = this.currentLayout.filter(entry => keys.indexOf(entry.i) === -1);
|
||||||
|
const newKeys = keys.filter(entry => this.currentLayout.findIndex(el => el.i === entry) === -1);
|
||||||
|
|
||||||
|
deletedKeys.forEach(key => this.removeBox(key.i));
|
||||||
|
newKeys.forEach(key => this.addBox(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
addBox(key: string) {
|
||||||
|
const newLayout = _.cloneDeep(this.currentLayout);
|
||||||
|
|
||||||
|
let newTile: Layout = { i: key, w: kGridScaleFactor, h: kGridScaleFactor, x: 0, y: 0 };
|
||||||
|
|
||||||
|
calculateNewTile:
|
||||||
|
if(newLayout.length === 0) {
|
||||||
|
/* Trivial case */
|
||||||
|
newTile.x = 0;
|
||||||
|
newTile.y = 0;
|
||||||
|
newTile.w = this.columnCount;
|
||||||
|
newTile.h = this.rowCount;
|
||||||
|
} else {
|
||||||
|
/* 1. try to find an empty spot */
|
||||||
|
{
|
||||||
|
let matrix = [];
|
||||||
|
for(let row = 0; row < this.rowCount; row++) {
|
||||||
|
let col = [];
|
||||||
|
|
||||||
|
columnLoop:
|
||||||
|
for(let column = 0; column < this.columnCount; column++) {
|
||||||
|
for(const entry of newLayout) {
|
||||||
|
if(entry.x > column || column >= entry.x + entry.w) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(entry.y > row || row >= entry.y + entry.h) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
col.push(false);
|
||||||
|
continue columnLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
col.push(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.push(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectangle = largestRectangle(matrix);
|
||||||
|
if(rectangle && rectangle.w >= Math.floor(kGridScaleFactor / 2) && rectangle.h >= Math.floor(kGridScaleFactor / 2)) {
|
||||||
|
/* TODO: Try to find neighbors which have the same border length and see if they've space on the opposite site */
|
||||||
|
|
||||||
|
newTile.x = rectangle.x;
|
||||||
|
newTile.y = rectangle.y;
|
||||||
|
newTile.w = rectangle.w;
|
||||||
|
newTile.h = rectangle.h;
|
||||||
|
break calculateNewTile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. No spot found. Break up a big tile into peaces */
|
||||||
|
{
|
||||||
|
let biggest: Layout = newLayout[0];
|
||||||
|
for(const entry of newLayout) {
|
||||||
|
if(entry.w * entry.h > biggest.w * biggest.h) {
|
||||||
|
biggest = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(biggest.h / kVideoMinWidth * kVideoMinHeight > biggest.w) {
|
||||||
|
/* split it by height */
|
||||||
|
newTile.h = biggest.h;
|
||||||
|
biggest.h = Math.floor(biggest.h / 2);
|
||||||
|
newTile.h -= biggest.h;
|
||||||
|
|
||||||
|
newTile.w = biggest.w;
|
||||||
|
|
||||||
|
newTile.x = biggest.x;
|
||||||
|
newTile.y = biggest.y + biggest.h;
|
||||||
|
} else {
|
||||||
|
/* split it by width */
|
||||||
|
newTile.w = biggest.w;
|
||||||
|
biggest.w = Math.floor(biggest.w / 2);
|
||||||
|
newTile.w -= biggest.w;
|
||||||
|
|
||||||
|
newTile.h = biggest.h;
|
||||||
|
|
||||||
|
newTile.x = biggest.x + biggest.w;
|
||||||
|
newTile.y = biggest.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newLayout.push(newTile);
|
||||||
|
this.currentLayout = newLayout;
|
||||||
|
this.setState({ layout: newLayout });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBox(key: string) {
|
||||||
|
const newLayout = _.cloneDeep(this.currentLayout);
|
||||||
|
const index = newLayout.findIndex(entry => entry.i === key);
|
||||||
|
if(index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ removedEntry ] = newLayout.splice(index, 1);
|
||||||
|
for(const entry of newLayout) {
|
||||||
|
if(removedEntry.h === entry.h && removedEntry.y === entry.y) {
|
||||||
|
if(removedEntry.x === entry.x + entry.w) {
|
||||||
|
entry.w += removedEntry.w;
|
||||||
|
} else if(removedEntry.x + removedEntry.w === entry.x) {
|
||||||
|
entry.x -= removedEntry.w;
|
||||||
|
entry.w += removedEntry.w;
|
||||||
|
}
|
||||||
|
} else if(removedEntry.w === entry.w && removedEntry.x === entry.x) {
|
||||||
|
if(removedEntry.y === entry.y + entry.h) {
|
||||||
|
entry.h += removedEntry.h;
|
||||||
|
} else if(removedEntry.y + removedEntry.h === entry.y) {
|
||||||
|
entry.y -= removedEntry.h;
|
||||||
|
entry.h += removedEntry.h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentLayout = newLayout;
|
||||||
|
this.setState({ layout: newLayout });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightGrid = (props: { fontSize: number }) => {
|
||||||
|
const refSpotlight = useRef<SpotlightGridController>();
|
||||||
|
|
||||||
|
const events = useContext(RendererVideoEventContext);
|
||||||
|
events.reactUse("notify_spotlight", event => refSpotlight.current?.updateBoxes(event.videoId), undefined, []);
|
||||||
|
useMemo(() => events.fire("query_spotlight"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotlightGridController fontSize={props.fontSize} ref={refSpotlight} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Spotlight = () => {
|
||||||
|
const mode = useGlobalSetting(Settings.KEY_VIDEO_SPOTLIGHT_MODE);
|
||||||
|
switch (mode) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<FontSizeObserver key={"key-1"}>
|
||||||
|
{fontSize => <SpotlightGrid fontSize={fontSize} />}
|
||||||
|
</FontSizeObserver>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
return <SpotlightSingle key={"key-0"} />;
|
||||||
|
}
|
||||||
|
};
|
|
@ -6,7 +6,7 @@ interface ErrorBoundaryState {
|
||||||
errorOccurred: boolean
|
errorOccurred: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> {
|
export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {CSSProperties, useEffect, useRef, useState} from "react";
|
||||||
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
|
|
||||||
|
const SpanCssProperties: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
fontSize: "inherit",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "1em",
|
||||||
|
height: 1,
|
||||||
|
translate: "none",
|
||||||
|
transition: "none",
|
||||||
|
transform: "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FontSizeObserver = React.memo((props: { children: (fontSize: number) => React.ReactNode | React.ReactNode[] }) => {
|
||||||
|
const refContainer = useRef<HTMLSpanElement>();
|
||||||
|
const [ fontSize, setFontSize ] = useState(() => {
|
||||||
|
return parseFloat(window.getComputedStyle(document.body, null).getPropertyValue('font-size'));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
|
const entry = entries.last();
|
||||||
|
setFontSize(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(refContainer.current);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<span style={SpanCssProperties} ref={refContainer}> </span>
|
||||||
|
{props.children(fontSize)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
});
|
|
@ -11,7 +11,6 @@ import {
|
||||||
LevelMeter,
|
LevelMeter,
|
||||||
NodeInputConsumer
|
NodeInputConsumer
|
||||||
} from "tc-shared/voice/RecorderBase";
|
} from "tc-shared/voice/RecorderBase";
|
||||||
import * as log from "tc-shared/log";
|
|
||||||
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
|
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
|
||||||
import * as aplayer from "./player";
|
import * as aplayer from "./player";
|
||||||
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";
|
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||||
import * as log from "tc-shared/log";
|
|
||||||
import {SoundFile} from "tc-shared/sound/Sounds";
|
import {SoundFile} from "tc-shared/sound/Sounds";
|
||||||
import * as aplayer from "./player";
|
import * as aplayer from "./player";
|
||||||
import { tr } from "tc-shared/i18n/localize";
|
import { tr } from "tc-shared/i18n/localize";
|
||||||
|
|
Loading…
Reference in New Issue