1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Receivers: Added playlist support

This commit is contained in:
Michael Hollister 2025-06-10 14:23:06 -05:00
parent 72d5c10918
commit 1afd421f7d
22 changed files with 1613 additions and 453 deletions

View file

@ -1,6 +1,7 @@
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);
@ -63,7 +64,7 @@ export abstract class ListenerService {
if (this.eventSubscribers.has(sessionId)) {
let sessionSubscriptions = this.eventSubscribers.get(sessionId);
const index = sessionSubscriptions.findIndex((obj) => ListenerService.deepEqual(obj, event));
const index = sessionSubscriptions.findIndex((obj) => deepEqual(obj, event));
if (index != -1) {
sessionSubscriptions.splice(index, 1);
}
@ -135,12 +136,4 @@ export abstract class ListenerService {
protected async handleServerError(err: NodeJS.ErrnoException) {
errorHandler(err);
}
private static 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 => this.deepEqual(x[key], y[key]))
) : (x === y);
}
}

View file

@ -0,0 +1,253 @@
import { PlaylistContent } from 'common/Packets';
import { downloadFile } from 'common/UtilityBackend';
import { Logger, LoggerType } from 'common/Logger';
import { fs } from 'modules/memfs';
import { v4 as uuidv4 } from 'modules/uuid';
import { Readable } from 'stream';
import * as os from 'os';
const logger = new Logger('MediaCache', LoggerType.BACKEND);
class CacheObject {
public id: string;
public size: number;
public url: string;
public path: string;
constructor() {
this.id = uuidv4();
this.size = 0;
this.path = `/cache/${this.id}`;
this.url = `app://${this.path}`;
}
}
export class MediaCache {
private static instance: MediaCache = null;
private cache = new Map<number, CacheObject>();
private cacheUrlMap = new Map<string,number>();
private playlist: PlaylistContent;
private quota: number;
private cacheSize: number = 0;
private cacheWindowStart: number = 0;
private cacheWindowEnd: number = 0;
constructor(playlist: PlaylistContent) {
MediaCache.instance = this;
this.playlist = playlist;
if (!fs.existsSync('/cache')) {
fs.mkdirSync('/cache');
}
// @ts-ignore
if (TARGET === 'electron') {
this.quota = Math.min(Math.floor(os.freemem() / 4), 4 * 1024 * 1024 * 1024); // 4GB
// @ts-ignore
} else if (TARGET === 'webOS' || TARGET === 'tizenOS') {
this.quota = Math.min(Math.floor(os.freemem() / 4), 250 * 1024 * 1024); // 250MB
}
else {
this.quota = Math.min(Math.floor(os.freemem() / 4), 250 * 1024 * 1024); // 250MB
}
logger.info('Created cache with storage byte quota:', this.quota);
}
public destroy() {
MediaCache.instance = null;
this.cache.clear();
this.cache = null;
this.cacheUrlMap.clear();
this.cacheUrlMap = null;
this.playlist = null;
this.quota = 0;
this.cacheSize = 0;
this.cacheWindowStart = 0;
this.cacheWindowEnd = 0;
}
public static getInstance() {
return MediaCache.instance;
}
public has(playlistIndex: number): boolean {
return this.cache.has(playlistIndex);
}
public getUrl(playlistIndex: number): string {
return this.cache.get(playlistIndex).url;
}
public getObject(url: string, start: number = 0, end: number = null): Readable {
const cacheObject = this.cache.get(this.cacheUrlMap.get(url));
end = end ? end : cacheObject.size - 1;
return fs.createReadStream(cacheObject.path, { start: start, end: end });
}
public getObjectSize(url: string): number {
return this.cache.get(this.cacheUrlMap.get(url)).size;
}
public cacheForwardItems(cacheIndex: number, cacheAmount: number, playlistIndex: number) {
if (cacheAmount > 0) {
for (let i = cacheIndex; i < this.playlist.items.length; i++) {
const item = this.playlist.items[i];
if (item.cache) {
if (this.cache.has(i)) {
this.cacheForwardItems(i + 1, cacheAmount - 1, playlistIndex);
break;
}
const tempCacheObject = new CacheObject();
downloadFile(item.url, tempCacheObject.path,
(downloadedBytes: number) => {
let underQuota = true;
if (this.cacheSize + downloadedBytes > this.quota) {
underQuota = this.purgeCacheItems(i, downloadedBytes, playlistIndex);
}
return underQuota;
}, null,
(downloadedBytes: number) => {
this.finalizeCacheItem(tempCacheObject, i, downloadedBytes, playlistIndex);
this.cacheForwardItems(i + 1, cacheAmount - 1, playlistIndex);
}, true)
.catch((error) => {
logger.error(error);
});
break;
}
}
}
}
public cacheBackwardItems(cacheIndex: number, cacheAmount: number, playlistIndex: number) {
if (cacheAmount > 0) {
for (let i = cacheIndex; i >= 0; i--) {
const item = this.playlist.items[i];
if (item.cache) {
if (this.cache.has(i)) {
this.cacheBackwardItems(i - 1, cacheAmount - 1, playlistIndex);
break;
}
const tempCacheObject = new CacheObject();
downloadFile(item.url, tempCacheObject.path,
(downloadedBytes: number) => {
let underQuota = true;
if (this.cacheSize + downloadedBytes > this.quota) {
underQuota = this.purgeCacheItems(i, downloadedBytes, playlistIndex);
}
return underQuota;
}, null,
(downloadedBytes: number) => {
this.finalizeCacheItem(tempCacheObject, i, downloadedBytes, playlistIndex);
this.cacheBackwardItems(i - 1, cacheAmount - 1, playlistIndex);
}, true)
.catch((error) => {
logger.error(error);
});
break;
}
}
}
}
private purgeCacheItems(downloadItem: number, downloadedBytes: number, playlistIndex: number): boolean {
this.updateCacheWindow(playlistIndex);
let underQuota = true;
let purgeIndex = playlistIndex;
let purgeDistance = 0;
logger.debug(`Downloading item ${downloadItem} with playlist index ${playlistIndex} and cache window: [${this.cacheWindowStart} - ${this.cacheWindowEnd}]`);
// Priority:
// 1. Purge first encountered item outside cache window
// 2. Purge item furthest from view index inside window (except next item from view index)
for (let index of this.cache.keys()) {
if (index === downloadItem || index === playlistIndex || index === playlistIndex + 1) {
continue;
}
if (index < this.cacheWindowStart) {
purgeIndex = index;
break;
}
else if (index > this.cacheWindowEnd) {
purgeIndex = index;
break;
}
else if (Math.abs(playlistIndex - index) > purgeDistance) {
purgeDistance = Math.abs(playlistIndex - index);
purgeIndex = index;
}
}
if (purgeIndex !== playlistIndex) {
const deleteItem = this.cache.get(purgeIndex);
this.cacheSize -= deleteItem.size;
this.cacheUrlMap.delete(deleteItem.url);
this.cache.delete(purgeIndex);
this.updateCacheWindow(playlistIndex);
logger.info(`Item ${downloadItem} pending download (${downloadedBytes} bytes) cannot fit in cache, purging ${purgeIndex} from cache. Remaining quota ${this.quota - this.cacheSize} bytes`);
if (this.cacheSize + downloadedBytes > this.quota) {
underQuota = this.purgeCacheItems(downloadItem, downloadedBytes, playlistIndex);
}
}
else {
// Cannot purge current item since we may already be streaming it
logger.warn(`Aborting item caching, cannot fit item ${downloadItem} (${downloadedBytes} bytes) within remaining space quota (${this.quota - this.cacheSize} bytes)`);
underQuota = false;
}
return underQuota;
}
private finalizeCacheItem(cacheObject: CacheObject, index: number, size: number, playlistIndex: number) {
cacheObject.size = size;
this.cacheSize += size;
logger.info(`Cached item ${index} (${cacheObject.size} bytes) with remaining quota ${this.quota - this.cacheSize} bytes: ${cacheObject.url}`);
this.cache.set(index, cacheObject);
this.cacheUrlMap.set(cacheObject.url, index);
this.updateCacheWindow(playlistIndex);
}
private updateCacheWindow(playlistIndex: number) {
if (this.playlist.forwardCache && this.playlist.forwardCache > 0) {
let forwardCacheItems = this.playlist.forwardCache;
for (let index of this.cache.keys()) {
if (index > playlistIndex) {
forwardCacheItems--;
if (forwardCacheItems === 0) {
this.cacheWindowEnd = index;
break;
}
}
}
}
else {
this.cacheWindowEnd = playlistIndex;
}
if (this.playlist.backwardCache && this.playlist.backwardCache > 0) {
let backwardCacheItems = this.playlist.backwardCache;
for (let index of this.cache.keys()) {
if (index < playlistIndex) {
backwardCacheItems--;
if (backwardCacheItems === 0) {
this.cacheWindowStart = index;
break;
}
}
}
}
else {
this.cacheWindowStart = playlistIndex
}
}
}

