mirror of
https://gitlab.com/futo-org/fcast.git
synced 2025-08-30 19:22:51 +00:00
Receivers: Added playlist support
This commit is contained in:
parent
72d5c10918
commit
1afd421f7d
22 changed files with 1613 additions and 453 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -1,3 +1,56 @@
|
|||
import 'common/viewer/Renderer';
|
||||
import { PlayerControlEvent, playerCtrlStateUpdate } from 'common/viewer/Renderer';
|
||||
|
||||
// const logger = window.targetAPI.logger;
|
||||
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
|
||||
let handledCase = false;
|
||||
|
||||
switch (event) {
|
||||
case PlayerControlEvent.ToggleFullscreen: {
|
||||
window.electronAPI.toggleFullScreen();
|
||||
|
||||
// window.electronAPI.isFullScreen().then((isFullScreen: boolean) => {
|
||||
// if (isFullScreen) {
|
||||
// playerCtrlFullscreen.setAttribute("class", "fullscreen_on");
|
||||
// } else {
|
||||
// playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
|
||||
// }
|
||||
// });
|
||||
|
||||
handledCase = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case PlayerControlEvent.ExitFullscreen:
|
||||
window.electronAPI.exitFullScreen();
|
||||
// playerCtrlFullscreen.setAttribute("class", "fullscreen_off");
|
||||
|
||||
handledCase = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return handledCase;
|
||||
}
|
||||
|
||||
export function targetKeyDownEventListener(event: KeyboardEvent): boolean {
|
||||
let handledCase = false;
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyF':
|
||||
case 'F11':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
|
||||
event.preventDefault();
|
||||
handledCase = true;
|
||||
break;
|
||||
case 'Escape':
|
||||
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
|
||||
event.preventDefault();
|
||||
handledCase = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return handledCase
|
||||
};
|
||||
|
|
|
@ -8,7 +8,10 @@
|
|||
<link rel="stylesheet" href="./common.css" />
|
||||
</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>>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue