diff --git a/ChangeLog.md b/ChangeLog.md index 7165537d..1e6c8bed 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/package-lock.json b/package-lock.json index b7f46275..be69e8a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9a785300..4c8ac6e7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/js/proto.ts b/shared/js/proto.ts index e64e2df4..c4d8d404 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -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(element: T): boolean { + Array.prototype.toggle = function(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"; } } } diff --git a/shared/js/settings.ts b/shared/js/settings.ts index bf3c4a6d..0b8f6fb5 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -758,6 +758,13 @@ export class Settings { valueType: "boolean", }; + static readonly KEY_VIDEO_SPOTLIGHT_MODE: ValuedRegistryKey = { + 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 = { key: "invite_short_url", defaultValue: true, diff --git a/shared/js/ui/AppRenderer.tsx b/shared/js/ui/AppRenderer.tsx index d10976f3..529ca799 100644 --- a/shared/js/ui/AppRenderer.tsx +++ b/shared/js/ui/AppRenderer.tsx @@ -21,39 +21,6 @@ import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions"; const cssStyle = require("./AppRenderer.scss"); -/* -
-
- -
-
-
-
-
-
-
-
- -
-
-
-
- -
- -
-
-
-
-
- -
-
-
-
- */ - const VideoFrame = React.memo((props: { events: Registry }) => { const refElement = React.useRef(); const [ container, setContainer ] = useState(() => { diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index 4a15bd57..c1193248 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -354,7 +354,7 @@ class ChannelVideoController { private localVideoController: LocalVideoController; private clientVideos: {[key: number]: RemoteClientVideoController} = {}; - private currentSpotlight: string; + private currentSpotlights: string[]; constructor(events: Registry, 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", { diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index a0140d11..b19b2d76 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -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, diff --git a/shared/js/ui/frames/video/Renderer.scss b/shared/js/ui/frames/video/Renderer.scss index 5621221b..46d4f41c 100644 --- a/shared/js/ui/frames/video/Renderer.scss +++ b/shared/js/ui/frames/video/Renderer.scss @@ -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 { diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index 83555141..d5f5052b 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -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(undefined); const EventContext = React.createContext>(undefined); const HandlerIdContext = React.createContext(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 = () => { ) -}; +}); 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(); const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype; @@ -438,14 +443,14 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea return (
{ 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(); const refArrowRight = useRef(); @@ -594,51 +599,53 @@ const VideoBar = () => {
) -}; +}); -const Spotlight = () => { - const events = useContext(EventContext); - const refContainer = useRef(); - const [ videoId, setVideoId ] = useState(() => { - 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(); + const [ spotlightDimensions, setSpotlightDimensions ] = useState({ width: 1200, height: 900 }); - let body; - if(videoId) { - body = ; - } else { - body = ( -
-
No spotlight selected
-
- ); - } + 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 ( -
{ - if(event.key === "Escape") { - events.fire("action_set_spotlight", { videoId: undefined, expend: false }); - } - }} - tabIndex={0} - ref={refContainer} - > - {body} -
- ) -}; + +
+ {props.children} +
+
+
+
+
+ + ); +} export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry }) => { return ( -
+ @@ -646,7 +653,7 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr -
+
); diff --git a/shared/js/ui/frames/video/RendererSpotlight.tsx b/shared/js/ui/frames/video/RendererSpotlight.tsx new file mode 100644 index 00000000..7b78a67b --- /dev/null +++ b/shared/js/ui/frames/video/RendererSpotlight.tsx @@ -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(undefined); + +const cssStyle = require("./Renderer.scss"); + +const SpotlightSingle = () => { + const events = useContext(RendererVideoEventContext); + const refContainer = useRef(); + + const [ videoId, setVideoId ] = useState(() => { + 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 = ; + } else { + body = ( +
+
No spotlight selected
+
+ ); + } + + return ( +
{ + if(event.key === "Escape") { + events.fire("action_toggle_spotlight", { videoIds: [ videoId ], expend: false, enabled: false }); + } + }} + tabIndex={0} + ref={refContainer} + > + {body} +
+ ) +}; + +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 ( + + {events => ( + + {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 ( +
+ { + 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 => ( +
+ + + +
+ ))} +
+
+ ); + }} +
+ )} +
+ ); + } + + 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(); + + const events = useContext(RendererVideoEventContext); + events.reactUse("notify_spotlight", event => refSpotlight.current?.updateBoxes(event.videoId), undefined, []); + useMemo(() => events.fire("query_spotlight"), []); + + return ( + + ); +}; + +export const Spotlight = () => { + const mode = useGlobalSetting(Settings.KEY_VIDEO_SPOTLIGHT_MODE); + switch (mode) { + case 1: + return ( + + {fontSize => } + + ); + + case 0: + default: + return ; + } +}; \ No newline at end of file diff --git a/shared/js/ui/react-elements/ErrorBoundary.tsx b/shared/js/ui/react-elements/ErrorBoundary.tsx index e3022f7f..73e50890 100644 --- a/shared/js/ui/react-elements/ErrorBoundary.tsx +++ b/shared/js/ui/react-elements/ErrorBoundary.tsx @@ -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); diff --git a/shared/js/ui/react-elements/FontSize.tsx b/shared/js/ui/react-elements/FontSize.tsx new file mode 100644 index 00000000..c815be33 --- /dev/null +++ b/shared/js/ui/react-elements/FontSize.tsx @@ -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(); + 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 ( + +   + {props.children(fontSize)} + + ) +}); \ No newline at end of file diff --git a/web/app/audio/Recorder.ts b/web/app/audio/Recorder.ts index b5de313d..71b2752a 100644 --- a/web/app/audio/Recorder.ts +++ b/web/app/audio/Recorder.ts @@ -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"; diff --git a/web/app/audio/sounds.ts b/web/app/audio/sounds.ts index daf95807..656c6ccb 100644 --- a/web/app/audio/sounds.ts +++ b/web/app/audio/sounds.ts @@ -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";