View file

@ -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,
);

View file

@ -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<string, { url: string, headers: { [key: string]: string } }> = new Map();
static proxiedFiles: Map<string, PlayMessage> = new Map();
private static setupProxyServer(): Promise<void> {
return new Promise<void>((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<PlayMessage> {
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<string> {
static async proxyFile(message: PlayMessage): Promise<string> {
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;
}
}

View file

@ -0,0 +1,74 @@
import * as fs from 'fs';
import { http, https } from 'modules/follow-redirects';
import * as memfs from 'modules/memfs';
import { Logger, LoggerType } from 'common/Logger';
const logger = new Logger('UtilityBackend', LoggerType.BACKEND);
export function deepEqual(x, y) {
const ok = Object.keys, tx = typeof x, ty = typeof y;
return x && y && tx === 'object' && tx === ty ? (
ok(x).length === ok(y).length &&
ok(x).every(key => deepEqual(x[key], y[key]))
) : (x === y);
}
export async function fetchJSON(url: string): Promise<any> {
const protocol = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
protocol.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
}).on('error', (err) => {
reject(err);
});
});
}
export async function downloadFile(url: string, destination: string, startCb: (downloadSize: number) => boolean = null, progressCb: (downloadedBytes: number, downloadSize: number) => void = null, finishCb: (downloadedBytes: number) => void = null, inMemory: boolean = false): Promise<void> {
return new Promise((resolve, reject) => {
const file = inMemory ? memfs.fs.createWriteStream(destination) : fs.createWriteStream(destination);
const protocol = url.startsWith('https') ? https : http;
protocol.get(url, (response) => {
const downloadSize = Number(response.headers['content-length']);
logger.info(`Downloading file ${url} to ${destination} with size: ${downloadSize} bytes`);
if (startCb) {
if (!startCb(downloadSize)) {
file.close();
reject('Error: Aborted download');
}
}
response.pipe(file);
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
if (progressCb) {
progressCb(downloadedBytes, downloadSize);
}
});
file.on('finish', () => {
file.close();
if (finishCb) {
finishCb(downloadedBytes);
}
resolve();
});
}).on('error', (err) => {
file.close();
reject(err);
});
});
}

