Files
tk-ws-scrcpy/src/app/player/BasePlayer.ts
2025-07-30 13:39:32 +08:00

578 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}