diff --git a/ChangeLog.md b/ChangeLog.md index 5de5a4aa..8888a998 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,10 +1,14 @@ # Changelog: -** 22.11.20** +* **29.11.20** + - Added support for the native client to show and broadcast video + - By default using a quick select method when sharing the screen + +* **22.11.20** - Added a ton of video settings - Added screen sharing (Currently via the camera channel) - Using codec H264 instead of VP8 -** 14.11.20** +* **14.11.20** - Fixed bug where the microphone has been requested when muting it. * **07.11.20** diff --git a/loader/app/loader/loader.ts b/loader/app/loader/loader.ts index d4a00b4a..6b5bd5b4 100644 --- a/loader/app/loader/loader.ts +++ b/loader/app/loader/loader.ts @@ -3,20 +3,6 @@ import * as template_loader from "./template_loader"; import * as Animation from "../animation"; import {getUrlParameter} from "./utils"; -declare global { - interface Window { - tr(message: string) : string; - tra(message: string, ...args: (string | number | boolean)[]) : string; - tra(message: string, ...args: any[]) : JQuery[]; - - log: any; - StaticSettings: any; - } - - //const tr: typeof window.tr; - //const tra: typeof window.tra; -} - export interface ApplicationLoader { execute(); } diff --git a/shared/declaration_fix.d.ts b/shared/declaration_fix.d.ts deleted file mode 100644 index 3d8ab5d0..00000000 --- a/shared/declaration_fix.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -declare global { - interface Window { - tr(message: string) : string; - tra(message: string, ...args: (string | number | boolean)[]) : string; - tra(message: string, ...args: any[]) : JQuery[]; - - log: any; - StaticSettings: any; - - detectedBrowser: any; - __native_client_init_shared: any; - } - - const tr: typeof window.tr; - const tra: typeof window.tra; - - /* webpack compiler variable */ - const __build; - const __webpack_require__; -} - -export {}; \ No newline at end of file diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 1215d85b..432376a2 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -187,11 +187,16 @@ export function initialize(event_registry: Registry) event_registry.on("action_toggle_video_broadcasting", event => { if(event.enabled) { - spawnVideoSourceSelectModal(event.broadcastType, true).then(async source => { + const connection = event.connection; + if(!connection.connected) { + createErrorModal(tr("You're not connected"), tr("You're not connected to any server!")).open(); + return; + } + spawnVideoSourceSelectModal(event.broadcastType, true, !!event.quickSelect).then(async source => { if(!source) { return; } try { - event.connection.getServerConnection().getVideoConnection().startBroadcasting(event.broadcastType, source) + connection.getServerConnection().getVideoConnection().startBroadcasting(event.broadcastType, source) .catch(error => { logError(LogCategory.VIDEO, tr("Failed to start %s broadcasting: %o"), event.broadcastType, error); if(typeof error !== "string") { diff --git a/shared/js/events/GlobalEvents.ts b/shared/js/events/GlobalEvents.ts index 6bd9a675..d8e6c5da 100644 --- a/shared/js/events/GlobalEvents.ts +++ b/shared/js/events/GlobalEvents.ts @@ -29,7 +29,7 @@ export interface ClientGlobalControlEvents { videoUrl: string, handlerId: string }, - action_toggle_video_broadcasting: { connection: ConnectionHandler, enabled: boolean, broadcastType: VideoBroadcastType } + action_toggle_video_broadcasting: { connection: ConnectionHandler, enabled: boolean, broadcastType: VideoBroadcastType, quickSelect?: boolean } /* some more specific window openings */ action_open_window_connect: { diff --git a/shared/js/i18n/localize.ts b/shared/js/i18n/localize.ts index 4ec136d6..1ade1a24 100644 --- a/shared/js/i18n/localize.ts +++ b/shared/js/i18n/localize.ts @@ -326,5 +326,19 @@ export async function initialize() { // await load_file("http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/web/environment/development/i18n/test.json"); } +declare global { + interface Window { + tr(message: string) : string; + tra(message: string, ...args: (string | number | boolean)[]) : string; + tra(message: string, ...args: any[]) : JQuery[]; + + log: any; + StaticSettings: any; + } + + const tr: typeof window.tr; + const tra: typeof window.tra; +} + window.tr = tr; window.tra = tra; \ No newline at end of file diff --git a/shared/js/media/Stream.ts b/shared/js/media/Stream.ts index 1275836a..6019a4fb 100644 --- a/shared/js/media/Stream.ts +++ b/shared/js/media/Stream.ts @@ -19,7 +19,7 @@ export interface MediaStreamEvents { export const mediaStreamEvents = new Registry(); */ -async function requestMediaStream0(constraints: MediaTrackConstraints, type: MediaStreamType, updateDeviceList: boolean) : Promise { +export async function requestMediaStreamWithConstraints(constraints: MediaTrackConstraints, type: MediaStreamType) : Promise { const beginTimestamp = Date.now(); try { log.info(LogCategory.AUDIO, tr("Requesting a %s stream for device %s in group %s"), type, constraints.deviceId, constraints.groupId); @@ -74,7 +74,7 @@ export async function requestMediaStream(deviceId: string | undefined, groupId: constrains.autoGainControl = true; constrains.noiseSuppression = true; - const promise = (currentMediaStreamRequest = requestMediaStream0(constrains, type, true)); + const promise = (currentMediaStreamRequest = requestMediaStreamWithConstraints(constrains, type)); try { return await currentMediaStreamRequest; } finally { diff --git a/shared/js/media/Video.ts b/shared/js/media/Video.ts index 7b622063..cebc959f 100644 --- a/shared/js/media/Video.ts +++ b/shared/js/media/Video.ts @@ -1,4 +1,5 @@ import { + ScreenCaptureDevice, VideoDevice, VideoDriver, VideoDriverEvents, @@ -212,7 +213,15 @@ export class WebVideoDriver implements VideoDriver { } } - async createScreenSource(): Promise { + screenQueryAvailable(): boolean { + return false; + } + + async queryScreenCaptureDevices(): Promise { + throw tr("screen capture device query not supported"); + } + + async createScreenSource(_id: string | undefined, _allowFocusLoss: boolean): Promise { try { const source = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true }); const videoTrack = source.getVideoTracks()[0]; @@ -233,7 +242,7 @@ export class WebVideoDriver implements VideoDriver { } } -class WebVideoSource implements VideoSource { +export class WebVideoSource implements VideoSource { private readonly deviceId: string; private readonly displayName: string; private readonly stream: MediaStream; diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index e3130130..636c8bcf 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -58,6 +58,7 @@ export interface ChannelVideoEvents { action_toggle_expended: { expended: boolean }, action_video_scroll: { direction: "left" | "right" }, action_set_spotlight: { videoId: string | undefined, expend: boolean }, + action_focus_spotlight: {}, action_set_fullscreen: { videoId: string | undefined }, action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType, muted: boolean }, diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index 31a5895c..72068982 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -240,6 +240,7 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea events.fire("action_set_fullscreen", { videoId: props.videoId }); } else { events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); + events.fire("action_focus_spotlight", { }); } }} onContextMenu={event => { @@ -262,6 +263,7 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea icon: ClientIcon.Fullscreen, click: () => { events.fire("action_set_spotlight", { videoId: props.isSpotlight ? undefined : props.videoId, expend: true }); + events.fire("action_focus_spotlight", { }); } } ]); @@ -277,6 +279,7 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId }); } else { events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); + events.fire("action_focus_spotlight", { }); } }} title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")} @@ -394,11 +397,14 @@ 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)); + events.reactUse("notify_spotlight", event => setVideoId(event.videoId), undefined, []); + events.reactUse("action_focus_spotlight", () => refContainer.current?.focus(), undefined, []); let body; if(videoId) { @@ -412,7 +418,16 @@ const Spotlight = () => { } return ( -
+
{ + if(event.key === "Escape") { + events.fire("action_set_spotlight", { videoId: undefined, expend: false }); + } + }} + tabIndex={0} + ref={refContainer} + > {body}
) diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index a5675976..530b55e5 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -7,14 +7,21 @@ import {LogCategory, logError} from "tc-shared/log"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; type VideoSourceRef = { source: VideoSource }; -export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectDefault: boolean) : Promise { + +/** + * @param type The video type which should be prompted + * @param selectDefault If we're trying to select a source on default + * @param quickSelect If we want to quickly select a source and instantly use it. + * This option is only useable for screen sharing. + */ +export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectDefault: boolean, quickSelect: boolean) : Promise { const refSource: VideoSourceRef = { source: undefined }; const events = new Registry(); events.enableDebug("video-source-select"); - initializeController(events, refSource, type); + initializeController(events, refSource, type, quickSelect); const modal = spawnReactModal(ModalVideoSource, events, type); modal.events.on("destroy", () => { @@ -25,12 +32,23 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele modal.destroy(); }); modal.show().then(() => { - if(selectDefault) { + if(type === "screen" && getVideoDriver().screenQueryAvailable()) { + events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); + } else if(selectDefault) { events.fire("action_select_source", { id: undefined }); } }); await new Promise(resolve => { + if(type === "screen" && quickSelect) { + events.on("notify_video_preview", event => { + if(event.status.status === "preview") { + /* we've successfully selected something */ + modal.destroy(); + } + }); + } + modal.events.one(["destroy", "close"], resolve); }); @@ -39,7 +57,7 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele type SourceConstraints = { width?: number, height?: number, frameRate?: number }; -function initializeController(events: Registry, currentSourceRef: VideoSourceRef, type: VideoBroadcastType) { +function initializeController(events: Registry, currentSourceRef: VideoSourceRef, type: VideoBroadcastType, quickSelect: boolean) { let currentSource: VideoSource | string; let currentConstraints: SourceConstraints; @@ -74,6 +92,20 @@ function initializeController(events: Registry, currentS }); } + const notifyScreenCaptureDevices = () => { + const driver = getVideoDriver(); + driver.queryScreenCaptureDevices().then(devices => { + 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"); + } + + events.fire_react("notify_screen_capture_devices", { devices: { status: "error", reason: error }}); + }) + } + const notifyVideoPreview = () => { const driver = getVideoDriver(); switch (driver.getPermissionStatus()) { @@ -188,6 +220,7 @@ function initializeController(events: Registry, currentS events.on("query_source", () => notifyCurrentSource()); events.on("query_device_list", () => notifyDeviceList()); + events.on("query_screen_capture_devices", () => notifyScreenCaptureDevices()); events.on("query_video_preview", () => notifyVideoPreview()); events.on("query_start_button", () => notifyStartButton()); events.on("query_setting_dimension", () => notifySettingDimension()); @@ -227,10 +260,12 @@ function initializeController(events: Registry, currentS setCurrentSource(tr("Failed to open video device (Lookup the console)")); } }); + } else if(driver.screenQueryAvailable() && typeof event.id === "undefined") { + events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); } else { currentSourceId = undefined; fallbackCurrentSourceName = tr("loading..."); - driver.createScreenSource().then(stream => { + driver.createScreenSource(event.id, quickSelect).then(stream => { setCurrentSource(stream); fallbackCurrentSourceName = stream?.getName() || tr("No stream"); }).catch(error => { diff --git a/shared/js/ui/modal/video-source/Definitions.ts b/shared/js/ui/modal/video-source/Definitions.ts index 8d08caaa..0252a68a 100644 --- a/shared/js/ui/modal/video-source/Definitions.ts +++ b/shared/js/ui/modal/video-source/Definitions.ts @@ -1,3 +1,5 @@ +import {ScreenCaptureDevice} from "tc-shared/video/VideoSource"; + export type DeviceListResult = { status: "success", devices: { id: string, displayName: string }[], @@ -30,6 +32,18 @@ export type VideoSourceState = { error: string }; +export type ScreenCaptureDeviceList = { + status: "success", + devices: ScreenCaptureDevice[], +} | { + status: "error", + reason: string +} | { + status: "not-supported" +} | { + status: "loading" +} + export type SettingFrameRate = { min: number, max: number, @@ -43,6 +57,8 @@ export interface ModalVideoSourceEvents { action_select_source: { id: string | undefined }, action_setting_dimension: { width: number, height: number }, action_setting_framerate: { frameRate: number }, + action_toggle_screen_capture_device_select: { shown: boolean }, + action_preselect_screen_capture_device: { deviceId: string }, query_source: {}, query_device_list: {}, @@ -50,6 +66,7 @@ export interface ModalVideoSourceEvents { query_start_button: {}, query_setting_dimension: {}, query_setting_framerate: {}, + query_screen_capture_devices: { } notify_source: { state: VideoSourceState } notify_device_list: { status: DeviceListResult }, @@ -70,6 +87,9 @@ export interface ModalVideoSourceEvents { }, notify_settings_framerate: { frameRate: SettingFrameRate | undefined + }, + notify_screen_capture_devices: { + devices: ScreenCaptureDeviceList } notify_destroy: {} diff --git a/shared/js/ui/modal/video-source/Renderer.scss b/shared/js/ui/modal/video-source/Renderer.scss index 0769e0bb..9a7eb04d 100644 --- a/shared/js/ui/modal/video-source/Renderer.scss +++ b/shared/js/ui/modal/video-source/Renderer.scss @@ -54,148 +54,148 @@ .section { margin-bottom: 1em; + } + } +} - .head { +.buttons { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.sectionHead { + display: flex; + flex-direction: row; + justify-content: flex-start; + + flex-grow: 0; + flex-shrink: 1; + + width: 100%; + + align-self: flex-end; + + &.title, .title { + font-size: 1.2em; + color: #557edc; + text-transform: uppercase; + + align-self: center; + + @include text-dotdotdot(); + } + + .advanced { + margin-left: auto; + align-self: center; + + label { + display: flex; + flex-direction: revert; + } + } +} + +.sectionBody { + display: flex; + flex-direction: column; + justify-content: flex-start; + + .selectError { + color: #a10000; + } + + .videoContainer { + position: relative; + + width: 37.5em; /* 600px for 16px/em */ + height: 25em; /* 400px for 16px/em */ + + border-radius: .2em; + border: 1px solid var(--boxed-input-field-border); + background-color: var(--boxed-input-field-background); + + display: flex; + flex-direction: column; + justify-content: center; + + video { + max-height: 100%; + max-width: 100%; + + min-height: 100%; + min-width: 100%; + + align-self: center; + } + + .overlay { + z-index: 10; + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: none; + flex-direction: column; + justify-content: center; + + text-align: center; + + background-color: var(--boxed-input-field-background); + + &.shown { display: flex; - flex-direction: row; - justify-content: flex-start; + } - flex-grow: 1; - flex-shrink: 1; - - width: 100%; - - align-self: flex-end; - - &.title, .title { + &.permissions { + .text { font-size: 1.2em; - color: #557edc; - text-transform: uppercase; - - align-self: center; - - @include text-dotdotdot(); + padding-bottom: 1em; } - .advanced { - margin-left: auto; + .button { + width: min-content; align-self: center; - - label { - display: flex; - flex-direction: revert; - } } } - .body { - display: flex; - flex-direction: column; - justify-content: flex-start; + .error { + font-size: 1.2em; + color: #a10000; + padding-bottom: 1em; + } - .selectError { + .info { + font-size: 1.8em; + padding-bottom: 1em; + font-weight: 600; + color: #4d4d4d; + + &.selected {} + &.none {} + &.error { color: #a10000; } - - .videoContainer { - position: relative; - - width: 37.5em; /* 600px for 16px/em */ - height: 25em; /* 400px for 16px/em */ - - border-radius: .2em; - border: 1px solid var(--boxed-input-field-border); - background-color: var(--boxed-input-field-background); - - display: flex; - flex-direction: column; - justify-content: center; - - video { - max-height: 100%; - max-width: 100%; - - min-height: 100%; - min-width: 100%; - - align-self: center; - } - - .overlay { - z-index: 10; - position: absolute; - - top: 0; - left: 0; - right: 0; - bottom: 0; - - display: none; - flex-direction: column; - justify-content: center; - - text-align: center; - - background-color: var(--boxed-input-field-background); - - &.shown { - display: flex; - } - - &.permissions { - .text { - font-size: 1.2em; - padding-bottom: 1em; - } - - .button { - width: min-content; - align-self: center; - } - } - - .error { - font-size: 1.2em; - color: #a10000; - padding-bottom: 1em; - } - - .info { - font-size: 1.8em; - padding-bottom: 1em; - font-weight: 600; - color: #4d4d4d; - - &.selected {} - &.none {} - &.error { - color: #a10000; - } - } - } - } - - .sourcePrompt { - display: flex; - flex-direction: row; - justify-content: flex-start; - - button { - margin-right: .5em; - } - - > * { - align-self: center; - } - } } } } - .buttons { + .sourcePrompt { display: flex; flex-direction: row; - justify-content: space-between; + justify-content: flex-start; + + button { + margin-right: .5em; + } + + > * { + align-self: center; + } } } @@ -208,11 +208,11 @@ margin-bottom: .5em; } - .head { + .sectionHead { flex-grow: 0!important; } - .body { + .sectionBody { flex-grow: 1; } @@ -249,4 +249,126 @@ font-size: .8em; } } +} + +.overlayScreenDeviceList { + position: absolute; + @include user-select(none); + + top: 0; + left: 0; + right: 0; + bottom: 0; + + background-color: inherit; + z-index: 10; + + padding: 1em; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + .sectionBody { + margin-top: .5em; + margin-bottom: .5em; + + .tab { + flex-grow: 1; + } + + flex-grow: 1; + } + + .overlay { + position: absolute; + + left: 0; + right: 0; + top: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + font-size: 1.2em; + text-align: center; + + a { + display: flex; + flex-direction: row; + justify-content: center; + } + + &.error { + color: #a32929; + } + } + + .listContainer { + position: relative; + + height: 100%; + width: 100%; + + max-width: 100%; + max-height: 100%; + + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + + @include chat-scrollbar-vertical(); + } +} + +.screenDeviceEntry { + display: flex; + flex-direction: column; + justify-content: space-around; + + margin: .25em .5em; + padding: .5em; + + border-radius: .2em; + overflow: hidden; + + width: 16em; + height: 9.5em; + + flex-shrink: 0; + flex-grow: 0; + cursor: pointer; + + &.selected { + background-color: #2f3137; + } + + &:hover { + background-color: #393c43; + } + + .preview { + /* 16:9 format */ + width: 15em; + height: 8.4375em; + + border-radius: .2em; + overflow: hidden; + + img { + height: 100%; + width: 100%; + object-fit: contain; + } + } + + .name { + line-height: 1.2em; + margin-top: .25em; + + @include text-dotdotdot(); + } } \ No newline at end of file diff --git a/shared/js/ui/modal/video-source/Renderer.tsx b/shared/js/ui/modal/video-source/Renderer.tsx index 6275ac68..b4d442e7 100644 --- a/shared/js/ui/modal/video-source/Renderer.tsx +++ b/shared/js/ui/modal/video-source/Renderer.tsx @@ -2,7 +2,7 @@ import {Registry} from "tc-shared/events"; import * as React from "react"; import { DeviceListResult, - ModalVideoSourceEvents, SettingFrameRate, + ModalVideoSourceEvents, ScreenCaptureDeviceList, SettingFrameRate, VideoPreviewStatus, VideoSourceState } from "tc-shared/ui/modal/video-source/Definitions"; import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; @@ -13,6 +13,9 @@ 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"; +import {Tab, TabEntry} from "tc-shared/ui/react-elements/Tab"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {ScreenCaptureDevice} from "tc-shared/video/VideoSource"; const cssStyle = require("./Renderer.scss"); const ModalEvents = React.createContext>(undefined); @@ -30,7 +33,7 @@ const VideoSourceSelector = () => { if(deviceList === "loading") { return ( -
+
@@ -52,7 +55,7 @@ const VideoSourceSelector = () => { break; } return ( -
+
@@ -60,7 +63,7 @@ const VideoSourceSelector = () => { ); } else { return ( -
+