View file

@ -0,0 +1,17 @@
import { MediaItem, PlayMessage } from 'common/Packets';
export function playMessageFromMediaItem(item: MediaItem) {
return item ? new PlayMessage(
item.container, item.url, item.content,
item.time, item.volume, item.speed,
item.headers, item.metadata
) : new PlayMessage("");
}
export function mediaItemFromPlayMessage(message: PlayMessage) {
return message ? new MediaItem(
message.container, message.url, message.content,
message.time, message.volume, message.speed,
null, null, message.headers, message.metadata
) : new MediaItem("");
}

View file

@ -38,7 +38,6 @@ if (TARGET === 'electron') {
preloadData.deviceInfo = value;
})
electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set<string>, keyUp: Set<string> }) => {
logger.info('MAIN WINDOW Updated key subscriptions', value);
preloadData.subscribedKeys.keyDown = value.keyDown;
preloadData.subscribedKeys.keyUp = value.keyUp;
})
@ -50,7 +49,7 @@ if (TARGET === 'electron') {
getSubscribedKeys: () => preloadData.subscribedKeys,
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
emitEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('emit-event', message),
sendEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('send-event', message),
logger: loggerInterface,
});

View file

@ -203,11 +203,11 @@ function renderQRCode(url: string) {
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) {
window.targetAPI.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, false)));
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.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
}
});

