diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index 5af6f5a2..8baf5251 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -5,6 +5,23 @@ import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; export type VideoBroadcastType = "camera" | "screen"; +export type VideoBroadcastStatistics = { + dimensions: { width: number, height: number }, + frameRate: number, + + codec: { name: string, payloadType: number } + + maxBandwidth: number, + bandwidth: number, + + qualityLimitation: "cpu" | "bandwidth", + + source: { + frameRate: number, + dimensions: { width: number, height: number }, + } +}; + export interface VideoConnectionEvent { notify_status_changed: { oldState: VideoConnectionStatus, newState: VideoConnectionStatus }, notify_local_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState }, @@ -53,6 +70,7 @@ export interface VideoConnection { isBroadcasting(type: VideoBroadcastType); getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined; getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState; + getBroadcastStatistics(type: VideoBroadcastType) : Promise; /** * @param type diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index d6614e99..0edb27d9 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -18,6 +18,7 @@ import {server_connections} from "tc-shared/ConnectionManager"; import {spawnAbout} from "tc-shared/ui/modal/ModalAbout"; import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller"; import {LogCategory, logError} from "tc-shared/log"; +import {getVideoDriver} from "tc-shared/video/VideoSource"; /* function initialize_sounds(event_registry: Registry) { @@ -178,26 +179,29 @@ export function initialize(event_registry: Registry) event_registry.on("action_toggle_video_broadcasting", event => { if(event.enabled) { - if(event.broadcastType === "camera") { - spawnVideoSourceSelectModal().then(source => { - if(!source) { return; } + spawnVideoSourceSelectModal(event.broadcastType, true).then(async source => { + if(!source) { return; } - try { - event.connection.getServerConnection().getVideoConnection().startBroadcasting("camera", source) - .catch(error => { - logError(LogCategory.VIDEO, tr("Failed to start video broadcasting: %o"), error); - if(typeof error !== "string") { - error = tr("lookup the console for detail"); - } + const videoTrack = source.getStream().getVideoTracks()[0]; + + try { + event.connection.getServerConnection().getVideoConnection().startBroadcasting("camera", source) + .catch(error => { + logError(LogCategory.VIDEO, tr("Failed to start %s broadcasting: %o"), event.broadcastType, error); + if(typeof error !== "string") { + error = tr("lookup the console for detail"); + } + + if(event.broadcastType === "camera") { createErrorModal(tr("Failed to start video broadcasting"), tra("Failed to start video broadcasting:\n{}", error)).open(); - }); - } finally { - - } - }); - } else { - /* TODO! */ - } + } else { + createErrorModal(tr("Failed to start screen sharing"), tra("Failed to start screen sharing:\n{}", error)).open(); + } + }); + } finally { + source.deref(); + } + }); } else { event.connection.getServerConnection().getVideoConnection().stopBroadcasting(event.broadcastType); } diff --git a/shared/js/main.tsx b/shared/js/main.tsx index ad33a905..7a0712a2 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -247,16 +247,6 @@ function main() { /* schedule it a bit later then the main because the main function is still within the loader */ setTimeout(() => { - (window as any).spawnVideo = async () => { - const source = await spawnVideoSourceSelectModal(); - if(!source) { return; } - - await server_connections.active_connection().getServerConnection().getVideoConnection().startBroadcasting("camera", source); - }; - - (window as any).videoDriver = getVideoDriver(); - const connection = server_connections.active_connection(); - //(window as any).spawnVideo(); /* Modals.createChannelModal(connection, undefined, undefined, connection.permissions, (cb, perms) => { diff --git a/shared/js/text/markdown.ts b/shared/js/text/markdown.ts index 7a9817ec..529c88b2 100644 --- a/shared/js/text/markdown.ts +++ b/shared/js/text/markdown.ts @@ -20,6 +20,7 @@ export class MD2BBCodeRenderer { "hardbreak": () => "\n", "paragraph_open": (renderer: MD2BBCodeRenderer, token: ParagraphOpenToken) => { + debugger; const last_line = !renderer.last_paragraph || !renderer.last_paragraph.lines ? 0 : renderer.last_paragraph.lines[1]; const lines = token.lines[0] - last_line; return [...new Array(lines)].map(() => "[br]").join(""); diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index 3818e634..64358fb4 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -4,7 +4,7 @@ import { ControlBarEvents, ControlBarMode, HostButtonInfo, - VideoCamaraState + VideoState } from "tc-shared/ui/frames/control-bar/Definitions"; import {server_connections} from "tc-shared/ConnectionManager"; import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; @@ -22,7 +22,7 @@ import { } from "tc-shared/bookmarks"; import {LogCategory, logWarn} from "tc-shared/log"; import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; -import {VideoBroadcastState, VideoConnectionStatus} from "tc-shared/connection/VideoConnection"; +import {VideoBroadcastState, VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection"; class InfoController { private readonly mode: ControlBarMode; @@ -49,13 +49,15 @@ class InfoController { this.registerGlobalHandlerEvents(event.handler); this.sendConnectionState(); this.sendAwayState(); - this.sendCamaraState(); + this.sendVideoState("screen"); + this.sendVideoState("camera"); })); events.push(server_connections.events().on("notify_handler_deleted", event => { this.unregisterGlobalHandlerEvents(event.handler); this.sendConnectionState(); this.sendAwayState(); - this.sendCamaraState(); + this.sendVideoState("screen"); + this.sendVideoState("camera"); })); events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks())); @@ -97,7 +99,8 @@ class InfoController { events.push(handler.events().on("notify_connection_state_changed", event => { if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) { this.sendHostButton(); - this.sendCamaraState(); + this.sendVideoState("screen"); + this.sendVideoState("camera"); } })); @@ -123,7 +126,8 @@ class InfoController { const videoConnection = handler.getServerConnection().getVideoConnection(); events.push(videoConnection.getEvents().on(["notify_local_broadcast_state_changed", "notify_status_changed"], () => { - this.sendCamaraState(); + this.sendVideoState("screen"); + this.sendVideoState("camera"); })); } @@ -149,7 +153,8 @@ class InfoController { this.sendSubscribeState(); this.sendQueryState(); this.sendHostButton(); - this.sendCamaraState(); + this.sendVideoState("screen"); + this.sendVideoState("camera"); } public sendConnectionState() { @@ -241,26 +246,26 @@ class InfoController { }); } - public sendCamaraState() { - let status: VideoCamaraState; + public sendVideoState(type: VideoBroadcastType) { + let state: VideoState; if(this.currentHandler?.connected) { const videoConnection = this.currentHandler.getServerConnection().getVideoConnection(); if(videoConnection.getStatus() === VideoConnectionStatus.Connected) { - if(videoConnection.getBroadcastingState("camera") === VideoBroadcastState.Running) { - status = "enabled"; + if(videoConnection.getBroadcastingState(type) === VideoBroadcastState.Running) { + state = "enabled"; } else { - status = "disabled"; + state = "disabled"; } } else if(videoConnection.getStatus() === VideoConnectionStatus.Unsupported) { - status = "unsupported"; + state = "unsupported"; } else { - status = "unavailable"; + state = "unavailable"; } } else { - status = "disconnected"; + state = "disconnected"; } - this.events.fire_react("notify_camara_state", { state: status }); + this.events.fire_react("notify_video_state", { state: state, broadcastType: type }); } } @@ -287,7 +292,7 @@ export function initializeControlBarController(events: Registry infoHandler.sendSpeakerState()); events.on("query_subscribe_state", () => infoHandler.sendSubscribeState()); events.on("query_host_button", () => infoHandler.sendHostButton()); - events.on("query_camara_state", () => infoHandler.sendCamaraState()); + events.on("query_video_state", event => infoHandler.sendVideoState(event.broadcastType)); events.on("action_connection_connect", event => global_client_actions.fire("action_open_window_connect", { newTab: event.newTab })); events.on("action_connection_disconnect", event => { diff --git a/shared/js/ui/frames/control-bar/Definitions.ts b/shared/js/ui/frames/control-bar/Definitions.ts index 2c5ba054..b1b15e75 100644 --- a/shared/js/ui/frames/control-bar/Definitions.ts +++ b/shared/js/ui/frames/control-bar/Definitions.ts @@ -6,7 +6,7 @@ export type ConnectionState = { currentlyConnected: boolean, generallyConnected: export type Bookmark = { uniqueId: string, label: string, icon: RemoteIconInfo | undefined, children?: Bookmark[] }; export type AwayState = { locallyAway: boolean, globallyAway: "partial" | "full" | "none" }; export type MicrophoneState = "enabled" | "disabled" | "muted"; -export type VideoCamaraState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected"; +export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected"; export type HostButtonInfo = { title?: string, target?: string, url: string }; export interface ControlBarEvents { @@ -32,7 +32,7 @@ export interface ControlBarEvents { query_subscribe_state: {}, query_query_state: {}, query_host_button: {}, - query_camara_state: {}, + query_video_state: { broadcastType: VideoBroadcastType }, notify_mode: { mode: ControlBarMode } notify_connection_state: { state: ConnectionState }, @@ -43,7 +43,7 @@ export interface ControlBarEvents { notify_subscribe_state: { subscribe: boolean }, notify_query_state: { shown: boolean }, notify_host_button: { button: HostButtonInfo | undefined }, - notify_camara_state: { state: VideoCamaraState }, + notify_video_state: { broadcastType: VideoBroadcastType, state: VideoState }, notify_destroy: {} } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index 909a5c82..189ee7a7 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -7,7 +7,7 @@ import { ControlBarMode, HostButtonInfo, MicrophoneState, - VideoCamaraState + VideoState } from "tc-shared/ui/frames/control-bar/Definitions"; import * as React from "react"; import {useContext, useRef, useState} from "react"; @@ -17,6 +17,7 @@ import {Button} from "tc-shared/ui/frames/control-bar/Button"; import {spawnContextMenu} from "tc-shared/ui/ContextMenu"; import {ClientIcon} from "svg-sprites/client-icons"; import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; const cssStyle = require("./Renderer.scss"); const cssButtonStyle = require("./Button.scss"); @@ -204,36 +205,51 @@ const AwayButton = () => { ); }; -const VideoButton = () => { +const VideoButton = (props: { type: VideoBroadcastType }) => { const events = useContext(Events); - const [ state, setState ] = useState(() => { - events.fire("query_camara_state"); + const [ state, setState ] = useState(() => { + events.fire("query_video_state", { broadcastType: props.type }); return "unsupported"; }); - events.on("notify_camara_state", event => setState(event.state)); + events.on("notify_video_state", event => event.broadcastType === props.type && setState(event.state)); + let icon: ClientIcon = props.type === "camera" ? ClientIcon.VideoMuted : ClientIcon.ShareScreen; switch (state) { - case "unsupported": - return + {info} + + + ); +}; + const VideoPreviewMessage = (props: { message: any, kind: "info" | "error" }) => { return (
@@ -198,12 +250,368 @@ const ButtonStart = () => { ); } +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(() => { + 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(); + const refSliderHeight = useRef(); + + 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 ( +
+
+
Dimension
+
{settings ? width + "x" + height : ""}
+
+
+ +
+
+ Aspect ratio} + disabled={selectValue !== "custom" || !settings} + value={aspectRatio} + onChange={value => setAspectRatio(value)} + /> +
+
+
Width
+
{settings ? width + "px" : ""}
+
+ updateWidth(value, false)} + disabled={selectValue !== "custom" || !settings} + onChange={value => updateWidth(value, true)} + ref={refSliderWidth} + /> +
+
Height
+
{settings ? height + "px" : ""}
+
+ updateHeight(value, false)} + onChange={value => updateHeight(value, true)} + disabled={selectValue !== "custom" || !settings} + ref={refSliderHeight} + /> +
+
+
+ ); +} + +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(() => { + 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 ( +
+
+
Framerate
+
{frameRate ? currentRate : ""}
+
+
+ +
+
+ ); +} + +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(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 ( +
+
+
Estimated Bitrate
+
{bpsText}
+
+
+ + 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. + +
+
+ ); +} + +const Settings = () => { + const [ advanced, setAdvanced ] = useState(false); + + return ( + +
+
+
Settings
+
+ Advanced} onChange={value => setAdvanced(value)} /> +
+
+
+ + + +
+
+
+ ); +} + export class ModalVideoSource extends InternalModal { protected readonly events: Registry; + private readonly sourceType: VideoBroadcastType; - constructor(events: Registry) { + constructor(events: Registry, type: VideoBroadcastType) { super(); + this.sourceType = type; this.events = events; } @@ -212,21 +620,23 @@ export class ModalVideoSource extends InternalModal {
-
-
- Select your source +
+
+
+ Select your source +
+ {this.sourceType === "camera" ? : }
- -
-
-
- Video preview -
-
- +
+
+ Video preview +
+
+ +
- { /* TODO: All the overlays */ } +