290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import {
|
|
ScreenCaptureDevice,
|
|
VideoDevice,
|
|
VideoDriver,
|
|
VideoDriverEvents,
|
|
VideoPermissionStatus,
|
|
VideoSource
|
|
} from "tc-shared/video/VideoSource";
|
|
import {Registry} from "tc-shared/events";
|
|
import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase";
|
|
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
|
|
import {queryMediaPermissions, requestMediaStream, stopMediaStream} from "tc-shared/media/Stream";
|
|
import { tr } from "tc-shared/i18n/localize";
|
|
|
|
declare global {
|
|
interface MediaDevices {
|
|
getDisplayMedia(options?: any) : Promise<MediaStream>;
|
|
}
|
|
}
|
|
|
|
function getStreamVideoDeviceId(stream: MediaStream) : string | undefined {
|
|
const track = stream.getVideoTracks()[0];
|
|
if(typeof track !== "object" || !("getCapabilities" in track)) { return undefined; }
|
|
return track.getCapabilities()?.deviceId;
|
|
}
|
|
|
|
export class WebVideoDriver implements VideoDriver {
|
|
private readonly events: Registry<VideoDriverEvents>;
|
|
private currentPermissionStatus: VideoPermissionStatus;
|
|
|
|
constructor() {
|
|
this.events = new Registry<VideoDriverEvents>();
|
|
this.currentPermissionStatus = VideoPermissionStatus.UserDenied;
|
|
}
|
|
|
|
private setPermissionStatus(status: VideoPermissionStatus) {
|
|
if(this.currentPermissionStatus === status) {
|
|
return;
|
|
}
|
|
|
|
const oldState = this.currentPermissionStatus;
|
|
this.currentPermissionStatus = status;
|
|
this.events.fire("notify_permissions_changed", { newStatus: status, oldStatus: oldState });
|
|
}
|
|
|
|
private async handleSystemPermissionState(state: PermissionState | undefined) {
|
|
switch(state) {
|
|
case "denied":
|
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
|
break;
|
|
|
|
case "prompt":
|
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
|
break;
|
|
|
|
case "granted":
|
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
|
break;
|
|
|
|
default:
|
|
/* this will query the initial permission state */
|
|
if(await this.getDevices() === false) {
|
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
|
} else {
|
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
async initialize() {
|
|
if(window.detectedBrowser?.name === "firefox") {
|
|
/* We've to do a normal request every time we want to access your camera. */
|
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
|
} else {
|
|
const permissionState = await queryMediaPermissions("video", newState => this.handleSystemPermissionState(newState));
|
|
await this.handleSystemPermissionState(permissionState);
|
|
}
|
|
}
|
|
|
|
async getDevices(): Promise<VideoDevice[] | false> {
|
|
if(window.detectedBrowser?.name === "firefox") {
|
|
return [{
|
|
name: tr("Default Firefox device"),
|
|
id: "default"
|
|
}];
|
|
}
|
|
|
|
/* TODO: Cache query response */
|
|
let devices = await navigator.mediaDevices.enumerateDevices();
|
|
let hasPermissions = devices.findIndex(e => e.kind === "videoinput" && e.label !== "") !== -1 || devices.findIndex(e => e.kind === "videoinput") === -1;
|
|
|
|
if(!hasPermissions) {
|
|
return false;
|
|
}
|
|
|
|
const inputDevices = devices.filter(e => e.kind === "videoinput");
|
|
/*
|
|
const oldDeviceList = this.devices;
|
|
this.devices = [];
|
|
|
|
let devicesAdded = 0;
|
|
for(const device of inputDevices) {
|
|
const oldIndex = oldDeviceList.findIndex(e => e.deviceId === device.deviceId);
|
|
if(oldIndex === -1) {
|
|
devicesAdded++;
|
|
} else {
|
|
oldDeviceList.splice(oldIndex, 1);
|
|
}
|
|
|
|
this.devices.push({
|
|
deviceId: device.deviceId,
|
|
driver: "WebAudio",
|
|
groupId: device.groupId,
|
|
name: device.label
|
|
});
|
|
}
|
|
*/
|
|
return inputDevices.map(info => {
|
|
return {
|
|
id: info.deviceId,
|
|
name: info.label
|
|
}
|
|
});
|
|
}
|
|
|
|
async requestPermissions(): Promise<VideoSource | boolean> {
|
|
const result = await requestMediaStream("default", undefined, "video");
|
|
if(result === MediaStreamRequestResult.ENOTALLOWED) {
|
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
|
return false;
|
|
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
|
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
|
return false;
|
|
}
|
|
|
|
/* TODO: May update the device list? */
|
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
|
if(result instanceof MediaStream) {
|
|
let deviceId = getStreamVideoDeviceId(result);
|
|
if(deviceId === undefined) {
|
|
if(window.detectedBrowser?.name === "firefox") {
|
|
/*
|
|
* Firefox does not support "getCapabilities".
|
|
* Since FF also just support one device, we know how the device id;
|
|
*/
|
|
deviceId = "default";
|
|
} else {
|
|
/* We can't identify the underlying device. It's better to close than returning an unknown stream. */
|
|
stopMediaStream(result);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const devices = await this.getDevices();
|
|
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
|
|
|
|
return new WebVideoSource(deviceId, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
getEvents(): Registry<VideoDriverEvents> {
|
|
return this.events;
|
|
}
|
|
|
|
getPermissionStatus(): VideoPermissionStatus {
|
|
return this.currentPermissionStatus;
|
|
}
|
|
|
|
async createVideoSource(id: string | undefined): Promise<VideoSource> {
|
|
const result = await requestMediaStream(id ? id : "default", undefined, "video");
|
|
|
|
/*
|
|
* If we've got denied of requesting a stream reset the state to not allowed.
|
|
* This also applies to Firefox since the user has to manually update the flag after that.
|
|
* Only the initial state for Firefox is and should be "Granted".
|
|
*/
|
|
if(result === MediaStreamRequestResult.ENOTALLOWED) {
|
|
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
|
|
throw tr("Device access has been denied");
|
|
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
|
|
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
|
|
throw tr("Device access has been denied");
|
|
}
|
|
|
|
if(this.currentPermissionStatus !== VideoPermissionStatus.Granted) {
|
|
/* TODO: May update the device list? */
|
|
this.setPermissionStatus(VideoPermissionStatus.Granted);
|
|
}
|
|
|
|
if(result instanceof MediaStream) {
|
|
const deviceId = getStreamVideoDeviceId(result);
|
|
if(id === undefined && deviceId === undefined) {
|
|
logWarn(LogCategory.GENERAL, tr("Requested default video source, but returned source is nothing."));
|
|
} else if(deviceId === undefined) {
|
|
/* Do nothing. We've to trust that the given track origins from the requested id. */
|
|
} else if(id === undefined) {
|
|
/* We requested the default id and received something */
|
|
} else if(deviceId !== id) {
|
|
logWarn(LogCategory.GENERAL, tr("Requested video source %s but received %s"), id, deviceId);
|
|
} else {
|
|
/* We're fine. We received the device we wanted. */
|
|
}
|
|
|
|
const devices = await this.getDevices();
|
|
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
|
|
|
|
return new WebVideoSource(id, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
|
|
} else {
|
|
throw tra("An unknown error happened while opening the device ({})", result);
|
|
}
|
|
}
|
|
|
|
screenQueryAvailable(): boolean {
|
|
return false;
|
|
}
|
|
|
|
async queryScreenCaptureDevices(): Promise<ScreenCaptureDevice[]> {
|
|
throw tr("screen capture device query not supported");
|
|
}
|
|
|
|
async createScreenSource(_id: string | undefined, _allowFocusLoss: boolean): Promise<VideoSource> {
|
|
try {
|
|
const source = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true });
|
|
const videoTrack = source.getVideoTracks()[0];
|
|
if(!videoTrack) { throw tr("missing video track"); }
|
|
|
|
logDebug(LogCategory.VIDEO, tr("Display media received with settings: %o"), videoTrack.getSettings());
|
|
return new WebVideoSource(videoTrack.getSettings().deviceId, tr("Screen"), source);
|
|
} catch (error) {
|
|
logWarn(LogCategory.VIDEO, tr("Failed to create a screen source: %o"), error);
|
|
if(error instanceof Error) {
|
|
throw error.message;
|
|
} else if(typeof error === "string") {
|
|
throw error;
|
|
} else {
|
|
throw tr("Failed to create screen source");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class WebVideoSource implements VideoSource {
|
|
private readonly deviceId: string;
|
|
private readonly displayName: string;
|
|
private readonly stream: MediaStream;
|
|
private referenceCount = 1;
|
|
|
|
constructor(deviceId: string, displayName: string, stream: MediaStream) {
|
|
this.deviceId = deviceId;
|
|
this.displayName = displayName;
|
|
this.stream = stream;
|
|
}
|
|
|
|
destroy() {
|
|
stopMediaStream(this.stream);
|
|
}
|
|
|
|
getId(): string {
|
|
return this.deviceId;
|
|
}
|
|
|
|
getName(): string {
|
|
return this.displayName;
|
|
}
|
|
|
|
getStream(): MediaStream {
|
|
return this.stream;
|
|
}
|
|
|
|
deref() {
|
|
this.referenceCount -= 1;
|
|
|
|
if(this.referenceCount === 0) {
|
|
this.destroy();
|
|
} else if(this.referenceCount < 0) {
|
|
logError(LogCategory.GENERAL, tr("Video source reference count went bellow zero! This indicates a critical system flaw."));
|
|
}
|
|
}
|
|
|
|
ref() {
|
|
if(this.referenceCount <= 0) {
|
|
throw tr("the video stream has already been destroyed");
|
|
}
|
|
this.referenceCount++;
|
|
return this;
|
|
}
|
|
} |