View file

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

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, Opcode, EventMessage } 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);
@ -36,28 +36,29 @@ if (TARGET === 'electron') {
const electronAPI = __non_webpack_require__('electron');
electronAPI.ipcRenderer.on("event-subscribed-keys-update", (_event, value: { keyDown: Set<string>, keyUp: Set<string> }) => {
logger.info('PLAYER Updated key subscriptions', value);
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),
onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback),
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback),
onSetPlaylistItem: (callback: any) => electronAPI.ipcRenderer.on("setplaylistitem", callback),
emitEvent: (message: EventMessage) => electronAPI.ipcRenderer.send('emit-event', message),
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,
});

View file

@ -1,9 +1,11 @@
import dashjs from 'modules/dashjs';
import Hls, { LevelLoadedData } from 'modules/hls.js';
import { EventMessage, EventType, KeyEvent, MediaItem, MediaItemEvent, 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) {
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,41 +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 });
}
window.targetAPI.emitEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemStart, cachedPlayMediaItem)));
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;
@ -112,27 +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;
cachedPlayMediaItem = new MediaItem(
value.container, value.url, value.content,
value.time, value.volume, value.speed,
null, null, value.headers, value.metadata
);
window.targetAPI.emitEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemChange, cachedPlayMediaItem)));
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)) {
@ -145,6 +171,7 @@ function onPlay(_event, value: PlayMessage) {
player.destroy();
}
playbackState = PlaybackState.Idle;
playerPrevTime = 0;
lastPlayerUpdateGenerationTime = 0;
isLive = false;
@ -152,63 +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);
window.targetAPI.emitEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemEnd, cachedPlayMediaItem)));
});
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)
@ -225,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
// },
@ -240,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;
@ -282,44 +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);
window.targetAPI.emitEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemEnd, cachedPlayMediaItem)));
};
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) => {
@ -336,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
@ -346,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) => {
@ -360,6 +382,7 @@ connectionMonitor.setUiUpdateCallbacks({
});
window.targetAPI.onPlay(onPlay);
window.targetAPI.onPlayPlaylist(onPlayPlaylist);
let scrubbing = false;
let volumeChanging = false;
@ -683,7 +706,7 @@ playbackRates.forEach(r => {
};
});
videoElement.onclick = () => {
function videoClickedHandler() {
if (!playerCtrlSpeedMenuShown) {
if (player?.isPaused()) {
player?.play();
@ -691,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;
@ -757,17 +840,6 @@ function keyDownEventListener(event: KeyboardEvent) {
if (!handledCase) {
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;
case 'ArrowLeft':
skipBack();
event.preventDefault();
@ -826,7 +898,7 @@ function keyDownEventListener(event: KeyboardEvent) {
}
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) {
window.targetAPI.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase)));
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase)));
}
}
@ -843,7 +915,7 @@ function skipForward() {
document.addEventListener('keydown', keyDownEventListener);
document.addEventListener('keyup', (event: KeyboardEvent) => {
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) {
window.targetAPI.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
}
});

View file

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

View file

@ -1,57 +1,138 @@
import { EventMessage, EventType, KeyEvent, MediaItem, MediaItemEvent, 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));
const playMediaItem = new MediaItem(
value.container, value.url, value.content,
value.time, value.volume, value.speed,
null, null, value.headers, value.metadata
);
window.targetAPI.emitEvent(new EventMessage(Date.now(), new MediaItemEvent(EventType.MediaItemChange, playMediaItem)));
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) => {
@ -65,14 +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.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, false)));
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.emitEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
}
});
export {
PlayerControlEvent,
onPlay,
playerCtrlStateUpdate,
};

