diff --git a/receivers/common/web/ConnectionMonitor.ts b/receivers/common/web/ConnectionMonitor.ts index 4a14d9b..26e09e4 100644 --- a/receivers/common/web/ConnectionMonitor.ts +++ b/receivers/common/web/ConnectionMonitor.ts @@ -58,14 +58,18 @@ export class ConnectionMonitor { setInterval(() => { if (ConnectionMonitor.backendConnections.size > 0) { for (const sessionId of ConnectionMonitor.backendConnections.keys()) { - if (ConnectionMonitor.heartbeatRetries.get(sessionId) > 3) { - ConnectionMonitor.logger.warn(`Could not ping device with connection id ${sessionId}. Disconnecting...`); - ConnectionMonitor.backendConnections.get(sessionId).disconnect(sessionId); - } + const listener = ConnectionMonitor.backendConnections.get(sessionId); - ConnectionMonitor.logger.debug(`Pinging session ${sessionId} with ${ConnectionMonitor.heartbeatRetries.get(sessionId)} retries left`); - ConnectionMonitor.backendConnections.get(sessionId).send(Opcode.Ping, null, sessionId); - ConnectionMonitor.heartbeatRetries.set(sessionId, ConnectionMonitor.heartbeatRetries.get(sessionId) + 1); + if (listener.getSessionProtocolVersion(sessionId) >= 2) { + if (ConnectionMonitor.heartbeatRetries.get(sessionId) > 3) { + ConnectionMonitor.logger.warn(`Could not ping device with connection id ${sessionId}. Disconnecting...`); + listener.disconnect(sessionId); + } + + ConnectionMonitor.logger.debug(`Pinging session ${sessionId} with ${ConnectionMonitor.heartbeatRetries.get(sessionId)} retries left`); + listener.send(Opcode.Ping, null, sessionId); + ConnectionMonitor.heartbeatRetries.set(sessionId, ConnectionMonitor.heartbeatRetries.get(sessionId) + 1); + } } } }, ConnectionMonitor.connectionPingTimeout); diff --git a/receivers/common/web/FCastSession.ts b/receivers/common/web/FCastSession.ts index 98ddf44..5b74ea5 100644 --- a/receivers/common/web/FCastSession.ts +++ b/receivers/common/web/FCastSession.ts @@ -1,7 +1,8 @@ import * as net from 'net'; 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 { getComputerName, getAppName, getAppVersion, getPlayMessage } from 'src/Main'; import { WebSocket } from 'modules/ws'; import { v4 as uuidv4 } from 'modules/uuid'; const logger = new Logger('FCastSession', LoggerType.BACKEND); @@ -18,6 +19,7 @@ const MAXIMUM_PACKET_LENGTH = 32000; export class FCastSession { public sessionId: string; + public protocolVersion: number; buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH); bytesRead = 0; packetLength = 0; @@ -26,14 +28,25 @@ export class FCastSession { state: SessionState; emitter = new EventEmitter(); + private sentInitialMessage: boolean; + constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) { 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.writer = writer; this.state = SessionState.WaitingForLength; } send(opcode: number, message = null) { + if (!this.isSupportedOpcode(opcode)) { + return; + } + + message = this.stripUnsupportedFields(opcode, message); const json = message ? JSON.stringify(message) : null; logger.info(`send: (session: ${this.sessionId}, opcode: ${opcode}, body: ${json})`); @@ -189,9 +202,23 @@ export class FCastSession { case Opcode.SetSpeed: this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage); break; - case Opcode.Version: - this.emitter.emit("version", JSON.parse(body) as VersionMessage); + case Opcode.Version: { + 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; + } case Opcode.Ping: this.send(Opcode.Pong); this.emitter.emit("ping"); @@ -199,6 +226,18 @@ export class FCastSession { case Opcode.Pong: this.emitter.emit("pong"); 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) { 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("ping", () => { emitter.emit("ping", { 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; } } diff --git a/receivers/common/web/ListenerService.ts b/receivers/common/web/ListenerService.ts new file mode 100644 index 0000000..16dbb85 --- /dev/null +++ b/receivers/common/web/ListenerService.ts @@ -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 = new Map(); + private eventSubscribers: Map = 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, keyUp: Set } { + let keyDown = new Set(); + let keyUp = new Set(); + + 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); + } +} diff --git a/receivers/common/web/MediaCache.ts b/receivers/common/web/MediaCache.ts new file mode 100644 index 0000000..428d21c --- /dev/null +++ b/receivers/common/web/MediaCache.ts @@ -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(); + private cacheUrlMap = new Map(); + 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 + } + } +} diff --git a/receivers/common/web/MimeTypes.ts b/receivers/common/web/MimeTypes.ts index 47b09f1..25a476a 100644 --- a/receivers/common/web/MimeTypes.ts +++ b/receivers/common/web/MimeTypes.ts @@ -1,11 +1,21 @@ export const streamingMediaTypes = [ - "application/vnd.apple.mpegurl", - "application/x-mpegURL", - "application/dash+xml" + 'application/vnd.apple.mpegurl', + 'application/x-mpegURL', + '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/flac', 'audio/mpeg', @@ -15,14 +25,7 @@ export const supportedPlayerTypes = streamingMediaTypes.concat([ 'audio/webm', 'audio/3gpp', 'audio/3gpp2', - 'video/mp4', - 'video/mpeg', - 'video/ogg', - 'video/webm', - 'video/x-matroska', - 'video/3gpp', - 'video/3gpp2' -]); +]; export const supportedImageTypes = [ 'image/apng', @@ -36,3 +39,8 @@ export const supportedImageTypes = [ 'image/vnd.microsoft.icon', 'image/webp' ]; + +export const supportedPlayerTypes = streamingMediaTypes.concat( + supportedVideoTypes, + supportedAudioTypes, +); diff --git a/receivers/common/web/NetworkService.ts b/receivers/common/web/NetworkService.ts index 8b68b97..d02183b 100644 --- a/receivers/common/web/NetworkService.ts +++ b/receivers/common/web/NetworkService.ts @@ -1,5 +1,6 @@ import { PlayMessage } from 'common/Packets'; import { streamingMediaTypes } from 'common/MimeTypes'; +import { MediaCache } from './MediaCache'; import * as http from 'http'; import * as url from 'url'; import { AddressInfo } from 'modules/ws'; @@ -12,7 +13,7 @@ export class NetworkService { static cert: string = null; static proxyServer: http.Server; static proxyServerAddress: AddressInfo; - static proxiedFiles: Map = new Map(); + static proxiedFiles: Map = new Map(); private static setupProxyServer(): Promise { return new Promise((resolve, reject) => { @@ -32,40 +33,79 @@ export class NetworkService { return; } - const omitHeaders = new Set([ - 'host', - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailers', - 'transfer-encoding', - 'upgrade' - ]); + 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; + } - const filteredHeaders = Object.fromEntries(Object.entries(req.headers) - .filter(([key]) => !omitHeaders.has(key.toLowerCase())) - .map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value])); + 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; - const parsedUrl = url.parse(proxyInfo.url); - const options: http.RequestOptions = { - ... parsedUrl, - method: req.method, - headers: { ...filteredHeaders, ...proxyInfo.headers } - }; + 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, + }; + } - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); + logger.debug(`Serving content ${proxyInfo.url} with response headers:`, responseHeaders); + res.writeHead(responseCode, responseHeaders); + stream.pipe(res); + } + else { + const omitHeaders = new Set([ + 'host', + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade' + ]); - req.pipe(proxyReq, { end: true }); - proxyReq.on('error', (e) => { - logger.error(`Problem with request: ${e.message}`); - res.writeHead(500); - res.end(); - }); + const filteredHeaders = Object.fromEntries(Object.entries(req.headers) + .filter(([key]) => !omitHeaders.has(key.toLowerCase())) + .map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value])); + + const parsedUrl = url.parse(proxyInfo.url); + const options: http.RequestOptions = { + ... parsedUrl, + method: req.method, + headers: { ...filteredHeaders, ...proxyInfo.headers } + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + req.pipe(proxyReq, { end: true }); + proxyReq.on('error', (e) => { + logger.error(`Problem with request: ${e.message}`); + res.writeHead(500); + res.end(); + }); + } }); NetworkService.proxyServer.on('error', e => { reject(e); @@ -82,20 +122,20 @@ export class NetworkService { } static async proxyPlayIfRequired(message: PlayMessage): Promise { - if (message.headers && message.url && !streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) { - return { ...message, url: await NetworkService.proxyFile(message.url, message.headers) }; + if (message.url && (message.url.startsWith('app://') || (message.headers && !streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())))) { + return { ...message, url: await NetworkService.proxyFile(message) }; } return message; } - static async proxyFile(url: string, headers: { [key: string]: string }): Promise { + static async proxyFile(message: PlayMessage): Promise { if (!NetworkService.proxyServer) { await NetworkService.setupProxyServer(); } const proxiedUrl = `http://127.0.0.1:${NetworkService.proxyServerAddress.port}/${uuidv4()}`; - logger.info("Proxied url", { proxiedUrl, url, headers }); - NetworkService.proxiedFiles.set(proxiedUrl, { url: url, headers: headers }); + logger.info("Proxied url", { proxiedUrl, message }); + NetworkService.proxiedFiles.set(proxiedUrl, message); return proxiedUrl; } } diff --git a/receivers/common/web/Packets.ts b/receivers/common/web/Packets.ts index a9c1a02..10394f7 100644 --- a/receivers/common/web/Packets.ts +++ b/receivers/common/web/Packets.ts @@ -1,3 +1,6 @@ +// Protocol Documentation: https://gitlab.futo.org/videostreaming/fcast/-/wikis/Protocol-version-3 +export const PROTOCOL_VERSION = 3; + export enum Opcode { None = 0, Play = 1, @@ -12,33 +15,101 @@ export enum Opcode { SetSpeed = 10, Version = 11, 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 { constructor( - public container: string, - public url: string = null, - public content: string = null, - public time: number = null, - public speed: number = null, - public headers: { [key: string]: string } = null + 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 headers: { [key: string]: string } = null, // HTTP request headers to add to the play request Map + public metadata: MetadataObject = null, ) {} } export class SeekMessage { constructor( - public time: number, + public time: number, // The time to seek to in seconds ) {} } export class PlaybackUpdateMessage { constructor( - public generationTime: number, - public time: number, - public duration: number, - public state: number, - public speed: number + public generationTime: number, // The time the packet was generated (unix time milliseconds) + public state: number, // The playback state + public time: number = null, // The current time playing in seconds + public duration: number = null, // The duration in seconds + 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 { constructor( - public speed: number, + public speed: number, // The factor to multiply playback speed by ) {} } export class VersionMessage { 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 + 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, ) {} } diff --git a/receivers/common/web/TcpListenerService.ts b/receivers/common/web/TcpListenerService.ts index 268c7ce..ab458ce 100644 --- a/receivers/common/web/TcpListenerService.ts +++ b/receivers/common/web/TcpListenerService.ts @@ -1,17 +1,13 @@ import * as net from 'net'; +import { ListenerService } from 'common/ListenerService'; 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 { EventEmitter } from 'events'; -import { errorHandler } from 'src/Main'; const logger = new Logger('TcpListenerService', LoggerType.BACKEND); -export class TcpListenerService { - public static PORT = 46899; - emitter = new EventEmitter(); - +export class TcpListenerService extends ListenerService { + public readonly PORT = 46899; private server: net.Server; - private sessionMap = new Map(); start() { if (this.server != null) { @@ -19,7 +15,7 @@ export class TcpListenerService { } this.server = net.createServer() - .listen(TcpListenerService.PORT) + .listen(this.PORT) .on("connection", this.handleConnection.bind(this)) .on("error", this.handleServerError.bind(this)); } @@ -35,29 +31,6 @@ export class TcpListenerService { 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) { this.sessionMap.get(sessionId)?.socket.destroy(); this.sessionMap.delete(sessionId); @@ -69,14 +42,6 @@ export class TcpListenerService { return senders; } - public getSessions(): string[] { - return [...this.sessionMap.keys()]; - } - - private async handleServerError(err: NodeJS.ErrnoException) { - errorHandler(err); - } - private handleConnection(socket: net.Socket) { 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 }}); try { logger.info('Sending version'); - session.send(Opcode.Version, {version: 2}); + session.send(Opcode.Version, new VersionMessage(PROTOCOL_VERSION)); } catch (e) { logger.info('Failed to send version', e); } diff --git a/receivers/common/web/UtilityBackend.ts b/receivers/common/web/UtilityBackend.ts new file mode 100644 index 0000000..95608ee --- /dev/null +++ b/receivers/common/web/UtilityBackend.ts @@ -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 { + 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 { + 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); + }); + }); +} diff --git a/receivers/common/web/UtilityFrontend.ts b/receivers/common/web/UtilityFrontend.ts new file mode 100644 index 0000000..d9817cf --- /dev/null +++ b/receivers/common/web/UtilityFrontend.ts @@ -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(""); +} diff --git a/receivers/common/web/WebSocketListenerService.ts b/receivers/common/web/WebSocketListenerService.ts index 4be5118..e3faad0 100644 --- a/receivers/common/web/WebSocketListenerService.ts +++ b/receivers/common/web/WebSocketListenerService.ts @@ -1,25 +1,20 @@ +import { ListenerService } from 'common/ListenerService'; 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 { EventEmitter } from 'events'; import { WebSocket, WebSocketServer } from 'modules/ws'; -import { errorHandler } from 'src/Main'; const logger = new Logger('WebSocketListenerService', LoggerType.BACKEND); -export class WebSocketListenerService { - public static PORT = 46898; - - emitter = new EventEmitter(); - +export class WebSocketListenerService extends ListenerService { + public readonly PORT = 46898; private server: WebSocketServer; - private sessionMap = new Map(); start() { if (this.server != null) { return; } - this.server = new WebSocketServer({ port: WebSocketListenerService.PORT }) + this.server = new WebSocketServer({ port: this.PORT }) .on("connection", this.handleConnection.bind(this)) .on("error", this.handleServerError.bind(this)); } @@ -35,39 +30,10 @@ export class WebSocketListenerService { 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) { 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) { 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 }}); try { logger.info('Sending version'); - session.send(Opcode.Version, {version: 2}); + session.send(Opcode.Version, new VersionMessage(PROTOCOL_VERSION)); } catch (e) { - logger.info('Failed to send version'); + logger.info('Failed to send version', e); } } } diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts index dfa8f76..528dc00 100644 --- a/receivers/common/web/main/Preload.ts +++ b/receivers/common/web/main/Preload.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Logger, LoggerType } from 'common/Logger'; +import { EventMessage } from 'common/Packets'; const logger = new Logger('MainWindow', LoggerType.FRONTEND); // Cannot directly pass the object to the renderer for some reason... @@ -23,6 +24,10 @@ declare global { } let preloadData: Record = {}; +preloadData.subscribedKeys = { + keyDown: new Set(), + keyUp: new Set(), +}; // @ts-ignore if (TARGET === 'electron') { @@ -32,13 +37,19 @@ if (TARGET === 'electron') { electronAPI.ipcRenderer.on("device-info", (_event, value: any) => { preloadData.deviceInfo = value; }) + electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = value.keyDown; + preloadData.subscribedKeys.keyUp = value.keyUp; + }) electronAPI.contextBridge.exposeInMainWorld('targetAPI', { onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on('device-info', callback), getDeviceInfo: () => preloadData.deviceInfo, getSessions: () => electronAPI.ipcRenderer.invoke('get-sessions'), + getSubscribedKeys: () => preloadData.subscribedKeys, onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback), onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback), + sendEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('send-event', message), logger: loggerInterface, }); diff --git a/receivers/common/web/main/Renderer.ts b/receivers/common/web/main/Renderer.ts index 74fbac5..eecd6a8 100644 --- a/receivers/common/web/main/Renderer.ts +++ b/receivers/common/web/main/Renderer.ts @@ -3,6 +3,7 @@ import QRCode from 'modules/qrcode'; import * as connectionMonitor from '../ConnectionMonitor'; import { onQRCodeRendered } from 'src/main/Renderer'; import { toast, ToastIcon } from '../components/Toast'; +import { EventMessage, EventType, KeyEvent } from 'common/Packets'; const connectionStatusText = document.getElementById('connection-status-text'); const connectionStatusSpinner = document.getElementById('connection-spinner'); @@ -199,3 +200,14 @@ function renderQRCode(url: string) { 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))); + } +}); diff --git a/receivers/common/web/player/Player.ts b/receivers/common/web/player/Player.ts index 5eb0735..f948aab 100644 --- a/receivers/common/web/player/Player.ts +++ b/receivers/common/web/player/Player.ts @@ -1,3 +1,4 @@ +import { PlayMessage } from 'common/Packets'; import dashjs from 'modules/dashjs'; import Hls from 'modules/hls.js'; @@ -10,28 +11,69 @@ export enum PlayerType { } export class Player { - private player: dashjs.MediaPlayerClass | HTMLVideoElement; - private hlsPlayer: Hls | undefined; + private player: HTMLVideoElement; + private playMessage: PlayMessage; private source: string; - public playerType: PlayerType; - constructor(playerType: PlayerType, player: dashjs.MediaPlayerClass | HTMLVideoElement, source: string, hlsPlayer?: Hls) { - this.playerType = playerType; + // Todo: use a common event handler interface instead of exposing internal players + public playerType: PlayerType; + public dashPlayer: dashjs.MediaPlayerClass = null; + public hlsPlayer: Hls = null; + + constructor(player: HTMLVideoElement, message: PlayMessage) { this.player = player; - this.source = source; - this.hlsPlayer = playerType === PlayerType.Hls ? hlsPlayer : null; + this.playMessage = message; + + 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); + } + } + + 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; + } } - destroy() { + public destroy() { switch (this.playerType) { case PlayerType.Dash: try { - (this.player as dashjs.MediaPlayerClass).destroy(); + this.dashPlayer.destroy(); } catch (e) { logger.warn("Failed to destroy dash player", e); } - this.player = null; - this.playerType = null; + break; case PlayerType.Hls: @@ -41,158 +83,231 @@ export class Player { } catch (e) { logger.warn("Failed to destroy hls player", e); } - // fall through + // fallthrough 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; } default: 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(); } - - isPaused(): boolean { + /** + * Load media specified in the PlayMessage provided on object initialization + */ + public load() { if (this.playerType === PlayerType.Dash) { - return (this.player as dashjs.MediaPlayerClass).isPaused(); - } else { // HLS, HTML - return (this.player as HTMLVideoElement).paused; + if (this.playMessage.content) { + this.dashPlayer.initialize(this.player, `data:${this.playMessage.container};base64,` + window.btoa(this.playMessage.content), true, this.playMessage.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'; + 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) { - return (this.player as dashjs.MediaPlayerClass).getVolume(); + this.dashPlayer.play(); } 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)); if (this.playerType === PlayerType.Dash) { - (this.player as dashjs.MediaPlayerClass).setVolume(sanitizedVolume); + this.dashPlayer.setVolume(sanitizedVolume); } else { // HLS, HTML - (this.player as HTMLVideoElement).volume = sanitizedVolume; + this.player.volume = sanitizedVolume; } } - isMuted(): boolean { + public isMuted(): boolean { if (this.playerType === PlayerType.Dash) { - return (this.player as dashjs.MediaPlayerClass).isMuted(); + return this.dashPlayer.isMuted(); } 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}`); if (this.playerType === PlayerType.Dash) { - (this.player as dashjs.MediaPlayerClass).setMute(value); + this.dashPlayer.setMute(value); } else { // HLS, HTML - (this.player as HTMLVideoElement).muted = value; + this.player.muted = value; } } - getPlaybackRate(): number { + public getPlaybackRate(): number { if (this.playerType === PlayerType.Dash) { - return (this.player as dashjs.MediaPlayerClass).getPlaybackRate(); + return this.dashPlayer.getPlaybackRate(); } 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}`); const sanitizedSpeed = Math.min(16.0, Math.max(0.0, value)); if (this.playerType === PlayerType.Dash) { - (this.player as dashjs.MediaPlayerClass).setPlaybackRate(sanitizedSpeed); + this.dashPlayer.setPlaybackRate(sanitizedSpeed); } else { // HLS, HTML - (this.player as HTMLVideoElement).playbackRate = sanitizedSpeed; + this.player.playbackRate = sanitizedSpeed; } } - getDuration(): number { + public getDuration(): number { if (this.playerType === PlayerType.Dash) { - const videoPlayer = this.player as dashjs.MediaPlayerClass; - return isFinite(videoPlayer.duration()) ? videoPlayer.duration() : 0; + return isFinite(this.dashPlayer.duration()) ? this.dashPlayer.duration() : 0; } else { // HLS, HTML - const videoPlayer = this.player as HTMLVideoElement; - return isFinite(videoPlayer.duration) ? videoPlayer.duration : 0; + return isFinite(this.player.duration) ? this.player.duration : 0; } } - getCurrentTime(): number { + public getCurrentTime(): number { if (this.playerType === PlayerType.Dash) { - return (this.player as dashjs.MediaPlayerClass).time(); + return this.dashPlayer.time(); } 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}`); const sanitizedTime = Math.min(this.getDuration(), Math.max(0.0, value)); if (this.playerType === PlayerType.Dash) { - (this.player as dashjs.MediaPlayerClass).seek(sanitizedTime); - const videoPlayer = this.player as dashjs.MediaPlayerClass; + this.dashPlayer.seek(sanitizedTime); - if (!videoPlayer.isSeeking()) { - videoPlayer.seek(sanitizedTime); + if (!this.dashPlayer.isSeeking()) { + this.dashPlayer.seek(sanitizedTime); } } else { // HLS, HTML - (this.player as HTMLVideoElement).currentTime = sanitizedTime; + this.player.currentTime = sanitizedTime; } } - getSource(): string { + public getSource(): string { return this.source; } - getBufferLength(): number { + public getAutoplay(): boolean { 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") - ?? dashPlayer.getBufferLength("audio") - ?? dashPlayer.getBufferLength("text") - ?? dashPlayer.getBufferLength("image") + public setAutoPlay(value: boolean) { + if (this.playerType === PlayerType.Dash) { + return this.dashPlayer.setAutoPlay(value); + } 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; if (Number.isNaN(dashBufferLength)) dashBufferLength = 0; - dashBufferLength += dashPlayer.time(); + dashBufferLength += this.dashPlayer.time(); return dashBufferLength; } else { // HLS, HTML - const videoPlayer = this.player as HTMLVideoElement; let maxBuffer = 0; - if (videoPlayer.buffered) { - for (let i = 0; i < videoPlayer.buffered.length; i++) { - const start = videoPlayer.buffered.start(i); - const end = videoPlayer.buffered.end(i); + if (this.player.buffered) { + for (let i = 0; i < this.player.buffered.length; i++) { + const start = this.player.buffered.start(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; } } @@ -202,9 +317,9 @@ export class Player { } } - isCaptionsSupported(): boolean { + public isCaptionsSupported(): boolean { 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) { return this.hlsPlayer.allSubtitleTracks.length > 0; } else { @@ -212,9 +327,9 @@ export class Player { } } - isCaptionsEnabled(): boolean { + public isCaptionsEnabled(): boolean { if (this.playerType === PlayerType.Dash) { - return (this.player as dashjs.MediaPlayerClass).isTextEnabled(); + return this.dashPlayer.isTextEnabled(); } else if (this.playerType === PlayerType.Hls) { return this.hlsPlayer.subtitleDisplay; } else { @@ -222,9 +337,9 @@ export class Player { } } - enableCaptions(enable: boolean) { + public enableCaptions(enable: boolean) { if (this.playerType === PlayerType.Dash) { - (this.player as dashjs.MediaPlayerClass).enableText(enable); + this.dashPlayer.enableText(enable); } else if (this.playerType === PlayerType.Hls) { this.hlsPlayer.subtitleDisplay = enable; } diff --git a/receivers/common/web/player/Preload.ts b/receivers/common/web/player/Preload.ts index 8bf5dc8..b645182 100644 --- a/receivers/common/web/player/Preload.ts +++ b/receivers/common/web/player/Preload.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* 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'; const logger = new Logger('PlayerWindow', LoggerType.FRONTEND); @@ -25,25 +25,40 @@ declare global { } let preloadData: Record = {}; +preloadData.subscribedKeys = { + keyDown: new Set(), + keyUp: new Set(), +}; // @ts-ignore if (TARGET === 'electron') { // @ts-ignore const electronAPI = __non_webpack_require__('electron'); + electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = value.keyDown; + preloadData.subscribedKeys.keyUp = value.keyUp; + }) + electronAPI.contextBridge.exposeInMainWorld('targetAPI', { - sendPlaybackError: (error: PlaybackErrorMessage) => electronAPI.ipcRenderer.send('send-playback-error', error), sendPlaybackUpdate: (update: PlaybackUpdateMessage) => electronAPI.ipcRenderer.send('send-playback-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), onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback), onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback), onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback), onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", 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'), + getSubscribedKeys: () => preloadData.subscribedKeys, onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback), onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback), + onPlayPlaylist: (callback: any) => electronAPI.ipcRenderer.on('play-playlist', callback), logger: loggerInterface, }); diff --git a/receivers/common/web/player/Renderer.ts b/receivers/common/web/player/Renderer.ts index 0e492fc..a178fe2 100644 --- a/receivers/common/web/player/Renderer.ts +++ b/receivers/common/web/player/Renderer.ts @@ -1,9 +1,11 @@ import dashjs from 'modules/dashjs'; 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 * as connectionMonitor from '../ConnectionMonitor'; -import { toast, ToastIcon } from '../components/Toast'; +import * as connectionMonitor from 'common/ConnectionMonitor'; +import { supportedAudioTypes } from 'common/MimeTypes'; +import { mediaItemFromPlayMessage, playMessageFromMediaItem } from 'common/UtilityFrontend'; +import { toast, ToastIcon } from 'common/components/Toast'; import { targetPlayerCtrlStateUpdate, targetKeyDownEventListener, @@ -34,8 +36,9 @@ function formatDuration(duration: number) { } } -function sendPlaybackUpdate(updateState: number) { - const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate()); +function sendPlaybackUpdate(updateState: PlaybackState) { + const updateMessage = new PlaybackUpdateMessage(Date.now(), updateState, player.getCurrentTime(), player.getDuration(), player.getPlaybackRate()); + playbackState = updateState; if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) { lastPlayerUpdateGenerationTime = updateMessage.generationTime; @@ -43,40 +46,53 @@ function sendPlaybackUpdate(updateState: number) { } }; -function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) { +function onPlayerLoad(value: PlayMessage) { playerCtrlStateUpdate(PlayerControlEvent.Load); + loadingSpinner.style.display = 'none'; - // Subtitles break when seeking post stream initialization for the DASH player. - // Its currently done on player initialization. - if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) { - if (value.time) { - player.setCurrentTime(value.time); + 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'; } - } - if (value.speed) { - player.setPlaybackRate(value.speed); - } else if (currentPlaybackRate) { - player.setPlaybackRate(currentPlaybackRate); - } else { - player.setPlaybackRate(1.0); - } - playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); + // Subtitles break when seeking post stream initialization for the DASH player. + // Its currently done on player initialization. + if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) { + if (value.time) { + player.setCurrentTime(value.time); + } + } + if (value.speed) { + player.setPlaybackRate(value.speed); + playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); + } + if (value.volume) { + volumeChangeHandler(value.volume); + } + else { + // 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. + volumeChangeHandler(1.0); + window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 }); + } + playerCtrlStateUpdate(PlayerControlEvent.VolumeChange); - if (currentVolume) { - volumeChangeHandler(currentVolume); + playbackState = PlaybackState.Playing; + logger.info('Media playback start:', cachedPlayMediaItem); + window.targetAPI.sendEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemStart, cachedPlayMediaItem))); + player.play(); } - else { - // FCast PlayMessage does not contain volume field and could result in the receiver - // getting out-of-sync with the sender on 1st playback. - volumeChangeHandler(1.0); - window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 }); - } - - player.play(); } // 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 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 playbackUpdateInterval = 1.0; +const playerVolumeUpdateInterval = 0.01; const livePositionDelta = 5.0; const livePositionWindow = livePositionDelta * 4; let player: Player; -let playerPrevTime: number = 0; +let playbackState: PlaybackState = PlaybackState.Idle; +let playerPrevTime: number = 1; +let playerPrevVolume: number = 1; let lastPlayerUpdateGenerationTime = 0; let isLive = false; let isLivePosition = false; let captionsBaseHeight = 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) { - logger.info("Handle play message renderer", JSON.stringify(value)); - const currentVolume = player ? player.getVolume() : null; - const currentPlaybackRate = player ? player.getPlaybackRate() : null; + 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; + + idleIcon.style.display = 'none'; + loadingSpinner.style.display = 'block'; + idleBackground.style.display = 'block'; if (player) { if ((player.getSource() === value.url) || (player.getSource() === value.content)) { @@ -137,6 +171,7 @@ function onPlay(_event, value: PlayMessage) { player.destroy(); } + playbackState = PlaybackState.Idle; playerPrevTime = 0; lastPlayerUpdateGenerationTime = 0; isLive = false; @@ -144,60 +179,48 @@ function onPlay(_event, value: PlayMessage) { captionsBaseHeight = captionsBaseHeightExpanded; 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') { - 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 - dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); }); - dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); }); - dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) }); - dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => { + player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { mediaStartHandler(value); }); + player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(PlaybackState.Paused); playerCtrlStateUpdate(PlayerControlEvent.Pause); }); + player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { mediaEndHandler(); }); + player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); - if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) { - sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1); - playerPrevTime = dashPlayer.time(); + if (Math.abs(player.dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) { + sendPlaybackUpdate(playbackState); + 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 - 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, () => { - const updateVolume = dashPlayer.isMuted() ? 0 : dashPlayer.getVolume(); + player.dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_VOLUME_CHANGED, () => { + const updateVolume = player.dashPlayer.isMuted() ? 0 : player.dashPlayer.getVolume(); playerCtrlStateUpdate(PlayerControlEvent.VolumeChange); - window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume }); + + if (Math.abs(updateVolume - playerPrevVolume) >= playerVolumeUpdateInterval) { + 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)}` })}); - 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)}` })}); - 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") 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(); }); - dashPlayer.updateSettings({ + player.dashPlayer.updateSettings({ // debug: { // 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)) { - logger.info("Loading hls player"); - - 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) => { + player.hlsPlayer.on(Hls.Events.ERROR, (eventName, data) => { window.targetAPI.sendPlaybackError({ 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; 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 if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) { - videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); }; - videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); }; - videoElement.onended = () => { sendPlaybackUpdate(0) }; + videoElement.onplay = () => { mediaStartHandler(value); }; + videoElement.onpause = () => { sendPlaybackUpdate(PlaybackState.Paused); playerCtrlStateUpdate(PlayerControlEvent.Pause); }; + videoElement.onended = () => { mediaEndHandler(); }; videoElement.ontimeupdate = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) { - sendPlaybackUpdate(videoElement.paused ? 2 : 1); + sendPlaybackUpdate(playbackState); playerPrevTime = videoElement.currentTime; } }; // Buffering UI update when paused videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); }; - videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) }; + videoElement.onratechange = () => { sendPlaybackUpdate(playbackState); }; videoElement.onvolumechange = () => { const updateVolume = videoElement.muted ? 0 : videoElement.volume; playerCtrlStateUpdate(PlayerControlEvent.VolumeChange); - window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume }); + + if (Math.abs(updateVolume - playerPrevVolume) >= playerVolumeUpdateInterval) { + window.targetAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume }); + playerPrevVolume = updateVolume; + } }; videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => { @@ -322,8 +314,16 @@ function onPlay(_event, value: PlayMessage) { 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 @@ -332,7 +332,43 @@ function onPlay(_event, value: PlayMessage) { window.targetAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); }); window.targetAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); }); 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({ onConnect: (connections: string[], initialUpdate: boolean = false) => { @@ -346,6 +382,7 @@ connectionMonitor.setUiUpdateCallbacks({ }); window.targetAPI.onPlay(onPlay); +window.targetAPI.onPlayPlaylist(onPlayPlaylist); let scrubbing = false; let volumeChanging = false; @@ -669,7 +706,7 @@ playbackRates.forEach(r => { }; }); -videoElement.onclick = () => { +function videoClickedHandler() { if (!playerCtrlSpeedMenuShown) { if (player?.isPaused()) { player?.play(); @@ -677,7 +714,67 @@ videoElement.onclick = () => { 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 let uiHideTimer = null; @@ -737,69 +834,71 @@ document.addEventListener('click', (event: MouseEvent) => { const skipInterval = 10; const volumeIncrement = 0.1; -function keyDownEventListener(event: any) { +function keyDownEventListener(event: KeyboardEvent) { // logger.info("KeyDown", event); - const handledCase = targetKeyDownEventListener(event); - if (handledCase) { - return; + let handledCase = targetKeyDownEventListener(event); + + if (!handledCase) { + switch (event.code) { + case 'ArrowLeft': + skipBack(); + event.preventDefault(); + handledCase = true; + break; + case 'ArrowRight': + skipForward(); + event.preventDefault(); + handledCase = true; + break; + case "Home": + player?.setCurrentTime(0); + event.preventDefault(); + handledCase = true; + break; + case "End": + if (isLive) { + setLivePosition(); + } + else { + player?.setCurrentTime(player?.getDuration()); + } + event.preventDefault(); + handledCase = true; + break; + case 'KeyK': + case 'Space': + case 'Enter': + // Play/pause toggle + if (player?.isPaused()) { + player?.play(); + } else { + player?.pause(); + } + event.preventDefault(); + handledCase = true; + break; + case 'KeyM': + // Mute toggle + player?.setMute(!player?.isMuted()); + handledCase = true; + break; + case 'ArrowUp': + // Volume up + volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); + handledCase = true; + break; + case 'ArrowDown': + // Volume down + volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); + handledCase = true; + break; + default: + break; + } } - switch (event.code) { - case 'KeyF': - case 'F11': - playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); - event.preventDefault(); - break; - case 'Escape': - playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen); - event.preventDefault(); - break; - case 'ArrowLeft': - skipBack(); - event.preventDefault(); - break; - case 'ArrowRight': - skipForward(); - event.preventDefault(); - break; - case "Home": - player?.setCurrentTime(0); - event.preventDefault(); - break; - case "End": - if (isLive) { - setLivePosition(); - } - else { - player?.setCurrentTime(player?.getDuration()); - } - event.preventDefault(); - break; - case 'KeyK': - case 'Space': - case 'Enter': - // Play/pause toggle - if (player?.isPaused()) { - player?.play(); - } else { - player?.pause(); - } - event.preventDefault(); - break; - case 'KeyM': - // Mute toggle - player?.setMute(!player?.isMuted()); - break; - case 'ArrowUp': - // Volume up - volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); - break; - case 'ArrowDown': - // Volume down - volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); - 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))); } } @@ -814,6 +913,11 @@ function skipForward() { } 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 { PlayerControlEvent, diff --git a/receivers/common/web/player/common.css b/receivers/common/web/player/common.css index 9d84cb4..7558aaf 100644 --- a/receivers/common/web/player/common.css +++ b/receivers/common/web/player/common.css @@ -15,6 +15,57 @@ body { 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 { object-fit: contain; width: 100%; @@ -528,6 +579,20 @@ body { /* Display scaling (Minimum supported resolution is 960x540) */ @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 { padding: 12px; } @@ -545,6 +610,20 @@ body { } @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 { padding: 12px; } @@ -562,6 +641,20 @@ body { } @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 { padding: 8px; } @@ -579,6 +672,20 @@ body { } @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 { padding: 4px; } diff --git a/receivers/common/web/viewer/Renderer.ts b/receivers/common/web/viewer/Renderer.ts index cb0e55a..7f11a4b 100644 --- a/receivers/common/web/viewer/Renderer.ts +++ b/receivers/common/web/viewer/Renderer.ts @@ -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 * as connectionMonitor from '../ConnectionMonitor'; -import { toast, ToastIcon } from '../components/Toast'; +import * as connectionMonitor from 'common/ConnectionMonitor'; +import { toast, ToastIcon } from 'common/components/Toast'; +import { + targetPlayerCtrlStateUpdate, + targetKeyDownEventListener, +} from 'src/viewer/Renderer'; + 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 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) { - 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; 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.src = ""; + genericViewer.style.display = 'none'; + genericViewer.src = ''; + idleBackground.style.display = 'none'; + idleIcon.style.display = 'none'; imageViewer.src = src; - imageViewer.style.display = "block"; + imageViewer.style.display = 'block'; } else if (src && genericViewer) { - logger.info("Loading generic viewer"); + logger.info('Loading generic viewer'); - imageViewer.style.display = "none"; - imageViewer.src = ""; + imageViewer.style.display = 'none'; + imageViewer.src = ''; + idleBackground.style.display = 'none'; + idleIcon.style.display = 'none'; genericViewer.src = src; - genericViewer.style.display = "block"; + genericViewer.style.display = 'block'; } else { - logger.error("Error loading content"); + logger.error('Error loading content'); - imageViewer.style.display = "none"; - imageViewer.src = ""; + imageViewer.style.display = 'none'; + imageViewer.src = ''; - genericViewer.style.display = "none"; - genericViewer.src = ""; + genericViewer.style.display = 'none'; + 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.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.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.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({ onConnect: (connections: string[], initialUpdate: boolean = false) => { @@ -59,3 +146,120 @@ connectionMonitor.setUiUpdateCallbacks({ }); 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, +}; diff --git a/receivers/common/web/viewer/common.css b/receivers/common/web/viewer/common.css index 29f3a2a..b673f49 100644 --- a/receivers/common/web/viewer/common.css +++ b/receivers/common/web/viewer/common.css @@ -15,15 +15,66 @@ body { max-height: 100%; } +*:focus { + outline: none; + box-shadow: none; +} + +.video { + height: 100%; + width: 100%; + object-fit: cover; +} + .viewer { object-fit: contain; width: 100%; height: 100%; } -*:focus { - outline: none; - box-shadow: none; +#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); + } } #toast-notification { @@ -97,6 +148,20 @@ body { /* Display scaling (Minimum supported resolution is 960x540) */ @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 { padding: 12px; } @@ -114,6 +179,20 @@ body { } @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 { padding: 12px; } @@ -131,6 +210,20 @@ body { } @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 { padding: 8px; } @@ -148,6 +241,20 @@ body { } @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 { padding: 4px; } diff --git a/receivers/electron/package-lock.json b/receivers/electron/package-lock.json index d26241f..a063821 100644 --- a/receivers/electron/package-lock.json +++ b/receivers/electron/package-lock.json @@ -14,10 +14,12 @@ "dashjs": "^4.7.4", "electron-json-storage": "^4.6.0", "extract-zip": "^2.0.1", + "follow-redirects": "^1.15.9", "hls.js": "^1.5.15", "http": "^0.0.1-security", "https": "^1.0.0", "log4js": "^6.9.1", + "memfs": "^4.17.2", "qrcode": "^1.5.3", "systeminformation": "^5.25.11", "url": "^0.11.4", @@ -2262,6 +2264,60 @@ "@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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -6215,6 +6271,26 @@ "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": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -6785,6 +6861,15 @@ "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": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8685,6 +8770,25 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11322,6 +11426,18 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", @@ -11446,6 +11562,22 @@ "dev": true, "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -11580,7 +11712,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/receivers/electron/package.json b/receivers/electron/package.json index e82bd6e..a299d57 100644 --- a/receivers/electron/package.json +++ b/receivers/electron/package.json @@ -51,10 +51,12 @@ "dashjs": "^4.7.4", "electron-json-storage": "^4.6.0", "extract-zip": "^2.0.1", + "follow-redirects": "^1.15.9", "hls.js": "^1.5.15", "http": "^0.0.1-security", "https": "^1.0.0", "log4js": "^6.9.1", + "memfs": "^4.17.2", "qrcode": "^1.5.3", "systeminformation": "^5.25.11", "url": "^0.11.4", diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts index 1b7d9ba..5b9bf4a 100644 --- a/receivers/electron/src/Main.ts +++ b/receivers/electron/src/Main.ts @@ -1,5 +1,5 @@ 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 { DiscoveryService } from 'common/DiscoveryService'; import { TcpListenerService } from 'common/TcpListenerService'; @@ -7,6 +7,8 @@ import { WebSocketListenerService } from 'common/WebSocketListenerService'; import { NetworkService } from 'common/NetworkService'; import { ConnectionMonitor } from 'common/ConnectionMonitor'; import { Logger, LoggerType } from 'common/Logger'; +import { fetchJSON } from 'common/UtilityBackend'; +import { MediaCache } from 'common/MediaCache'; import { Updater } from './Updater'; import * as os from 'os'; import * as path from 'path'; @@ -15,6 +17,17 @@ import { hideBin } from 'yargs/helpers'; const cp = require('child_process'); 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(); +} + export class Main { static shouldOpenMainWindow = true; static startFullscreen = false; @@ -26,9 +39,11 @@ export class Main { static discoveryService: DiscoveryService; static connectionMonitor: ConnectionMonitor; static tray: Tray; + static cache: AppCache = new AppCache(); - private static cachedInterfaces = null; private static playerWindowContentViewer = null; + private static listeners = []; + private static mediaCache: MediaCache = null; private static toggleMainWindow() { if (Main.mainWindow) { @@ -144,6 +159,80 @@ export class Main { this.tray = tray; } + private static async play(message: PlayMessage) { + Main.listeners.forEach(l => l.send(Opcode.PlayUpdate, new PlayUpdateMessage(Date.now(), message))); + Main.cache.playMessage = message; + + // Protocol v2 FCast PlayMessage does not contain volume field and could result in the receiver + // getting out-of-sync with the sender when player windows are closed and re-opened. Volume + // 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; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let rendererMessage: any = await NetworkService.proxyPlayIfRequired(message); + let rendererEvent = 'play'; + let contentViewer = supportedPlayerTypes.find(v => v === message.container.toLocaleLowerCase()) ? 'player' : 'viewer'; + + if (message.container === 'application/json') { + const json: ContentObject = message.url ? await fetchJSON(message.url) : JSON.parse(message.content); + + 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) { + Main.playerWindow = new BrowserWindow({ + fullscreen: true, + autoHideMenuBar: true, + icon: path.join(__dirname, 'icon512.png'), + webPreferences: { + preload: path.join(__dirname, 'player/preload.js') + } + }); + + Main.playerWindow.setAlwaysOnTop(false, 'pop-up-menu'); + Main.playerWindow.show(); + + Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`)); + Main.playerWindow.on('ready-to-show', async () => { + Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage); + }); + Main.playerWindow.on('closed', () => { + Main.playerWindow = null; + Main.playerWindowContentViewer = null; + }); + } + else if (Main.playerWindow && contentViewer !== Main.playerWindowContentViewer) { + Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`)); + Main.playerWindow.on('ready-to-show', async () => { + Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage); + }); + } else { + Main.playerWindow?.webContents?.send(rendererEvent, rendererMessage); + } + + Main.playerWindowContentViewer = contentViewer; + } + private static onReady() { Main.createTray(); @@ -154,45 +243,9 @@ export class Main { Main.tcpListenerService = new TcpListenerService(); Main.webSocketListenerService = new WebSocketListenerService(); - const listeners = [Main.tcpListenerService, Main.webSocketListenerService]; - listeners.forEach(l => { - l.emitter.on("play", async (message) => { - const contentViewer = supportedPlayerTypes.find(v => v === message.container.toLocaleLowerCase()) ? 'player' : 'viewer'; - - if (!Main.playerWindow) { - Main.playerWindow = new BrowserWindow({ - fullscreen: true, - autoHideMenuBar: true, - icon: path.join(__dirname, 'icon512.png'), - webPreferences: { - preload: path.join(__dirname, 'player/preload.js') - } - }); - - Main.playerWindow.setAlwaysOnTop(false, 'pop-up-menu'); - Main.playerWindow.show(); - - Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`)); - Main.playerWindow.on('ready-to-show', async () => { - Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message)); - }); - Main.playerWindow.on('closed', () => { - Main.playerWindow = null; - Main.playerWindowContentViewer = null; - }); - } - else if (Main.playerWindow && contentViewer !== Main.playerWindowContentViewer) { - Main.playerWindow.loadFile(path.join(__dirname, `${contentViewer}/index.html`)); - Main.playerWindow.on('ready-to-show', async () => { - Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message)); - }); - } else { - Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message)); - } - - Main.playerWindowContentViewer = contentViewer; - }); - + 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("resume", () => Main.playerWindow?.webContents?.send("resume")); @@ -202,9 +255,12 @@ export class Main { Main.playerWindowContentViewer = null; }); - l.emitter.on("seek", (message) => Main.playerWindow?.webContents?.send("seek", message)); - l.emitter.on("setvolume", (message) => Main.playerWindow?.webContents?.send("setvolume", message)); - l.emitter.on("setspeed", (message) => Main.playerWindow?.webContents?.send("setspeed", message)); + l.emitter.on("seek", (message: SeekMessage) => Main.playerWindow?.webContents?.send("seek", message)); + l.emitter.on("setvolume", (message: SetVolumeMessage) => { + 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) => { ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => { @@ -224,6 +280,26 @@ export class Main { l.emitter.on('pong', (message) => { 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(); 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) => { + Main.cache.playerVolume = value.volume; 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 () => { if (!Updater.isDownloading) { try { @@ -299,7 +401,7 @@ export class Main { // eslint-disable-next-line @typescript-eslint/no-explicit-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 }); }); @@ -364,8 +466,8 @@ export class Main { Main.mainWindow.show(); Main.mainWindow.on('ready-to-show', () => { - if (Main.cachedInterfaces) { - Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: Main.cachedInterfaces }); + if (Main.cache.interfaces) { + Main.mainWindow?.webContents?.send("device-info", { name: os.hostname(), interfaces: Main.cache.interfaces }); } networkWorker.loadFile(path.join(__dirname, 'main/worker.html')); @@ -375,6 +477,8 @@ export class Main { static async main(app: Electron.App) { try { Main.application = app; + Main.cache.appName = app.name; + Main.cache.appVersion = app.getVersion(); const argv = yargs(hideBin(process.argv)) .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) { logger.error(error); logger.shutdown(); diff --git a/receivers/electron/src/Updater.ts b/receivers/electron/src/Updater.ts index 0cdbf27..ba73d71 100644 --- a/receivers/electron/src/Updater.ts +++ b/receivers/electron/src/Updater.ts @@ -1,11 +1,11 @@ import * as fs from 'fs'; -import * as https from 'https'; import * as path from 'path'; import * as crypto from 'crypto'; import { app } from 'electron'; import { Store } from './Store'; import sudo from 'sudo-prompt'; import { Logger, LoggerType } from 'common/Logger'; +import { fetchJSON, downloadFile } from 'common/UtilityBackend'; const cp = require('child_process'); const extract = require('extract-zip'); @@ -91,52 +91,6 @@ export class Updater { Store.set('updater', updaterSettings); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private static async fetchJSON(url: string): Promise { - 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 { - 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) { try { 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...'); 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 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); logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`); 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 hash = crypto.createHash('sha256').end(downloadedFile).digest('hex'); diff --git a/receivers/electron/src/player/Renderer.ts b/receivers/electron/src/player/Renderer.ts index 761c052..c4fad92 100644 --- a/receivers/electron/src/player/Renderer.ts +++ b/receivers/electron/src/player/Renderer.ts @@ -41,8 +41,7 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean return handledCase; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function targetKeyDownEventListener(event: any): boolean { +export function targetKeyDownEventListener(event: KeyboardEvent): boolean { let handledCase = false; switch (event.code) { diff --git a/receivers/electron/src/player/index.html b/receivers/electron/src/player/index.html index caad4a8..842d5c5 100644 --- a/receivers/electron/src/player/index.html +++ b/receivers/electron/src/player/index.html @@ -9,6 +9,9 @@ +
+
+
diff --git a/receivers/electron/src/viewer/Renderer.ts b/receivers/electron/src/viewer/Renderer.ts index d31174d..bfbc3d5 100644 --- a/receivers/electron/src/viewer/Renderer.ts +++ b/receivers/electron/src/viewer/Renderer.ts @@ -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 +}; diff --git a/receivers/electron/src/viewer/index.html b/receivers/electron/src/viewer/index.html index f0aa89a..784323f 100644 --- a/receivers/electron/src/viewer/index.html +++ b/receivers/electron/src/viewer/index.html @@ -8,7 +8,10 @@ + +
+
>