import { ScreenCaptureDevice, VideoDevice, VideoDriver, VideoDriverEvents, VideoPermissionStatus, VideoSource, VideoSourceCapabilities, VideoSourceInitialSettings } from "tc-shared/video/VideoSource"; import {Registry} from "tc-shared/events"; import {InputStartError} 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, tra} from "tc-shared/i18n/localize"; declare global { interface MediaDevices { getDisplayMedia(options?: any) : Promise; } } 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; private currentPermissionStatus: VideoPermissionStatus; constructor() { this.events = new Registry(); 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 { 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 { const result = await requestMediaStream("default", undefined, "video"); if(result === InputStartError.ENOTALLOWED) { this.setPermissionStatus(VideoPermissionStatus.UserDenied); return false; } else if(result === InputStartError.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 { return this.events; } getPermissionStatus(): VideoPermissionStatus { return this.currentPermissionStatus; } async createVideoSource(id: string | undefined): Promise { 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 === InputStartError.ENOTALLOWED) { this.setPermissionStatus(VideoPermissionStatus.UserDenied); throw tr("Device access has been denied"); } else if(result === InputStartError.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 { 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]; 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 readonly initialSettings: VideoSourceInitialSettings; private referenceCount = 1; constructor(deviceId: string, displayName: string, stream: MediaStream) { this.deviceId = deviceId; this.displayName = displayName; this.stream = stream; const settings = stream.getVideoTracks()[0].getSettings(); this.initialSettings = { frameRate: settings.frameRate, height: settings.height, width: settings.width }; } destroy() { stopMediaStream(this.stream); } getId(): string { return this.deviceId; } getName(): string { return this.displayName; } getStream(): MediaStream { return this.stream; } getInitialSettings(): VideoSourceInitialSettings { return this.initialSettings; } getCapabilities(): VideoSourceCapabilities { const videoTrack = this.stream.getVideoTracks()[0]; const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; return { minWidth: capabilities?.width?.min || 1, maxWidth: capabilities?.width?.max || this.initialSettings.width || undefined, minHeight: capabilities?.height?.min || 1, maxHeight: capabilities?.height?.max || this.initialSettings.height || undefined, minFrameRate: capabilities?.frameRate?.min || 1, maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate || undefined }; } 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; } }