View file

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

View file

@ -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": {

View file

@ -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",

View file

@ -1,5 +1,5 @@
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog, shell } from 'electron';
import { Opcode, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, PlayMessage, PlayUpdateMessage, EventMessage, EventType, ContentObject, ContentType, PlaylistContent } 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,9 +7,9 @@ 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 http from 'http';
import * as https from 'https';
import * as os from 'os';
import * as path from 'path';
import yargs from 'yargs';
@ -23,6 +23,8 @@ class AppCache {
public appName: string = null;
public appVersion: string = null;
public playMessage: PlayMessage = null;
public playerVolume: number = null;
public playlist: PlaylistContent = null;
public subscribedKeys = new Set<string>();
}
@ -40,6 +42,8 @@ export class Main {
static cache: AppCache = new AppCache();
private static playerWindowContentViewer = null;
private static listeners = [];
private static mediaCache: MediaCache = null;
private static toggleMainWindow() {
if (Main.mainWindow) {
@ -155,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();
@ -165,69 +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: PlayMessage) => {
Main.cache.playMessage = message;
l.send(Opcode.PlayUpdate, new PlayUpdateMessage(Date.now(), message));
// todo: finish implementation (player window playlist context, main process media caching)
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: {
const playlist = json as PlaylistContent;
const offset = playlist.offset ? playlist.offset : 0;
message = new PlayMessage(playlist.items[offset].container, playlist.items[offset].url, playlist.items[offset].content,
playlist.items[offset].time, playlist.items[offset].volume, playlist.items[offset].speed,
playlist.items[offset].headers, playlist.items[offset].metadata);
break;
}
default:
break;
}
}
}
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"));
@ -237,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, () => {
@ -262,7 +283,7 @@ export class Main {
l.emitter.on('initial', (message) => {
logger.info(`Received 'Initial' message from sender: ${message}`);
});
l.emitter.on("setplaylistitem", (message) => Main.playerWindow?.webContents?.send("setplaylistitem", 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);
@ -290,14 +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('emit-event', (event: IpcMainEvent, value: EventMessage) => {
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 {
@ -570,27 +613,3 @@ export async function errorHandler(error: Error) {
Main.application.exit(0);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function fetchJSON(url: string): Promise<any> {
const protocol = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
protocol.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
}).on('error', (err) => {
reject(err);
});
});
}

View file

@ -1,12 +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 } from './Main';
import { fetchJSON, downloadFile } from 'common/UtilityBackend';
const cp = require('child_process');
const extract = require('extract-zip');
@ -92,30 +91,6 @@ export class Updater {
Store.set('updater', updaterSettings);
}
private static async downloadFile(url: string, destination: string): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
https.get(url, (response) => {
const downloadSize = Number(response.headers['content-length']);
logger.info(`Update size: ${downloadSize} bytes`);
response.pipe(file);
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
Updater.updateProgress = downloadedBytes / downloadSize;
});
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err) => {
file.close();
reject(err);
});
});
}
private static async applyUpdate(src: string, dst: string) {
try {
fs.accessSync(dst, fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
@ -390,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');

View file

@ -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) {

View file

@ -9,6 +9,9 @@
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="title-icon"></div>
<div id="loading-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="idle-background"></div>
<video id="videoPlayer" autoplay preload="auto"></video>
<div id="videoCaptions" class="captionsContainer"></div>

View file

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

View file

@ -8,7 +8,10 @@
<link rel="stylesheet" href="./common.css" />
</head>
<body>
<!-- Empty video element as a workaround to fix issue with white border outline without it... -->
<video id="video-player" class="video"></video>
<div id="viewer" class="viewer">
<div id="title-icon"></div>
<img id="viewer-image" class="viewer" />
<iframe id="viewer-generic" class="viewer"></iframe>
</div>>