mirror of
https://gitlab.com/futo-org/fcast.git
synced 2025-06-24 21:25:23 +00:00
Merge branch 'michael/protocol-v3' into 'master'
Receivers: Add support for protocol v3 See merge request videostreaming/fcast!14
This commit is contained in:
commit
c95f254b3b
27 changed files with 2265 additions and 557 deletions
|
@ -58,16 +58,20 @@ export class ConnectionMonitor {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (ConnectionMonitor.backendConnections.size > 0) {
|
if (ConnectionMonitor.backendConnections.size > 0) {
|
||||||
for (const sessionId of ConnectionMonitor.backendConnections.keys()) {
|
for (const sessionId of ConnectionMonitor.backendConnections.keys()) {
|
||||||
|
const listener = ConnectionMonitor.backendConnections.get(sessionId);
|
||||||
|
|
||||||
|
if (listener.getSessionProtocolVersion(sessionId) >= 2) {
|
||||||
if (ConnectionMonitor.heartbeatRetries.get(sessionId) > 3) {
|
if (ConnectionMonitor.heartbeatRetries.get(sessionId) > 3) {
|
||||||
ConnectionMonitor.logger.warn(`Could not ping device with connection id ${sessionId}. Disconnecting...`);
|
ConnectionMonitor.logger.warn(`Could not ping device with connection id ${sessionId}. Disconnecting...`);
|
||||||
ConnectionMonitor.backendConnections.get(sessionId).disconnect(sessionId);
|
listener.disconnect(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionMonitor.logger.debug(`Pinging session ${sessionId} with ${ConnectionMonitor.heartbeatRetries.get(sessionId)} retries left`);
|
ConnectionMonitor.logger.debug(`Pinging session ${sessionId} with ${ConnectionMonitor.heartbeatRetries.get(sessionId)} retries left`);
|
||||||
ConnectionMonitor.backendConnections.get(sessionId).send(Opcode.Ping, null, sessionId);
|
listener.send(Opcode.Ping, null, sessionId);
|
||||||
ConnectionMonitor.heartbeatRetries.set(sessionId, ConnectionMonitor.heartbeatRetries.get(sessionId) + 1);
|
ConnectionMonitor.heartbeatRetries.set(sessionId, ConnectionMonitor.heartbeatRetries.get(sessionId) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, ConnectionMonitor.connectionPingTimeout);
|
}, ConnectionMonitor.connectionPingTimeout);
|
||||||
|
|
||||||
ConnectionMonitor.initialized = true;
|
ConnectionMonitor.initialized = true;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { Opcode, PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from 'common/Packets';
|
import { Opcode, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, InitialSenderMessage, SetPlaylistItemMessage, SubscribeEventMessage, UnsubscribeEventMessage, PROTOCOL_VERSION, InitialReceiverMessage } from 'common/Packets';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { getComputerName, getAppName, getAppVersion, getPlayMessage } from 'src/Main';
|
||||||
import { WebSocket } from 'modules/ws';
|
import { WebSocket } from 'modules/ws';
|
||||||
import { v4 as uuidv4 } from 'modules/uuid';
|
import { v4 as uuidv4 } from 'modules/uuid';
|
||||||
const logger = new Logger('FCastSession', LoggerType.BACKEND);
|
const logger = new Logger('FCastSession', LoggerType.BACKEND);
|
||||||
|
@ -18,6 +19,7 @@ const MAXIMUM_PACKET_LENGTH = 32000;
|
||||||
|
|
||||||
export class FCastSession {
|
export class FCastSession {
|
||||||
public sessionId: string;
|
public sessionId: string;
|
||||||
|
public protocolVersion: number;
|
||||||
buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH);
|
buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH);
|
||||||
bytesRead = 0;
|
bytesRead = 0;
|
||||||
packetLength = 0;
|
packetLength = 0;
|
||||||
|
@ -26,14 +28,25 @@ export class FCastSession {
|
||||||
state: SessionState;
|
state: SessionState;
|
||||||
emitter = new EventEmitter();
|
emitter = new EventEmitter();
|
||||||
|
|
||||||
|
private sentInitialMessage: boolean;
|
||||||
|
|
||||||
constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) {
|
constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) {
|
||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
|
// Not all senders send a version message to the receiver on connection. Choosing version 2
|
||||||
|
// as the base version since most/all current senders support this version.
|
||||||
|
this.protocolVersion = 2;
|
||||||
|
this.sentInitialMessage = false;
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.writer = writer;
|
this.writer = writer;
|
||||||
this.state = SessionState.WaitingForLength;
|
this.state = SessionState.WaitingForLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
send(opcode: number, message = null) {
|
send(opcode: number, message = null) {
|
||||||
|
if (!this.isSupportedOpcode(opcode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = this.stripUnsupportedFields(opcode, message);
|
||||||
const json = message ? JSON.stringify(message) : null;
|
const json = message ? JSON.stringify(message) : null;
|
||||||
logger.info(`send: (session: ${this.sessionId}, opcode: ${opcode}, body: ${json})`);
|
logger.info(`send: (session: ${this.sessionId}, opcode: ${opcode}, body: ${json})`);
|
||||||
|
|
||||||
|
@ -189,9 +202,23 @@ export class FCastSession {
|
||||||
case Opcode.SetSpeed:
|
case Opcode.SetSpeed:
|
||||||
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
|
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
|
||||||
break;
|
break;
|
||||||
case Opcode.Version:
|
case Opcode.Version: {
|
||||||
this.emitter.emit("version", JSON.parse(body) as VersionMessage);
|
const versionMessage = JSON.parse(body) as VersionMessage;
|
||||||
|
this.protocolVersion = (versionMessage.version > 0 && versionMessage.version <= PROTOCOL_VERSION) ? versionMessage.version : this.protocolVersion;
|
||||||
|
if (!this.sentInitialMessage && this.protocolVersion >= 3) {
|
||||||
|
this.send(Opcode.Initial, new InitialReceiverMessage(
|
||||||
|
getComputerName(),
|
||||||
|
getAppName(),
|
||||||
|
getAppVersion(),
|
||||||
|
getPlayMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
this.sentInitialMessage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter.emit("version", versionMessage);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case Opcode.Ping:
|
case Opcode.Ping:
|
||||||
this.send(Opcode.Pong);
|
this.send(Opcode.Pong);
|
||||||
this.emitter.emit("ping");
|
this.emitter.emit("ping");
|
||||||
|
@ -199,6 +226,18 @@ export class FCastSession {
|
||||||
case Opcode.Pong:
|
case Opcode.Pong:
|
||||||
this.emitter.emit("pong");
|
this.emitter.emit("pong");
|
||||||
break;
|
break;
|
||||||
|
case Opcode.Initial:
|
||||||
|
this.emitter.emit("initial", JSON.parse(body) as InitialSenderMessage);
|
||||||
|
break;
|
||||||
|
case Opcode.SetPlaylistItem:
|
||||||
|
this.emitter.emit("setplaylistitem", JSON.parse(body) as SetPlaylistItemMessage);
|
||||||
|
break;
|
||||||
|
case Opcode.SubscribeEvent:
|
||||||
|
this.emitter.emit("subscribeevent", JSON.parse(body) as SubscribeEventMessage);
|
||||||
|
break;
|
||||||
|
case Opcode.UnsubscribeEvent:
|
||||||
|
this.emitter.emit("unsubscribeevent", JSON.parse(body) as UnsubscribeEventMessage);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`Error handling packet from.`, e);
|
logger.warn(`Error handling packet from.`, e);
|
||||||
|
@ -222,5 +261,72 @@ export class FCastSession {
|
||||||
this.emitter.on("version", (body: VersionMessage) => { emitter.emit("version", body) });
|
this.emitter.on("version", (body: VersionMessage) => { emitter.emit("version", body) });
|
||||||
this.emitter.on("ping", () => { emitter.emit("ping", { sessionId: this.sessionId }) });
|
this.emitter.on("ping", () => { emitter.emit("ping", { sessionId: this.sessionId }) });
|
||||||
this.emitter.on("pong", () => { emitter.emit("pong", { sessionId: this.sessionId }) });
|
this.emitter.on("pong", () => { emitter.emit("pong", { sessionId: this.sessionId }) });
|
||||||
|
this.emitter.on("initial", (body: InitialSenderMessage) => { emitter.emit("initial", body) });
|
||||||
|
this.emitter.on("setplaylistitem", (body: SetPlaylistItemMessage) => { emitter.emit("setplaylistitem", body) });
|
||||||
|
this.emitter.on("subscribeevent", (body: SubscribeEventMessage) => { emitter.emit("subscribeevent", { sessionId: this.sessionId, body: body }) });
|
||||||
|
this.emitter.on("unsubscribeevent", (body: UnsubscribeEventMessage) => { emitter.emit("unsubscribeevent", { sessionId: this.sessionId, body: body }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedOpcode(opcode: number) {
|
||||||
|
switch (this.protocolVersion) {
|
||||||
|
case 1:
|
||||||
|
return opcode <= 8;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return opcode <= 13;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
return opcode <= 19;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripUnsupportedFields(opcode: number, message: any = null): any {
|
||||||
|
switch (this.protocolVersion) {
|
||||||
|
case 1: {
|
||||||
|
switch (opcode) {
|
||||||
|
case Opcode.Play:
|
||||||
|
delete message.speed;
|
||||||
|
delete message.headers;
|
||||||
|
break;
|
||||||
|
case Opcode.PlaybackUpdate:
|
||||||
|
delete message.generationTime;
|
||||||
|
delete message.duration;
|
||||||
|
delete message.speed;
|
||||||
|
break;
|
||||||
|
case Opcode.VolumeUpdate:
|
||||||
|
delete message.generationTime;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
switch (opcode) {
|
||||||
|
case Opcode.Play:
|
||||||
|
delete message.volume;
|
||||||
|
delete message.metadata;
|
||||||
|
break;
|
||||||
|
case Opcode.PlaybackUpdate:
|
||||||
|
delete message.itemIndex;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
139
receivers/common/web/ListenerService.ts
Normal file
139
receivers/common/web/ListenerService.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { FCastSession } from 'common/FCastSession';
|
||||||
|
import { Opcode, EventSubscribeObject, EventType, KeyEvent, KeyDownEvent, KeyUpEvent } from 'common/Packets';
|
||||||
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { deepEqual } from 'common/UtilityBackend';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { errorHandler } from 'src/Main';
|
||||||
|
const logger = new Logger('ListenerService', LoggerType.BACKEND);
|
||||||
|
|
||||||
|
export abstract class ListenerService {
|
||||||
|
public readonly PORT: number;
|
||||||
|
public emitter: EventEmitter = new EventEmitter();
|
||||||
|
protected sessionMap: Map<string, FCastSession> = new Map();
|
||||||
|
private eventSubscribers: Map<string, EventSubscribeObject[]> = new Map();
|
||||||
|
|
||||||
|
public abstract start(): void;
|
||||||
|
public abstract stop(): void;
|
||||||
|
|
||||||
|
public send(opcode: number, message = null, sessionId = null) {
|
||||||
|
// logger.info(`Sending message ${JSON.stringify(message)}`);
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
if (opcode === Opcode.Event.valueOf() && !this.isSubscribedToEvent(sessionId, message.event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.sessionMap.get(sessionId)?.send(opcode, message);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Failed to send error.", e);
|
||||||
|
this.sessionMap.get(sessionId)?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (const session of this.sessionMap.values()) {
|
||||||
|
if (opcode === Opcode.Event.valueOf() && !this.isSubscribedToEvent(session.sessionId, message.event)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.send(opcode, message);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("Failed to send error.", e);
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeEvent(sessionId: string, event: EventSubscribeObject): any {
|
||||||
|
if (!this.eventSubscribers.has(sessionId)) {
|
||||||
|
this.eventSubscribers.set(sessionId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionSubscriptions = this.eventSubscribers.get(sessionId);
|
||||||
|
sessionSubscriptions.push(event);
|
||||||
|
this.eventSubscribers.set(sessionId, sessionSubscriptions);
|
||||||
|
|
||||||
|
if (event.type === EventType.KeyDown.valueOf() || event.type === EventType.KeyUp.valueOf()) {
|
||||||
|
return this.getAllSubscribedKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribeEvent(sessionId: string, event: EventSubscribeObject): any {
|
||||||
|
if (this.eventSubscribers.has(sessionId)) {
|
||||||
|
let sessionSubscriptions = this.eventSubscribers.get(sessionId);
|
||||||
|
|
||||||
|
const index = sessionSubscriptions.findIndex((obj) => deepEqual(obj, event));
|
||||||
|
if (index != -1) {
|
||||||
|
sessionSubscriptions.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventSubscribers.set(sessionId, sessionSubscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === EventType.KeyDown.valueOf() || event.type === EventType.KeyUp.valueOf()) {
|
||||||
|
return this.getAllSubscribedKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSessions(): string[] {
|
||||||
|
return [...this.sessionMap.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSessionProtocolVersion(sessionId: string) {
|
||||||
|
return this.sessionMap.get(sessionId).protocolVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSubscribedToEvent(sessionId: string, event: EventSubscribeObject) {
|
||||||
|
let isSubscribed = false;
|
||||||
|
|
||||||
|
if (this.eventSubscribers.has(sessionId)) {
|
||||||
|
for (const e of this.eventSubscribers.get(sessionId).values()) {
|
||||||
|
if (e.type === event.type) {
|
||||||
|
if (e.type === EventType.KeyDown.valueOf() || e.type === EventType.KeyUp.valueOf()) {
|
||||||
|
const subscribeEvent = e.type === EventType.KeyDown.valueOf() ? e as KeyDownEvent : e as KeyUpEvent;
|
||||||
|
const keyEvent = event as KeyEvent;
|
||||||
|
|
||||||
|
if (!subscribeEvent.keys.includes(keyEvent.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubscribed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSubscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAllSubscribedKeys(): { keyDown: Set<string>, keyUp: Set<string> } {
|
||||||
|
let keyDown = new Set<string>();
|
||||||
|
let keyUp = new Set<string>();
|
||||||
|
|
||||||
|
for (const session of this.eventSubscribers.values()) {
|
||||||
|
for (const event of session) {
|
||||||
|
switch (event.type) {
|
||||||
|
case EventType.KeyDown:
|
||||||
|
keyDown = new Set([...keyDown, ...(event as KeyDownEvent).keys]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventType.KeyUp:
|
||||||
|
keyUp = new Set([...keyUp, ...(event as KeyUpEvent).keys]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyDown: keyDown, keyUp: keyUp };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleServerError(err: NodeJS.ErrnoException) {
|
||||||
|
errorHandler(err);
|
||||||
|
}
|
||||||
|
}
|
253
receivers/common/web/MediaCache.ts
Normal file
253
receivers/common/web/MediaCache.ts
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
import { PlaylistContent } from 'common/Packets';
|
||||||
|
import { downloadFile } from 'common/UtilityBackend';
|
||||||
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { fs } from 'modules/memfs';
|
||||||
|
import { v4 as uuidv4 } from 'modules/uuid';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import * as os from 'os';
|
||||||
|
const logger = new Logger('MediaCache', LoggerType.BACKEND);
|
||||||
|
|
||||||
|
class CacheObject {
|
||||||
|
public id: string;
|
||||||
|
public size: number;
|
||||||
|
public url: string;
|
||||||
|
public path: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.id = uuidv4();
|
||||||
|
this.size = 0;
|
||||||
|
this.path = `/cache/${this.id}`;
|
||||||
|
this.url = `app://${this.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaCache {
|
||||||
|
private static instance: MediaCache = null;
|
||||||
|
private cache = new Map<number, CacheObject>();
|
||||||
|
private cacheUrlMap = new Map<string,number>();
|
||||||
|
private playlist: PlaylistContent;
|
||||||
|
private quota: number;
|
||||||
|
private cacheSize: number = 0;
|
||||||
|
private cacheWindowStart: number = 0;
|
||||||
|
private cacheWindowEnd: number = 0;
|
||||||
|
|
||||||
|
constructor(playlist: PlaylistContent) {
|
||||||
|
MediaCache.instance = this;
|
||||||
|
this.playlist = playlist;
|
||||||
|
|
||||||
|
if (!fs.existsSync('/cache')) {
|
||||||
|
fs.mkdirSync('/cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (TARGET === 'electron') {
|
||||||
|
this.quota = Math.min(Math.floor(os.freemem() / 4), 4 * 1024 * 1024 * 1024); // 4GB
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
} else if (TARGET === 'webOS' || TARGET === 'tizenOS') {
|
||||||
|
this.quota = Math.min(Math.floor(os.freemem() / 4), 250 * 1024 * 1024); // 250MB
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.quota = Math.min(Math.floor(os.freemem() / 4), 250 * 1024 * 1024); // 250MB
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Created cache with storage byte quota:', this.quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
MediaCache.instance = null;
|
||||||
|
this.cache.clear();
|
||||||
|
this.cache = null;
|
||||||
|
this.cacheUrlMap.clear();
|
||||||
|
this.cacheUrlMap = null;
|
||||||
|
this.playlist = null;
|
||||||
|
this.quota = 0;
|
||||||
|
this.cacheSize = 0;
|
||||||
|
this.cacheWindowStart = 0;
|
||||||
|
this.cacheWindowEnd = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance() {
|
||||||
|
return MediaCache.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public has(playlistIndex: number): boolean {
|
||||||
|
return this.cache.has(playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUrl(playlistIndex: number): string {
|
||||||
|
return this.cache.get(playlistIndex).url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getObject(url: string, start: number = 0, end: number = null): Readable {
|
||||||
|
const cacheObject = this.cache.get(this.cacheUrlMap.get(url));
|
||||||
|
end = end ? end : cacheObject.size - 1;
|
||||||
|
return fs.createReadStream(cacheObject.path, { start: start, end: end });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getObjectSize(url: string): number {
|
||||||
|
return this.cache.get(this.cacheUrlMap.get(url)).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public cacheForwardItems(cacheIndex: number, cacheAmount: number, playlistIndex: number) {
|
||||||
|
if (cacheAmount > 0) {
|
||||||
|
for (let i = cacheIndex; i < this.playlist.items.length; i++) {
|
||||||
|
const item = this.playlist.items[i];
|
||||||
|
if (item.cache) {
|
||||||
|
if (this.cache.has(i)) {
|
||||||
|
this.cacheForwardItems(i + 1, cacheAmount - 1, playlistIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const tempCacheObject = new CacheObject();
|
||||||
|
|
||||||
|
downloadFile(item.url, tempCacheObject.path,
|
||||||
|
(downloadedBytes: number) => {
|
||||||
|
let underQuota = true;
|
||||||
|
if (this.cacheSize + downloadedBytes > this.quota) {
|
||||||
|
underQuota = this.purgeCacheItems(i, downloadedBytes, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return underQuota;
|
||||||
|
}, null,
|
||||||
|
(downloadedBytes: number) => {
|
||||||
|
this.finalizeCacheItem(tempCacheObject, i, downloadedBytes, playlistIndex);
|
||||||
|
this.cacheForwardItems(i + 1, cacheAmount - 1, playlistIndex);
|
||||||
|
}, true)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(error);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cacheBackwardItems(cacheIndex: number, cacheAmount: number, playlistIndex: number) {
|
||||||
|
if (cacheAmount > 0) {
|
||||||
|
for (let i = cacheIndex; i >= 0; i--) {
|
||||||
|
const item = this.playlist.items[i];
|
||||||
|
if (item.cache) {
|
||||||
|
if (this.cache.has(i)) {
|
||||||
|
this.cacheBackwardItems(i - 1, cacheAmount - 1, playlistIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const tempCacheObject = new CacheObject();
|
||||||
|
|
||||||
|
downloadFile(item.url, tempCacheObject.path,
|
||||||
|
(downloadedBytes: number) => {
|
||||||
|
let underQuota = true;
|
||||||
|
if (this.cacheSize + downloadedBytes > this.quota) {
|
||||||
|
underQuota = this.purgeCacheItems(i, downloadedBytes, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return underQuota;
|
||||||
|
}, null,
|
||||||
|
(downloadedBytes: number) => {
|
||||||
|
this.finalizeCacheItem(tempCacheObject, i, downloadedBytes, playlistIndex);
|
||||||
|
this.cacheBackwardItems(i - 1, cacheAmount - 1, playlistIndex);
|
||||||
|
}, true)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(error);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private purgeCacheItems(downloadItem: number, downloadedBytes: number, playlistIndex: number): boolean {
|
||||||
|
this.updateCacheWindow(playlistIndex);
|
||||||
|
let underQuota = true;
|
||||||
|
let purgeIndex = playlistIndex;
|
||||||
|
let purgeDistance = 0;
|
||||||
|
logger.debug(`Downloading item ${downloadItem} with playlist index ${playlistIndex} and cache window: [${this.cacheWindowStart} - ${this.cacheWindowEnd}]`);
|
||||||
|
|
||||||
|
// Priority:
|
||||||
|
// 1. Purge first encountered item outside cache window
|
||||||
|
// 2. Purge item furthest from view index inside window (except next item from view index)
|
||||||
|
for (let index of this.cache.keys()) {
|
||||||
|
if (index === downloadItem || index === playlistIndex || index === playlistIndex + 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < this.cacheWindowStart) {
|
||||||
|
purgeIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (index > this.cacheWindowEnd) {
|
||||||
|
purgeIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (Math.abs(playlistIndex - index) > purgeDistance) {
|
||||||
|
purgeDistance = Math.abs(playlistIndex - index);
|
||||||
|
purgeIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purgeIndex !== playlistIndex) {
|
||||||
|
const deleteItem = this.cache.get(purgeIndex);
|
||||||
|
this.cacheSize -= deleteItem.size;
|
||||||
|
this.cacheUrlMap.delete(deleteItem.url);
|
||||||
|
this.cache.delete(purgeIndex);
|
||||||
|
this.updateCacheWindow(playlistIndex);
|
||||||
|
logger.info(`Item ${downloadItem} pending download (${downloadedBytes} bytes) cannot fit in cache, purging ${purgeIndex} from cache. Remaining quota ${this.quota - this.cacheSize} bytes`);
|
||||||
|
|
||||||
|
if (this.cacheSize + downloadedBytes > this.quota) {
|
||||||
|
underQuota = this.purgeCacheItems(downloadItem, downloadedBytes, playlistIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Cannot purge current item since we may already be streaming it
|
||||||
|
logger.warn(`Aborting item caching, cannot fit item ${downloadItem} (${downloadedBytes} bytes) within remaining space quota (${this.quota - this.cacheSize} bytes)`);
|
||||||
|
underQuota = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return underQuota;
|
||||||
|
}
|
||||||
|
|
||||||
|
private finalizeCacheItem(cacheObject: CacheObject, index: number, size: number, playlistIndex: number) {
|
||||||
|
cacheObject.size = size;
|
||||||
|
this.cacheSize += size;
|
||||||
|
logger.info(`Cached item ${index} (${cacheObject.size} bytes) with remaining quota ${this.quota - this.cacheSize} bytes: ${cacheObject.url}`);
|
||||||
|
|
||||||
|
this.cache.set(index, cacheObject);
|
||||||
|
this.cacheUrlMap.set(cacheObject.url, index);
|
||||||
|
this.updateCacheWindow(playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCacheWindow(playlistIndex: number) {
|
||||||
|
if (this.playlist.forwardCache && this.playlist.forwardCache > 0) {
|
||||||
|
let forwardCacheItems = this.playlist.forwardCache;
|
||||||
|
for (let index of this.cache.keys()) {
|
||||||
|
if (index > playlistIndex) {
|
||||||
|
forwardCacheItems--;
|
||||||
|
|
||||||
|
if (forwardCacheItems === 0) {
|
||||||
|
this.cacheWindowEnd = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.cacheWindowEnd = playlistIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playlist.backwardCache && this.playlist.backwardCache > 0) {
|
||||||
|
let backwardCacheItems = this.playlist.backwardCache;
|
||||||
|
for (let index of this.cache.keys()) {
|
||||||
|
if (index < playlistIndex) {
|
||||||
|
backwardCacheItems--;
|
||||||
|
|
||||||
|
if (backwardCacheItems === 0) {
|
||||||
|
this.cacheWindowStart = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.cacheWindowStart = playlistIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,21 @@
|
||||||
|
|
||||||
export const streamingMediaTypes = [
|
export const streamingMediaTypes = [
|
||||||
"application/vnd.apple.mpegurl",
|
'application/vnd.apple.mpegurl',
|
||||||
"application/x-mpegURL",
|
'application/x-mpegURL',
|
||||||
"application/dash+xml"
|
'application/dash+xml',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const supportedPlayerTypes = streamingMediaTypes.concat([
|
export const supportedVideoTypes = [
|
||||||
|
'video/mp4',
|
||||||
|
'video/mpeg',
|
||||||
|
'video/ogg',
|
||||||
|
'video/webm',
|
||||||
|
'video/x-matroska',
|
||||||
|
'video/3gpp',
|
||||||
|
'video/3gpp2',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const supportedAudioTypes = [
|
||||||
'audio/aac',
|
'audio/aac',
|
||||||
'audio/flac',
|
'audio/flac',
|
||||||
'audio/mpeg',
|
'audio/mpeg',
|
||||||
|
@ -15,14 +25,7 @@ export const supportedPlayerTypes = streamingMediaTypes.concat([
|
||||||
'audio/webm',
|
'audio/webm',
|
||||||
'audio/3gpp',
|
'audio/3gpp',
|
||||||
'audio/3gpp2',
|
'audio/3gpp2',
|
||||||
'video/mp4',
|
];
|
||||||
'video/mpeg',
|
|
||||||
'video/ogg',
|
|
||||||
'video/webm',
|
|
||||||
'video/x-matroska',
|
|
||||||
'video/3gpp',
|
|
||||||
'video/3gpp2'
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const supportedImageTypes = [
|
export const supportedImageTypes = [
|
||||||
'image/apng',
|
'image/apng',
|
||||||
|
@ -36,3 +39,8 @@ export const supportedImageTypes = [
|
||||||
'image/vnd.microsoft.icon',
|
'image/vnd.microsoft.icon',
|
||||||
'image/webp'
|
'image/webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const supportedPlayerTypes = streamingMediaTypes.concat(
|
||||||
|
supportedVideoTypes,
|
||||||
|
supportedAudioTypes,
|
||||||
|
);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { PlayMessage } from 'common/Packets';
|
import { PlayMessage } from 'common/Packets';
|
||||||
import { streamingMediaTypes } from 'common/MimeTypes';
|
import { streamingMediaTypes } from 'common/MimeTypes';
|
||||||
|
import { MediaCache } from './MediaCache';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
import { AddressInfo } from 'modules/ws';
|
import { AddressInfo } from 'modules/ws';
|
||||||
|
@ -12,7 +13,7 @@ export class NetworkService {
|
||||||
static cert: string = null;
|
static cert: string = null;
|
||||||
static proxyServer: http.Server;
|
static proxyServer: http.Server;
|
||||||
static proxyServerAddress: AddressInfo;
|
static proxyServerAddress: AddressInfo;
|
||||||
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
|
static proxiedFiles: Map<string, PlayMessage> = new Map();
|
||||||
|
|
||||||
private static setupProxyServer(): Promise<void> {
|
private static setupProxyServer(): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
@ -32,6 +33,44 @@ export class NetworkService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (proxyInfo.url.startsWith('app://')) {
|
||||||
|
let start: number = 0;
|
||||||
|
let end: number = null;
|
||||||
|
const contentSize = MediaCache.getInstance().getObjectSize(proxyInfo.url);
|
||||||
|
if (req.headers.range) {
|
||||||
|
const range = req.headers.range.slice(6).split('-');
|
||||||
|
start = (range.length > 0) ? parseInt(range[0]) : 0;
|
||||||
|
end = (range.length > 1) ? parseInt(range[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Fetching byte range from cache: start=${start}, end=${end}`);
|
||||||
|
const stream = MediaCache.getInstance().getObject(proxyInfo.url, start, end);
|
||||||
|
let responseCode = null;
|
||||||
|
let responseHeaders = null;
|
||||||
|
|
||||||
|
if (start != 0) {
|
||||||
|
responseCode = 206;
|
||||||
|
responseHeaders = {
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': contentSize - start,
|
||||||
|
'Content-Range': `bytes ${start}-${end ? end : contentSize - 1}/${contentSize}`,
|
||||||
|
'Content-Type': proxyInfo.container,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
responseCode = 200;
|
||||||
|
responseHeaders = {
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': contentSize,
|
||||||
|
'Content-Type': proxyInfo.container,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Serving content ${proxyInfo.url} with response headers:`, responseHeaders);
|
||||||
|
res.writeHead(responseCode, responseHeaders);
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
else {
|
||||||
const omitHeaders = new Set([
|
const omitHeaders = new Set([
|
||||||
'host',
|
'host',
|
||||||
'connection',
|
'connection',
|
||||||
|
@ -66,6 +105,7 @@ export class NetworkService {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
res.end();
|
res.end();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
NetworkService.proxyServer.on('error', e => {
|
NetworkService.proxyServer.on('error', e => {
|
||||||
reject(e);
|
reject(e);
|
||||||
|
@ -82,20 +122,20 @@ export class NetworkService {
|
||||||
}
|
}
|
||||||
|
|
||||||
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
|
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
|
||||||
if (message.headers && message.url && !streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
|
if (message.url && (message.url.startsWith('app://') || (message.headers && !streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())))) {
|
||||||
return { ...message, url: await NetworkService.proxyFile(message.url, message.headers) };
|
return { ...message, url: await NetworkService.proxyFile(message) };
|
||||||
}
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
|
static async proxyFile(message: PlayMessage): Promise<string> {
|
||||||
if (!NetworkService.proxyServer) {
|
if (!NetworkService.proxyServer) {
|
||||||
await NetworkService.setupProxyServer();
|
await NetworkService.setupProxyServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxiedUrl = `http://127.0.0.1:${NetworkService.proxyServerAddress.port}/${uuidv4()}`;
|
const proxiedUrl = `http://127.0.0.1:${NetworkService.proxyServerAddress.port}/${uuidv4()}`;
|
||||||
logger.info("Proxied url", { proxiedUrl, url, headers });
|
logger.info("Proxied url", { proxiedUrl, message });
|
||||||
NetworkService.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
|
NetworkService.proxiedFiles.set(proxiedUrl, message);
|
||||||
return proxiedUrl;
|
return proxiedUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Protocol Documentation: https://gitlab.futo.org/videostreaming/fcast/-/wikis/Protocol-version-3
|
||||||
|
export const PROTOCOL_VERSION = 3;
|
||||||
|
|
||||||
export enum Opcode {
|
export enum Opcode {
|
||||||
None = 0,
|
None = 0,
|
||||||
Play = 1,
|
Play = 1,
|
||||||
|
@ -12,33 +15,101 @@ export enum Opcode {
|
||||||
SetSpeed = 10,
|
SetSpeed = 10,
|
||||||
Version = 11,
|
Version = 11,
|
||||||
Ping = 12,
|
Ping = 12,
|
||||||
Pong = 13
|
Pong = 13,
|
||||||
|
Initial = 14,
|
||||||
|
PlayUpdate = 15,
|
||||||
|
SetPlaylistItem = 16,
|
||||||
|
SubscribeEvent = 17,
|
||||||
|
UnsubscribeEvent = 18,
|
||||||
|
Event = 19,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum PlaybackState {
|
||||||
|
Idle = 0,
|
||||||
|
Playing = 1,
|
||||||
|
Paused = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ContentType {
|
||||||
|
Playlist = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MetadataType {
|
||||||
|
Generic = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EventType {
|
||||||
|
MediaItemStart = 0,
|
||||||
|
MediaItemEnd = 1,
|
||||||
|
MediaItemChange = 2,
|
||||||
|
KeyDown = 3,
|
||||||
|
KeyUp = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required supported keys for listener events defined below.
|
||||||
|
// Optionally supported key values list: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
|
||||||
|
export enum KeyNames {
|
||||||
|
Left = 'ArrowLeft',
|
||||||
|
Right = 'ArrowRight',
|
||||||
|
Up = 'ArrowUp',
|
||||||
|
Down = 'ArrowDown',
|
||||||
|
Ok = 'Enter',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataObject {
|
||||||
|
type: MetadataType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericMediaMetadata implements MetadataObject {
|
||||||
|
readonly type = MetadataType.Generic;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public title: string = null,
|
||||||
|
public thumbnailUrl: string = null,
|
||||||
|
public custom: any = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class PlayMessage {
|
export class PlayMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public container: string,
|
public container: string, // The MIME type (video/mp4)
|
||||||
public url: string = null,
|
public url: string = null, // The URL to load (optional)
|
||||||
public content: string = null,
|
public content: string = null, // The content to load (i.e. a DASH manifest, json content, optional)
|
||||||
public time: number = null,
|
public time: number = null, // The time to start playing in seconds
|
||||||
public speed: number = null,
|
public volume: number = null, // The desired volume (0-1)
|
||||||
public headers: { [key: string]: string } = null
|
public speed: number = null, // The factor to multiply playback speed by (defaults to 1.0)
|
||||||
|
public headers: { [key: string]: string } = null, // HTTP request headers to add to the play request Map<string, string>
|
||||||
|
public metadata: MetadataObject = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SeekMessage {
|
export class SeekMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public time: number,
|
public time: number, // The time to seek to in seconds
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlaybackUpdateMessage {
|
export class PlaybackUpdateMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public generationTime: number,
|
public generationTime: number, // The time the packet was generated (unix time milliseconds)
|
||||||
public time: number,
|
public state: number, // The playback state
|
||||||
public duration: number,
|
public time: number = null, // The current time playing in seconds
|
||||||
public state: number,
|
public duration: number = null, // The duration in seconds
|
||||||
public speed: number
|
public speed: number = null, // The playback speed factor
|
||||||
|
public itemIndex: number = null, // The playlist item index currently being played on receiver
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VolumeUpdateMessage {
|
||||||
|
constructor(
|
||||||
|
public generationTime: number, // The time the packet was generated (unix time milliseconds)
|
||||||
|
public volume: number, // The current volume (0-1)
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetVolumeMessage {
|
||||||
|
constructor(
|
||||||
|
public volume: number, // The desired volume (0-1)
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,27 +119,154 @@ export class PlaybackErrorMessage {
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VolumeUpdateMessage {
|
|
||||||
constructor(
|
|
||||||
public generationTime: number,
|
|
||||||
public volume: number
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SetVolumeMessage {
|
|
||||||
constructor(
|
|
||||||
public volume: number,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SetSpeedMessage {
|
export class SetSpeedMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public speed: number,
|
public speed: number, // The factor to multiply playback speed by
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VersionMessage {
|
export class VersionMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public version: number,
|
public version: number, // Protocol version number (integer)
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentObject {
|
||||||
|
contentType: ContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaItem {
|
||||||
|
constructor(
|
||||||
|
public container: string, // The MIME type (video/mp4)
|
||||||
|
public url: string = null, // The URL to load (optional)
|
||||||
|
public content: string = null, // The content to load (i.e. a DASH manifest, json content, optional)
|
||||||
|
public time: number = null, // The time to start playing in seconds
|
||||||
|
public volume: number = null, // The desired volume (0-1)
|
||||||
|
public speed: number = null, // The factor to multiply playback speed by (defaults to 1.0)
|
||||||
|
public cache: boolean = null, // Indicates if the receiver should preload the media item
|
||||||
|
public showDuration: number = null, // Indicates how long the item content is presented on screen in seconds
|
||||||
|
public headers: { [key: string]: string } = null, // HTTP request headers to add to the play request Map<string, string>
|
||||||
|
public metadata: MetadataObject = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaylistContent implements ContentObject {
|
||||||
|
readonly contentType = ContentType.Playlist;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public items: MediaItem[],
|
||||||
|
public offset: number = null, // Start position of the first item to play from the playlist
|
||||||
|
public volume: number = null, // The desired volume (0-1)
|
||||||
|
public speed: number = null, // The factor to multiply playback speed by (defaults to 1.0)
|
||||||
|
public forwardCache: number = null, // Count of media items should be pre-loaded forward from the current view index
|
||||||
|
public backwardCache: number = null, // Count of media items should be pre-loaded backward from the current view index
|
||||||
|
public metadata: MetadataObject = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InitialSenderMessage {
|
||||||
|
constructor(
|
||||||
|
public displayName: string = null,
|
||||||
|
public appName: string = null,
|
||||||
|
public appVersion: string = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InitialReceiverMessage {
|
||||||
|
constructor(
|
||||||
|
public displayName: string = null,
|
||||||
|
public appName: string = null,
|
||||||
|
public appVersion: string = null,
|
||||||
|
public playData: PlayMessage = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayUpdateMessage {
|
||||||
|
constructor(
|
||||||
|
public generationTime: number,
|
||||||
|
public playData: PlayMessage = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetPlaylistItemMessage {
|
||||||
|
constructor(
|
||||||
|
public itemIndex: number, // The playlist item index to play on receiver
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSubscribeObject {
|
||||||
|
type: EventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventObject {
|
||||||
|
type: EventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaItemStartEvent implements EventSubscribeObject {
|
||||||
|
readonly type = EventType.MediaItemStart;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaItemEndEvent implements EventSubscribeObject {
|
||||||
|
readonly type = EventType.MediaItemEnd;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaItemChangeEvent implements EventSubscribeObject {
|
||||||
|
readonly type = EventType.MediaItemChange;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyDownEvent implements EventSubscribeObject {
|
||||||
|
readonly type = EventType.KeyDown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public keys: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyUpEvent implements EventSubscribeObject {
|
||||||
|
readonly type = EventType.KeyUp;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public keys: string[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscribeEventMessage {
|
||||||
|
constructor(
|
||||||
|
public event: EventSubscribeObject,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsubscribeEventMessage {
|
||||||
|
constructor(
|
||||||
|
public event: EventSubscribeObject,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaItemEvent implements EventObject {
|
||||||
|
constructor(
|
||||||
|
public type: EventType,
|
||||||
|
public mediaItem: MediaItem,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyEvent implements EventObject {
|
||||||
|
constructor(
|
||||||
|
public type: EventType,
|
||||||
|
public key: string,
|
||||||
|
public repeat: boolean,
|
||||||
|
public handled: boolean,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventMessage {
|
||||||
|
constructor(
|
||||||
|
public generationTime: number,
|
||||||
|
public event: EventObject,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
|
import { ListenerService } from 'common/ListenerService';
|
||||||
import { FCastSession } from 'common/FCastSession';
|
import { FCastSession } from 'common/FCastSession';
|
||||||
import { Opcode } from 'common/Packets';
|
import { Opcode, PROTOCOL_VERSION, VersionMessage } from 'common/Packets';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { errorHandler } from 'src/Main';
|
|
||||||
const logger = new Logger('TcpListenerService', LoggerType.BACKEND);
|
const logger = new Logger('TcpListenerService', LoggerType.BACKEND);
|
||||||
|
|
||||||
export class TcpListenerService {
|
export class TcpListenerService extends ListenerService {
|
||||||
public static PORT = 46899;
|
public readonly PORT = 46899;
|
||||||
emitter = new EventEmitter();
|
|
||||||
|
|
||||||
private server: net.Server;
|
private server: net.Server;
|
||||||
private sessionMap = new Map();
|
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.server != null) {
|
if (this.server != null) {
|
||||||
|
@ -19,7 +15,7 @@ export class TcpListenerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.server = net.createServer()
|
this.server = net.createServer()
|
||||||
.listen(TcpListenerService.PORT)
|
.listen(this.PORT)
|
||||||
.on("connection", this.handleConnection.bind(this))
|
.on("connection", this.handleConnection.bind(this))
|
||||||
.on("error", this.handleServerError.bind(this));
|
.on("error", this.handleServerError.bind(this));
|
||||||
}
|
}
|
||||||
|
@ -35,29 +31,6 @@ export class TcpListenerService {
|
||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
send(opcode: number, message = null, sessionId = null) {
|
|
||||||
// logger.info(`Sending message ${JSON.stringify(message)}`);
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
try {
|
|
||||||
this.sessionMap.get(sessionId)?.send(opcode, message);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("Failed to send error.", e);
|
|
||||||
this.sessionMap.get(sessionId)?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const session of this.sessionMap.values()) {
|
|
||||||
try {
|
|
||||||
session.send(opcode, message);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("Failed to send error.", e);
|
|
||||||
session.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(sessionId: string) {
|
disconnect(sessionId: string) {
|
||||||
this.sessionMap.get(sessionId)?.socket.destroy();
|
this.sessionMap.get(sessionId)?.socket.destroy();
|
||||||
this.sessionMap.delete(sessionId);
|
this.sessionMap.delete(sessionId);
|
||||||
|
@ -69,14 +42,6 @@ export class TcpListenerService {
|
||||||
return senders;
|
return senders;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessions(): string[] {
|
|
||||||
return [...this.sessionMap.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
|
||||||
errorHandler(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleConnection(socket: net.Socket) {
|
private handleConnection(socket: net.Socket) {
|
||||||
logger.info(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
|
logger.info(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
|
|
||||||
|
@ -106,7 +71,7 @@ export class TcpListenerService {
|
||||||
this.emitter.emit('connect', { sessionId: session.sessionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
|
this.emitter.emit('connect', { sessionId: session.sessionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
|
||||||
try {
|
try {
|
||||||
logger.info('Sending version');
|
logger.info('Sending version');
|
||||||
session.send(Opcode.Version, {version: 2});
|
session.send(Opcode.Version, new VersionMessage(PROTOCOL_VERSION));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.info('Failed to send version', e);
|
logger.info('Failed to send version', e);
|
||||||
}
|
}
|
||||||
|
|
74
receivers/common/web/UtilityBackend.ts
Normal file
74
receivers/common/web/UtilityBackend.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { http, https } from 'modules/follow-redirects';
|
||||||
|
import * as memfs from 'modules/memfs';
|
||||||
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
const logger = new Logger('UtilityBackend', LoggerType.BACKEND);
|
||||||
|
|
||||||
|
export function deepEqual(x, y) {
|
||||||
|
const ok = Object.keys, tx = typeof x, ty = typeof y;
|
||||||
|
return x && y && tx === 'object' && tx === ty ? (
|
||||||
|
ok(x).length === ok(y).length &&
|
||||||
|
ok(x).every(key => deepEqual(x[key], y[key]))
|
||||||
|
) : (x === y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJSON(url: string): Promise<any> {
|
||||||
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
protocol.get(url, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(url: string, destination: string, startCb: (downloadSize: number) => boolean = null, progressCb: (downloadedBytes: number, downloadSize: number) => void = null, finishCb: (downloadedBytes: number) => void = null, inMemory: boolean = false): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = inMemory ? memfs.fs.createWriteStream(destination) : fs.createWriteStream(destination);
|
||||||
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
|
||||||
|
protocol.get(url, (response) => {
|
||||||
|
const downloadSize = Number(response.headers['content-length']);
|
||||||
|
logger.info(`Downloading file ${url} to ${destination} with size: ${downloadSize} bytes`);
|
||||||
|
if (startCb) {
|
||||||
|
if (!startCb(downloadSize)) {
|
||||||
|
file.close();
|
||||||
|
reject('Error: Aborted download');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.pipe(file);
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
if (progressCb) {
|
||||||
|
progressCb(downloadedBytes, downloadSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
if (finishCb) {
|
||||||
|
finishCb(downloadedBytes);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}).on('error', (err) => {
|
||||||
|
file.close();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
17
receivers/common/web/UtilityFrontend.ts
Normal file
17
receivers/common/web/UtilityFrontend.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { MediaItem, PlayMessage } from 'common/Packets';
|
||||||
|
|
||||||
|
export function playMessageFromMediaItem(item: MediaItem) {
|
||||||
|
return item ? new PlayMessage(
|
||||||
|
item.container, item.url, item.content,
|
||||||
|
item.time, item.volume, item.speed,
|
||||||
|
item.headers, item.metadata
|
||||||
|
) : new PlayMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaItemFromPlayMessage(message: PlayMessage) {
|
||||||
|
return message ? new MediaItem(
|
||||||
|
message.container, message.url, message.content,
|
||||||
|
message.time, message.volume, message.speed,
|
||||||
|
null, null, message.headers, message.metadata
|
||||||
|
) : new MediaItem("");
|
||||||
|
}
|
|
@ -1,25 +1,20 @@
|
||||||
|
import { ListenerService } from 'common/ListenerService';
|
||||||
import { FCastSession } from 'common/FCastSession';
|
import { FCastSession } from 'common/FCastSession';
|
||||||
import { Opcode } from 'common/Packets';
|
import { Opcode, PROTOCOL_VERSION, VersionMessage } from 'common/Packets';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { WebSocket, WebSocketServer } from 'modules/ws';
|
import { WebSocket, WebSocketServer } from 'modules/ws';
|
||||||
import { errorHandler } from 'src/Main';
|
|
||||||
const logger = new Logger('WebSocketListenerService', LoggerType.BACKEND);
|
const logger = new Logger('WebSocketListenerService', LoggerType.BACKEND);
|
||||||
|
|
||||||
export class WebSocketListenerService {
|
export class WebSocketListenerService extends ListenerService {
|
||||||
public static PORT = 46898;
|
public readonly PORT = 46898;
|
||||||
|
|
||||||
emitter = new EventEmitter();
|
|
||||||
|
|
||||||
private server: WebSocketServer;
|
private server: WebSocketServer;
|
||||||
private sessionMap = new Map();
|
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.server != null) {
|
if (this.server != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.server = new WebSocketServer({ port: WebSocketListenerService.PORT })
|
this.server = new WebSocketServer({ port: this.PORT })
|
||||||
.on("connection", this.handleConnection.bind(this))
|
.on("connection", this.handleConnection.bind(this))
|
||||||
.on("error", this.handleServerError.bind(this));
|
.on("error", this.handleServerError.bind(this));
|
||||||
}
|
}
|
||||||
|
@ -35,39 +30,10 @@ export class WebSocketListenerService {
|
||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
send(opcode: number, message = null, sessionId = null) {
|
|
||||||
if (sessionId) {
|
|
||||||
try {
|
|
||||||
this.sessionMap.get(sessionId)?.send(opcode, message);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("Failed to send error.", e);
|
|
||||||
this.sessionMap.get(sessionId)?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const session of this.sessionMap.values()) {
|
|
||||||
try {
|
|
||||||
session.send(opcode, message);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("Failed to send error.", e);
|
|
||||||
session.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(sessionId: string) {
|
disconnect(sessionId: string) {
|
||||||
this.sessionMap.get(sessionId)?.close();
|
this.sessionMap.get(sessionId)?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessions(): string[] {
|
|
||||||
return [...this.sessionMap.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleServerError(err: NodeJS.ErrnoException) {
|
|
||||||
errorHandler(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleConnection(socket: WebSocket, request: any) {
|
private handleConnection(socket: WebSocket, request: any) {
|
||||||
logger.info('New WebSocket connection');
|
logger.info('New WebSocket connection');
|
||||||
|
|
||||||
|
@ -101,9 +67,9 @@ export class WebSocketListenerService {
|
||||||
this.emitter.emit('connect', { sessionId: session.sessionId, type: 'ws', data: { url: socket.url }});
|
this.emitter.emit('connect', { sessionId: session.sessionId, type: 'ws', data: { url: socket.url }});
|
||||||
try {
|
try {
|
||||||
logger.info('Sending version');
|
logger.info('Sending version');
|
||||||
session.send(Opcode.Version, {version: 2});
|
session.send(Opcode.Version, new VersionMessage(PROTOCOL_VERSION));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.info('Failed to send version');
|
logger.info('Failed to send version', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { EventMessage } from 'common/Packets';
|
||||||
const logger = new Logger('MainWindow', LoggerType.FRONTEND);
|
const logger = new Logger('MainWindow', LoggerType.FRONTEND);
|
||||||
|
|
||||||
// Cannot directly pass the object to the renderer for some reason...
|
// Cannot directly pass the object to the renderer for some reason...
|
||||||
|
@ -23,6 +24,10 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
let preloadData: Record<string, any> = {};
|
let preloadData: Record<string, any> = {};
|
||||||
|
preloadData.subscribedKeys = {
|
||||||
|
keyDown: new Set<string>(),
|
||||||
|
keyUp: new Set<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (TARGET === 'electron') {
|
if (TARGET === 'electron') {
|
||||||
|
@ -32,13 +37,19 @@ if (TARGET === 'electron') {
|
||||||
electronAPI.ipcRenderer.on("device-info", (_event, value: any) => {
|
electronAPI.ipcRenderer.on("device-info", (_event, value: any) => {
|
||||||
preloadData.deviceInfo = value;
|
preloadData.deviceInfo = value;
|
||||||
})
|
})
|
||||||
|
electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set<string>, keyUp: Set<string> }) => {
|
||||||
|
preloadData.subscribedKeys.keyDown = value.keyDown;
|
||||||
|
preloadData.subscribedKeys.keyUp = value.keyUp;
|
||||||
|
})
|
||||||
|
|
||||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||||
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on('device-info', callback),
|
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on('device-info', callback),
|
||||||
getDeviceInfo: () => preloadData.deviceInfo,
|
getDeviceInfo: () => preloadData.deviceInfo,
|
||||||
getSessions: () => electronAPI.ipcRenderer.invoke('get-sessions'),
|
getSessions: () => electronAPI.ipcRenderer.invoke('get-sessions'),
|
||||||
|
getSubscribedKeys: () => preloadData.subscribedKeys,
|
||||||
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
|
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
|
||||||
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
|
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
|
||||||
|
sendEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('send-event', message),
|
||||||
logger: loggerInterface,
|
logger: loggerInterface,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import QRCode from 'modules/qrcode';
|
||||||
import * as connectionMonitor from '../ConnectionMonitor';
|
import * as connectionMonitor from '../ConnectionMonitor';
|
||||||
import { onQRCodeRendered } from 'src/main/Renderer';
|
import { onQRCodeRendered } from 'src/main/Renderer';
|
||||||
import { toast, ToastIcon } from '../components/Toast';
|
import { toast, ToastIcon } from '../components/Toast';
|
||||||
|
import { EventMessage, EventType, KeyEvent } from 'common/Packets';
|
||||||
|
|
||||||
const connectionStatusText = document.getElementById('connection-status-text');
|
const connectionStatusText = document.getElementById('connection-status-text');
|
||||||
const connectionStatusSpinner = document.getElementById('connection-spinner');
|
const connectionStatusSpinner = document.getElementById('connection-spinner');
|
||||||
|
@ -199,3 +200,14 @@ function renderQRCode(url: string) {
|
||||||
|
|
||||||
onQRCodeRendered();
|
onQRCodeRendered();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, false)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { PlayMessage } from 'common/Packets';
|
||||||
import dashjs from 'modules/dashjs';
|
import dashjs from 'modules/dashjs';
|
||||||
import Hls from 'modules/hls.js';
|
import Hls from 'modules/hls.js';
|
||||||
|
|
||||||
|
@ -10,28 +11,69 @@ export enum PlayerType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Player {
|
export class Player {
|
||||||
private player: dashjs.MediaPlayerClass | HTMLVideoElement;
|
private player: HTMLVideoElement;
|
||||||
private hlsPlayer: Hls | undefined;
|
private playMessage: PlayMessage;
|
||||||
private source: string;
|
private source: string;
|
||||||
public playerType: PlayerType;
|
|
||||||
|
|
||||||
constructor(playerType: PlayerType, player: dashjs.MediaPlayerClass | HTMLVideoElement, source: string, hlsPlayer?: Hls) {
|
// Todo: use a common event handler interface instead of exposing internal players
|
||||||
this.playerType = playerType;
|
public playerType: PlayerType;
|
||||||
|
public dashPlayer: dashjs.MediaPlayerClass = null;
|
||||||
|
public hlsPlayer: Hls = null;
|
||||||
|
|
||||||
|
constructor(player: HTMLVideoElement, message: PlayMessage) {
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.source = source;
|
this.playMessage = message;
|
||||||
this.hlsPlayer = playerType === PlayerType.Hls ? hlsPlayer : null;
|
|
||||||
|
if (message.container === 'application/dash+xml') {
|
||||||
|
this.playerType = PlayerType.Dash;
|
||||||
|
this.source = message.content ? message.content : message.url;
|
||||||
|
this.dashPlayer = dashjs.MediaPlayer().create();
|
||||||
|
|
||||||
|
this.dashPlayer.extend("RequestModifier", () => {
|
||||||
|
return {
|
||||||
|
modifyRequestHeader: function (xhr) {
|
||||||
|
if (message.headers) {
|
||||||
|
for (const [key, val] of Object.entries(message.headers)) {
|
||||||
|
xhr.setRequestHeader(key, val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
return xhr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
} else if ((message.container === 'application/vnd.apple.mpegurl' || message.container === 'application/x-mpegURL') && !player.canPlayType(message.container)) {
|
||||||
|
this.playerType = PlayerType.Hls;
|
||||||
|
this.source = message.url;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
xhrSetup: function (xhr: XMLHttpRequest) {
|
||||||
|
if (message.headers) {
|
||||||
|
for (const [key, val] of Object.entries(message.headers)) {
|
||||||
|
xhr.setRequestHeader(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.hlsPlayer = new Hls(config);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.playerType = PlayerType.Html;
|
||||||
|
this.source = message.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
switch (this.playerType) {
|
switch (this.playerType) {
|
||||||
case PlayerType.Dash:
|
case PlayerType.Dash:
|
||||||
try {
|
try {
|
||||||
(this.player as dashjs.MediaPlayerClass).destroy();
|
this.dashPlayer.destroy();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("Failed to destroy dash player", e);
|
logger.warn("Failed to destroy dash player", e);
|
||||||
}
|
}
|
||||||
this.player = null;
|
|
||||||
this.playerType = null;
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlayerType.Hls:
|
case PlayerType.Hls:
|
||||||
|
@ -41,158 +83,231 @@ export class Player {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("Failed to destroy hls player", e);
|
logger.warn("Failed to destroy hls player", e);
|
||||||
}
|
}
|
||||||
// fall through
|
// fallthrough
|
||||||
|
|
||||||
case PlayerType.Html: {
|
case PlayerType.Html: {
|
||||||
const videoPlayer = this.player as HTMLVideoElement;
|
this.player.src = "";
|
||||||
|
// this.player.onerror = null;
|
||||||
|
this.player.onloadedmetadata = null;
|
||||||
|
this.player.ontimeupdate = null;
|
||||||
|
this.player.onplay = null;
|
||||||
|
this.player.onpause = null;
|
||||||
|
this.player.onended = null;
|
||||||
|
this.player.ontimeupdate = null;
|
||||||
|
this.player.onratechange = null;
|
||||||
|
this.player.onvolumechange = null;
|
||||||
|
|
||||||
videoPlayer.src = "";
|
|
||||||
// videoPlayer.onerror = null;
|
|
||||||
videoPlayer.onloadedmetadata = null;
|
|
||||||
videoPlayer.ontimeupdate = null;
|
|
||||||
videoPlayer.onplay = null;
|
|
||||||
videoPlayer.onpause = null;
|
|
||||||
videoPlayer.onended = null;
|
|
||||||
videoPlayer.ontimeupdate = null;
|
|
||||||
videoPlayer.onratechange = null;
|
|
||||||
videoPlayer.onvolumechange = null;
|
|
||||||
|
|
||||||
this.player = null;
|
|
||||||
this.playerType = null;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.player = null;
|
||||||
|
this.playerType = null;
|
||||||
|
this.dashPlayer = null;
|
||||||
|
this.hlsPlayer = null;
|
||||||
|
this.playMessage = null;
|
||||||
|
this.source = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
play() { logger.info("Player: play"); this.player.play(); }
|
/**
|
||||||
|
* Load media specified in the PlayMessage provided on object initialization
|
||||||
isPaused(): boolean {
|
*/
|
||||||
|
public load() {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).isPaused();
|
if (this.playMessage.content) {
|
||||||
} else { // HLS, HTML
|
this.dashPlayer.initialize(this.player, `data:${this.playMessage.container};base64,` + window.btoa(this.playMessage.content), true, this.playMessage.time);
|
||||||
return (this.player as HTMLVideoElement).paused;
|
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
||||||
|
} else {
|
||||||
|
// value.url = 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd';
|
||||||
|
this.dashPlayer.initialize(this.player, this.playMessage.url, true, this.playMessage.time);
|
||||||
|
}
|
||||||
|
} else if (this.playerType === PlayerType.Hls) {
|
||||||
|
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
||||||
|
this.hlsPlayer.loadSource(this.playMessage.url);
|
||||||
|
this.hlsPlayer.attachMedia(this.player);
|
||||||
|
// hlsPlayer.subtitleDisplay = true;
|
||||||
|
} else { // HTML
|
||||||
|
this.player.src = this.playMessage.url;
|
||||||
|
this.player.load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pause() { logger.info("Player: pause"); this.player.pause(); }
|
|
||||||
|
|
||||||
getVolume(): number {
|
public play() {
|
||||||
|
logger.info("Player: play");
|
||||||
|
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).getVolume();
|
this.dashPlayer.play();
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
return (this.player as HTMLVideoElement).volume;
|
this.player.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVolume(value: number) {
|
|
||||||
logger.info(`Player: setVolume ${value}`);
|
public isPaused(): boolean {
|
||||||
|
if (this.playerType === PlayerType.Dash) {
|
||||||
|
return this.dashPlayer.isPaused();
|
||||||
|
} else { // HLS, HTML
|
||||||
|
return this.player.paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause() {
|
||||||
|
logger.info("Player: pause");
|
||||||
|
|
||||||
|
if (this.playerType === PlayerType.Dash) {
|
||||||
|
this.dashPlayer.pause();
|
||||||
|
} else { // HLS, HTML
|
||||||
|
this.player.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
const playbackRate = this.getPlaybackRate();
|
||||||
|
const volume = this.getVolume();
|
||||||
|
|
||||||
|
if (this.playerType === PlayerType.Dash) {
|
||||||
|
if (this.playMessage.content) {
|
||||||
|
this.dashPlayer.initialize(this.player, `data:${this.playMessage.container};base64,` + window.btoa(this.playMessage.content), false);
|
||||||
|
} else {
|
||||||
|
this.dashPlayer.initialize(this.player, this.playMessage.url, false);
|
||||||
|
}
|
||||||
|
} else if (this.playerType === PlayerType.Hls) {
|
||||||
|
this.hlsPlayer.loadSource(this.source);
|
||||||
|
} else {
|
||||||
|
this.player.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setPlaybackRate(playbackRate);
|
||||||
|
this.setVolume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVolume(): number {
|
||||||
|
if (this.playerType === PlayerType.Dash) {
|
||||||
|
return this.dashPlayer.getVolume();
|
||||||
|
} else { // HLS, HTML
|
||||||
|
return this.player.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public setVolume(value: number) {
|
||||||
|
// logger.info(`Player: setVolume ${value}`);
|
||||||
const sanitizedVolume = Math.min(1.0, Math.max(0.0, value));
|
const sanitizedVolume = Math.min(1.0, Math.max(0.0, value));
|
||||||
|
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
(this.player as dashjs.MediaPlayerClass).setVolume(sanitizedVolume);
|
this.dashPlayer.setVolume(sanitizedVolume);
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
(this.player as HTMLVideoElement).volume = sanitizedVolume;
|
this.player.volume = sanitizedVolume;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMuted(): boolean {
|
public isMuted(): boolean {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).isMuted();
|
return this.dashPlayer.isMuted();
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
return (this.player as HTMLVideoElement).muted;
|
return this.player.muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMute(value: boolean) {
|
public setMute(value: boolean) {
|
||||||
logger.info(`Player: setMute ${value}`);
|
logger.info(`Player: setMute ${value}`);
|
||||||
|
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
(this.player as dashjs.MediaPlayerClass).setMute(value);
|
this.dashPlayer.setMute(value);
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
(this.player as HTMLVideoElement).muted = value;
|
this.player.muted = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackRate(): number {
|
public getPlaybackRate(): number {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).getPlaybackRate();
|
return this.dashPlayer.getPlaybackRate();
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
return (this.player as HTMLVideoElement).playbackRate;
|
return this.player.playbackRate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPlaybackRate(value: number) {
|
public setPlaybackRate(value: number) {
|
||||||
logger.info(`Player: setPlaybackRate ${value}`);
|
logger.info(`Player: setPlaybackRate ${value}`);
|
||||||
const sanitizedSpeed = Math.min(16.0, Math.max(0.0, value));
|
const sanitizedSpeed = Math.min(16.0, Math.max(0.0, value));
|
||||||
|
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
(this.player as dashjs.MediaPlayerClass).setPlaybackRate(sanitizedSpeed);
|
this.dashPlayer.setPlaybackRate(sanitizedSpeed);
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
(this.player as HTMLVideoElement).playbackRate = sanitizedSpeed;
|
this.player.playbackRate = sanitizedSpeed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDuration(): number {
|
public getDuration(): number {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
const videoPlayer = this.player as dashjs.MediaPlayerClass;
|
return isFinite(this.dashPlayer.duration()) ? this.dashPlayer.duration() : 0;
|
||||||
return isFinite(videoPlayer.duration()) ? videoPlayer.duration() : 0;
|
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
const videoPlayer = this.player as HTMLVideoElement;
|
return isFinite(this.player.duration) ? this.player.duration : 0;
|
||||||
return isFinite(videoPlayer.duration) ? videoPlayer.duration : 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentTime(): number {
|
public getCurrentTime(): number {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).time();
|
return this.dashPlayer.time();
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
return (this.player as HTMLVideoElement).currentTime;
|
return this.player.currentTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCurrentTime(value: number) {
|
public setCurrentTime(value: number) {
|
||||||
// logger.info(`Player: setCurrentTime ${value}`);
|
// logger.info(`Player: setCurrentTime ${value}`);
|
||||||
const sanitizedTime = Math.min(this.getDuration(), Math.max(0.0, value));
|
const sanitizedTime = Math.min(this.getDuration(), Math.max(0.0, value));
|
||||||
|
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
(this.player as dashjs.MediaPlayerClass).seek(sanitizedTime);
|
this.dashPlayer.seek(sanitizedTime);
|
||||||
const videoPlayer = this.player as dashjs.MediaPlayerClass;
|
|
||||||
|
|
||||||
if (!videoPlayer.isSeeking()) {
|
if (!this.dashPlayer.isSeeking()) {
|
||||||
videoPlayer.seek(sanitizedTime);
|
this.dashPlayer.seek(sanitizedTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
(this.player as HTMLVideoElement).currentTime = sanitizedTime;
|
this.player.currentTime = sanitizedTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getSource(): string {
|
public getSource(): string {
|
||||||
return this.source;
|
return this.source;
|
||||||
}
|
}
|
||||||
|
|
||||||
getBufferLength(): number {
|
public getAutoplay(): boolean {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
const dashPlayer = this.player as dashjs.MediaPlayerClass;
|
return this.dashPlayer.getAutoPlay();
|
||||||
|
} else { // HLS, HTML
|
||||||
|
return this.player.autoplay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dashBufferLength = dashPlayer.getBufferLength("video")
|
public setAutoPlay(value: boolean) {
|
||||||
?? dashPlayer.getBufferLength("audio")
|
if (this.playerType === PlayerType.Dash) {
|
||||||
?? dashPlayer.getBufferLength("text")
|
return this.dashPlayer.setAutoPlay(value);
|
||||||
?? dashPlayer.getBufferLength("image")
|
} else { // HLS, HTML
|
||||||
|
return this.player.autoplay = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBufferLength(): number {
|
||||||
|
if (this.playerType === PlayerType.Dash) {
|
||||||
|
let dashBufferLength = this.dashPlayer.getBufferLength("video")
|
||||||
|
?? this.dashPlayer.getBufferLength("audio")
|
||||||
|
?? this.dashPlayer.getBufferLength("text")
|
||||||
|
?? this.dashPlayer.getBufferLength("image")
|
||||||
?? 0;
|
?? 0;
|
||||||
if (Number.isNaN(dashBufferLength))
|
if (Number.isNaN(dashBufferLength))
|
||||||
dashBufferLength = 0;
|
dashBufferLength = 0;
|
||||||
|
|
||||||
dashBufferLength += dashPlayer.time();
|
dashBufferLength += this.dashPlayer.time();
|
||||||
return dashBufferLength;
|
return dashBufferLength;
|
||||||
} else { // HLS, HTML
|
} else { // HLS, HTML
|
||||||
const videoPlayer = this.player as HTMLVideoElement;
|
|
||||||
let maxBuffer = 0;
|
let maxBuffer = 0;
|
||||||
|
|
||||||
if (videoPlayer.buffered) {
|
if (this.player.buffered) {
|
||||||
for (let i = 0; i < videoPlayer.buffered.length; i++) {
|
for (let i = 0; i < this.player.buffered.length; i++) {
|
||||||
const start = videoPlayer.buffered.start(i);
|
const start = this.player.buffered.start(i);
|
||||||
const end = videoPlayer.buffered.end(i);
|
const end = this.player.buffered.end(i);
|
||||||
|
|
||||||
if (videoPlayer.currentTime >= start && videoPlayer.currentTime <= end) {
|
if (this.player.currentTime >= start && this.player.currentTime <= end) {
|
||||||
maxBuffer = end;
|
maxBuffer = end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -202,9 +317,9 @@ export class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCaptionsSupported(): boolean {
|
public isCaptionsSupported(): boolean {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).getTracksFor('text').length > 0;
|
return this.dashPlayer.getTracksFor('text').length > 0;
|
||||||
} else if (this.playerType === PlayerType.Hls) {
|
} else if (this.playerType === PlayerType.Hls) {
|
||||||
return this.hlsPlayer.allSubtitleTracks.length > 0;
|
return this.hlsPlayer.allSubtitleTracks.length > 0;
|
||||||
} else {
|
} else {
|
||||||
|
@ -212,9 +327,9 @@ export class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isCaptionsEnabled(): boolean {
|
public isCaptionsEnabled(): boolean {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
return (this.player as dashjs.MediaPlayerClass).isTextEnabled();
|
return this.dashPlayer.isTextEnabled();
|
||||||
} else if (this.playerType === PlayerType.Hls) {
|
} else if (this.playerType === PlayerType.Hls) {
|
||||||
return this.hlsPlayer.subtitleDisplay;
|
return this.hlsPlayer.subtitleDisplay;
|
||||||
} else {
|
} else {
|
||||||
|
@ -222,9 +337,9 @@ export class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enableCaptions(enable: boolean) {
|
public enableCaptions(enable: boolean) {
|
||||||
if (this.playerType === PlayerType.Dash) {
|
if (this.playerType === PlayerType.Dash) {
|
||||||
(this.player as dashjs.MediaPlayerClass).enableText(enable);
|
this.dashPlayer.enableText(enable);
|
||||||
} else if (this.playerType === PlayerType.Hls) {
|
} else if (this.playerType === PlayerType.Hls) {
|
||||||
this.hlsPlayer.subtitleDisplay = enable;
|
this.hlsPlayer.subtitleDisplay = enable;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, Opcode } from 'common/Packets';
|
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, EventMessage, PlayMessage } from 'common/Packets';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
const logger = new Logger('PlayerWindow', LoggerType.FRONTEND);
|
const logger = new Logger('PlayerWindow', LoggerType.FRONTEND);
|
||||||
|
|
||||||
|
@ -25,25 +25,40 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
let preloadData: Record<string, any> = {};
|
let preloadData: Record<string, any> = {};
|
||||||
|
preloadData.subscribedKeys = {
|
||||||
|
keyDown: new Set<string>(),
|
||||||
|
keyUp: new Set<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (TARGET === 'electron') {
|
if (TARGET === 'electron') {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const electronAPI = __non_webpack_require__('electron');
|
const electronAPI = __non_webpack_require__('electron');
|
||||||
|
|
||||||
|
electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set<string>, keyUp: Set<string> }) => {
|
||||||
|
preloadData.subscribedKeys.keyDown = value.keyDown;
|
||||||
|
preloadData.subscribedKeys.keyUp = value.keyUp;
|
||||||
|
})
|
||||||
|
|
||||||
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
|
||||||
sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error),
|
|
||||||
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-update', update),
|
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-update', update),
|
||||||
sendVolumeUpdate: (update: VolumeUpdateMessage) => electronAPI.ipcRenderer.send('send-volume-update', update),
|
sendVolumeUpdate: (update: VolumeUpdateMessage) => electronAPI.ipcRenderer.send('send-volume-update', update),
|
||||||
|
sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error),
|
||||||
|
sendEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('send-event', message),
|
||||||
onPlay: (callback: any) => electronAPI.ipcRenderer.on("play", callback),
|
onPlay: (callback: any) => electronAPI.ipcRenderer.on("play", callback),
|
||||||
onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback),
|
onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback),
|
||||||
onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback),
|
onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback),
|
||||||
onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback),
|
onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback),
|
||||||
onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback),
|
onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback),
|
||||||
onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback),
|
onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback),
|
||||||
|
onSetPlaylistItem: (callback: any) => electronAPI.ipcRenderer.on("setplaylistitem", callback),
|
||||||
|
|
||||||
|
sendPlayRequest: (message: PlayMessage, playlistIndex: number) => electronAPI.ipcRenderer.send('play-request', message, playlistIndex),
|
||||||
getSessions: () => electronAPI.ipcRenderer.invoke('get-sessions'),
|
getSessions: () => electronAPI.ipcRenderer.invoke('get-sessions'),
|
||||||
|
getSubscribedKeys: () => preloadData.subscribedKeys,
|
||||||
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
|
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
|
||||||
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
|
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
|
||||||
|
onPlayPlaylist: (callback: any) => electronAPI.ipcRenderer.on('play-playlist', callback),
|
||||||
logger: loggerInterface,
|
logger: loggerInterface,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import dashjs from 'modules/dashjs';
|
import dashjs from 'modules/dashjs';
|
||||||
import Hls, { LevelLoadedData } from 'modules/hls.js';
|
import Hls, { LevelLoadedData } from 'modules/hls.js';
|
||||||
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
import { EventMessage, EventType, KeyEvent, MediaItem, MediaItemEvent, PlaybackState, PlaybackUpdateMessage, PlaylistContent, PlayMessage, SeekMessage, SetPlaylistItemMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
||||||
import { Player, PlayerType } from './Player';
|
import { Player, PlayerType } from './Player';
|
||||||
import * as connectionMonitor from '../ConnectionMonitor';
|
import * as connectionMonitor from 'common/ConnectionMonitor';
|
||||||
import { toast, ToastIcon } from '../components/Toast';
|
import { supportedAudioTypes } from 'common/MimeTypes';
|
||||||
|
import { mediaItemFromPlayMessage, playMessageFromMediaItem } from 'common/UtilityFrontend';
|
||||||
|
import { toast, ToastIcon } from 'common/components/Toast';
|
||||||
import {
|
import {
|
||||||
targetPlayerCtrlStateUpdate,
|
targetPlayerCtrlStateUpdate,
|
||||||
targetKeyDownEventListener,
|
targetKeyDownEventListener,
|
||||||
|
@ -34,8 +36,9 @@ function formatDuration(duration: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendPlaybackUpdate(updateState: number) {
|
function sendPlaybackUpdate(updateState: PlaybackState) {
|
||||||
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
|
const updateMessage = new PlaybackUpdateMessage(Date.now(), updateState, player.getCurrentTime(), player.getDuration(), player.getPlaybackRate());
|
||||||
|
playbackState = updateState;
|
||||||
|
|
||||||
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
|
||||||
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
|
||||||
|
@ -43,8 +46,19 @@ function sendPlaybackUpdate(updateState: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
|
function onPlayerLoad(value: PlayMessage) {
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
playerCtrlStateUpdate(PlayerControlEvent.Load);
|
||||||
|
loadingSpinner.style.display = 'none';
|
||||||
|
|
||||||
|
if (player.getAutoplay()) {
|
||||||
|
if (!supportedAudioTypes.find(v => v === value.container.toLocaleLowerCase())) {
|
||||||
|
idleIcon.style.display = 'none';
|
||||||
|
idleBackground.style.display = 'none';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
// Subtitles break when seeking post stream initialization for the DASH player.
|
// Subtitles break when seeking post stream initialization for the DASH player.
|
||||||
// Its currently done on player initialization.
|
// Its currently done on player initialization.
|
||||||
|
@ -53,30 +67,32 @@ function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentV
|
||||||
player.setCurrentTime(value.time);
|
player.setCurrentTime(value.time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.speed) {
|
if (value.speed) {
|
||||||
player.setPlaybackRate(value.speed);
|
player.setPlaybackRate(value.speed);
|
||||||
} else if (currentPlaybackRate) {
|
|
||||||
player.setPlaybackRate(currentPlaybackRate);
|
|
||||||
} else {
|
|
||||||
player.setPlaybackRate(1.0);
|
|
||||||
}
|
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
|
||||||
|
}
|
||||||
if (currentVolume) {
|
if (value.volume) {
|
||||||
volumeChangeHandler(currentVolume);
|
volumeChangeHandler(value.volume);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// FCast PlayMessage does not contain volume field and could result in the receiver
|
// Protocol v2 FCast PlayMessage does not contain volume field and could result in the receiver
|
||||||
// getting out-of-sync with the sender on 1st playback.
|
// getting out-of-sync with the sender on 1st playback.
|
||||||
volumeChangeHandler(1.0);
|
volumeChangeHandler(1.0);
|
||||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
|
||||||
}
|
}
|
||||||
|
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||||
|
|
||||||
|
playbackState = PlaybackState.Playing;
|
||||||
|
logger.info('Media playback start:', cachedPlayMediaItem);
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemStart, cachedPlayMediaItem)));
|
||||||
player.play();
|
player.play();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML elements
|
// HTML elements
|
||||||
|
const idleIcon = document.getElementById('title-icon');
|
||||||
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
|
const idleBackground = document.getElementById('idle-background');
|
||||||
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
|
||||||
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
|
||||||
|
|
||||||
|
@ -111,20 +127,38 @@ let playerCtrlSpeedMenuShown = false;
|
||||||
|
|
||||||
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
const playbackRates = ["0.25", "0.50", "0.75", "1.00", "1.25", "1.50", "1.75", "2.00"];
|
||||||
const playbackUpdateInterval = 1.0;
|
const playbackUpdateInterval = 1.0;
|
||||||
|
const playerVolumeUpdateInterval = 0.01;
|
||||||
const livePositionDelta = 5.0;
|
const livePositionDelta = 5.0;
|
||||||
const livePositionWindow = livePositionDelta * 4;
|
const livePositionWindow = livePositionDelta * 4;
|
||||||
let player: Player;
|
let player: Player;
|
||||||
let playerPrevTime: number = 0;
|
let playbackState: PlaybackState = PlaybackState.Idle;
|
||||||
|
let playerPrevTime: number = 1;
|
||||||
|
let playerPrevVolume: number = 1;
|
||||||
let lastPlayerUpdateGenerationTime = 0;
|
let lastPlayerUpdateGenerationTime = 0;
|
||||||
let isLive = false;
|
let isLive = false;
|
||||||
let isLivePosition = false;
|
let isLivePosition = false;
|
||||||
let captionsBaseHeight = 0;
|
let captionsBaseHeight = 0;
|
||||||
let captionsContentHeight = 0;
|
let captionsContentHeight = 0;
|
||||||
|
|
||||||
|
let cachedPlaylist: PlaylistContent = null;
|
||||||
|
let cachedPlayMediaItem: MediaItem = null;
|
||||||
|
let showDurationTimeout: number = null;
|
||||||
|
let playlistIndex = 0;
|
||||||
|
let isMediaItem = false;
|
||||||
|
let playItemCached = false;
|
||||||
|
|
||||||
function onPlay(_event, value: PlayMessage) {
|
function onPlay(_event, value: PlayMessage) {
|
||||||
logger.info("Handle play message renderer", JSON.stringify(value));
|
if (!playItemCached) {
|
||||||
const currentVolume = player ? player.getVolume() : null;
|
cachedPlayMediaItem = mediaItemFromPlayMessage(value);
|
||||||
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
|
isMediaItem = false;
|
||||||
|
}
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemChange, cachedPlayMediaItem)));
|
||||||
|
logger.info('Media playback changed:', cachedPlayMediaItem);
|
||||||
|
playItemCached = false;
|
||||||
|
|
||||||
|
idleIcon.style.display = 'none';
|
||||||
|
loadingSpinner.style.display = 'block';
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
if ((player.getSource() === value.url) || (player.getSource() === value.content)) {
|
if ((player.getSource() === value.url) || (player.getSource() === value.content)) {
|
||||||
|
@ -137,6 +171,7 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
player.destroy();
|
player.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playbackState = PlaybackState.Idle;
|
||||||
playerPrevTime = 0;
|
playerPrevTime = 0;
|
||||||
lastPlayerUpdateGenerationTime = 0;
|
lastPlayerUpdateGenerationTime = 0;
|
||||||
isLive = false;
|
isLive = false;
|
||||||
|
@ -144,60 +179,48 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
captionsBaseHeight = captionsBaseHeightExpanded;
|
captionsBaseHeight = captionsBaseHeightExpanded;
|
||||||
|
|
||||||
if ((value.url || value.content) && value.container && videoElement) {
|
if ((value.url || value.content) && value.container && videoElement) {
|
||||||
|
player = new Player(videoElement, value);
|
||||||
|
logger.info(`Loaded ${PlayerType[player.playerType]} player`);
|
||||||
|
|
||||||
if (value.container === 'application/dash+xml') {
|
if (value.container === 'application/dash+xml') {
|
||||||
logger.info("Loading dash player");
|
|
||||||
const dashPlayer = dashjs.MediaPlayer().create();
|
|
||||||
const source = value.content ? value.content : value.url;
|
|
||||||
player = new Player(PlayerType.Dash, dashPlayer, source);
|
|
||||||
|
|
||||||
dashPlayer.extend("RequestModifier", () => {
|
|
||||||
return {
|
|
||||||
modifyRequestHeader: function (xhr) {
|
|
||||||
if (value.headers) {
|
|
||||||
for (const [key, val] of Object.entries(value.headers)) {
|
|
||||||
xhr.setRequestHeader(key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return xhr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Player event handlers
|
// Player event handlers
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { mediaStartHandler(value); });
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(PlaybackState.Paused); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { mediaEndHandler(); });
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||||
|
|
||||||
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
if (Math.abs(player.dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
|
||||||
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
|
sendPlaybackUpdate(playbackState);
|
||||||
playerPrevTime = dashPlayer.time();
|
playerPrevTime = player.dashPlayer.time();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(playbackState); });
|
||||||
|
|
||||||
// Buffering UI update when paused
|
// Buffering UI update when paused
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PROGRESS, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); });
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => {
|
||||||
const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume();
|
const updateVolume = player.dashPlayer.isMuted() ? 0 : player.dashPlayer.getVolume();
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||||
|
|
||||||
|
if (Math.abs(updateVolume - playerPrevVolume) >= playerVolumeUpdateInterval) {
|
||||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||||
|
playerPrevVolume = updateVolume;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
player.dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||||
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
message: `DashJS ERROR: ${JSON.stringify(data)}`
|
||||||
})});
|
})});
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.targetAPI.sendPlaybackError({
|
||||||
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
|
||||||
})});
|
})});
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
|
player.dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value); });
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
player.dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
|
||||||
const subtitle = document.createElement("p")
|
const subtitle = document.createElement("p")
|
||||||
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
subtitle.setAttribute("id", "subtitle-" + e.cueID)
|
||||||
|
|
||||||
|
@ -214,11 +237,11 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
player.dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
|
||||||
document.getElementById("subtitle-" + e.cueID)?.remove();
|
document.getElementById("subtitle-" + e.cueID)?.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
dashPlayer.updateSettings({
|
player.dashPlayer.updateSettings({
|
||||||
// debug: {
|
// debug: {
|
||||||
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
|
||||||
// },
|
// },
|
||||||
|
@ -229,36 +252,14 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (value.content) {
|
|
||||||
dashPlayer.initialize(videoElement, `data:${value.container};base64,` + window.btoa(value.content), true, value.time);
|
|
||||||
// dashPlayer.initialize(videoElement, "https://dash.akamaized.net/akamai/test/caption_test/ElephantsDream/elephants_dream_480p_heaac5_1_https.mpd", true);
|
|
||||||
} else {
|
|
||||||
// value.url = 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd';
|
|
||||||
dashPlayer.initialize(videoElement, value.url, true, value.time);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
} else if ((value.container === 'application/vnd.apple.mpegurl' || value.container === 'application/x-mpegURL') && !videoElement.canPlayType(value.container)) {
|
||||||
logger.info("Loading hls player");
|
player.hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
||||||
|
|
||||||
const config = {
|
|
||||||
xhrSetup: function (xhr: XMLHttpRequest) {
|
|
||||||
if (value.headers) {
|
|
||||||
for (const [key, val] of Object.entries(value.headers)) {
|
|
||||||
xhr.setRequestHeader(key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const hlsPlayer = new Hls(config);
|
|
||||||
|
|
||||||
hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => {
|
|
||||||
window.targetAPI.sendPlaybackError({
|
window.targetAPI.sendPlaybackError({
|
||||||
message: `HLS player error: ${JSON.stringify(data)}`
|
message: `HLS player error: ${JSON.stringify(data)}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
player.hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
|
||||||
isLive = level.details.live;
|
isLive = level.details.live;
|
||||||
isLivePosition = isLive ? true : false;
|
isLivePosition = isLive ? true : false;
|
||||||
|
|
||||||
|
@ -271,41 +272,32 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
player = new Player(PlayerType.Hls, videoElement, value.url, hlsPlayer);
|
|
||||||
|
|
||||||
// value.url = "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8?ref=developerinsider.co";
|
|
||||||
hlsPlayer.loadSource(value.url);
|
|
||||||
hlsPlayer.attachMedia(videoElement);
|
|
||||||
// hlsPlayer.subtitleDisplay = true;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
logger.info("Loading html player");
|
|
||||||
player = new Player(PlayerType.Html, videoElement, value.url);
|
|
||||||
|
|
||||||
videoElement.src = value.url;
|
|
||||||
videoElement.load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player event handlers
|
// Player event handlers
|
||||||
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
|
||||||
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
|
videoElement.onplay = () => { mediaStartHandler(value); };
|
||||||
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
videoElement.onpause = () => { sendPlaybackUpdate(PlaybackState.Paused); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
|
||||||
videoElement.onended = () => { sendPlaybackUpdate(0) };
|
videoElement.onended = () => { mediaEndHandler(); };
|
||||||
videoElement.ontimeupdate = () => {
|
videoElement.ontimeupdate = () => {
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
|
||||||
|
|
||||||
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
|
||||||
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
|
sendPlaybackUpdate(playbackState);
|
||||||
playerPrevTime = videoElement.currentTime;
|
playerPrevTime = videoElement.currentTime;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Buffering UI update when paused
|
// Buffering UI update when paused
|
||||||
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
|
||||||
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
|
videoElement.onratechange = () => { sendPlaybackUpdate(playbackState); };
|
||||||
videoElement.onvolumechange = () => {
|
videoElement.onvolumechange = () => {
|
||||||
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
|
||||||
|
|
||||||
|
if (Math.abs(updateVolume - playerPrevVolume) >= playerVolumeUpdateInterval) {
|
||||||
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
|
||||||
|
playerPrevVolume = updateVolume;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||||
|
@ -322,8 +314,16 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
isLivePosition = false;
|
isLivePosition = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPlayerLoad(value, currentPlaybackRate, currentVolume); };
|
onPlayerLoad(value);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player.setAutoPlay(true);
|
||||||
|
player.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMediaItem && cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) {
|
||||||
|
showDurationTimeout = window.setTimeout(mediaEndHandler, cachedPlayMediaItem.showDuration * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sender generated event handlers
|
// Sender generated event handlers
|
||||||
|
@ -332,7 +332,43 @@ function onPlay(_event, value: PlayMessage) {
|
||||||
window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
|
||||||
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
|
||||||
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function onPlayPlaylist(_event, value: PlaylistContent) {
|
||||||
|
logger.info('Handle play playlist message', JSON.stringify(value));
|
||||||
|
cachedPlaylist = value;
|
||||||
|
|
||||||
|
const offset = value.offset ? value.offset : 0;
|
||||||
|
const volume = value.items[offset].volume ? value.items[offset].volume : value.volume;
|
||||||
|
const speed = value.items[offset].speed ? value.items[offset].speed : value.speed;
|
||||||
|
const playMessage = new PlayMessage(
|
||||||
|
value.items[offset].container, value.items[offset].url, value.items[offset].content,
|
||||||
|
value.items[offset].time, volume, speed, value.items[offset].headers, value.items[offset].metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
isMediaItem = true;
|
||||||
|
cachedPlayMediaItem = value.items[offset];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessage, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.targetAPI.onSetPlaylistItem((_event, value: SetPlaylistItemMessage) => {
|
||||||
|
if (value.itemIndex >= 0 && value.itemIndex < cachedPlaylist.items.length) {
|
||||||
|
logger.info(`Setting playlist item to index ${value.itemIndex}`);
|
||||||
|
playlistIndex = value.itemIndex;
|
||||||
|
cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
|
||||||
|
if (showDurationTimeout) {
|
||||||
|
window.clearTimeout(showDurationTimeout);
|
||||||
|
showDurationTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.warn(`Playlist index out of bounds ${value.itemIndex}, ignoring...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
connectionMonitor.setUiUpdateCallbacks({
|
connectionMonitor.setUiUpdateCallbacks({
|
||||||
onConnect: (connections: string[], initialUpdate: boolean = false) => {
|
onConnect: (connections: string[], initialUpdate: boolean = false) => {
|
||||||
|
@ -346,6 +382,7 @@ connectionMonitor.setUiUpdateCallbacks({
|
||||||
});
|
});
|
||||||
|
|
||||||
window.targetAPI.onPlay(onPlay);
|
window.targetAPI.onPlay(onPlay);
|
||||||
|
window.targetAPI.onPlayPlaylist(onPlayPlaylist);
|
||||||
|
|
||||||
let scrubbing = false;
|
let scrubbing = false;
|
||||||
let volumeChanging = false;
|
let volumeChanging = false;
|
||||||
|
@ -669,7 +706,7 @@ playbackRates.forEach(r => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
videoElement.onclick = () => {
|
function videoClickedHandler() {
|
||||||
if (!playerCtrlSpeedMenuShown) {
|
if (!playerCtrlSpeedMenuShown) {
|
||||||
if (player?.isPaused()) {
|
if (player?.isPaused()) {
|
||||||
player?.play();
|
player?.play();
|
||||||
|
@ -677,7 +714,67 @@ videoElement.onclick = () => {
|
||||||
player?.pause();
|
player?.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
videoElement.onclick = () => { videoClickedHandler(); };
|
||||||
|
idleBackground.onclick = () => { videoClickedHandler(); };
|
||||||
|
idleIcon.onclick = () => { videoClickedHandler(); };
|
||||||
|
|
||||||
|
function mediaStartHandler(message: PlayMessage) {
|
||||||
|
if (playbackState === PlaybackState.Idle) {
|
||||||
|
logger.info('Media playback start:', cachedPlayMediaItem);
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemStart, cachedPlayMediaItem)));
|
||||||
|
|
||||||
|
if (!supportedAudioTypes.find(v => v === message.container.toLocaleLowerCase())) {
|
||||||
|
idleIcon.style.display = 'none';
|
||||||
|
idleBackground.style.display = 'none';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPlaybackUpdate(PlaybackState.Playing);
|
||||||
|
playerCtrlStateUpdate(PlayerControlEvent.Play);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaEndHandler() {
|
||||||
|
if (showDurationTimeout) {
|
||||||
|
window.clearTimeout(showDurationTimeout);
|
||||||
|
showDurationTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMediaItem) {
|
||||||
|
playlistIndex++;
|
||||||
|
|
||||||
|
if (playlistIndex < cachedPlaylist.items.length) {
|
||||||
|
cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.info('End of playlist:', cachedPlayMediaItem);
|
||||||
|
sendPlaybackUpdate(PlaybackState.Idle);
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemEnd, cachedPlayMediaItem)));
|
||||||
|
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
player.setAutoPlay(false);
|
||||||
|
player.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.info('Media playback ended:', cachedPlayMediaItem);
|
||||||
|
sendPlaybackUpdate(PlaybackState.Idle);
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemEnd, cachedPlayMediaItem)));
|
||||||
|
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
player.setAutoPlay(false);
|
||||||
|
player.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Component hiding
|
// Component hiding
|
||||||
let uiHideTimer = null;
|
let uiHideTimer = null;
|
||||||
|
@ -737,34 +834,26 @@ document.addEventListener('click', (event: MouseEvent) => {
|
||||||
const skipInterval = 10;
|
const skipInterval = 10;
|
||||||
const volumeIncrement = 0.1;
|
const volumeIncrement = 0.1;
|
||||||
|
|
||||||
function keyDownEventListener(event: any) {
|
function keyDownEventListener(event: KeyboardEvent) {
|
||||||
// logger.info("KeyDown", event);
|
// logger.info("KeyDown", event);
|
||||||
const handledCase = targetKeyDownEventListener(event);
|
let handledCase = targetKeyDownEventListener(event);
|
||||||
if (handledCase) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!handledCase) {
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
case 'KeyF':
|
|
||||||
case 'F11':
|
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
|
||||||
event.preventDefault();
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
|
||||||
event.preventDefault();
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
skipBack();
|
skipBack();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
skipForward();
|
skipForward();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case "Home":
|
case "Home":
|
||||||
player?.setCurrentTime(0);
|
player?.setCurrentTime(0);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case "End":
|
case "End":
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
|
@ -774,6 +863,7 @@ function keyDownEventListener(event: any) {
|
||||||
player?.setCurrentTime(player?.getDuration());
|
player?.setCurrentTime(player?.getDuration());
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case 'KeyK':
|
case 'KeyK':
|
||||||
case 'Space':
|
case 'Space':
|
||||||
|
@ -785,22 +875,31 @@ function keyDownEventListener(event: any) {
|
||||||
player?.pause();
|
player?.pause();
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case 'KeyM':
|
case 'KeyM':
|
||||||
// Mute toggle
|
// Mute toggle
|
||||||
player?.setMute(!player?.isMuted());
|
player?.setMute(!player?.isMuted());
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
// Volume up
|
// Volume up
|
||||||
volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1));
|
volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1));
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
// Volume down
|
// Volume down
|
||||||
volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0));
|
volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0));
|
||||||
|
handledCase = true;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipBack() {
|
function skipBack() {
|
||||||
|
@ -814,6 +913,11 @@ function skipForward() {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', keyDownEventListener);
|
document.addEventListener('keydown', keyDownEventListener);
|
||||||
|
document.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PlayerControlEvent,
|
PlayerControlEvent,
|
||||||
|
|
|
@ -15,6 +15,57 @@ body {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#title-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
background-image: url(../assets/icons/app/icon.svg);
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
margin: 8px;
|
||||||
|
border: 8px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #fff transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
@keyframes lds-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#idle-background {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
#videoPlayer {
|
#videoPlayer {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -528,6 +579,20 @@ body {
|
||||||
|
|
||||||
/* Display scaling (Minimum supported resolution is 960x540) */
|
/* Display scaling (Minimum supported resolution is 960x540) */
|
||||||
@media only screen and ((min-width: 2560px) or (min-height: 1440px)) {
|
@media only screen and ((min-width: 2560px) or (min-height: 1440px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 164px;
|
||||||
|
height: 164px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
@ -545,6 +610,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 2559px) or (max-height: 1439px)) {
|
@media only screen and ((max-width: 2559px) or (max-height: 1439px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
@ -562,6 +641,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 1919px) or (max-height: 1079px)) {
|
@media only screen and ((max-width: 1919px) or (max-height: 1079px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
@ -579,6 +672,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 1279px) or (max-height: 719px)) {
|
@media only screen and ((max-width: 1279px) or (max-height: 719px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,51 +1,138 @@
|
||||||
import { PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
import { EventMessage, EventType, KeyEvent, MediaItem, MediaItemEvent, PlaylistContent, PlayMessage, SeekMessage, SetPlaylistItemMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets';
|
||||||
|
import { mediaItemFromPlayMessage, playMessageFromMediaItem } from 'common/UtilityFrontend';
|
||||||
import { supportedImageTypes } from 'common/MimeTypes';
|
import { supportedImageTypes } from 'common/MimeTypes';
|
||||||
import * as connectionMonitor from '../ConnectionMonitor';
|
import * as connectionMonitor from 'common/ConnectionMonitor';
|
||||||
import { toast, ToastIcon } from '../components/Toast';
|
import { toast, ToastIcon } from 'common/components/Toast';
|
||||||
|
import {
|
||||||
|
targetPlayerCtrlStateUpdate,
|
||||||
|
targetKeyDownEventListener,
|
||||||
|
} from 'src/viewer/Renderer';
|
||||||
|
|
||||||
const logger = window.targetAPI.logger;
|
const logger = window.targetAPI.logger;
|
||||||
|
|
||||||
|
const idleBackground = document.getElementById('video-player');
|
||||||
|
const idleIcon = document.getElementById('title-icon');
|
||||||
|
// todo: add callbacks for on-load events for image and generic content viewer
|
||||||
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
const imageViewer = document.getElementById('viewer-image') as HTMLImageElement;
|
const imageViewer = document.getElementById('viewer-image') as HTMLImageElement;
|
||||||
const genericViewer = document.getElementById('viewer-generic') as HTMLIFrameElement;
|
const genericViewer = document.getElementById('viewer-generic') as HTMLIFrameElement;
|
||||||
|
let cachedPlaylist: PlaylistContent = null;
|
||||||
|
let cachedPlayMediaItem: MediaItem = null;
|
||||||
|
let showDurationTimeout: number = null;
|
||||||
|
let playlistIndex = 0;
|
||||||
|
let isMediaItem = false;
|
||||||
|
let playItemCached = false;
|
||||||
|
|
||||||
function onPlay(_event, value: PlayMessage) {
|
function onPlay(_event, value: PlayMessage) {
|
||||||
logger.info("Handle play message renderer", JSON.stringify(value));
|
if (!playItemCached) {
|
||||||
|
cachedPlayMediaItem = mediaItemFromPlayMessage(value);
|
||||||
|
isMediaItem = false;
|
||||||
|
}
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemChange, cachedPlayMediaItem)));
|
||||||
|
logger.info('Media playback changed:', cachedPlayMediaItem);
|
||||||
|
playItemCached = false;
|
||||||
|
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemChange, cachedPlayMediaItem)));
|
||||||
const src = value.url ? value.url : value.content;
|
const src = value.url ? value.url : value.content;
|
||||||
|
|
||||||
if (src && value.container && supportedImageTypes.find(v => v === value.container.toLocaleLowerCase()) && imageViewer) {
|
if (src && value.container && supportedImageTypes.find(v => v === value.container.toLocaleLowerCase()) && imageViewer) {
|
||||||
logger.info("Loading image viewer");
|
logger.info('Loading image viewer');
|
||||||
|
|
||||||
genericViewer.style.display = "none";
|
genericViewer.style.display = 'none';
|
||||||
genericViewer.src = "";
|
genericViewer.src = '';
|
||||||
|
idleBackground.style.display = 'none';
|
||||||
|
idleIcon.style.display = 'none';
|
||||||
|
|
||||||
imageViewer.src = src;
|
imageViewer.src = src;
|
||||||
imageViewer.style.display = "block";
|
imageViewer.style.display = 'block';
|
||||||
}
|
}
|
||||||
else if (src && genericViewer) {
|
else if (src && genericViewer) {
|
||||||
logger.info("Loading generic viewer");
|
logger.info('Loading generic viewer');
|
||||||
|
|
||||||
imageViewer.style.display = "none";
|
imageViewer.style.display = 'none';
|
||||||
imageViewer.src = "";
|
imageViewer.src = '';
|
||||||
|
idleBackground.style.display = 'none';
|
||||||
|
idleIcon.style.display = 'none';
|
||||||
|
|
||||||
genericViewer.src = src;
|
genericViewer.src = src;
|
||||||
genericViewer.style.display = "block";
|
genericViewer.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
logger.error("Error loading content");
|
logger.error('Error loading content');
|
||||||
|
|
||||||
imageViewer.style.display = "none";
|
imageViewer.style.display = 'none';
|
||||||
imageViewer.src = "";
|
imageViewer.src = '';
|
||||||
|
|
||||||
genericViewer.style.display = "none";
|
genericViewer.style.display = 'none';
|
||||||
genericViewer.src = "";
|
genericViewer.src = '';
|
||||||
|
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMediaItem && cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) {
|
||||||
|
showDurationTimeout = window.setTimeout(() => {
|
||||||
|
playlistIndex++;
|
||||||
|
|
||||||
|
if (playlistIndex < cachedPlaylist.items.length) {
|
||||||
|
cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.info('End of playlist');
|
||||||
|
imageViewer.style.display = 'none';
|
||||||
|
imageViewer.src = '';
|
||||||
|
|
||||||
|
genericViewer.style.display = 'none';
|
||||||
|
genericViewer.src = '';
|
||||||
|
|
||||||
|
idleBackground.style.display = 'block';
|
||||||
|
idleIcon.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, cachedPlayMediaItem.showDuration * 1000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function onPlayPlaylist(_event, value: PlaylistContent) {
|
||||||
|
logger.info('Handle play playlist message', JSON.stringify(value));
|
||||||
|
cachedPlaylist = value;
|
||||||
|
|
||||||
|
const offset = value.offset ? value.offset : 0;
|
||||||
|
const volume = value.items[offset].volume ? value.items[offset].volume : value.volume;
|
||||||
|
const speed = value.items[offset].speed ? value.items[offset].speed : value.speed;
|
||||||
|
const playMessage = new PlayMessage(
|
||||||
|
value.items[offset].container, value.items[offset].url, value.items[offset].content,
|
||||||
|
value.items[offset].time, volume, speed, value.items[offset].headers, value.items[offset].metadata
|
||||||
|
);
|
||||||
|
|
||||||
|
isMediaItem = true;
|
||||||
|
cachedPlayMediaItem = value.items[offset];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessage, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
window.targetAPI.onPause(() => { logger.warn('onPause handler invoked for generic content viewer'); });
|
window.targetAPI.onPause(() => { logger.warn('onPause handler invoked for generic content viewer'); });
|
||||||
window.targetAPI.onResume(() => { logger.warn('onResume handler invoked for generic content viewer'); });
|
window.targetAPI.onResume(() => { logger.warn('onResume handler invoked for generic content viewer'); });
|
||||||
window.targetAPI.onSeek((_event, value: SeekMessage) => { logger.warn('onSeek handler invoked for generic content viewer'); });
|
window.targetAPI.onSeek((_event, value: SeekMessage) => { logger.warn('onSeek handler invoked for generic content viewer'); });
|
||||||
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { logger.warn('onSetVolume handler invoked for generic content viewer'); });
|
window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { logger.warn('onSetVolume handler invoked for generic content viewer'); });
|
||||||
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { logger.warn('onSetSpeed handler invoked for generic content viewer'); });
|
window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { logger.warn('onSetSpeed handler invoked for generic content viewer'); });
|
||||||
|
window.targetAPI.onSetPlaylistItem((_event, value: SetPlaylistItemMessage) => {
|
||||||
|
if (value.itemIndex >= 0 && value.itemIndex < cachedPlaylist.items.length) {
|
||||||
|
logger.info(`Setting playlist item to index ${value.itemIndex}`);
|
||||||
|
playlistIndex = value.itemIndex;
|
||||||
|
cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
playItemCached = true;
|
||||||
|
window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
|
||||||
|
if (showDurationTimeout) {
|
||||||
|
window.clearTimeout(showDurationTimeout);
|
||||||
|
showDurationTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.warn(`Playlist index out of bounds ${value.itemIndex}, ignoring...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
connectionMonitor.setUiUpdateCallbacks({
|
connectionMonitor.setUiUpdateCallbacks({
|
||||||
onConnect: (connections: string[], initialUpdate: boolean = false) => {
|
onConnect: (connections: string[], initialUpdate: boolean = false) => {
|
||||||
|
@ -59,3 +146,120 @@ connectionMonitor.setUiUpdateCallbacks({
|
||||||
});
|
});
|
||||||
|
|
||||||
window.targetAPI.onPlay(onPlay);
|
window.targetAPI.onPlay(onPlay);
|
||||||
|
window.targetAPI.onPlayPlaylist(onPlayPlaylist);
|
||||||
|
|
||||||
|
enum PlayerControlEvent {
|
||||||
|
Load,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
VolumeChange,
|
||||||
|
TimeUpdate,
|
||||||
|
UiFadeOut,
|
||||||
|
UiFadeIn,
|
||||||
|
SetCaptions,
|
||||||
|
ToggleSpeedMenu,
|
||||||
|
SetPlaybackRate,
|
||||||
|
ToggleFullscreen,
|
||||||
|
ExitFullscreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI update handlers
|
||||||
|
function playerCtrlStateUpdate(event: PlayerControlEvent) {
|
||||||
|
const handledCase = targetPlayerCtrlStateUpdate(event);
|
||||||
|
if (handledCase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case PlayerControlEvent.Load: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case PlayerControlEvent.UiFadeOut: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case PlayerControlEvent.UiFadeIn: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
// logger.info("KeyDown", event);
|
||||||
|
let handledCase = targetKeyDownEventListener(event);
|
||||||
|
|
||||||
|
if (!handledCase) {
|
||||||
|
switch (event.code) {
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
// skipBack();
|
||||||
|
// event.preventDefault();
|
||||||
|
// handledCase = true;
|
||||||
|
|
||||||
|
// const value = { itemIndex: playlistIndex - 1 };
|
||||||
|
// if (value.itemIndex >= 0 && value.itemIndex < cachedPlaylist.items.length) {
|
||||||
|
// logger.info(`Setting playlist item to index ${value.itemIndex}`);
|
||||||
|
// playlistIndex = value.itemIndex;
|
||||||
|
// cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
// playItemCached = true;
|
||||||
|
// window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
|
||||||
|
// if (showDurationTimeout) {
|
||||||
|
// window.clearTimeout(showDurationTimeout);
|
||||||
|
// showDurationTimeout = null;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// else {
|
||||||
|
// logger.warn(`Playlist index out of bounds ${value.itemIndex}, ignoring...`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowRight': {
|
||||||
|
// skipForward();
|
||||||
|
// event.preventDefault();
|
||||||
|
// handledCase = true;
|
||||||
|
|
||||||
|
// const value = { itemIndex: playlistIndex + 1 };
|
||||||
|
// if (value.itemIndex >= 0 && value.itemIndex < cachedPlaylist.items.length) {
|
||||||
|
// logger.info(`Setting playlist item to index ${value.itemIndex}`);
|
||||||
|
// playlistIndex = value.itemIndex;
|
||||||
|
// cachedPlayMediaItem = cachedPlaylist.items[playlistIndex];
|
||||||
|
// playItemCached = true;
|
||||||
|
// window.targetAPI.sendPlayRequest(playMessageFromMediaItem(cachedPlaylist.items[playlistIndex]), playlistIndex);
|
||||||
|
|
||||||
|
// if (showDurationTimeout) {
|
||||||
|
// window.clearTimeout(showDurationTimeout);
|
||||||
|
// showDurationTimeout = null;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// else {
|
||||||
|
// logger.warn(`Playlist index out of bounds ${value.itemIndex}, ignoring...`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||||
|
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) {
|
||||||
|
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
PlayerControlEvent,
|
||||||
|
onPlay,
|
||||||
|
playerCtrlStateUpdate,
|
||||||
|
};
|
||||||
|
|
|
@ -15,15 +15,66 @@ body {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer {
|
.viewer {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
*:focus {
|
#title-icon {
|
||||||
outline: none;
|
position: absolute;
|
||||||
box-shadow: none;
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
background-image: url(../assets/icons/app/icon.svg);
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
margin: 8px;
|
||||||
|
border: 8px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #fff transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
@keyframes lds-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
|
@ -97,6 +148,20 @@ body {
|
||||||
|
|
||||||
/* Display scaling (Minimum supported resolution is 960x540) */
|
/* Display scaling (Minimum supported resolution is 960x540) */
|
||||||
@media only screen and ((min-width: 2560px) or (min-height: 1440px)) {
|
@media only screen and ((min-width: 2560px) or (min-height: 1440px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 164px;
|
||||||
|
height: 164px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
@ -114,6 +179,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 2559px) or (max-height: 1439px)) {
|
@media only screen and ((max-width: 2559px) or (max-height: 1439px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
@ -131,6 +210,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 1919px) or (max-height: 1079px)) {
|
@media only screen and ((max-width: 1919px) or (max-height: 1079px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
@ -148,6 +241,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and ((max-width: 1279px) or (max-height: 719px)) {
|
@media only screen and ((max-width: 1279px) or (max-height: 719px)) {
|
||||||
|
#title-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
133
receivers/electron/package-lock.json
generated
133
receivers/electron/package-lock.json
generated
|
@ -14,10 +14,12 @@
|
||||||
"dashjs": "^4.7.4",
|
"dashjs": "^4.7.4",
|
||||||
"electron-json-storage": "^4.6.0",
|
"electron-json-storage": "^4.6.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
|
"follow-redirects": "^1.15.9",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.5.15",
|
||||||
"http": "^0.0.1-security",
|
"http": "^0.0.1-security",
|
||||||
"https": "^1.0.0",
|
"https": "^1.0.0",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
|
"memfs": "^4.17.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"systeminformation": "^5.25.11",
|
"systeminformation": "^5.25.11",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
|
@ -2262,6 +2264,60 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jsonjoy.com/base64": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/streamich"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jsonjoy.com/json-pack": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jsonjoy.com/base64": "^1.1.1",
|
||||||
|
"@jsonjoy.com/util": "^1.1.2",
|
||||||
|
"hyperdyperid": "^1.2.0",
|
||||||
|
"thingies": "^1.20.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/streamich"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jsonjoy.com/util": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/streamich"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@malept/cross-spawn-promise": {
|
"node_modules/@malept/cross-spawn-promise": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
|
||||||
|
@ -6215,6 +6271,26 @@
|
||||||
"imul": "^1.0.0"
|
"imul": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||||
|
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
|
@ -6785,6 +6861,15 @@
|
||||||
"ms": "^2.0.0"
|
"ms": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyperdyperid": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
@ -8685,6 +8770,25 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memfs": {
|
||||||
|
"version": "4.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz",
|
||||||
|
"integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jsonjoy.com/json-pack": "^1.0.3",
|
||||||
|
"@jsonjoy.com/util": "^1.3.0",
|
||||||
|
"tree-dump": "^1.0.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/streamich"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
@ -11322,6 +11426,18 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thingies": {
|
||||||
|
"version": "1.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz",
|
||||||
|
"integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tslib": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-each-async": {
|
"node_modules/tiny-each-async": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz",
|
||||||
|
@ -11446,6 +11562,22 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tree-dump": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/streamich"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/trim-repeated": {
|
"node_modules/trim-repeated": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
|
||||||
|
@ -11580,7 +11712,6 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
|
|
|
@ -51,10 +51,12 @@
|
||||||
"dashjs": "^4.7.4",
|
"dashjs": "^4.7.4",
|
||||||
"electron-json-storage": "^4.6.0",
|
"electron-json-storage": "^4.6.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
|
"follow-redirects": "^1.15.9",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.5.15",
|
||||||
"http": "^0.0.1-security",
|
"http": "^0.0.1-security",
|
||||||
"https": "^1.0.0",
|
"https": "^1.0.0",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
|
"memfs": "^4.17.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"systeminformation": "^5.25.11",
|
"systeminformation": "^5.25.11",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog, shell } from 'electron';
|
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog, shell } from 'electron';
|
||||||
import { Opcode, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
|
import { Opcode, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, PlayMessage, PlayUpdateMessage, EventMessage, EventType, ContentObject, ContentType, PlaylistContent, SeekMessage, SetVolumeMessage, SetSpeedMessage, SetPlaylistItemMessage } from 'common/Packets';
|
||||||
import { supportedPlayerTypes } from 'common/MimeTypes';
|
import { supportedPlayerTypes } from 'common/MimeTypes';
|
||||||
import { DiscoveryService } from 'common/DiscoveryService';
|
import { DiscoveryService } from 'common/DiscoveryService';
|
||||||
import { TcpListenerService } from 'common/TcpListenerService';
|
import { TcpListenerService } from 'common/TcpListenerService';
|
||||||
|
@ -7,6 +7,8 @@ import { WebSocketListenerService } from 'common/WebSocketListenerService';
|
||||||
import { NetworkService } from 'common/NetworkService';
|
import { NetworkService } from 'common/NetworkService';
|
||||||
import { ConnectionMonitor } from 'common/ConnectionMonitor';
|
import { ConnectionMonitor } from 'common/ConnectionMonitor';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { fetchJSON } from 'common/UtilityBackend';
|
||||||
|
import { MediaCache } from 'common/MediaCache';
|
||||||
import { Updater } from './Updater';
|
import { Updater } from './Updater';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
@ -15,6 +17,17 @@ import { hideBin } from 'yargs/helpers';
|
||||||
const cp = require('child_process');
|
const cp = require('child_process');
|
||||||
let logger = null;
|
let logger = null;
|
||||||
|
|
||||||
|
class AppCache {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public interfaces: any = null;
|
||||||
|
public appName: string = null;
|
||||||
|
public appVersion: string = null;
|
||||||
|
public playMessage: PlayMessage = null;
|
||||||
|
public playerVolume: number = null;
|
||||||
|
public playlist: PlaylistContent = null;
|
||||||
|
public subscribedKeys = new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
export class Main {
|
export class Main {
|
||||||
static shouldOpenMainWindow = true;
|
static shouldOpenMainWindow = true;
|
||||||
static startFullscreen = false;
|
static startFullscreen = false;
|
||||||
|
@ -26,9 +39,11 @@ export class Main {
|
||||||
static discoveryService: DiscoveryService;
|
static discoveryService: DiscoveryService;
|
||||||
static connectionMonitor: ConnectionMonitor;
|
static connectionMonitor: ConnectionMonitor;
|
||||||
static tray: Tray;
|
static tray: Tray;
|
||||||
|
static cache: AppCache = new AppCache();
|
||||||
|
|
||||||
private static cachedInterfaces = null;
|
|
||||||
private static playerWindowContentViewer = null;
|
private static playerWindowContentViewer = null;
|
||||||
|
private static listeners = [];
|
||||||
|
private static mediaCache: MediaCache = null;
|
||||||
|
|
||||||
private static toggleMainWindow() {
|
private static toggleMainWindow() {
|
||||||
if (Main.mainWindow) {
|
if (Main.mainWindow) {
|
||||||
|
@ -144,20 +159,45 @@ export class Main {
|
||||||
this.tray = tray;
|
this.tray = tray;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static onReady() {
|
private static async play(message: PlayMessage) {
|
||||||
Main.createTray();
|
Main.listeners.forEach(l => l.send(Opcode.PlayUpdate, new PlayUpdateMessage(Date.now(), message)));
|
||||||
|
Main.cache.playMessage = message;
|
||||||
|
|
||||||
Main.connectionMonitor = new ConnectionMonitor();
|
// Protocol v2 FCast PlayMessage does not contain volume field and could result in the receiver
|
||||||
Main.discoveryService = new DiscoveryService();
|
// getting out-of-sync with the sender when player windows are closed and re-opened. Volume
|
||||||
Main.discoveryService.start();
|
// is cached in the play message when volume is not set in v3 PlayMessage.
|
||||||
|
message.volume = message.volume || message.volume === undefined ? Main.cache.playerVolume : message.volume;
|
||||||
|
|
||||||
Main.tcpListenerService = new TcpListenerService();
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
Main.webSocketListenerService = new WebSocketListenerService();
|
let rendererMessage: any = await NetworkService.proxyPlayIfRequired(message);
|
||||||
|
let rendererEvent = 'play';
|
||||||
|
let contentViewer = supportedPlayerTypes.find(v => v === message.container.toLocaleLowerCase()) ? 'player' : 'viewer';
|
||||||
|
|
||||||
const listeners = [Main.tcpListenerService, Main.webSocketListenerService];
|
if (message.container === 'application/json') {
|
||||||
listeners.forEach(l => {
|
const json: ContentObject = message.url ? await fetchJSON(message.url) : JSON.parse(message.content);
|
||||||
l.emitter.on("play", async (message) => {
|
|
||||||
const contentViewer = supportedPlayerTypes.find(v => v === message.container.toLocaleLowerCase()) ? 'player' : 'viewer';
|
if (json && json.contentType !== undefined) {
|
||||||
|
switch (json.contentType) {
|
||||||
|
case ContentType.Playlist: {
|
||||||
|
rendererMessage = json as PlaylistContent;
|
||||||
|
rendererEvent = 'play-playlist';
|
||||||
|
Main.cache.playlist = rendererMessage;
|
||||||
|
|
||||||
|
if ((rendererMessage.forwardCache && rendererMessage.forwardCache > 0) || (rendererMessage.backwardCache && rendererMessage.backwardCache > 0)) {
|
||||||
|
Main.mediaCache?.destroy();
|
||||||
|
Main.mediaCache = new MediaCache(rendererMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = rendererMessage.offset ? rendererMessage.offset : 0;
|
||||||
|
contentViewer = supportedPlayerTypes.find(v => v === rendererMessage.items[offset].container.toLocaleLowerCase()) ? 'player' : 'viewer';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!Main.playerWindow) {
|
if (!Main.playerWindow) {
|
||||||
Main.playerWindow = new BrowserWindow({
|
Main.playerWindow = new BrowserWindow({
|
||||||
|
@ -174,7 +214,7 @@ export class Main {
|
||||||
|
|
||||||
Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`));
|
Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`));
|
||||||
Main.playerWindow.on('ready-to-show', async () => {
|
Main.playerWindow.on('ready-to-show', async () => {
|
||||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage);
|
||||||
});
|
});
|
||||||
Main.playerWindow.on('closed', () => {
|
Main.playerWindow.on('closed', () => {
|
||||||
Main.playerWindow = null;
|
Main.playerWindow = null;
|
||||||
|
@ -184,15 +224,28 @@ export class Main {
|
||||||
else if (Main.playerWindow && contentViewer !== Main.playerWindowContentViewer) {
|
else if (Main.playerWindow && contentViewer !== Main.playerWindowContentViewer) {
|
||||||
Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`));
|
Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`));
|
||||||
Main.playerWindow.on('ready-to-show', async () => {
|
Main.playerWindow.on('ready-to-show', async () => {
|
||||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
|
Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
Main.playerWindowContentViewer = contentViewer;
|
Main.playerWindowContentViewer = contentViewer;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
private static onReady() {
|
||||||
|
Main.createTray();
|
||||||
|
|
||||||
|
Main.connectionMonitor = new ConnectionMonitor();
|
||||||
|
Main.discoveryService = new DiscoveryService();
|
||||||
|
Main.discoveryService.start();
|
||||||
|
|
||||||
|
Main.tcpListenerService = new TcpListenerService();
|
||||||
|
Main.webSocketListenerService = new WebSocketListenerService();
|
||||||
|
|
||||||
|
Main.listeners = [Main.tcpListenerService, Main.webSocketListenerService];
|
||||||
|
Main.listeners.forEach(l => {
|
||||||
|
l.emitter.on("play", (message: PlayMessage) => Main.play(message));
|
||||||
l.emitter.on("pause", () => Main.playerWindow?.webContents?.send("pause"));
|
l.emitter.on("pause", () => Main.playerWindow?.webContents?.send("pause"));
|
||||||
l.emitter.on("resume", () => Main.playerWindow?.webContents?.send("resume"));
|
l.emitter.on("resume", () => Main.playerWindow?.webContents?.send("resume"));
|
||||||
|
|
||||||
|
@ -202,9 +255,12 @@ export class Main {
|
||||||
Main.playerWindowContentViewer = null;
|
Main.playerWindowContentViewer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
l.emitter.on("seek", (message) => Main.playerWindow?.webContents?.send("seek", message));
|
l.emitter.on("seek", (message: SeekMessage) => Main.playerWindow?.webContents?.send("seek", message));
|
||||||
l.emitter.on("setvolume", (message) => Main.playerWindow?.webContents?.send("setvolume", message));
|
l.emitter.on("setvolume", (message: SetVolumeMessage) => {
|
||||||
l.emitter.on("setspeed", (message) => Main.playerWindow?.webContents?.send("setspeed", message));
|
Main.cache.playerVolume = message.volume;
|
||||||
|
Main.playerWindow?.webContents?.send("setvolume", message);
|
||||||
|
});
|
||||||
|
l.emitter.on("setspeed", (message: SetSpeedMessage) => Main.playerWindow?.webContents?.send("setspeed", message));
|
||||||
|
|
||||||
l.emitter.on('connect', (message) => {
|
l.emitter.on('connect', (message) => {
|
||||||
ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => {
|
ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => {
|
||||||
|
@ -224,6 +280,26 @@ export class Main {
|
||||||
l.emitter.on('pong', (message) => {
|
l.emitter.on('pong', (message) => {
|
||||||
ConnectionMonitor.onPingPong(message, l instanceof WebSocketListenerService);
|
ConnectionMonitor.onPingPong(message, l instanceof WebSocketListenerService);
|
||||||
});
|
});
|
||||||
|
l.emitter.on('initial', (message) => {
|
||||||
|
logger.info(`Received 'Initial' message from sender: ${message}`);
|
||||||
|
});
|
||||||
|
l.emitter.on("setplaylistitem", (message: SetPlaylistItemMessage) => Main.playerWindow?.webContents?.send("setplaylistitem", message));
|
||||||
|
l.emitter.on('subscribeevent', (message) => {
|
||||||
|
const subscribeData = l.subscribeEvent(message.sessionId, message.body.event);
|
||||||
|
|
||||||
|
if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) {
|
||||||
|
Main.mainWindow?.webContents?.send("event-subscribed-keys-update", subscribeData);
|
||||||
|
Main.playerWindow?.webContents?.send("event-subscribed-keys-update", subscribeData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
l.emitter.on('unsubscribeevent', (message) => {
|
||||||
|
const unsubscribeData = l.unsubscribeEvent(message.sessionId, message.body.event);
|
||||||
|
|
||||||
|
if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) {
|
||||||
|
Main.mainWindow?.webContents?.send("event-subscribed-keys-update", unsubscribeData);
|
||||||
|
Main.playerWindow?.webContents?.send("event-subscribed-keys-update", unsubscribeData);
|
||||||
|
}
|
||||||
|
});
|
||||||
l.start();
|
l.start();
|
||||||
|
|
||||||
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
|
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
|
||||||
|
@ -235,10 +311,36 @@ export class Main {
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => {
|
ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => {
|
||||||
|
Main.cache.playerVolume = value.volume;
|
||||||
l.send(Opcode.VolumeUpdate, value);
|
l.send(Opcode.VolumeUpdate, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('send-event', (event: IpcMainEvent, value: EventMessage) => {
|
||||||
|
l.send(Opcode.Event, value);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('play-request', (event: IpcMainEvent, value: PlayMessage, playlistIndex: number) => {
|
||||||
|
logger.debug(`Received play request for index ${playlistIndex}:`, value);
|
||||||
|
|
||||||
|
if (Main.cache.playlist.forwardCache && Main.cache.playlist.forwardCache > 0) {
|
||||||
|
if (Main.mediaCache.has(playlistIndex)) {
|
||||||
|
value.url = Main.mediaCache.getUrl(playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Main.mediaCache.cacheForwardItems(playlistIndex + 1, Main.cache.playlist.forwardCache, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Main.cache.playlist.backwardCache && Main.cache.playlist.backwardCache > 0) {
|
||||||
|
if (Main.mediaCache.has(playlistIndex)) {
|
||||||
|
value.url = Main.mediaCache.getUrl(playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Main.mediaCache.cacheBackwardItems(playlistIndex - 1, Main.cache.playlist.backwardCache, playlistIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Main.play(value);
|
||||||
|
});
|
||||||
ipcMain.on('send-download-request', async () => {
|
ipcMain.on('send-download-request', async () => {
|
||||||
if (!Updater.isDownloading) {
|
if (!Updater.isDownloading) {
|
||||||
try {
|
try {
|
||||||
|
@ -299,7 +401,7 @@ export class Main {
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
ipcMain.on('network-changed', (event: IpcMainEvent, value: any) => {
|
ipcMain.on('network-changed', (event: IpcMainEvent, value: any) => {
|
||||||
Main.cachedInterfaces = value;
|
Main.cache.interfaces = value;
|
||||||
Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: value });
|
Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: value });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -364,8 +466,8 @@ export class Main {
|
||||||
Main.mainWindow.show();
|
Main.mainWindow.show();
|
||||||
|
|
||||||
Main.mainWindow.on('ready-to-show', () => {
|
Main.mainWindow.on('ready-to-show', () => {
|
||||||
if (Main.cachedInterfaces) {
|
if (Main.cache.interfaces) {
|
||||||
Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: Main.cachedInterfaces });
|
Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: Main.cache.interfaces });
|
||||||
}
|
}
|
||||||
|
|
||||||
networkWorker.loadFile(path.join(__dirname, 'main/worker.html'));
|
networkWorker.loadFile(path.join(__dirname, 'main/worker.html'));
|
||||||
|
@ -375,6 +477,8 @@ export class Main {
|
||||||
static async main(app: Electron.App) {
|
static async main(app: Electron.App) {
|
||||||
try {
|
try {
|
||||||
Main.application = app;
|
Main.application = app;
|
||||||
|
Main.cache.appName = app.name;
|
||||||
|
Main.cache.appVersion = app.getVersion();
|
||||||
|
|
||||||
const argv = yargs(hideBin(process.argv))
|
const argv = yargs(hideBin(process.argv))
|
||||||
.version(app.getVersion())
|
.version(app.getVersion())
|
||||||
|
@ -477,6 +581,18 @@ export function getComputerName() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAppName() {
|
||||||
|
return Main.cache.appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppVersion() {
|
||||||
|
return Main.cache.appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayMessage() {
|
||||||
|
return Main.cache.playMessage;
|
||||||
|
}
|
||||||
|
|
||||||
export async function errorHandler(error: Error) {
|
export async function errorHandler(error: Error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
logger.shutdown();
|
logger.shutdown();
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as https from 'https';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { Store } from './Store';
|
import { Store } from './Store';
|
||||||
import sudo from 'sudo-prompt';
|
import sudo from 'sudo-prompt';
|
||||||
import { Logger, LoggerType } from 'common/Logger';
|
import { Logger, LoggerType } from 'common/Logger';
|
||||||
|
import { fetchJSON, downloadFile } from 'common/UtilityBackend';
|
||||||
|
|
||||||
const cp = require('child_process');
|
const cp = require('child_process');
|
||||||
const extract = require('extract-zip');
|
const extract = require('extract-zip');
|
||||||
|
@ -91,52 +91,6 @@ export class Updater {
|
||||||
Store.set('updater', updaterSettings);
|
Store.set('updater', updaterSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
private static async fetchJSON(url: string): Promise<any> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
https.get(url, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (chunk) => {
|
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(data));
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async downloadFile(url: string, destination: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const file = fs.createWriteStream(destination);
|
|
||||||
https.get(url, (response) => {
|
|
||||||
const downloadSize = Number(response.headers['content-length']);
|
|
||||||
logger.info(`Update size: ${downloadSize} bytes`);
|
|
||||||
response.pipe(file);
|
|
||||||
let downloadedBytes = 0;
|
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
|
||||||
downloadedBytes += chunk.length;
|
|
||||||
Updater.updateProgress = downloadedBytes / downloadSize;
|
|
||||||
});
|
|
||||||
file.on('finish', () => {
|
|
||||||
file.close();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}).on('error', (err) => {
|
|
||||||
file.close();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async applyUpdate(src: string, dst: string) {
|
private static async applyUpdate(src: string, dst: string) {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(dst, fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
|
fs.accessSync(dst, fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
|
||||||
|
@ -345,7 +299,7 @@ export class Updater {
|
||||||
logger.info('Checking for updates...');
|
logger.info('Checking for updates...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Updater.releasesJson = await Updater.fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo;
|
Updater.releasesJson = await fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo;
|
||||||
|
|
||||||
const localChannelVersion: number = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0;
|
const localChannelVersion: number = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0;
|
||||||
const currentChannelVersion: number = Updater.releasesJson.channelCurrentVersions[Updater.updateChannel] ? Updater.releasesJson.channelCurrentVersions[Updater.updateChannel] : 0;
|
const currentChannelVersion: number = Updater.releasesJson.channelCurrentVersions[Updater.updateChannel] ? Updater.releasesJson.channelCurrentVersions[Updater.updateChannel] : 0;
|
||||||
|
@ -411,7 +365,9 @@ export class Updater {
|
||||||
const destination = path.join(Updater.updateDataPath, file);
|
const destination = path.join(Updater.updateDataPath, file);
|
||||||
logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`);
|
logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`);
|
||||||
Updater.isDownloading = true;
|
Updater.isDownloading = true;
|
||||||
await Updater.downloadFile(fileInfo.url.toString(), destination);
|
await downloadFile(fileInfo.url.toString(), destination, null, (downloadedBytes: number, downloadSize: number) => {
|
||||||
|
Updater.updateProgress = downloadedBytes / downloadSize;
|
||||||
|
});
|
||||||
|
|
||||||
const downloadedFile = await fs.promises.readFile(destination);
|
const downloadedFile = await fs.promises.readFile(destination);
|
||||||
const hash = crypto.createHash('sha256').end(downloadedFile).digest('hex');
|
const hash = crypto.createHash('sha256').end(downloadedFile).digest('hex');
|
||||||
|
|
|
@ -41,8 +41,7 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean
|
||||||
return handledCase;
|
return handledCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export function targetKeyDownEventListener(event: KeyboardEvent): boolean {
|
||||||
export function targetKeyDownEventListener(event: any): boolean {
|
|
||||||
let handledCase = false;
|
let handledCase = false;
|
||||||
|
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
|
|
|
@ -9,6 +9,9 @@
|
||||||
<link rel="stylesheet" href="./style.css" />
|
<link rel="stylesheet" href="./style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="title-icon"></div>
|
||||||
|
<div id="loading-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
|
||||||
|
<div id="idle-background"></div>
|
||||||
<video id="videoPlayer" autoplay preload="auto"></video>
|
<video id="videoPlayer" autoplay preload="auto"></video>
|
||||||
<div id="videoCaptions" class="captionsContainer"></div>
|
<div id="videoCaptions" class="captionsContainer"></div>
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,56 @@
|
||||||
import 'common/viewer/Renderer';
|
import { PlayerControlEvent, playerCtrlStateUpdate } from 'common/viewer/Renderer';
|
||||||
|
|
||||||
// const logger = window.targetAPI.logger;
|
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
|
||||||
|
let handledCase = false;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case PlayerControlEvent.ToggleFullscreen: {
|
||||||
|
window.electronAPI.toggleFullScreen();
|
||||||
|
|
||||||
|
// window.electronAPI.isFullScreen().then((isFullScreen: boolean) => {
|
||||||
|
// if (isFullScreen) {
|
||||||
|
// playerCtrlFullscreen.setAttribute("class", "fullscreen_on");
|
||||||
|
// } else {
|
||||||
|
// playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
handledCase = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case PlayerControlEvent.ExitFullscreen:
|
||||||
|
window.electronAPI.exitFullScreen();
|
||||||
|
// playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
|
||||||
|
|
||||||
|
handledCase = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return handledCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetKeyDownEventListener(event: KeyboardEvent): boolean {
|
||||||
|
let handledCase = false;
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case 'KeyF':
|
||||||
|
case 'F11':
|
||||||
|
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
||||||
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||||
|
event.preventDefault();
|
||||||
|
handledCase = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return handledCase
|
||||||
|
};
|
||||||
|
|
|
@ -8,7 +8,10 @@
|
||||||
<link rel="stylesheet" href="./common.css" />
|
<link rel="stylesheet" href="./common.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Empty video element as a workaround to fix issue with white border outline without it... -->
|
||||||
|
<video id="video-player" class="video"></video>
|
||||||
<div id="viewer" class="viewer">
|
<div id="viewer" class="viewer">
|
||||||
|
<div id="title-icon"></div>
|
||||||
<img id="viewer-image" class="viewer" />
|
<img id="viewer-image" class="viewer" />
|
||||||
<iframe id="viewer-generic" class="viewer"></iframe>
|
<iframe id="viewer-generic" class="viewer"></iframe>
|
||||||
</div>>
|
</div>>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue