Adding the new video spotlight mode

master
WolverinDEV 2021-03-12 17:46:27 +01:00
parent ea79d9d6a4
commit 4394d36383
15 changed files with 611 additions and 113 deletions

View File

@ -1,4 +1,8 @@
# 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**
- Improved the browser IPC module
- Added support for client invite links

48
package-lock.json generated
View File

@ -1529,6 +1529,14 @@
"@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": {
"version": "4.4.0",
"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": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
@ -9138,6 +9151,11 @@
"integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=",
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -11456,11 +11474,32 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
"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": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -11478,6 +11517,15 @@
"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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",

View File

@ -91,6 +91,7 @@
},
"homepage": "https://www.teaspeak.de",
"dependencies": {
"@types/react-grid-layout": "^1.1.1",
"@types/react-transition-group": "^4.4.0",
"broadcastchannel-polyfill": "^1.0.1",
"detect-browser": "^5.2.0",
@ -104,6 +105,7 @@
"moment": "^2.24.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-grid-layout": "^1.2.2",
"react-player": "^2.5.0",
"react-transition-group": "^4.4.1",
"remarkable": "^2.0.1",

View File

@ -19,7 +19,19 @@ declare global {
last?(): T;
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;
/**
* @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 {
@ -173,14 +185,16 @@ if (!Array.prototype.pop_front) {
}
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);
if(index === -1) {
if((index !== -1) === insert) {
return false;
} else if(index === -1) {
this.push(element);
return true;
} else {
this.splice(index, 1);
return false;
return typeof insert === "boolean";
}
}
}

View File

@ -758,6 +758,13 @@ export class Settings {
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> = {
key: "invite_short_url",
defaultValue: true,

View File

@ -21,39 +21,6 @@ import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
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 refElement = React.useRef<HTMLDivElement>();
const [ container, setContainer ] = useState<HTMLDivElement | undefined>(() => {

View File

@ -354,7 +354,7 @@ class ChannelVideoController {
private localVideoController: LocalVideoController;
private clientVideos: {[key: number]: RemoteClientVideoController} = {};
private currentSpotlight: string;
private currentSpotlights: string[];
constructor(events: Registry<ChannelVideoEvents>, connection: ConnectionHandler) {
this.events = events;
@ -366,6 +366,8 @@ class ChannelVideoController {
this.localVideoController = new LocalVideoController(connection.getClient(), this.events);
this.localVideoController.callbackBroadcastStateChanged = () => this.notifyVideoList();
});
this.currentSpotlights = [];
this.currentlyVisible = false;
this.expended = false;
}
@ -395,8 +397,8 @@ class ChannelVideoController {
this.events.fire_react("notify_expended", { expended: this.expended });
});
this.events.on("action_set_spotlight", event => {
this.setSpotlight(event.videoId);
this.events.on("action_toggle_spotlight", event => {
this.toggleSpotlight(event.videoIds, event.enabled);
if(!this.isExpended()) {
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()));
}
setSpotlight(videoId: string | undefined) {
if(this.currentSpotlight === videoId) { return; }
toggleSpotlight(videoId: string[], enabled: boolean) {
const updated = videoId.map(entry => this.currentSpotlights.toggle(entry, enabled)).find(updated => updated);
if(!updated) {
return;
}
/* TODO: test if the video event exists? */
this.currentSpotlight = videoId;
this.notifySpotlight()
this.notifySpotlight();
this.notifyVideoList();
}
@ -597,7 +599,7 @@ class ChannelVideoController {
}
private resetClientVideos() {
this.currentSpotlight = undefined;
this.currentSpotlights = [];
for(const clientId of Object.keys(this.clientVideos)) {
this.destroyClientVideo(parseInt(clientId));
}
@ -614,10 +616,7 @@ class ChannelVideoController {
video.destroy();
delete this.clientVideos[clientId];
if(video.videoId === this.currentSpotlight) {
this.currentSpotlight = undefined;
this.notifySpotlight();
}
this.toggleSpotlight([ video.videoId ], false);
return true;
} else {
return false;
@ -635,7 +634,7 @@ class ChannelVideoController {
}
private notifySpotlight() {
this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlight });
this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlights });
}
private notifyVideoList() {
@ -677,7 +676,7 @@ class ChannelVideoController {
this.updateVisibility(videoStreamingCount !== 0);
if(this.expended) {
videoIds.remove(this.currentSpotlight);
this.currentSpotlights.forEach(entry => videoIds.remove(entry));
}
this.events.fire_react("notify_videos", {

View File

@ -69,7 +69,11 @@ export type LocalVideoState = "muted" | "unset" | "empty";
export interface ChannelVideoEvents {
action_toggle_expended: { expended: boolean },
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_set_fullscreen: { videoId: string | undefined },
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
@ -108,7 +112,7 @@ export interface ChannelVideoEvents {
right: boolean
},
notify_spotlight: {
videoId: string | undefined
videoId: string[]
},
notify_video_statistics: {
videoId: string | undefined,

View File

@ -24,12 +24,22 @@ $small_height: 10em;
.panel {
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 {
.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-right-radius: 0;
}
@ -62,6 +72,24 @@ $small_height: 10em;
@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 {
position: absolute;
@ -122,8 +150,15 @@ $small_height: 10em;
height: ($small_height - 1em);
width: ($small_height * 16 / 9);
margin-top: .5em;
margin-bottom: .5em;
flex-shrink: 0;
flex-grow: 0;
&:not(:last-of-type) {
margin-right: .5em;
}
}
}
@ -181,11 +216,14 @@ $small_height: 10em;
flex-direction: column;
justify-content: stretch;
position: relative;
min-height: 5em;
min-width: 5em;
margin-left: .5em;
margin-right: .5em;
margin-bottom: .5em;
flex-shrink: 1;
flex-grow: 1;
@ -198,20 +236,21 @@ $small_height: 10em;
max-width: 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;
margin-top: .5em;
margin-bottom: .5em;
flex-shrink: 1;
flex-grow: 1;
background-color: #2e2e2e;
box-shadow: inset 0 0 5px #00000040;
border-radius: .2em;
overflow: hidden;
@ -219,8 +258,19 @@ $small_height: 10em;
flex-direction: column;
justify-content: center;
&:not(:last-of-type) {
margin-right: .5em;
&.outlined, &:global(.react-grid-item.react-grid-placeholder) {
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 {

View File

@ -11,24 +11,29 @@ import {
makeVideoAutoplay,
VideoStreamState,
VideoSubscribeInfo
} from "tc-shared/ui/frames/video/Definitions";
} from "./Definitions";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
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 {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
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 EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
const HandlerIdContext = React.createContext<string>(undefined);
export const RendererVideoEventContext = EventContext;
const cssStyle = require("./Renderer.scss");
const ExpendArrow = () => {
const ExpendArrow = React.memo(() => {
const events = useContext(EventContext);
const [ expended, setExpended ] = useState(() => {
@ -43,7 +48,7 @@ const ExpendArrow = () => {
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
</div>
)
};
});
const VideoInfo = React.memo((props: { videoId: string }) => {
const events = useContext(EventContext);
@ -368,7 +373,7 @@ const VideoControlButtons = React.memo((props: {
if(props.isSpotlight) {
events.fire("action_set_fullscreen", { videoId: props.fullscreenMode === "set" ? undefined : props.videoId });
} 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", { });
}
}}
@ -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 refContainer = useRef<HTMLDivElement>();
const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype;
@ -438,14 +443,14 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
return (
<div
className={cssStyle.videoContainer}
className={cssStyle.videoContainer + " " + cssStyle.outlined}
onDoubleClick={() => {
if(isFullscreen) {
events.fire("action_set_fullscreen", { videoId: undefined });
} else if(props.isSpotlight) {
events.fire("action_set_fullscreen", { videoId: props.videoId });
} 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", { });
}
}}
@ -479,7 +484,7 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
label: props.isSpotlight ? tr("Release spotlight") : tr("Put client in spotlight"),
icon: ClientIcon.Fullscreen,
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", { });
}
}
@ -514,7 +519,7 @@ const VideoBarArrow = React.memo((props: { direction: "left" | "right", containe
);
});
const VideoBar = () => {
const VideoBar = React.memo(() => {
const events = useContext(EventContext);
const refVideos = useRef<HTMLDivElement>();
const refArrowRight = useRef<HTMLDivElement>();
@ -594,51 +599,53 @@ const VideoBar = () => {
<VideoBarArrow direction={"right"} containerRef={refArrowRight} />
</div>
)
};
});
const Spotlight = () => {
const events = useContext(EventContext);
const refContainer = useRef<HTMLDivElement>();
const [ videoId, setVideoId ] = useState<string>(() => {
events.fire("query_spotlight");
return undefined;
});
events.reactUse("notify_spotlight", event => setVideoId(event.videoId), undefined, []);
events.reactUse("action_focus_spotlight", () => refContainer.current?.focus(), undefined, []);
const PanelContainer = (props: { children }) => {
const refSpotlightContainer = useRef<HTMLDivElement>();
const [ spotlightDimensions, setSpotlightDimensions ] = useState<SpotlightDimensions>({ width: 1200, height: 900 });
let body;
if(videoId) {
body = <VideoContainer videoId={videoId} key={"video-" + videoId} isSpotlight={true} />;
} else {
body = (
<div className={cssStyle.videoContainer} key={"no-video"}>
<div className={cssStyle.text}><Translatable>No spotlight selected</Translatable></div>
</div>
);
useEffect(() => {
const resizeObserver = new ResizeObserver(entries => {
const entry = entries.last();
const newDimensions = { height: entry.contentRect.height, width: entry.contentRect.width };
if(newDimensions.width === 0) {
/* div most likely got removed or something idk... */
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 (
<div
className={cssStyle.spotlight}
onKeyDown={event => {
if(event.key === "Escape") {
events.fire("action_set_spotlight", { videoId: undefined, expend: false });
}
}}
tabIndex={0}
ref={refContainer}
>
{body}
<SpotlightDimensionsContext.Provider value={spotlightDimensions}>
<div className={cssStyle.panel}>
{props.children}
</div>
)
};
<div className={cssStyle.heightProvider}>
<div className={cssStyle.videoBar} />
<div className={cssStyle.spotlight} ref={refSpotlightContainer} />
</div>
</SpotlightDimensionsContext.Provider>
);
}
export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
return (
<EventContext.Provider value={props.events}>
<HandlerIdContext.Provider value={props.handlerId}>
<div className={cssStyle.panel}>
<PanelContainer>
<VideoSubscribeContextProvider>
<VideoBar />
<ExpendArrow />
@ -646,7 +653,7 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr
<Spotlight />
</ErrorBoundary>
</VideoSubscribeContextProvider>
</div>
</PanelContainer>
</HandlerIdContext.Provider>
</EventContext.Provider>
);

View File

@ -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"} />;
}
};

View File

@ -6,7 +6,7 @@ interface ErrorBoundaryState {
errorOccurred: boolean
}
export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> {
export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
constructor(props) {
super(props);

View File

@ -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}>&nbsp;</span>
{props.children(fontSize)}
</React.Fragment>
)
});

View File

@ -11,7 +11,6 @@ import {
LevelMeter,
NodeInputConsumer
} from "tc-shared/voice/RecorderBase";
import * as log from "tc-shared/log";
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
import * as aplayer from "./player";
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";

View File

@ -1,5 +1,4 @@
import {LogCategory, logError, logWarn} from "tc-shared/log";
import * as log from "tc-shared/log";
import {SoundFile} from "tc-shared/sound/Sounds";
import * as aplayer from "./player";
import { tr } from "tc-shared/i18n/localize";