TeaWeb/shared/js/ui/frames/video/RendererSpotlight.tsx

359 lines
No EOL
14 KiB
TypeScript

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