578 lines
20 KiB
TypeScript
578 lines
20 KiB
TypeScript
import VideoSettings from '../VideoSettings';
|
||
import ScreenInfo from '../ScreenInfo';
|
||
import Rect from '../Rect';
|
||
import Size from '../Size';
|
||
import Util from '../Util';
|
||
import { TypedEmitter } from '../../common/TypedEmitter';
|
||
import { DisplayInfo } from '../DisplayInfo';
|
||
|
||
interface BitrateStat {
|
||
timestamp: number;
|
||
bytes: number;
|
||
}
|
||
|
||
interface FramesPerSecondStats {
|
||
avgInput: number;
|
||
avgDecoded: number;
|
||
avgDropped: number;
|
||
avgSize: number;
|
||
}
|
||
|
||
export interface PlaybackQuality {
|
||
decodedFrames: number;
|
||
droppedFrames: number;
|
||
inputFrames: number;
|
||
inputBytes: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
export interface PlayerEvents {
|
||
'video-view-resize': Size;
|
||
'input-video-resize': ScreenInfo;
|
||
'video-settings': VideoSettings;
|
||
}
|
||
|
||
export interface PlayerClass {
|
||
playerFullName: string;
|
||
playerCodeName: string;
|
||
storageKeyPrefix: string;
|
||
isSupported(): boolean;
|
||
getPreferredVideoSetting(): VideoSettings;
|
||
getFitToScreenStatus(deviceName: string, displayInfo?: DisplayInfo): boolean;
|
||
loadVideoSettings(deviceName: string, displayInfo?: DisplayInfo): VideoSettings;
|
||
saveVideoSettings(
|
||
deviceName: string,
|
||
videoSettings: VideoSettings,
|
||
fitToScreen: boolean,
|
||
displayInfo?: DisplayInfo,
|
||
): void;
|
||
new(udid: string, displayInfo?: DisplayInfo): BasePlayer;
|
||
}
|
||
|
||
export abstract class BasePlayer extends TypedEmitter<PlayerEvents> {
|
||
private static readonly STAT_BACKGROUND: string = 'rgba(0, 0, 0, 0.5)';
|
||
private static readonly STAT_TEXT_COLOR: string = 'hsl(24, 85%, 50%)';
|
||
public static readonly DEFAULT_SHOW_QUALITY_STATS = false;
|
||
public static STATE: Record<string, number> = {
|
||
PLAYING: 1,
|
||
PAUSED: 2,
|
||
STOPPED: 3,
|
||
};
|
||
private static STATS_HEIGHT = 12;
|
||
protected screenInfo?: ScreenInfo;
|
||
protected videoSettings: VideoSettings;
|
||
protected parentElement?: HTMLElement;
|
||
protected touchableCanvas: HTMLCanvasElement;
|
||
protected inputBytes: BitrateStat[] = [];
|
||
protected perSecondQualityStats?: FramesPerSecondStats;
|
||
protected momentumQualityStats?: PlaybackQuality;
|
||
protected bounds: Size | null = null;
|
||
private totalStats: PlaybackQuality = {
|
||
decodedFrames: 0,
|
||
droppedFrames: 0,
|
||
inputFrames: 0,
|
||
inputBytes: 0,
|
||
timestamp: 0,
|
||
};
|
||
private totalStatsCounter = 0;
|
||
private dirtyStatsWidth = 0;
|
||
private state: number = BasePlayer.STATE.STOPPED;
|
||
private qualityAnimationId?: number;
|
||
private showQualityStats = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
|
||
protected receivedFirstFrame = false;
|
||
private statLines: string[] = [];
|
||
public readonly supportsScreenshot: boolean = false;
|
||
public readonly resizeVideoToBounds: boolean = false;
|
||
protected videoHeight = -1;
|
||
protected videoWidth = -1;
|
||
|
||
public static storageKeyPrefix = 'BaseDecoder';
|
||
public static playerFullName = 'BasePlayer';
|
||
public static playerCodeName = 'baseplayer';
|
||
public static preferredVideoSettings: VideoSettings = new VideoSettings({
|
||
lockedVideoOrientation: -1,
|
||
bitrate: 524288,
|
||
maxFps: 24,
|
||
iFrameInterval: 5,
|
||
bounds: new Size(480, 480),
|
||
sendFrameMeta: false,
|
||
});
|
||
|
||
public static isSupported(): boolean {
|
||
// Implement the check in a child class
|
||
return false;
|
||
}
|
||
|
||
constructor(
|
||
public readonly udid: string,
|
||
protected displayInfo?: DisplayInfo,
|
||
protected name: string = 'BasePlayer',
|
||
protected storageKeyPrefix: string = 'Dummy',
|
||
protected tag: HTMLElement = document.createElement('div'),
|
||
) {
|
||
super();
|
||
this.touchableCanvas = document.createElement('canvas');
|
||
this.touchableCanvas.className = 'touch-layer';
|
||
this.touchableCanvas.oncontextmenu = function (event: MouseEvent): void {
|
||
event.preventDefault();
|
||
};
|
||
const preferred = this.getPreferredVideoSetting();
|
||
this.videoSettings = BasePlayer.getVideoSettingFromStorage(preferred, this.storageKeyPrefix, udid, displayInfo);
|
||
}
|
||
|
||
protected calculateScreenInfoForBounds(videoWidth: number, videoHeight: number): void {
|
||
this.videoWidth = videoWidth;
|
||
this.videoHeight = videoHeight;
|
||
if (this.resizeVideoToBounds) {
|
||
let w = videoWidth;
|
||
let h = videoHeight;
|
||
if (this.bounds) {
|
||
let { w: boundsWidth, h: boundsHeight } = this.bounds;
|
||
if (w > boundsWidth || h > boundsHeight) {
|
||
let scaledHeight;
|
||
let scaledWidth;
|
||
if (boundsWidth > w) {
|
||
scaledHeight = h;
|
||
} else {
|
||
scaledHeight = (boundsWidth * h) / w;
|
||
}
|
||
if (boundsHeight > scaledHeight) {
|
||
boundsHeight = scaledHeight;
|
||
}
|
||
if (boundsHeight == h) {
|
||
scaledWidth = w;
|
||
} else {
|
||
scaledWidth = (boundsHeight * w) / h;
|
||
}
|
||
if (boundsWidth > scaledWidth) {
|
||
boundsWidth = scaledWidth;
|
||
}
|
||
w = boundsWidth | 0;
|
||
h = boundsHeight | 0;
|
||
this.tag.style.maxWidth = `${w}px`;
|
||
this.tag.style.maxHeight = `${h}px`;
|
||
}
|
||
}
|
||
const realScreen = new ScreenInfo(new Rect(0, 0, videoWidth, videoHeight), new Size(w, h), 0);
|
||
this.emit('input-video-resize', realScreen);
|
||
this.setScreenInfo(new ScreenInfo(new Rect(0, 0, w, h), new Size(w, h), 0));
|
||
}
|
||
}
|
||
|
||
protected static isIFrame(frame: Uint8Array): boolean {
|
||
// last 5 bits === 5: Coded slice of an IDR picture
|
||
|
||
// https://www.ietf.org/rfc/rfc3984.txt
|
||
// 1.3. Network Abstraction Layer Unit Types
|
||
// https://www.itu.int/rec/T-REC-H.264-201906-I/en
|
||
// Table 7-1 – NAL unit type codes, syntax element categories, and NAL unit type classes
|
||
return frame && frame.length > 4 && (frame[4] & 31) === 5;
|
||
}
|
||
|
||
private static getStorageKey(storageKeyPrefix: string, udid: string): string {
|
||
const { innerHeight, innerWidth } = window;
|
||
return `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
|
||
}
|
||
|
||
private static getFullStorageKey(storageKeyPrefix: string, udid: string, displayInfo?: DisplayInfo): string {
|
||
const { innerHeight, innerWidth } = window;
|
||
let base = `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
|
||
if (displayInfo) {
|
||
const { displayId, size } = displayInfo;
|
||
base = `${base}:${displayId}:${size.width}x${size.height}`;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
public static getFromStorageCompat(prefix: string, udid: string, displayInfo?: DisplayInfo): string | null {
|
||
const shortKey = this.getStorageKey(prefix, udid);
|
||
const savedInShort = window.localStorage.getItem(shortKey);
|
||
if (!displayInfo) {
|
||
return savedInShort;
|
||
}
|
||
const isDefaultDisplay = displayInfo.displayId === DisplayInfo.DEFAULT_DISPLAY;
|
||
const fullKey = this.getFullStorageKey(prefix, udid, displayInfo);
|
||
const savedInFull = window.localStorage.getItem(fullKey);
|
||
if (savedInFull) {
|
||
if (savedInShort && isDefaultDisplay) {
|
||
window.localStorage.removeItem(shortKey);
|
||
}
|
||
return savedInFull;
|
||
}
|
||
if (isDefaultDisplay) {
|
||
return savedInShort;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public static getFitToScreenFromStorage(
|
||
storageKeyPrefix: string,
|
||
udid: string,
|
||
displayInfo?: DisplayInfo,
|
||
): boolean {
|
||
if (!window.localStorage) {
|
||
return false;
|
||
}
|
||
let parsedValue = false;
|
||
const key = `${this.getFullStorageKey(storageKeyPrefix, udid, displayInfo)}:fit`;
|
||
const saved = window.localStorage.getItem(key);
|
||
if (!saved) {
|
||
return false;
|
||
}
|
||
try {
|
||
parsedValue = JSON.parse(saved);
|
||
} catch (error: any) {
|
||
console.error(`[${this.name}]`, 'Failed to parse', saved);
|
||
}
|
||
return parsedValue;
|
||
}
|
||
|
||
public static getVideoSettingFromStorage(
|
||
preferred: VideoSettings,
|
||
storageKeyPrefix: string,
|
||
udid: string,
|
||
displayInfo?: DisplayInfo,
|
||
): VideoSettings {
|
||
if (!window.localStorage) {
|
||
return preferred;
|
||
}
|
||
const saved = this.getFromStorageCompat(storageKeyPrefix, udid, displayInfo);
|
||
if (!saved) {
|
||
return preferred;
|
||
}
|
||
const parsed = JSON.parse(saved);
|
||
const {
|
||
displayId,
|
||
crop,
|
||
bitrate,
|
||
iFrameInterval,
|
||
sendFrameMeta,
|
||
lockedVideoOrientation,
|
||
codecOptions,
|
||
encoderName,
|
||
} = parsed;
|
||
|
||
// REMOVE `frameRate`
|
||
const maxFps = isNaN(parsed.maxFps) ? parsed.frameRate : parsed.maxFps;
|
||
// REMOVE `maxSize`
|
||
let bounds: Size | null = null;
|
||
if (typeof parsed.bounds !== 'object' || isNaN(parsed.bounds.width) || isNaN(parsed.bounds.height)) {
|
||
if (!isNaN(parsed.maxSize)) {
|
||
bounds = new Size(parsed.maxSize, parsed.maxSize);
|
||
}
|
||
} else {
|
||
bounds = new Size(parsed.bounds.width, parsed.bounds.height);
|
||
}
|
||
return new VideoSettings({
|
||
displayId: typeof displayId === 'number' ? displayId : 0,
|
||
crop: crop ? new Rect(crop.left, crop.top, crop.right, crop.bottom) : preferred.crop,
|
||
bitrate: !isNaN(bitrate) ? bitrate : preferred.bitrate,
|
||
bounds: bounds !== null ? bounds : preferred.bounds,
|
||
maxFps: !isNaN(maxFps) ? maxFps : preferred.maxFps,
|
||
iFrameInterval: !isNaN(iFrameInterval) ? iFrameInterval : preferred.iFrameInterval,
|
||
sendFrameMeta: typeof sendFrameMeta === 'boolean' ? sendFrameMeta : preferred.sendFrameMeta,
|
||
lockedVideoOrientation: !isNaN(lockedVideoOrientation)
|
||
? lockedVideoOrientation
|
||
: preferred.lockedVideoOrientation,
|
||
codecOptions,
|
||
encoderName,
|
||
});
|
||
}
|
||
|
||
protected static putVideoSettingsToStorage(
|
||
storageKeyPrefix: string,
|
||
udid: string,
|
||
videoSettings: VideoSettings,
|
||
fitToScreen: boolean,
|
||
displayInfo?: DisplayInfo,
|
||
): void {
|
||
if (!window.localStorage) {
|
||
return;
|
||
}
|
||
const key = this.getFullStorageKey(storageKeyPrefix, udid, displayInfo);
|
||
window.localStorage.setItem(key, JSON.stringify(videoSettings));
|
||
const fitKey = `${key}:fit`;
|
||
window.localStorage.setItem(fitKey, JSON.stringify(fitToScreen));
|
||
}
|
||
|
||
public abstract getImageDataURL(): string;
|
||
|
||
public createScreenshot(deviceName: string): void {
|
||
const a = document.createElement('a');
|
||
a.href = this.getImageDataURL();
|
||
a.download = `${deviceName} ${new Date().toLocaleString()}.png`;
|
||
a.click();
|
||
}
|
||
|
||
public play(): void {
|
||
if (this.needScreenInfoBeforePlay() && !this.screenInfo) {
|
||
return;
|
||
}
|
||
this.state = BasePlayer.STATE.PLAYING;
|
||
}
|
||
|
||
public pause(): void {
|
||
this.state = BasePlayer.STATE.PAUSED;
|
||
}
|
||
|
||
public stop(): void {
|
||
this.state = BasePlayer.STATE.STOPPED;
|
||
}
|
||
|
||
public getState(): number {
|
||
return this.state;
|
||
}
|
||
|
||
public pushFrame(frame: Uint8Array): void {
|
||
if (!this.receivedFirstFrame) {
|
||
this.receivedFirstFrame = true;
|
||
if (typeof this.qualityAnimationId !== 'number') {
|
||
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
|
||
}
|
||
}
|
||
this.inputBytes.push({
|
||
timestamp: Date.now(),
|
||
bytes: frame.byteLength,
|
||
});
|
||
}
|
||
|
||
public abstract getPreferredVideoSetting(): VideoSettings;
|
||
protected abstract calculateMomentumStats(): void;
|
||
|
||
public getTouchableElement(): HTMLCanvasElement {
|
||
return this.touchableCanvas;
|
||
}
|
||
|
||
public setParent(parent: HTMLElement): void {
|
||
this.parentElement = parent;
|
||
parent.appendChild(this.tag);
|
||
parent.appendChild(this.touchableCanvas);
|
||
}
|
||
|
||
protected needScreenInfoBeforePlay(): boolean {
|
||
return true;
|
||
}
|
||
|
||
public getVideoSettings(): VideoSettings {
|
||
return this.videoSettings;
|
||
}
|
||
|
||
public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
|
||
this.videoSettings = videoSettings;
|
||
if (saveToStorage) {
|
||
BasePlayer.putVideoSettingsToStorage(
|
||
this.storageKeyPrefix,
|
||
this.udid,
|
||
videoSettings,
|
||
fitToScreen,
|
||
this.displayInfo,
|
||
);
|
||
}
|
||
this.resetStats();
|
||
this.emit('video-settings', VideoSettings.copy(videoSettings));
|
||
}
|
||
|
||
public getScreenInfo(): ScreenInfo | undefined {
|
||
return this.screenInfo;
|
||
}
|
||
|
||
public setScreenInfo(screenInfo: ScreenInfo): void {
|
||
if (this.needScreenInfoBeforePlay()) {
|
||
this.pause();
|
||
}
|
||
this.receivedFirstFrame = false;
|
||
this.screenInfo = screenInfo;
|
||
const { width, height } = screenInfo.videoSize;
|
||
this.touchableCanvas.width = width;
|
||
this.touchableCanvas.height = height;
|
||
if (this.parentElement) {
|
||
this.parentElement.style.height = `${height}px`;
|
||
this.parentElement.style.width = `${width}px`;
|
||
}
|
||
const size = new Size(width, height);
|
||
this.emit('video-view-resize', size);
|
||
}
|
||
|
||
public getName(): string {
|
||
return this.name;
|
||
}
|
||
|
||
protected resetStats(): void {
|
||
this.receivedFirstFrame = false;
|
||
this.totalStatsCounter = 0;
|
||
this.totalStats = {
|
||
droppedFrames: 0,
|
||
decodedFrames: 0,
|
||
inputFrames: 0,
|
||
inputBytes: 0,
|
||
timestamp: 0,
|
||
};
|
||
this.perSecondQualityStats = {
|
||
avgDecoded: 0,
|
||
avgDropped: 0,
|
||
avgInput: 0,
|
||
avgSize: 0,
|
||
};
|
||
}
|
||
|
||
private updateQualityStats = (): void => {
|
||
const now = Date.now();
|
||
const oneSecondBefore = now - 1000;
|
||
this.calculateMomentumStats();
|
||
if (!this.momentumQualityStats) {
|
||
return;
|
||
}
|
||
if (this.totalStats.timestamp < oneSecondBefore) {
|
||
this.totalStats = {
|
||
timestamp: now,
|
||
decodedFrames: this.totalStats.decodedFrames + this.momentumQualityStats.decodedFrames,
|
||
droppedFrames: this.totalStats.droppedFrames + this.momentumQualityStats.droppedFrames,
|
||
inputFrames: this.totalStats.inputFrames + this.momentumQualityStats.inputFrames,
|
||
inputBytes: this.totalStats.inputBytes + this.momentumQualityStats.inputBytes,
|
||
};
|
||
|
||
if (this.totalStatsCounter !== 0) {
|
||
this.perSecondQualityStats = {
|
||
avgDecoded: this.totalStats.decodedFrames / this.totalStatsCounter,
|
||
avgDropped: this.totalStats.droppedFrames / this.totalStatsCounter,
|
||
avgInput: this.totalStats.inputFrames / this.totalStatsCounter,
|
||
avgSize: this.totalStats.inputBytes / this.totalStatsCounter,
|
||
};
|
||
}
|
||
this.totalStatsCounter++;
|
||
}
|
||
this.drawStats();
|
||
if (this.state !== BasePlayer.STATE.STOPPED) {
|
||
this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
|
||
}
|
||
};
|
||
|
||
private drawStats(): void {
|
||
if (!this.showQualityStats) {
|
||
return;
|
||
}
|
||
const ctx = this.touchableCanvas.getContext('2d');
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
const newStats = [];
|
||
if (this.perSecondQualityStats && this.momentumQualityStats) {
|
||
const { decodedFrames, droppedFrames, inputBytes, inputFrames } = this.momentumQualityStats;
|
||
const { avgDecoded, avgDropped, avgSize, avgInput } = this.perSecondQualityStats;
|
||
const padInput = inputFrames.toString().padStart(3, ' ');
|
||
const padDecoded = decodedFrames.toString().padStart(3, ' ');
|
||
const padDropped = droppedFrames.toString().padStart(3, ' ');
|
||
const padAvgDecoded = avgDecoded.toFixed(1).padStart(5, ' ');
|
||
const padAvgDropped = avgDropped.toFixed(1).padStart(5, ' ');
|
||
const padAvgInput = avgInput.toFixed(1).padStart(5, ' ');
|
||
const prettyBytes = Util.prettyBytes(inputBytes).padStart(8, ' ');
|
||
const prettyAvgBytes = Util.prettyBytes(avgSize).padStart(8, ' ');
|
||
|
||
newStats.push(`Input bytes: ${prettyBytes} (avg: ${prettyAvgBytes}/s)`);
|
||
newStats.push(`Input FPS: ${padInput} (avg: ${padAvgInput})`);
|
||
newStats.push(`Dropped FPS: ${padDropped} (avg: ${padAvgDropped})`);
|
||
newStats.push(`Decoded FPS: ${padDecoded} (avg: ${padAvgDecoded})`);
|
||
} else {
|
||
newStats.push(`Not supported`);
|
||
}
|
||
let changed = this.statLines.length !== newStats.length;
|
||
let i = 0;
|
||
while (!changed && i++ < newStats.length) {
|
||
if (newStats[i] !== this.statLines[i]) {
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
this.statLines = newStats;
|
||
this.updateCanvas(false);
|
||
}
|
||
}
|
||
|
||
private updateCanvas(onlyClear: boolean): void {
|
||
const ctx = this.touchableCanvas.getContext('2d');
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
console.log("123")
|
||
const y = this.touchableCanvas.height;
|
||
const height = BasePlayer.STATS_HEIGHT;
|
||
const lines = this.statLines.length;
|
||
const spaces = lines + 1;
|
||
const p = height / 2;
|
||
const d = p * 2;
|
||
const totalHeight = height * lines + p * spaces;
|
||
|
||
ctx.clearRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
|
||
this.dirtyStatsWidth = 0;
|
||
|
||
if (onlyClear) {
|
||
return;
|
||
}
|
||
ctx.save();
|
||
ctx.font = `${height}px monospace`;
|
||
this.statLines.forEach((text) => {
|
||
const textMetrics = ctx.measureText(text);
|
||
const dirty = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
|
||
this.dirtyStatsWidth = Math.max(dirty, this.dirtyStatsWidth);
|
||
});
|
||
ctx.fillStyle = BasePlayer.STAT_BACKGROUND;
|
||
ctx.fillRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
|
||
ctx.fillStyle = BasePlayer.STAT_TEXT_COLOR;
|
||
this.statLines.forEach((text, line) => {
|
||
ctx.fillText(text, p, y - p - line * (height + p));
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
public setShowQualityStats(value: boolean): void {
|
||
this.showQualityStats = value;
|
||
if (!value) {
|
||
this.updateCanvas(true);
|
||
} else {
|
||
this.drawStats();
|
||
}
|
||
}
|
||
|
||
public getShowQualityStats(): boolean {
|
||
return this.showQualityStats;
|
||
}
|
||
|
||
public setBounds(bounds: Size): void {
|
||
this.bounds = Size.copy(bounds);
|
||
}
|
||
|
||
public getDisplayInfo(): DisplayInfo | undefined {
|
||
return this.displayInfo;
|
||
}
|
||
|
||
public setDisplayInfo(displayInfo: DisplayInfo): void {
|
||
this.displayInfo = displayInfo;
|
||
}
|
||
|
||
public abstract getFitToScreenStatus(): boolean;
|
||
|
||
public abstract loadVideoSettings(): VideoSettings;
|
||
|
||
public static loadVideoSettings(udid: string, displayInfo?: DisplayInfo): VideoSettings {
|
||
return this.getVideoSettingFromStorage(this.preferredVideoSettings, this.storageKeyPrefix, udid, displayInfo);
|
||
}
|
||
|
||
public static getFitToScreenStatus(udid: string, displayInfo?: DisplayInfo): boolean {
|
||
return this.getFitToScreenFromStorage(this.storageKeyPrefix, udid, displayInfo);
|
||
}
|
||
|
||
public static getPreferredVideoSetting(): VideoSettings {
|
||
return this.preferredVideoSettings;
|
||
}
|
||
|
||
public static saveVideoSettings(
|
||
udid: string,
|
||
videoSettings: VideoSettings,
|
||
fitToScreen: boolean,
|
||
displayInfo?: DisplayInfo,
|
||
): void {
|
||
this.putVideoSettingsToStorage(this.storageKeyPrefix, udid, videoSettings, fitToScreen, displayInfo);
|
||
}
|
||
}
|