Adding the new video spotlight mode
parent
ea79d9d6a4
commit
4394d36383
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>(() => {
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> {
|
||||
export class ErrorBoundary extends React.PureComponent<{}, ErrorBoundaryState> {
|
||||
constructor(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,
|
||||
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";
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Reference in New Issue