TeaWeb/shared/js/ui/modal/video-source/Controller.tsx
2021-02-09 10:11:40 +01:00

590 lines
No EOL
23 KiB
TypeScript

import {Registry} from "tc-shared/events";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions";
import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer";
import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {VideoBroadcastConfig, VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {Settings, settings} from "tc-shared/settings";
import {tr} from "tc-shared/i18n/localize";
export type VideoSourceModalAction = {
mode: "select-quick",
defaultDevice?: string
} | {
mode: "select-default",
defaultDevice?: string
} | {
mode: "new"
} | {
mode: "edit",
source: VideoSource,
broadcastConstraints: VideoBroadcastConfig
};
export type VideoSourceSelectResult = { source: VideoSource | undefined, config: VideoBroadcastConfig | undefined };
/**
* @param type The video type which should be prompted
* @param mode
*/
export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, mode: VideoSourceModalAction) : Promise<VideoSourceSelectResult> {
const controller = new VideoSourceController(type);
let defaultSelectDevice: string | true;
if(mode.mode === "select-quick") {
/* We need the modal itself for the native client in order to present the window selector */
if(type === "camera" || __build.target === "web") {
/* Try to get the default device. If we succeeded directly return that */
if(await controller.selectSource(mode.defaultDevice)) {
/* select succeeded */
const resultSource = controller.getCurrentSource()?.ref();
const resultConstraints = controller.getBroadcastConstraints();
controller.destroy();
return {
source: resultSource,
config: resultConstraints
};
} else {
/* Select failed. We'll open the modal and show the error. */
}
} else {
defaultSelectDevice = mode.defaultDevice || true;
}
} else if(mode.mode === "select-default") {
defaultSelectDevice = mode.defaultDevice || true;
} else if(mode.mode === "edit") {
await controller.useSettings(mode.source, mode.broadcastConstraints);
}
let result: VideoSourceSelectResult = {
config: undefined,
source: undefined
};
const modal = spawnReactModal(ModalVideoSource, controller.events, type, mode.mode === "edit");
controller.events.on(["action_start", "action_cancel"], event => {
result.source?.deref();
if(event.type === "action_start") {
result.source = controller.getCurrentSource()?.ref();
result.config = controller.getBroadcastConstraints();
} else {
result.source = undefined;
result.config = undefined;
}
modal.destroy();
});
modal.show().then(() => {
if(defaultSelectDevice) {
if(type === "screen" && getVideoDriver().screenQueryAvailable()) {
controller.events.fire_react("action_toggle_screen_capture_device_select", { shown: true });
} else {
controller.selectSource(defaultSelectDevice === true ? undefined : defaultSelectDevice);
}
}
});
await new Promise(resolve => {
if(mode.mode === "select-quick" && __build.target !== "web") {
/* We need the modal event for quick select */
const callbackRemove = controller.events.on("notify_video_preview", event => {
if(event.status.status === "error") {
callbackRemove();
}
if(event.status.status === "preview") {
/* We've successfully selected something. Use that device instead. */
result.source?.deref();
result.source = controller.getCurrentSource()?.ref();
result.config = controller.getBroadcastConstraints();
modal.destroy();
}
});
}
modal.events.one(["destroy", "close"], resolve);
});
controller.destroy();
return result;
}
function updateBroadcastConfigFromSource(source: VideoSource, constraints: VideoBroadcastConfig) {
const videoTrack = source.getStream().getVideoTracks()[0];
const trackSettings = videoTrack.getSettings();
constraints.width = trackSettings.width;
constraints.height = trackSettings.height;
constraints.maxFrameRate = trackSettings.frameRate;
}
async function generateAndApplyDefaultConfig(source: VideoSource) : Promise<VideoBroadcastConfig> {
const videoTrack = source.getStream().getVideoTracks()[0];
let maxHeight = settings.getValue(Settings.KEY_VIDEO_DEFAULT_MAX_HEIGHT);
let maxWidth = settings.getValue(Settings.KEY_VIDEO_DEFAULT_MAX_WIDTH);
const trackSettings = videoTrack.getSettings();
const capabilities = source.getCapabilities();
/* Safari */
if(trackSettings.height === 0) {
trackSettings.height = capabilities.maxHeight;
}
if(trackSettings.width === 0) {
trackSettings.width = capabilities.maxWidth;
}
maxHeight = maxHeight ? Math.min(maxHeight, capabilities.maxHeight) : capabilities.maxHeight;
maxWidth = maxWidth ? Math.min(maxWidth, capabilities.maxWidth) : capabilities.maxWidth;
const broadcastConstraints: VideoBroadcastConfig = {
maxBandwidth: settings.getValue(Settings.KEY_VIDEO_DEFAULT_MAX_BANDWIDTH),
keyframeInterval: settings.getValue(Settings.KEY_VIDEO_DEFAULT_KEYFRAME_INTERVAL)
} as VideoBroadcastConfig;
{
let ratio = 1;
if(trackSettings.height > maxHeight) {
ratio = Math.min(maxHeight / trackSettings.height, ratio);
}
if(trackSettings.width > maxWidth) {
ratio = Math.min(maxWidth / trackSettings.width, ratio);
}
if(ratio !== 1) {
broadcastConstraints.width = Math.ceil(ratio * trackSettings.width);
broadcastConstraints.height = Math.ceil(ratio * trackSettings.height);
} else {
broadcastConstraints.width = trackSettings.width;
broadcastConstraints.height = trackSettings.height;
}
}
broadcastConstraints.dynamicQuality = settings.getValue(Settings.KEY_VIDEO_DYNAMIC_QUALITY);
broadcastConstraints.dynamicFrameRate = settings.getValue(Settings.KEY_VIDEO_DYNAMIC_FRAME_RATE);
try {
await applyBroadcastConfig(source, broadcastConstraints);
} catch (error) {
logWarn(LogCategory.VIDEO, tr("Failed to apply initial default broadcast config (%o): %o"), broadcastConstraints, error);
}
updateBroadcastConfigFromSource(source, broadcastConstraints);
return broadcastConstraints;
}
/* May throws an overconstraint error */
async function applyBroadcastConfig(source: VideoSource, constraints: VideoBroadcastConfig) {
const videoTrack = source.getStream().getVideoTracks()[0];
if(!videoTrack) { return; }
await videoTrack.applyConstraints({
frameRate: constraints.dynamicFrameRate ? {
min: 1,
max: constraints.maxFrameRate,
ideal: constraints.maxFrameRate
} : constraints.maxFrameRate,
width: constraints.dynamicQuality ? {
min: 1,
max: constraints.width,
ideal: constraints.width
} : constraints.width,
height: constraints.dynamicQuality ? {
min: 1,
max: constraints.height,
ideal: constraints.height
} : constraints.height
});
}
class VideoSourceController {
readonly events: Registry<ModalVideoSourceEvents>;
private readonly type: VideoBroadcastType;
private currentSource: VideoSource | string;
private currentConstraints: VideoBroadcastConfig;
/* preselected current source id */
private currentSourceId: string;
/* fallback current source name if "currentSource" is empty */
private fallbackCurrentSourceName: string;
constructor(type: VideoBroadcastType) {
this.type = type;
this.events = new Registry<ModalVideoSourceEvents>();
this.events.enableDebug("video-source-select");
this.events.on("query_source", () => this.notifyCurrentSource());
this.events.on("query_device_list", () => this.notifyDeviceList());
this.events.on("query_screen_capture_devices", () => this.notifyScreenCaptureDevices());
this.events.on("query_video_preview", () => this.notifyVideoPreview());
this.events.on("query_start_button", () => this.notifyStartButton());
this.events.on("query_setting_dimension", () => this.notifySettingDimension());
this.events.on("query_setting_framerate", () => this.notifySettingFramerate());
this.events.on("query_setting_bitrate_max", () => this.notifySettingBitrate());
this.events.on("query_setting_keyframe_sender", () => this.notifySettingKeyframeInterval());
this.events.on("action_request_permissions", () => {
getVideoDriver().requestPermissions().then(result => {
if(typeof result === "object") {
this.currentSourceId = result.getId() + " --";
this.fallbackCurrentSourceName = result.getName();
this.notifyDeviceList();
this.setCurrentSource(result);
} else {
/* the device list will already be updated due to the notify_permissions_changed event */
}
});
});
this.events.on("action_select_source", event => {
const driver = getVideoDriver();
if(type === "camera") {
this.currentSourceId = event.id;
this.fallbackCurrentSourceName = tr("loading...");
this.notifyDeviceList();
driver.createVideoSource(this.currentSourceId).then(stream => {
this.fallbackCurrentSourceName = stream.getName();
this.setCurrentSource(stream);
}).catch(error => {
this.fallbackCurrentSourceName = "invalid device";
if(typeof error === "string") {
this.setCurrentSource(error);
} else {
logError(LogCategory.GENERAL, tr("Failed to open video device %s: %o"), event.id, error);
this.setCurrentSource(tr("Failed to open video device (Lookup the console)"));
}
});
} else if(driver.screenQueryAvailable() && typeof event.id === "undefined") {
this.events.fire_react("action_toggle_screen_capture_device_select", { shown: true });
} else {
this.currentSourceId = undefined;
this.fallbackCurrentSourceName = tr("loading...");
driver.createScreenSource(event.id, false).then(stream => {
this.setCurrentSource(stream);
this.fallbackCurrentSourceName = stream?.getName() || tr("No stream");
}).catch(error => {
this.fallbackCurrentSourceName = "screen capture failed";
if(typeof error === "string") {
this.setCurrentSource(error);
} else {
logError(LogCategory.GENERAL, tr("Failed to open screen capture device %s: %o"), event.id, error);
this.setCurrentSource(tr("Failed to open screen capture device (Lookup the console)"));
}
});
}
});
this.events.on("action_cancel", () => {
this.setCurrentSource(undefined);
});
if(type === "camera") {
/* only the camara requires a device list */
this.events.on("notify_destroy", getVideoDriver().getEvents().on("notify_permissions_changed", () => {
if(getVideoDriver().getPermissionStatus() !== VideoPermissionStatus.Granted) {
this.currentSourceId = undefined;
this.fallbackCurrentSourceName = undefined;
this.notifyDeviceList();
/* implicitly updates the start button */
this.setCurrentSource(undefined);
} else {
this.notifyDeviceList();
this.notifyVideoPreview();
this.notifyStartButton();
}
}));
}
this.events.on("action_setting_dimension", event => {
this.currentConstraints.height = event.height;
this.currentConstraints.width = event.width;
});
this.events.on("action_setting_framerate", event => {
this.currentConstraints.maxFrameRate = event.frameRate;
});
this.events.on("action_setting_bitrate_max", event => {
this.currentConstraints.maxBandwidth = event.bitrate;
});
this.events.on("action_setting_keyframe_sender", event => {
this.currentConstraints.keyframeInterval = event.interval;
});
}
destroy() {
if(typeof this.currentSource === "object") {
this.currentSource.deref();
this.currentSource = undefined;
}
this.events.fire("notify_destroy");
this.events.destroy();
}
async setCurrentSource(source: VideoSource | string | undefined) {
if(typeof this.currentSource === "object") {
this.currentSource.deref();
}
if(typeof source === "object") {
if(this.currentConstraints) {
try {
/* TODO: Automatically scale down resolution if new one isn't capable of supplying our current resolution */
await applyBroadcastConfig(source, this.currentConstraints);
} catch (error) {
logWarn(LogCategory.VIDEO, tr("Failed to apply broadcast constraints to new source: %o"), error);
this.currentConstraints = undefined;
}
}
if(!this.currentConstraints) {
this.currentConstraints = await generateAndApplyDefaultConfig(source);
}
}
this.currentSource = source;
this.notifyVideoPreview();
this.notifyStartButton();
this.notifyCurrentSource();
this.notifySettingDimension();
this.notifySettingFramerate();
this.notifySettingBitrate();
this.notifySettingKeyframeInterval();
}
async useSettings(source: VideoSource, constraints: VideoBroadcastConfig) {
if(typeof this.currentSource === "object") {
this.currentSource.deref();
}
this.currentSource = source.ref();
this.currentConstraints = constraints;
this.notifyVideoPreview();
this.notifyStartButton();
this.notifyCurrentSource();
this.notifySettingDimension();
this.notifySettingFramerate();
this.notifySettingBitrate();
this.notifySettingKeyframeInterval();
}
async selectSource(sourceId: string) : Promise<boolean> {
const driver = getVideoDriver();
let streamPromise: Promise<VideoSource>;
if(this.type === "camera") {
this.currentSourceId = sourceId;
this.fallbackCurrentSourceName = tr("loading...");
this.notifyDeviceList();
streamPromise = driver.createVideoSource(this.currentSourceId);
} else if(driver.screenQueryAvailable() && typeof sourceId === "undefined") {
/* TODO: What the hack is this?! */
this.events.fire_react("action_toggle_screen_capture_device_select", { shown: true });
return;
} else {
this.currentSourceId = undefined;
this.fallbackCurrentSourceName = tr("loading...");
streamPromise = driver.createScreenSource(sourceId, false);
}
try {
const stream = await streamPromise;
await this.setCurrentSource(stream);
this.fallbackCurrentSourceName = stream?.getName() || tr("No stream");
return !!stream;
} catch (error) {
this.fallbackCurrentSourceName = tr("failed to attach to device");
if(typeof error === "string") {
await this.setCurrentSource(error);
} else {
logError(LogCategory.GENERAL, tr("Failed to open capture device %s: %o"), sourceId, error);
await this.setCurrentSource(tr("Failed to open capture device (Lookup the console)"));
}
return false;
}
}
getCurrentSource() : VideoSource | undefined {
return typeof this.currentSource === "object" ? this.currentSource : undefined;
}
getBroadcastConstraints() : VideoBroadcastConfig {
return this.currentConstraints;
}
private notifyStartButton() {
this.events.fire_react("notify_start_button", { enabled: typeof this.currentSource === "object" })
}
private notifyDeviceList(){
const driver = getVideoDriver();
driver.getDevices().then(devices => {
if(devices === false) {
if(driver.getPermissionStatus() === VideoPermissionStatus.SystemDenied) {
this.events.fire_react("notify_device_list", { status: { status: "error", reason: "no-permissions" } });
} else {
this.events.fire_react("notify_device_list", { status: { status: "error", reason: "request-permissions" } });
}
} else {
this.events.fire_react("notify_device_list", {
status: {
status: "success",
devices: devices.map(e => { return { id: e.id, displayName: e.name }}),
selectedDeviceId: this.currentSourceId,
fallbackSelectedDeviceName: this.fallbackCurrentSourceName
}
});
}
});
}
private notifyScreenCaptureDevices() {
const driver = getVideoDriver();
driver.queryScreenCaptureDevices().then(devices => {
this.events.fire_react("notify_screen_capture_devices", { devices: { status: "success", devices: devices }});
}).catch(error => {
if(typeof error !== "string") {
logError(LogCategory.VIDEO, tr("Failed to query screen capture devices: %o"), error);
error = tr("lookup the console");
}
this.events.fire_react("notify_screen_capture_devices", { devices: { status: "error", reason: error }});
})
}
private notifyVideoPreview() {
const driver = getVideoDriver();
switch (driver.getPermissionStatus()) {
case VideoPermissionStatus.SystemDenied:
this.events.fire_react("notify_video_preview", { status: { status: "error", reason: "no-permissions" }});
break;
case VideoPermissionStatus.UserDenied:
this.events.fire_react("notify_video_preview", { status: { status: "error", reason: "request-permissions" }});
break;
case VideoPermissionStatus.Granted:
if(typeof this.currentSource === "string") {
this.events.fire_react("notify_video_preview", { status: {
status: "error",
reason: "custom",
message: this.currentSource
}});
} else if(this.currentSource) {
this.events.fire_react("notify_video_preview", { status: {
status: "preview",
stream: this.currentSource.getStream()
}});
} else {
this.events.fire_react("notify_video_preview", { status: { status: "none" }});
}
break;
}
};
private notifyCurrentSource() {
if(typeof this.currentSource === "object") {
this.events.fire_react("notify_source", {
state: {
type: "selected",
deviceId: this.currentSource.getId(),
name: this.currentSource?.getName() || this.fallbackCurrentSourceName
}
});
} else if(typeof this.currentSource === "string") {
this.events.fire_react("notify_source", {
state: {
type: "errored",
error: this.currentSource
}
});
} else {
this.events.fire_react("notify_source", {
state: {
type: "none"
}
});
}
}
private notifySettingDimension() {
if(typeof this.currentSource === "object") {
const initialSettings = this.currentSource.getInitialSettings();
const capabilities = this.currentSource.getCapabilities();
const constraints = this.currentConstraints;
this.events.fire_react("notify_setting_dimension", {
setting: {
minWidth: capabilities.minWidth,
maxWidth: capabilities.maxWidth,
minHeight: capabilities.minHeight,
maxHeight: capabilities.maxHeight,
originalWidth: initialSettings.width,
originalHeight: initialSettings.height,
currentWidth: constraints.width,
currentHeight: constraints.height
}
});
} else {
this.events.fire_react("notify_setting_dimension", { setting: undefined });
}
};
notifySettingFramerate() {
if(typeof this.currentSource === "object") {
const initialSettings = this.currentSource.getInitialSettings();
const capabilities = this.currentSource.getCapabilities();
const round = (value: number) => Math.round(value * 100) / 100;
this.events.fire_react("notify_settings_framerate", {
frameRate: {
min: round(capabilities.minFrameRate),
max: round(capabilities.maxFrameRate),
original: round(initialSettings.frameRate),
current: round(this.currentConstraints.maxFrameRate)
}
});
} else {
this.events.fire_react("notify_settings_framerate", { frameRate: undefined });
}
};
private notifySettingBitrate() {
if(this.currentConstraints) {
this.events.fire_react("notify_setting_bitrate_max", {
bitrate: {
allowedBitrate: 0,
bitrate: this.currentConstraints.maxBandwidth
}
});
} else {
this.events.fire_react("notify_setting_bitrate_max", undefined);
}
}
private notifySettingKeyframeInterval() {
this.events.fire_react("notify_settings_keyframe_sender", {
interval: this.currentConstraints?.keyframeInterval || 0
});
}
}