TeaWeb/shared/js/ui/modal/video-source/Renderer.tsx
2020-11-22 16:11:00 +01:00

661 lines
25 KiB
TypeScript

import {Registry} from "tc-shared/events";
import * as React from "react";
import {
DeviceListResult,
ModalVideoSourceEvents, SettingFrameRate,
VideoPreviewStatus, VideoSourceState
} from "tc-shared/ui/modal/video-source/Definitions";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {Select} from "tc-shared/ui/react-elements/InputField";
import {Button} from "tc-shared/ui/react-elements/Button";
import {useContext, useEffect, useRef, useState} from "react";
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {Slider} from "tc-shared/ui/react-elements/Slider";
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
const cssStyle = require("./Renderer.scss");
const ModalEvents = React.createContext<Registry<ModalVideoSourceEvents>>(undefined);
const AdvancedSettings = React.createContext<boolean>(false);
const kNoDeviceId = "__no__device";
const VideoSourceSelector = () => {
const events = useContext(ModalEvents);
const [ deviceList, setDeviceList ] = useState<DeviceListResult | "loading">(() => {
events.fire("query_device_list");
return "loading";
});
events.reactUse("notify_device_list", event => setDeviceList(event.status));
if(deviceList === "loading") {
return (
<div className={cssStyle.body} key={"loading"}>
<Select type={"boxed"} disabled={true}>
<option>{tr("loading ...")}</option>
</Select>
</div>
);
} else if(deviceList.status === "error") {
let message;
switch (deviceList.reason) {
case "no-permissions":
message = tr("Missing device query permissions");
break;
case "request-permissions":
message = tr("Please grant video device permissions");
break;
case "custom":
message = tr("An error happened");
break;
}
return (
<div className={cssStyle.body} key={"error"}>
<Select type={"boxed"} disabled={true} className={cssStyle.selectError}>
<option>{message}</option>
</Select>
</div>
);
} else {
return (
<div className={cssStyle.body} key={"normal"}>
<Select
type={"boxed"}
value={deviceList.selectedDeviceId || kNoDeviceId}
onChange={event => events.fire("action_select_source", { id: event.target.value })}
>
<option key={kNoDeviceId} value={kNoDeviceId} style={{ display: "none" }}>{tr("No device")}</option>
{deviceList.devices.map(device => <option value={device.id} key={device.id}>{device.displayName}</option>)}
{deviceList.devices.findIndex(device => device.id === deviceList.selectedDeviceId) === -1 ?
<option key={"selected-device-" + deviceList.selectedDeviceId} style={{ display: "none" }} value={deviceList.selectedDeviceId}>
{deviceList.fallbackSelectedDeviceName}
</option> :
undefined
}
</Select>
</div>
);
}
};
const VideoSourceRequester = () => {
const events = useContext(ModalEvents);
const [ source, setSource ] = useState<VideoSourceState>(() => {
events.fire("query_source");
return { type: "none" };
});
events.reactUse("notify_source", event => setSource(event.state), undefined, []);
let info;
switch (source.type) {
case "selected":
let name = source.name === "Screen" ? source.deviceId : source.name;
info = (
<div className={cssStyle.info + " " + cssStyle.selected} key={"selected"}>
<VariadicTranslatable text={"Selected source: {}"}>{name || source.deviceId}</VariadicTranslatable>
</div>
);
break;
case "errored":
info = (
<div className={cssStyle.info + " " + cssStyle.error} key={"errored"}>
{source.error}
</div>
);
break;
case "none":
info = (
<div className={cssStyle.info + " " + cssStyle.none} key={"none"}>
<Translatable>No source has been selected</Translatable>
</div>
);
break;
}
return (
<div className={cssStyle.body} key={"normal"}>
<div className={cssStyle.sourcePrompt}>
<Button type={"small"} onClick={() => events.fire("action_select_source", { id: undefined })}>
<Translatable>Select source</Translatable>
</Button>
{info}
</div>
</div>
);
};
const VideoPreviewMessage = (props: { message: any, kind: "info" | "error" }) => {
return (
<div className={cssStyle.overlay + " " + (props.message ? cssStyle.shown : "")}>
<div className={cssStyle[props.kind]}>
{props.message}
</div>
</div>
);
}
const VideoRequestPermissions = (props: { systemDenied: boolean }) => {
const events = useContext(ModalEvents);
let body;
let button;
if(props.systemDenied) {
body = (
<div className={cssStyle.text} key={"system-denied"}>
<Translatable>Camara access has been denied by your browser.<br />Please allow camara access in order to broadcast video.</Translatable>
</div>
);
button = <Translatable key={"retry"}>Retry to query</Translatable>;
} else {
body = (
<div className={cssStyle.text} key={"user-denied"}>
<Translatable>In order to be able to broadcast video,<br /> you have to allow camara access.</Translatable>
</div>
);
button = <Translatable key={"request"}>Request permissions</Translatable>;
}
return (
<div className={cssStyle.overlay + " " + cssStyle.shown + " " + cssStyle.permissions}>
{body}
<Button
type={"normal"}
color={"green"}
className={cssStyle.button}
onClick={() => events.fire("action_request_permissions")}
>
{button}
</Button>
</div>
);
}
const VideoPreview = () => {
const events = useContext(ModalEvents);
const refVideo = useRef<HTMLVideoElement>();
const [ status, setStatus ] = useState<VideoPreviewStatus | "loading">(() => {
events.fire("query_video_preview");
return "loading";
});
events.reactUse("notify_video_preview", event => {
setStatus(event.status);
});
let body;
if(status === "loading") {
/* Nothing to show */
} else {
switch (status.status) {
case "none":
body = <VideoPreviewMessage message={tr("No video source")} kind={"info"} key={"none"} />;
break;
case "error":
if(status.reason === "no-permissions" || status.reason === "request-permissions") {
body = <VideoRequestPermissions systemDenied={status.reason === "no-permissions"} key={"permissions"} />;
} else {
body = <VideoPreviewMessage message={status.message} kind={"error"} key={"error"} />;
}
break;
case "preview":
body = (
<video
key={"preview"}
ref={refVideo}
autoPlay={true}
/>
)
break;
}
}
useEffect(() => {
const stream = status !== "loading" && status.status === "preview" && status.stream;
if(stream && refVideo.current) {
refVideo.current.srcObject = stream;
}
}, [status !== "loading" && status.status === "preview" && status.stream])
return (
<div className={cssStyle.body}>
<div className={cssStyle.videoContainer}>
{body}
</div>
</div>
);
}
const ButtonStart = () => {
const events = useContext(ModalEvents);
const [ enabled, setEnabled ] = useState(() => {
events.fire("query_start_button");
return false;
});
events.reactUse("notify_start_button", event => setEnabled(event.enabled));
return (
<Button
type={"small"}
color={"green"}
disabled={!enabled}
onClick={() => enabled && events.fire("action_start")}
>
<Translatable>Start</Translatable>
</Button>
);
}
type DimensionPresetId = "480p" | "720p" | "1080p" | "2160p";
const DimensionPresets: {[T in DimensionPresetId]: {
name: string,
width: number,
height: number
}} = {
"480p": {
name: "480p",
width: 640,
height: 480
},
"720p": {
name: "720p",
width: 1280,
height: 720
},
"1080p": {
name: "Full HD",
width: 1920,
height: 1080
},
"2160p": {
name: "4k UHD",
width: 3840,
height: 2160
}
};
type SettingDimensionValues = {
minWidth: number,
maxWidth: number,
minHeight: number,
maxHeight: number,
originalHeight: number,
originalWidth: number
}
const SettingDimension = () => {
const events = useContext(ModalEvents);
const advanced = useContext(AdvancedSettings);
const [ settings, setSettings ] = useState<SettingDimensionValues | undefined>(() => {
events.fire("query_setting_dimension");
return undefined;
});
events.reactUse("notify_setting_dimension", event => {
if(event.setting) {
setSettings({
minWidth: event.setting.minWidth,
minHeight: event.setting.minHeight,
maxWidth: event.setting.maxWidth,
maxHeight: event.setting.maxHeight,
originalHeight: event.setting.originalHeight,
originalWidth: event.setting.originalWidth
});
setWidth(event.setting.currentWidth);
setHeight(event.setting.currentHeight);
refSliderWidth.current?.setState({ value: event.setting.currentWidth });
refSliderHeight.current?.setState({ value: event.setting.currentHeight });
setSelectValue("original");
} else {
setSettings(undefined);
setSelectValue("no-source");
}
}, undefined, []);
const [ width, setWidth ] = useState(1);
const [ height, setHeight ] = useState(1);
const [ selectValue, setSelectValue ] = useState("no-source");
const [ aspectRatio, setAspectRatio ] = useState(true);
const refSliderWidth = useRef<Slider>();
const refSliderHeight = useRef<Slider>();
const bounds = (id: DimensionPresetId) => {
const dimension = DimensionPresets[id];
let scale = Math.min(dimension.height / settings.maxHeight, dimension.width / settings.maxWidth);
return [Math.ceil(settings.maxWidth * scale), Math.ceil(settings.maxHeight * scale)];
}
const boundsString = (id: DimensionPresetId) => {
const value = bounds(id);
return value[0] + "x" + value[1];
}
const updateHeight = (newHeight: number, triggerUpdate: boolean) => {
let newWidth = width;
setHeight(newHeight);
if(aspectRatio) {
newWidth = Math.ceil(settings.originalWidth * (newHeight / settings.originalHeight));
setWidth(newWidth);
refSliderWidth.current?.setState({ value: newWidth });
}
if(triggerUpdate) {
events.fire("action_setting_dimension", { height: newHeight, width: newWidth });
}
}
const updateWidth = (newWidth: number, triggerUpdate: boolean) => {
let newHeight = height;
setWidth(newWidth);
if(aspectRatio) {
newHeight = Math.ceil(settings.originalHeight * (newWidth / settings.originalWidth));
setHeight(newHeight);
refSliderHeight.current?.setState({ value: newHeight });
}
if(triggerUpdate) {
events.fire("action_setting_dimension", { height: newHeight, width: newWidth });
}
}
return (
<div className={cssStyle.setting + " " + cssStyle.dimensions}>
<div className={cssStyle.title}>
<div><Translatable>Dimension</Translatable></div>
<div>{settings ? width + "x" + height : ""}</div>
</div>
<div className={cssStyle.body}>
<Select
type={"boxed"}
onChange={event => {
const value = event.target.value;
setSelectValue(value);
let newWidth, newHeight;
if(value === "original") {
newWidth = settings.originalWidth;
newHeight = settings.originalHeight;
} else if(value === "custom") {
/* nothing to do; no need to trigger an update as well */
return;
} else if(DimensionPresets[value]) {
const val = bounds(value as any);
newWidth = val[0];
newHeight = val[1];
}
setWidth(newWidth);
setHeight(newHeight);
refSliderWidth.current?.setState({ value: newWidth });
refSliderHeight.current?.setState({ value: newHeight });
events.fire("action_setting_dimension", { height: newHeight, width: newWidth });
}}
value={selectValue}
disabled={!settings}
>
{Object.keys(DimensionPresets).filter(e => {
if(!settings) { return false; }
if(DimensionPresets[e].height < settings.minHeight || DimensionPresets[e].height > settings.maxHeight) { return false; }
return !(DimensionPresets[e].width < settings.minWidth || DimensionPresets[e].width > settings.maxWidth);
}).map(dimensionId =>
<option value={dimensionId} key={dimensionId}>{DimensionPresets[dimensionId].name + " (" + boundsString(dimensionId as any) + ")"}</option>
)}
<option value={"original"} key={"original"}>{tr("Default")} ({(settings ? settings.originalWidth + "x" + settings.originalHeight : "0x0")})</option>
<option value={"custom"} key={"custom"} style={{ display: advanced ? undefined : "none" }}>{tr("Custom")}</option>
<option value={"no-source"} key={"no-source"} style={{ display: "none" }}>{tr("No source selected")}</option>
</Select>
<div className={cssStyle.advanced + " " + (advanced ? cssStyle.shown : "")}>
<div className={cssStyle.aspectRatio}>
<Checkbox
label={<Translatable>Aspect ratio</Translatable>}
disabled={selectValue !== "custom" || !settings}
value={aspectRatio}
onChange={value => setAspectRatio(value)}
/>
</div>
<div className={cssStyle.sliderTitle}>
<div><Translatable>Width</Translatable></div>
<div>{settings ? width + "px" : ""}</div>
</div>
<Slider tooltip={null} minValue={settings ? settings.minWidth : 0} maxValue={settings ? settings.maxWidth : 1}
stepSize={1} value={width} className={cssStyle.slider}
onInput={value => updateWidth(value, false)}
disabled={selectValue !== "custom" || !settings}
onChange={value => updateWidth(value, true)}
ref={refSliderWidth}
/>
<div className={cssStyle.sliderTitle}>
<div><Translatable>Height</Translatable></div>
<div>{settings ? height + "px" : ""}</div>
</div>
<Slider tooltip={null} minValue={settings ? settings.minHeight : 0} maxValue={settings ? settings.maxHeight : 1}
stepSize={1} value={height} className={cssStyle.slider}
onInput={value => updateHeight(value, false)}
onChange={value => updateHeight(value, true)}
disabled={selectValue !== "custom" || !settings}
ref={refSliderHeight}
/>
</div>
</div>
</div>
);
}
const FrameRatePresents = {
"10": 10,
"20": 20,
"24 NTSC": 24,
"25 PAL": 25,
"29.97": 29.97,
"30": 30,
"48": 48,
"50 PAL": 50,
"59.94": 59.94,
"60": 60
}
const SettingFramerate = () => {
const events = useContext(ModalEvents);
const [ frameRate, setFrameRate ] = useState<SettingFrameRate | undefined>(() => {
events.fire("query_setting_framerate");
return undefined;
});
const [ currentRate, setCurrentRate ] = useState(0);
const [ selectedValue, setSelectedValue ] = useState("original");
events.reactUse("notify_settings_framerate", event => {
setFrameRate(event.frameRate);
setCurrentRate(event.frameRate ? event.frameRate.original : 1);
if(event.frameRate) {
setSelectedValue(event.frameRate.original.toString());
} else {
setSelectedValue("no-source");
}
});
const FrameRates = Object.assign({}, FrameRatePresents);
if(frameRate) {
if(Object.keys(FrameRates).findIndex(key => FrameRates[key] === frameRate.original) === -1) {
FrameRates[frameRate.original.toString()] = frameRate.original;
}
}
return (
<div className={cssStyle.setting + " " + cssStyle.dimensions}>
<div className={cssStyle.title}>
<div><Translatable>Framerate</Translatable></div>
<div>{frameRate ? currentRate : ""}</div>
</div>
<div className={cssStyle.body}>
<Select
type={"boxed"}
disabled={!frameRate}
value={selectedValue}
onChange={event => {
const value = parseFloat(event.target.value);
if(!isNaN(value)) {
setSelectedValue(event.target.value);
setCurrentRate(value);
events.fire("action_setting_framerate", { frameRate: value });
}
}}
>
{Object.keys(FrameRates).sort((a, b) => FrameRates[a] - FrameRates[b]).filter(key => {
if(!frameRate) { return false; }
const value = FrameRates[key];
if(frameRate.min > value) { return false; }
return frameRate.max >= value;
}).map(key => (
<option value={FrameRates[key]} key={key}>{key}</option>
))}
<option value={"1"} key={"no-source"} style={{ display: "none" }}>{tr("No source selected")}</option>
</Select>
</div>
</div>
);
}
const calculateBps = (width: number, height: number, frameRate: number) => {
/* Based on the tables showed here: http://www.lighterra.com/papers/videoencodingh264/ */
const estimatedBitsPerPixed = 3.9;
return estimatedBitsPerPixed * width * height * (frameRate / 30);
}
const BpsInfo = () => {
const events = useContext(ModalEvents);
const [ dimensions, setDimensions ] = useState<{ width: number, height: number } | undefined>(undefined);
const [ frameRate, setFrameRate ] = useState<number | undefined>(undefined);
events.reactUse("notify_setting_dimension", event => setDimensions(event.setting ? {
height: event.setting.currentHeight,
width: event.setting.currentWidth
} : undefined));
events.reactUse("notify_settings_framerate", event => setFrameRate(event.frameRate ? event.frameRate.original : undefined))
events.reactUse("action_setting_dimension", event => setDimensions({ width: event.width, height: event.height }));
events.reactUse("action_setting_framerate", event => setFrameRate(event.frameRate));
let bpsText;
if(dimensions && frameRate) {
const bps = calculateBps(dimensions.width, dimensions.height, frameRate);
if(bps > 1000 * 1000) {
bpsText = (bps / 1000 / 1000).toFixed(2) + " MBits/Second";
} else {
bpsText = (bps / 1000).toFixed(2) + " KBits/Second";
}
}
return (
<div className={cssStyle.setting + " " + cssStyle.bpsInfo}>
<div className={cssStyle.title}>
<div><Translatable>Estimated Bitrate</Translatable></div>
<div>{bpsText}</div>
</div>
<div className={cssStyle.body}>
<Translatable>
This is a rough estimate of the bitrate used to transfer video of that quality.
The real bitrate might have higher peaks but averages below the estimate.
Influential factors are the video size as well as the frame rate.
</Translatable>
</div>
</div>
);
}
const Settings = () => {
if(window.detectedBrowser.name === "firefox") {
/* Firefox does not seem to give a fuck about any of our settings */
return null;
}
const [ advanced, setAdvanced ] = useState(false);
return (
<AdvancedSettings.Provider value={advanced}>
<div className={cssStyle.section + " " + cssStyle.columnSettings}>
<div className={cssStyle.head}>
<div className={cssStyle.title}><Translatable>Settings</Translatable></div>
<div className={cssStyle.advanced}>
<Checkbox label={<Translatable>Advanced</Translatable>} onChange={value => setAdvanced(value)} />
</div>
</div>
<div className={cssStyle.body}>
<SettingDimension />
<SettingFramerate />
<BpsInfo />
</div>
</div>
</AdvancedSettings.Provider>
);
}
export class ModalVideoSource extends InternalModal {
protected readonly events: Registry<ModalVideoSourceEvents>;
private readonly sourceType: VideoBroadcastType;
constructor(events: Registry<ModalVideoSourceEvents>, type: VideoBroadcastType) {
super();
this.sourceType = type;
this.events = events;
}
renderBody(): React.ReactElement {
return (
<ModalEvents.Provider value={this.events}>
<div className={cssStyle.container}>
<div className={cssStyle.content}>
<div className={cssStyle.columnSource}>
<div className={cssStyle.section}>
<div className={cssStyle.head + " " + cssStyle.title}>
<Translatable>Select your source</Translatable>
</div>
{this.sourceType === "camera" ? <VideoSourceSelector key={"source-selector"} /> : <VideoSourceRequester key={"source-requester"} />}
</div>
<div className={cssStyle.section}>
<div className={cssStyle.head + " " + cssStyle.title}>
<Translatable>Video preview</Translatable>
</div>
<div className={cssStyle.body}>
<VideoPreview />
</div>
</div>
</div>
<Settings />
</div>
<div className={cssStyle.buttons}>
<Button type={"small"} color={"red"} onClick={() => this.events.fire("action_cancel")}>
<Translatable>Cancel</Translatable>
</Button>
<ButtonStart />
</div>
</div>
</ModalEvents.Provider>
);
}
title(): string | React.ReactElement<Translatable> {
return <Translatable>Start video Broadcasting</Translatable>;
}
}