1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-08-17 04:42:49 +00:00

Initial commit of WebOS receiver

This commit is contained in:
Michael Hollister 2024-12-09 00:56:55 -06:00
parent b7e304b987
commit 90e1f4de1a
118 changed files with 18279 additions and 1746 deletions

View file

@ -1,4 +1,4 @@
import { app } from 'electron';
import Main from './Main';
import { Main } from './Main';
await Main.main(app);

View file

@ -1,74 +0,0 @@
import mdns from 'mdns-js';
import * as log4js from "log4js";
const cp = require('child_process');
const os = require('os');
const logger = log4js.getLogger();
export class DiscoveryService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private serviceTcp: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private serviceWs: any;
private static getComputerName() {
switch (process.platform) {
case "win32":
return process.env.COMPUTERNAME;
case "darwin":
return cp.execSync("scutil --get ComputerName").toString().trim();
case "linux": {
let hostname: string;
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
try {
hostname = os.hostname();
}
catch (err) {
logger.warn('Error fetching hostname, trying different method...');
logger.warn(err);
try {
hostname = cp.execSync("hostnamectl hostname").toString().trim();
}
catch (err2) {
logger.warn('Error fetching hostname again, using generic name...');
logger.warn(err2);
hostname = 'linux device';
}
}
return hostname;
}
default:
return os.hostname();
}
}
start() {
if (this.serviceTcp || this.serviceWs) {
return;
}
const name = `FCast-${DiscoveryService.getComputerName()}`;
logger.info("Discovery service started.", name);
this.serviceTcp = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name });
this.serviceTcp.start();
this.serviceWs = mdns.createAdvertisement(mdns.tcp('_fcast-ws'), 46898, { name: name });
this.serviceWs.start();
}
stop() {
if (this.serviceTcp) {
this.serviceTcp.stop();
this.serviceTcp = null;
}
if (this.serviceWs) {
this.serviceWs.stop();
this.serviceWs = null;
}
}
}

View file

@ -1,208 +0,0 @@
import * as net from 'net';
import * as log4js from "log4js";
import { EventEmitter } from 'node:events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
import { WebSocket } from 'ws';
const logger = log4js.getLogger();
enum SessionState {
Idle = 0,
WaitingForLength,
WaitingForData,
Disconnected,
};
export enum Opcode {
None = 0,
Play = 1,
Pause = 2,
Resume = 3,
Stop = 4,
Seek = 5,
PlaybackUpdate = 6,
VolumeUpdate = 7,
SetVolume = 8,
PlaybackError = 9,
SetSpeed = 10,
Version = 11,
Ping = 12,
Pong = 13
};
const LENGTH_BYTES = 4;
const MAXIMUM_PACKET_LENGTH = 32000;
export class FCastSession {
buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH);
bytesRead = 0;
packetLength = 0;
socket: net.Socket | WebSocket;
writer: (data: Buffer) => void;
state: SessionState;
emitter = new EventEmitter();
constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) {
this.socket = socket;
this.writer = writer;
this.state = SessionState.WaitingForLength;
}
send(opcode: number, message = null) {
const json = message ? JSON.stringify(message) : null;
logger.info(`send (opcode: ${opcode}, body: ${json})`);
let data: Uint8Array;
if (json) {
const utf8Encode = new TextEncoder();
data = utf8Encode.encode(json);
} else {
data = new Uint8Array(0);
}
const size = 1 + data.length;
const header = Buffer.alloc(4 + 1);
header.writeUint32LE(size, 0);
header[4] = opcode;
let packet: Buffer;
if (data.length > 0) {
packet = Buffer.concat([ header, data ]);
} else {
packet = header;
}
this.writer(packet);
}
close() {
if (this.socket instanceof WebSocket) {
this.socket.close();
} else if (this.socket instanceof net.Socket) {
this.socket.end();
}
}
processBytes(receivedBytes: Buffer) {
//TODO: Multithreading?
if (receivedBytes.length == 0) {
return;
}
logger.info(`${receivedBytes.length} bytes received`);
switch (this.state) {
case SessionState.WaitingForLength:
this.handleLengthBytes(receivedBytes);
break;
case SessionState.WaitingForData:
this.handlePacketBytes(receivedBytes);
break;
default:
logger.info(`Data received is unhandled in current session state ${this.state}.`);
break;
}
}
private handleLengthBytes(receivedBytes: Buffer) {
const bytesToRead = Math.min(LENGTH_BYTES, receivedBytes.length);
const bytesRemaining = receivedBytes.length - bytesToRead;
receivedBytes.copy(this.buffer, this.bytesRead, 0, bytesToRead);
this.bytesRead += bytesToRead;
logger.info(`handleLengthBytes: Read ${bytesToRead} bytes from packet`);
if (this.bytesRead >= LENGTH_BYTES) {
this.state = SessionState.WaitingForData;
this.packetLength = this.buffer.readUInt32LE(0);
this.bytesRead = 0;
logger.info(`Packet length header received from: ${this.packetLength}`);
if (this.packetLength > MAXIMUM_PACKET_LENGTH) {
throw new Error(`Maximum packet length is 32kB: ${this.packetLength}`);
}
if (bytesRemaining > 0) {
logger.info(`${bytesRemaining} remaining bytes pushed to handlePacketBytes`);
this.handlePacketBytes(receivedBytes.slice(bytesToRead));
}
}
}
private handlePacketBytes(receivedBytes: Buffer) {
const bytesToRead = Math.min(this.packetLength, receivedBytes.length);
const bytesRemaining = receivedBytes.length - bytesToRead;
receivedBytes.copy(this.buffer, this.bytesRead, 0, bytesToRead);
this.bytesRead += bytesToRead;
logger.info(`handlePacketBytes: Read ${bytesToRead} bytes from packet`);
if (this.bytesRead >= this.packetLength) {
logger.info(`Packet finished receiving from of ${this.packetLength} bytes.`);
this.handleNextPacket();
this.state = SessionState.WaitingForLength;
this.packetLength = 0;
this.bytesRead = 0;
if (bytesRemaining > 0) {
logger.info(`${bytesRemaining} remaining bytes pushed to handleLengthBytes`);
this.handleLengthBytes(receivedBytes.slice(bytesToRead));
}
}
}
private handlePacket(opcode: number, body: string | undefined) {
logger.info(`handlePacket (opcode: ${opcode}, body: ${body})`);
try {
switch (opcode) {
case Opcode.Play:
this.emitter.emit("play", JSON.parse(body) as PlayMessage);
break;
case Opcode.Pause:
this.emitter.emit("pause");
break;
case Opcode.Resume:
this.emitter.emit("resume");
break;
case Opcode.Stop:
this.emitter.emit("stop");
break;
case Opcode.Seek:
this.emitter.emit("seek", JSON.parse(body) as SeekMessage);
break;
case Opcode.SetVolume:
this.emitter.emit("setvolume", JSON.parse(body) as SetVolumeMessage);
break;
case Opcode.SetSpeed:
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
break;
case Opcode.Ping:
this.send(Opcode.Pong);
break;
}
} catch (e) {
logger.warn(`Error handling packet from.`, e);
}
}
private handleNextPacket() {
logger.info(`Processing packet of ${this.bytesRead} bytes from`);
const opcode = this.buffer[0];
const body = this.packetLength > 1 ? this.buffer.toString('utf8', 1, this.packetLength) : null;
logger.info('body', body);
this.handlePacket(opcode, body);
}
bindEvents(emitter: EventEmitter) {
this.emitter.on("play", (body: PlayMessage) => { emitter.emit("play", body) });
this.emitter.on("pause", () => { emitter.emit("pause") });
this.emitter.on("resume", () => { emitter.emit("resume") });
this.emitter.on("stop", () => { emitter.emit("stop") });
this.emitter.on("seek", (body: SeekMessage) => { emitter.emit("seek", body) });
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
}
}

View file

@ -1,21 +1,19 @@
import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron';
import { TcpListenerService } from './TcpListenerService';
import { PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from './Packets';
import { DiscoveryService } from './DiscoveryService';
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets';
import { DiscoveryService } from 'common/DiscoveryService';
import { TcpListenerService } from 'common/TcpListenerService';
import { WebSocketListenerService } from 'common/WebSocketListenerService';
import { NetworkService } from 'common/NetworkService';
import { Opcode } from 'common/FCastSession';
import { Updater } from './Updater';
import { WebSocketListenerService } from './WebSocketListenerService';
import { Opcode } from './FCastSession';
import * as os from 'os';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as log4js from "log4js";
import { AddressInfo } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
const cp = require('child_process');
export default class Main {
export class Main {
static shouldOpenMainWindow = true;
static startFullscreen = false;
static playerWindow: Electron.BrowserWindow;
@ -25,11 +23,6 @@ export default class Main {
static webSocketListenerService: WebSocketListenerService;
static discoveryService: DiscoveryService;
static tray: Tray;
static key: string = null;
static cert: string = null;
static proxyServer: http.Server;
static proxyServerAddress: AddressInfo;
static proxiedFiles: Map<string, { url: string, headers: { [key: string]: string } }> = new Map();
static logger: log4js.Logger;
private static toggleMainWindow() {
@ -80,7 +73,7 @@ export default class Main {
}
private static createTray() {
const icon = (process.platform === 'win32') ? path.join(__dirname, 'icon.ico') : path.join(__dirname, 'icon.png');
const icon = (process.platform === 'win32') ? path.join(__dirname, 'assets/icons/app/icon.ico') : path.join(__dirname, 'assets/icons/app/icon.png');
const trayicon = nativeImage.createFromPath(icon)
const tray = new Tray(trayicon.resize({ width: 16 }));
const contextMenu = Menu.buildFromTemplate([
@ -175,13 +168,13 @@ export default class Main {
Main.playerWindow.loadFile(path.join(__dirname, 'player/index.html'));
Main.playerWindow.on('ready-to-show', async () => {
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
});
Main.playerWindow.on('closed', () => {
Main.playerWindow = null;
});
} else {
Main.playerWindow?.webContents?.send("play", await Main.proxyPlayIfRequired(message));
Main.playerWindow?.webContents?.send("play", await NetworkService.proxyPlayIfRequired(message));
}
});
@ -283,116 +276,6 @@ export default class Main {
}
}
private static setupProxyServer(): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
Main.logger.info(`Proxy server starting`);
const port = 0;
Main.proxyServer = http.createServer((req, res) => {
Main.logger.info(`Request received`);
const requestUrl = `http://${req.headers.host}${req.url}`;
const proxyInfo = Main.proxiedFiles.get(requestUrl);
if (!proxyInfo) {
res.writeHead(404);
res.end('Not found');
return;
}
const omitHeaders = new Set([
'host',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade'
]);
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) => {
Main.logger.error(`Problem with request: ${e.message}`);
res.writeHead(500);
res.end();
});
});
Main.proxyServer.on('error', e => {
reject(e);
});
Main.proxyServer.listen(port, '127.0.0.1', () => {
Main.proxyServerAddress = Main.proxyServer.address() as AddressInfo;
Main.logger.info(`Proxy server running at http://127.0.0.1:${Main.proxyServerAddress.port}/`);
resolve();
});
} catch (e) {
reject(e);
}
});
}
static streamingMediaTypes = [
"application/vnd.apple.mpegurl",
"application/x-mpegURL",
"application/dash+xml"
];
static async proxyPlayIfRequired(message: PlayMessage): Promise<PlayMessage> {
if (message.headers && message.url && !Main.streamingMediaTypes.find(v => v === message.container.toLocaleLowerCase())) {
return { ...message, url: await Main.proxyFile(message.url, message.headers) };
}
return message;
}
static async proxyFile(url: string, headers: { [key: string]: string }): Promise<string> {
if (!Main.proxyServer) {
await Main.setupProxyServer();
}
const proxiedUrl = `http://127.0.0.1:${Main.proxyServerAddress.port}/${uuidv4()}`;
Main.logger.info("Proxied url", { proxiedUrl, url, headers });
Main.proxiedFiles.set(proxiedUrl, { url: url, headers: headers });
return proxiedUrl;
}
static getAllIPv4Addresses() {
const interfaces = os.networkInterfaces();
const ipv4Addresses: string[] = [];
for (const interfaceName in interfaces) {
const addresses = interfaces[interfaceName];
if (!addresses) continue;
for (const addressInfo of addresses) {
if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
ipv4Addresses.push(addressInfo.address);
}
}
}
return ipv4Addresses;
}
static openMainWindow() {
if (Main.mainWindow) {
Main.mainWindow.focus();
@ -419,7 +302,7 @@ export default class Main {
Main.mainWindow.show();
Main.mainWindow.on('ready-to-show', () => {
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: Main.getAllIPv4Addresses()});
Main.mainWindow.webContents.send("device-info", {name: os.hostname(), addresses: NetworkService.getAllIPv4Addresses()});
});
}
@ -471,3 +354,59 @@ export default class Main {
}
}
}
export function getComputerName() {
switch (process.platform) {
case "win32":
return process.env.COMPUTERNAME;
case "darwin":
return cp.execSync("scutil --get ComputerName").toString().trim();
case "linux": {
let hostname: string;
// Some distro's don't work with `os.hostname()`, but work with `hostnamectl` and vice versa...
try {
hostname = os.hostname();
}
catch (err) {
Main.logger.warn('Error fetching hostname, trying different method...');
Main.logger.warn(err);
try {
hostname = cp.execSync("hostnamectl hostname").toString().trim();
}
catch (err2) {
Main.logger.warn('Error fetching hostname again, using generic name...');
Main.logger.warn(err2);
hostname = 'linux device';
}
}
return hostname;
}
default:
return os.hostname();
}
}
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}

View file

@ -1,57 +0,0 @@
export class PlayMessage {
constructor(
public container: string,
public url: string = null,
public content: string = null,
public time: number = null,
public speed: number = null,
public headers: { [key: string]: string } = null
) {}
}
export class SeekMessage {
constructor(
public time: number,
) {}
}
export class PlaybackUpdateMessage {
constructor(
public generationTime: number,
public time: number,
public duration: number,
public state: number,
public speed: number
) {}
}
export class PlaybackErrorMessage {
constructor(
public message: string
) {}
}
export class VolumeUpdateMessage {
constructor(
public generationTime: number,
public volume: number
) {}
}
export class SetVolumeMessage {
constructor(
public volume: number,
) {}
}
export class SetSpeedMessage {
constructor(
public speed: number,
) {}
}
export class VersionMessage {
constructor(
public version: number,
) {}
}

View file

@ -1,103 +0,0 @@
import * as net from 'net';
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
export class TcpListenerService {
public static PORT = 46899;
emitter = new EventEmitter();
private server: net.Server;
private sessions: FCastSession[] = [];
start() {
if (this.server != null) {
return;
}
this.server = net.createServer()
.listen(TcpListenerService.PORT)
.on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this));
}
stop() {
if (this.server == null) {
return;
}
const server = this.server;
this.server = null;
server.close();
}
send(opcode: number, message = null) {
this.sessions.forEach(session => {
try {
session.send(opcode, message);
} catch (e) {
Main.logger.warn("Failed to send error.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) {
Main.logger.error("Server error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}
private handleConnection(socket: net.Socket) {
Main.logger.info(`new connection from ${socket.remoteAddress}:${socket.remotePort}`);
const session = new FCastSession(socket, (data) => socket.write(data));
session.bindEvents(this.emitter);
this.sessions.push(session);
socket.on("error", (err) => {
Main.logger.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
socket.destroy();
});
socket.on("data", buffer => {
try {
session.processBytes(buffer);
} catch (e) {
Main.logger.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.end();
}
});
socket.on("close", () => {
const index = this.sessions.indexOf(session);
if (index != -1) {
this.sessions.splice(index, 1);
}
});
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});
} catch (e) {
Main.logger.info('Failed to send version', e);
}
}
}

View file

@ -1,108 +0,0 @@
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { WebSocket, WebSocketServer } from 'ws';
export class WebSocketListenerService {
public static PORT = 46898;
emitter = new EventEmitter();
private server: WebSocketServer;
private sessions: FCastSession[] = [];
start() {
if (this.server != null) {
return;
}
this.server = new WebSocketServer({ port: WebSocketListenerService.PORT })
.on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this));
}
stop() {
if (this.server == null) {
return;
}
const server = this.server;
this.server = null;
server.close();
}
send(opcode: number, message = null) {
this.sessions.forEach(session => {
try {
session.send(opcode, message);
} catch (e) {
Main.logger.warn("Failed to send error.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) {
Main.logger.error("Server error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}
private handleConnection(socket: WebSocket) {
Main.logger.info('New WebSocket connection');
const session = new FCastSession(socket, (data) => socket.send(data));
session.bindEvents(this.emitter);
this.sessions.push(session);
socket.on("error", (err) => {
Main.logger.warn(`Error.`, err);
session.close();
});
socket.on('message', data => {
try {
if (data instanceof Buffer) {
session.processBytes(data);
} else {
Main.logger.warn("Received unhandled string message", data);
}
} catch (e) {
Main.logger.warn(`Error while handling packet.`, e);
session.close();
}
});
socket.on("close", () => {
Main.logger.info('WebSocket connection closed');
const index = this.sessions.indexOf(session);
if (index != -1) {
this.sessions.splice(index, 1);
}
});
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});
} catch (e) {
Main.logger.info('Failed to send version');
}
}
}

View file

@ -1,17 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
let deviceInfo;
ipcRenderer.on("device-info", (_event, value) => {
deviceInfo = value;
})
import 'common/main/Preload';
contextBridge.exposeInMainWorld('electronAPI', {
updaterProgress: () => ipcRenderer.invoke('updater-progress'),
onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateAvailable: (callback: any) => ipcRenderer.on("update-available", callback),
sendDownloadRequest: () => ipcRenderer.send('send-download-request'),
onDownloadComplete: (callback) => ipcRenderer.on("download-complete", callback),
onDownloadFailed: (callback) => ipcRenderer.on("download-failed", callback),
onDownloadComplete: (callback: any) => ipcRenderer.on("download-complete", callback),
onDownloadFailed: (callback: any) => ipcRenderer.on("download-failed", callback),
sendRestartRequest: () => ipcRenderer.send('send-restart-request'),
getDeviceInfo: () => deviceInfo,
});

View file

@ -1,4 +1,4 @@
import QRCode from 'qrcode';
import 'common/main/Renderer';
const updateView = document.getElementById("update-view");
const updateViewTitle = document.getElementById("update-view-title");
@ -10,51 +10,6 @@ const progressBar = document.getElementById("progress-bar");
const progressBarProgress = document.getElementById("progress-bar-progress");
let updaterProgressUIUpdateTimer = null;
window.electronAPI.onDeviceInfo(renderIPsAndQRCode);
if(window.electronAPI.getDeviceInfo()) {
console.log("device info already present");
renderIPsAndQRCode();
}
function renderIPsAndQRCode() {
const value = window.electronAPI.getDeviceInfo();
console.log("device info", value);
const ipsElement = document.getElementById('ips');
if (ipsElement) {
ipsElement.innerHTML = `IPs<br>${value.addresses.join('<br>')}`;
}
const fcastConfig = {
name: value.name,
addresses: value.addresses,
services: [
{ port: 46899, type: 0 }, //TCP
{ port: 46898, type: 1 }, //WS
]
};
const json = JSON.stringify(fcastConfig);
let base64 = btoa(json);
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const url = `fcast://r/${base64}`;
console.log("qr", {json, url, base64});
const qrCodeElement = document.getElementById('qr-code');
QRCode.toCanvas(qrCodeElement, url, {
margin: 0,
width: 256,
color: {
dark : "#000000",
light : "#ffffff",
},
errorCorrectionLevel : "M",
},
(e) => {
console.log(`Error rendering QR Code: ${e}`)
});
}
window.electronAPI.onUpdateAvailable(() => {
console.log(`Received UpdateAvailable event`);

View file

@ -1,16 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../../assets/fonts/outfit.css" />
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
<link rel="stylesheet" href="./style.css" />
<title>FCast Receiver</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="../assets/fonts/outfit.css" />
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="main-container">
<video id="video-player" class="video" autoplay loop>
<source src="../../assets/video/background.mp4" type="video/mp4">
<source src="../assets/video/background.mp4" type="video/mp4">
</video>
<div id="ui-container">
<div id="overlay">

View file

@ -1,50 +1,3 @@
body, html {
height: 100%;
margin: 0;
}
#main-container {
position: relative;
height: 100%;
overflow: hidden;
}
.video {
height: 100%;
width: 100%;
object-fit: cover;
}
.non-selectable {
user-select: none;
}
.card {
display: flex;
flex-direction: column;
text-align: center;
background-color: rgba(20, 20, 20, 0.5);
padding: 25px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
}
.card-title {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.card-title-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.button {
display: inline-block;
align-items: center;
@ -83,98 +36,6 @@ body, html {
background: #3E3E3E;
}
#ui-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
color: white;
gap: 15vw;
font-family: InterVariable;
font-size: 20px;
font-style: normal;
font-weight: 400;
}
#title-container {
display: flex;
justify-content: center;
align-items: center;
}
#title-text {
font-family: Outfit;
font-size: 100px;
font-weight: 800;
text-align: center;
background-image: linear-gradient(180deg, #FFFFFF 5.9%, #D3D3D3 100%);
background-clip: text;
-webkit-text-fill-color: transparent;
}
#title-icon {
width: 84px;
height: 84px;
background-image: url(../../assets/icons/app/icon.svg);
background-size: cover;
margin-right: 15px;
}
#connection-status {
padding: 25px;
text-align: center;
}
#main-view {
padding: 25px;
}
#manual-connection-info {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
#manual-connection-info-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
#qr-code {
display: flex;
margin: 20px auto;
flex-direction: column;
align-items: center;
padding: 20px;
background-color: white;
}
#scan-to-connect {
margin-top: 20px;
font-weight: bold;
}
#waiting-for-connection, #ips, #automatic-discovery {
margin-top: 20px;
}
#update-text {
margin-top: 20px;
width: 320px;
@ -188,10 +49,6 @@ body, html {
display: none;
}
#spinner {
padding: 20px;
}
#update-button-container {
display: flex;
flex-direction: row;
@ -225,51 +82,3 @@ body, html {
background-position: 0 0;
}
}
#window-can-be-closed {
color: #666666;
position: absolute;
bottom: 0;
margin-bottom: 20px;
font-family: InterVariable;
font-size: 18px;
font-style: normal;
font-weight: 400;
}
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
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);
}
}

View file

@ -1,235 +0,0 @@
import dashjs from 'dashjs';
import Hls from 'hls.js';
export enum PlayerType {
Html,
Dash,
Hls,
}
export class Player {
private player: dashjs.MediaPlayerClass | HTMLVideoElement
private hlsPlayer: Hls | undefined
public playerType: PlayerType
constructor(playerType: PlayerType, player: dashjs.MediaPlayerClass | HTMLVideoElement, hlsPlayer?: Hls) {
this.playerType = playerType;
this.player = player;
this.hlsPlayer = playerType === PlayerType.Hls ? hlsPlayer : null;
}
destroy() {
switch (this.playerType) {
case PlayerType.Dash:
try {
(this.player as dashjs.MediaPlayerClass).destroy();
} catch (e) {
console.warn("Failed to destroy dash player", e);
}
this.player = null;
this.playerType = null;
break;
case PlayerType.Hls:
// HLS also uses html player
try {
this.hlsPlayer.destroy();
} catch (e) {
console.warn("Failed to destroy hls player", e);
}
// fall through
case PlayerType.Html: {
const videoPlayer = this.player as HTMLVideoElement;
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;
}
}
play() { console.log("Player: play"); this.player.play(); }
isPaused(): boolean {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).isPaused();
} else { // HLS, HTML
return (this.player as HTMLVideoElement).paused;
}
}
pause() { console.log("Player: pause"); this.player.pause(); }
getVolume(): number {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).getVolume();
} else { // HLS, HTML
return (this.player as HTMLVideoElement).volume;
}
}
setVolume(value: number) {
console.log(`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);
} else { // HLS, HTML
(this.player as HTMLVideoElement).volume = sanitizedVolume;
}
}
isMuted(): boolean {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).isMuted();
} else { // HLS, HTML
return (this.player as HTMLVideoElement).muted;
}
}
setMute(value: boolean) {
console.log(`Player: setMute ${value}`);
if (this.playerType === PlayerType.Dash) {
(this.player as dashjs.MediaPlayerClass).setMute(value);
} else { // HLS, HTML
(this.player as HTMLVideoElement).muted = value;
}
}
getPlaybackRate(): number {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).getPlaybackRate();
} else { // HLS, HTML
return (this.player as HTMLVideoElement).playbackRate;
}
}
setPlaybackRate(value: number) {
console.log(`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);
} else { // HLS, HTML
(this.player as HTMLVideoElement).playbackRate = sanitizedSpeed;
}
}
getDuration(): number {
if (this.playerType === PlayerType.Dash) {
const videoPlayer = this.player as dashjs.MediaPlayerClass;
return isFinite(videoPlayer.duration()) ? videoPlayer.duration() : 0;
} else { // HLS, HTML
const videoPlayer = this.player as HTMLVideoElement;
return isFinite(videoPlayer.duration) ? videoPlayer.duration : 0;
}
}
getCurrentTime(): number {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).time();
} else { // HLS, HTML
return (this.player as HTMLVideoElement).currentTime;
}
}
setCurrentTime(value: number) {
// console.log(`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;
if (!videoPlayer.isSeeking()) {
videoPlayer.seek(sanitizedTime);
}
} else { // HLS, HTML
(this.player as HTMLVideoElement).currentTime = sanitizedTime;
}
}
getSource(): string {
if (this.playerType === PlayerType.Dash) {
const videoPlayer = this.player as dashjs.MediaPlayerClass;
return videoPlayer.getSource() instanceof String ? videoPlayer.getSource() as string : JSON.stringify(videoPlayer.getSource());
} else { // HLS, HTML
return (this.player as HTMLVideoElement).src;
}
}
getBufferLength(): number {
if (this.playerType === PlayerType.Dash) {
const dashPlayer = this.player as dashjs.MediaPlayerClass;
let dashBufferLength = dashPlayer.getBufferLength("video")
?? dashPlayer.getBufferLength("audio")
?? dashPlayer.getBufferLength("text")
?? dashPlayer.getBufferLength("image")
?? 0;
if (Number.isNaN(dashBufferLength))
dashBufferLength = 0;
dashBufferLength += 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 (videoPlayer.currentTime >= start && videoPlayer.currentTime <= end) {
maxBuffer = end;
}
}
}
return maxBuffer;
}
}
isCaptionsSupported(): boolean {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).getTracksFor('text').length > 0;
} else if (this.playerType === PlayerType.Hls) {
return this.hlsPlayer.allSubtitleTracks.length > 0;
} else {
return false; // HTML captions not currently supported
}
}
isCaptionsEnabled(): boolean {
if (this.playerType === PlayerType.Dash) {
return (this.player as dashjs.MediaPlayerClass).isTextEnabled();
} else if (this.playerType === PlayerType.Hls) {
return this.hlsPlayer.subtitleDisplay;
} else {
return false; // HTML captions not currently supported
}
}
enableCaptions(enable: boolean) {
if (this.playerType === PlayerType.Dash) {
(this.player as dashjs.MediaPlayerClass).enableText(enable);
} else if (this.playerType === PlayerType.Hls) {
this.hlsPlayer.subtitleDisplay = enable;
}
// HTML captions not currently supported
}
}

View file

@ -1,24 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from '../Packets';
declare global {
interface Window {
electronAPI: any;
}
}
import 'common/player/Preload';
contextBridge.exposeInMainWorld('electronAPI', {
isFullScreen: () => ipcRenderer.invoke('is-full-screen'),
toggleFullScreen: () => ipcRenderer.send('toggle-full-screen'),
exitFullScreen: () => ipcRenderer.send('exit-full-screen'),
sendPlaybackError: (error: PlaybackErrorMessage) => ipcRenderer.send('send-playback-error', error),
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => ipcRenderer.send('send-playback-update', update),
sendVolumeUpdate: (update: VolumeUpdateMessage) => ipcRenderer.send('send-volume-update', update),
onPlay: (callback: any) => ipcRenderer.on("play", callback),
onPause: (callback: any) => ipcRenderer.on("pause", callback),
onResume: (callback: any) => ipcRenderer.on("resume", callback),
onSeek: (callback: any) => ipcRenderer.on("seek", callback),
onSetVolume: (callback: any) => ipcRenderer.on("setvolume", callback),
onSetSpeed: (callback: any) => ipcRenderer.on("setspeed", callback)
});

View file

@ -1,483 +1,11 @@
import dashjs from 'dashjs';
import Hls, { LevelLoadedData } from 'hls.js';
import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from '../Packets';
import { Player, PlayerType } from './Player';
import { videoElement, PlayerControlEvent, playerCtrlStateUpdate } from 'common/player/Renderer';
function formatDuration(duration: number) {
const totalSeconds = Math.floor(duration);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
} else {
return `${paddedMinutes}:${paddedSeconds}`;
}
}
function sendPlaybackUpdate(updateState: number) {
const updateMessage = new PlaybackUpdateMessage(Date.now(), player.getCurrentTime(), player.getDuration(), updateState, player.getPlaybackRate());
if (updateMessage.generationTime > lastPlayerUpdateGenerationTime) {
lastPlayerUpdateGenerationTime = updateMessage.generationTime;
window.electronAPI.sendPlaybackUpdate(updateMessage);
}
};
function onPlayerLoad(value: PlayMessage, currentPlaybackRate?: number, currentVolume?: number) {
playerCtrlStateUpdate(PlayerControlEvent.Load);
// 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);
} else if (currentPlaybackRate) {
player.setPlaybackRate(currentPlaybackRate);
} else {
player.setPlaybackRate(1.0);
}
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
if (currentVolume) {
volumeChangeHandler(currentVolume);
}
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.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: 1.0 });
}
player.play();
}
// HTML elements
const videoElement = document.getElementById("videoPlayer") as HTMLVideoElement;
const videoCaptions = document.getElementById("videoCaptions") as HTMLDivElement;
const playerControls = document.getElementById("controls");
const playerCtrlAction = document.getElementById("action");
const playerCtrlVolume = document.getElementById("volume");
const playerCtrlProgressBar = document.getElementById("progressBar");
const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer");
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
const playerCtrlVolumeBar = document.getElementById("volumeBar");
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
const playerCtrlVolumeBarHandle = document.getElementById("volumeBarHandle");
const playerCtrlVolumeBarInteractiveArea = document.getElementById("volumeBarInteractiveArea");
const playerCtrlLiveBadge = document.getElementById("liveBadge");
const playerCtrlPosition = document.getElementById("position");
const playerCtrlDuration = document.getElementById("duration");
const playerCtrlCaptions = document.getElementById("captions");
const playerCtrlSpeed = document.getElementById("speed");
const playerCtrlFullscreen = document.getElementById("fullscreen");
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
const playerCtrlSpeedMenu = document.getElementById("speedMenu");
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 livePositionDelta = 5.0;
const livePositionWindow = livePositionDelta * 4;
let player: Player;
let playerPrevTime: number = 0;
let lastPlayerUpdateGenerationTime = 0;
let isLive = false;
let isLivePosition = false;
window.electronAPI.onPlay((_event, value: PlayMessage) => {
console.log("Handle play message renderer", JSON.stringify(value));
const currentVolume = player ? player.getVolume() : null;
const currentPlaybackRate = player ? player.getPlaybackRate() : null;
playerPrevTime = 0;
lastPlayerUpdateGenerationTime = 0;
isLive = false;
isLivePosition = false;
if (player) {
if (player.getSource() === value.url) {
if (value.time) {
if (Math.abs(value.time - player.getCurrentTime()) < 5000) {
console.warn(`Skipped changing video URL because URL and time is (nearly) unchanged: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
} else {
console.info(`Skipped changing video URL because URL is the same, but time was changed, seeking instead: ${value.url}, ${player.getSource()}, ${formatDuration(value.time)}, ${formatDuration(player.getCurrentTime())}`);
player.setCurrentTime(value.time);
}
}
return;
}
player.destroy();
}
if ((value.url || value.content) && value.container && videoElement) {
if (value.container === 'application/dash+xml') {
console.log("Loading dash player");
const dashPlayer = dashjs.MediaPlayer().create();
player = new Player(PlayerType.Dash, dashPlayer);
dashPlayer.extend("RequestModifier", () => {
return {
modifyRequestHeader: function (xhr) {
if (value.headers) {
for (const [key, val] of Object.entries(value.headers)) {
xhr.setRequestHeader(key, val);
}
}
return xhr;
}
};
}, true);
// Player event handlers
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PLAYING, () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_PAUSED, () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ENDED, () => { sendPlaybackUpdate(0) });
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(dashPlayer.time() - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1);
playerPrevTime = dashPlayer.time();
}
});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_RATE_CHANGED, () => { sendPlaybackUpdate(dashPlayer.isPaused() ? 2 : 1) });
// Buffering UI update when paused
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();
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
});
dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (data) => { window.electronAPI.sendPlaybackError({
message: `DashJS ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, (data) => { window.electronAPI.sendPlaybackError({
message: `DashJS PLAYBACK_ERROR: ${JSON.stringify(data)}`
})});
dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); });
dashPlayer.on(dashjs.MediaPlayer.events.CUE_ENTER, (e: any) => {
const subtitle = document.createElement("p")
subtitle.setAttribute("id", "subtitle-" + e.cueID)
subtitle.textContent = e.text;
videoCaptions.appendChild(subtitle);
});
dashPlayer.on(dashjs.MediaPlayer.events.CUE_EXIT, (e: any) => {
document.getElementById("subtitle-" + e.cueID)?.remove();
});
dashPlayer.updateSettings({
// debug: {
// logLevel: dashjs.LogLevel.LOG_LEVEL_INFO
// },
streaming: {
text: {
dispatchForManualRendering: true
}
}
});
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 {
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)) {
console.log("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) => {
window.electronAPI.sendPlaybackError({
message: `HLS player error: ${JSON.stringify(data)}`
});
});
hlsPlayer.on(Hls.Events.LEVEL_LOADED, (eventName, level: LevelLoadedData) => {
isLive = level.details.live;
isLivePosition = isLive ? true : false;
});
player = new Player(PlayerType.Hls, videoElement, 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 {
console.log("Loading html player");
player = new Player(PlayerType.Html, videoElement);
videoElement.src = value.url;
videoElement.load();
}
// Player event handlers
if (player.playerType === PlayerType.Hls || player.playerType === PlayerType.Html) {
videoElement.onplay = () => { sendPlaybackUpdate(1); playerCtrlStateUpdate(PlayerControlEvent.Play); };
videoElement.onpause = () => { sendPlaybackUpdate(2); playerCtrlStateUpdate(PlayerControlEvent.Pause); };
videoElement.onended = () => { sendPlaybackUpdate(0) };
videoElement.ontimeupdate = () => {
playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate);
if (Math.abs(videoElement.currentTime - playerPrevTime) >= playbackUpdateInterval) {
sendPlaybackUpdate(videoElement.paused ? 2 : 1);
playerPrevTime = videoElement.currentTime;
}
};
// Buffering UI update when paused
videoElement.onprogress = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
videoElement.onratechange = () => { sendPlaybackUpdate(videoElement.paused ? 2 : 1) };
videoElement.onvolumechange = () => {
const updateVolume = videoElement.muted ? 0 : videoElement.volume;
playerCtrlStateUpdate(PlayerControlEvent.VolumeChange);
window.electronAPI.sendVolumeUpdate({ generationTime: Date.now(), volume: updateVolume });
};
videoElement.onerror = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
console.error("Player error", {source, lineno, colno, error});
};
videoElement.onloadedmetadata = () => { onPlayerLoad(value, currentPlaybackRate, currentVolume); };
}
}
// Sender generated event handlers
window.electronAPI.onPause(() => { player.pause(); });
window.electronAPI.onResume(() => { player.play(); });
window.electronAPI.onSeek((_event, value: SeekMessage) => { player.setCurrentTime(value.time); });
window.electronAPI.onSetVolume((_event, value: SetVolumeMessage) => { volumeChangeHandler(value.volume); });
window.electronAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); });
});
let scrubbing = false;
let volumeChanging = false;
enum PlayerControlEvent {
Load,
Pause,
Play,
VolumeChange,
TimeUpdate,
UiFadeOut,
UiFadeIn,
SetCaptions,
ToggleSpeedMenu,
SetPlaybackRate,
ToggleFullscreen,
ExitFullscreen,
}
// UI update handlers
function playerCtrlStateUpdate(event: PlayerControlEvent) {
export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent) {
switch (event) {
case PlayerControlEvent.Load: {
playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px");
playerCtrlProgressBarProgress.setAttribute("style", "width: 0px");
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`);
if (isLive) {
playerCtrlLiveBadge.setAttribute("style", "display: block");
playerCtrlPosition.setAttribute("style", "display: none");
playerCtrlDuration.setAttribute("style", "display: none");
}
else {
playerCtrlLiveBadge.setAttribute("style", "display: none");
playerCtrlPosition.setAttribute("style", "display: block");
playerCtrlDuration.setAttribute("style", "display: block");
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
playerCtrlDuration.innerHTML = `/&nbsp&nbsp${formatDuration(player.getDuration())}`;
}
if (player.isCaptionsSupported()) {
playerCtrlCaptions.setAttribute("style", "display: block");
videoCaptions.setAttribute("style", "display: block");
}
else {
playerCtrlCaptions.setAttribute("style", "display: none");
videoCaptions.setAttribute("style", "display: none");
player.enableCaptions(false);
}
playerCtrlStateUpdate(PlayerControlEvent.SetCaptions);
break;
}
case PlayerControlEvent.Pause:
playerCtrlAction.setAttribute("class", "play");
stopUiHideTimer();
break;
case PlayerControlEvent.Play:
playerCtrlAction.setAttribute("class", "pause");
startUiHideTimer();
break;
case PlayerControlEvent.VolumeChange: {
// console.log(`VolumeChange: isMute ${player.isMuted()}, volume: ${player.getVolume()}`);
const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth);
if (player.isMuted()) {
playerCtrlVolume.setAttribute("class", "mute");
playerCtrlVolumeBarProgress.setAttribute("style", `width: 0px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: 0px`);
}
else if (player.getVolume() >= 0.5) {
playerCtrlVolume.setAttribute("class", "volume_high");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
} else {
playerCtrlVolume.setAttribute("class", "volume_low");
playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`);
playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume}px`);
}
break;
}
case PlayerControlEvent.TimeUpdate: {
// console.log(`TimeUpdate: Position: ${player.getCurrentTime()}, Duration: ${player.getDuration()}`);
if (isLive) {
if (isLivePosition && player.getDuration() - player.getCurrentTime() > livePositionWindow) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
else if (!isLivePosition && player.getDuration() - player.getCurrentTime() <= livePositionWindow) {
isLivePosition = true;
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
}
}
if (isLivePosition) {
playerCtrlProgressBarProgress.setAttribute("style", `width: ${playerCtrlProgressBar.offsetWidth}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetWidth + playerCtrlProgressBar.offsetLeft}px`);
}
else {
const buffer = Math.round((player.getBufferLength() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const progress = Math.round((player.getCurrentTime() / player.getDuration()) * playerCtrlProgressBar.offsetWidth);
const handle = progress + playerCtrlProgressBar.offsetLeft;
playerCtrlProgressBarBuffer.setAttribute("style", `width: ${buffer}px`);
playerCtrlProgressBarProgress.setAttribute("style", `width: ${progress}px`);
playerCtrlProgressBarHandle.setAttribute("style", `left: ${handle}px`);
playerCtrlPosition.textContent = formatDuration(player.getCurrentTime());
}
break;
}
case PlayerControlEvent.UiFadeOut:
document.body.style.cursor = "none";
playerControls.setAttribute("style", "opacity: 0");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 75px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 75px;");
}
break;
case PlayerControlEvent.UiFadeIn:
document.body.style.cursor = "default";
playerControls.setAttribute("style", "opacity: 1");
if (player.isCaptionsEnabled()) {
videoCaptions.setAttribute("style", "display: block; bottom: 160px;");
} else {
videoCaptions.setAttribute("style", "display: none; bottom: 160px;");
}
break;
case PlayerControlEvent.SetCaptions:
if (player.isCaptionsEnabled()) {
playerCtrlCaptions.setAttribute("class", "captions_on");
videoCaptions.setAttribute("style", "display: block");
} else {
playerCtrlCaptions.setAttribute("class", "captions_off");
videoCaptions.setAttribute("style", "display: none");
}
break;
case PlayerControlEvent.ToggleSpeedMenu: {
if (playerCtrlSpeedMenuShown) {
playerCtrlSpeedMenu.setAttribute("style", "display: none");
} else {
playerCtrlSpeedMenu.setAttribute("style", "display: block");
}
playerCtrlSpeedMenuShown = !playerCtrlSpeedMenuShown;
break;
}
case PlayerControlEvent.SetPlaybackRate: {
const rate = player.getPlaybackRate().toFixed(2);
const entryElement = document.getElementById(`speedMenuEntry_${rate}_enabled`);
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}_enabled`);
entry.setAttribute("style", "opacity: 0");
});
// Ignore updating GUI for custom rates
if (entryElement !== null) {
entryElement.setAttribute("style", "opacity: 1");
}
break;
}
case PlayerControlEvent.ToggleFullscreen: {
window.electronAPI.toggleFullScreen();
@ -502,199 +30,8 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
}
}
function scrubbingMouseUIHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && isLive && e.buttons === 1) {
isLivePosition = false;
playerCtrlLiveBadge.setAttribute("style", `background-color: #595959`);
}
const livePrefix = isLive && Math.floor(time) !== 0 ? "-" : "";
playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2);
offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset));
playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
}
// Receiver generated event handlers
playerCtrlAction.onclick = () => {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
};
playerCtrlVolume.onclick = () => { player.setMute(!player.isMuted()); };
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
volumeChanging = false;
}
scrubbingMouseUIHandler(e);
};
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
function scrubbingMouseHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - 8;
const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - 16;
let time = Math.round((progressBarOffset / progressBarWidth) * player.getDuration());
time = Math.min(player.getDuration(), Math.max(0.0, time));
if (scrubbing && e.buttons === 1) {
player.setCurrentTime(time);
}
scrubbingMouseUIHandler(e);
}
playerCtrlVolumeBarInteractiveArea.onmousedown = (e: MouseEvent) => { volumeChanging = true; volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onmouseup = () => { volumeChanging = false; };
playerCtrlVolumeBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) {
scrubbing = false;
}
};
playerCtrlVolumeBarInteractiveArea.onmousemove = (e: MouseEvent) => { volumeChangeMouseHandler(e) };
playerCtrlVolumeBarInteractiveArea.onwheel = (e: WheelEvent) => {
const delta = -e.deltaY;
if (delta > 0 ) {
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
} else if (delta < 0) {
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
}
};
function volumeChangeMouseHandler(e: MouseEvent) {
if (volumeChanging && e.buttons === 1) {
const volumeBarOffsetX = e.offsetX - 8;
const volumeBarWidth = playerCtrlVolumeBarInteractiveArea.offsetWidth - 16;
const volume = volumeBarOffsetX / volumeBarWidth;
volumeChangeHandler(volume);
}
}
function volumeChangeHandler(volume: number) {
if (!player.isMuted() && volume <= 0) {
player.setMute(true);
}
else if (player.isMuted() && volume > 0) {
player.setMute(false);
}
player.setVolume(volume);
}
playerCtrlLiveBadge.onclick = () => { setLivePosition(); };
function setLivePosition() {
if (!isLivePosition) {
isLivePosition = true;
player.setCurrentTime(player.getDuration() - livePositionDelta);
playerCtrlLiveBadge.setAttribute("style", `background-color: red`);
if (player.isPaused()) {
player.play();
}
}
}
playerCtrlCaptions.onclick = () => { player.enableCaptions(!player.isCaptionsEnabled()); playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); };
playerCtrlSpeed.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu); };
playerCtrlFullscreen.onclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
playbackRates.forEach(r => {
const entry = document.getElementById(`speedMenuEntry_${r}`);
entry.onclick = () => {
player.setPlaybackRate(parseFloat(r));
playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate);
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
};
});
videoElement.onclick = () => {
if (!playerCtrlSpeedMenuShown) {
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
}
};
videoElement.ondblclick = () => { playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); };
// Component hiding
let uiHideTimer = null;
let uiVisible = true;
function startUiHideTimer() {
if (uiHideTimer === null) {
uiHideTimer = window.setTimeout(() => {
uiHideTimer = null;
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}, 3000);
}
}
function stopUiHideTimer() {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
if (!uiVisible) {
uiVisible = true;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
}
}
document.onmouseout = () => {
if (uiHideTimer) {
window.clearTimeout(uiHideTimer);
uiHideTimer = null;
}
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}
document.onmousemove = () => {
stopUiHideTimer();
if (player && !player.isPaused()) {
startUiHideTimer();
}
};
window.onresize = () => { playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); };
// Listener for hiding speed menu when clicking outside element
document.addEventListener('click', (event: MouseEvent) => {
const node = event.target as Node;
if (playerCtrlSpeedMenuShown && !playerCtrlSpeed.contains(node) && !playerCtrlSpeedMenu.contains(node)){
playerCtrlStateUpdate(PlayerControlEvent.ToggleSpeedMenu);
}
});
// Add the keydown event listener to the document
const skipInterval = 10;
const volumeIncrement = 0.1;
document.addEventListener('keydown', (event) => {
// console.log("KeyDown", event);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function targetKeyDownEventListener(event: any) {
switch (event.code) {
case 'KeyF':
case 'F11':
@ -705,53 +42,7 @@ document.addEventListener('keydown', (event) => {
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault();
break;
case 'ArrowLeft':
// Skip back
player.setCurrentTime(Math.max(player.getCurrentTime() - skipInterval, 0));
event.preventDefault();
break;
case 'ArrowRight':
// Skip forward
if (!isLivePosition) {
player.setCurrentTime(Math.min(player.getCurrentTime() + skipInterval, player.getDuration()));
}
event.preventDefault();
break;
case "Home":
player.setCurrentTime(0);
event.preventDefault();
break;
case "End":
if (isLive) {
setLivePosition();
}
else {
player.setCurrentTime(player.getDuration());
}
event.preventDefault();
break;
case 'KeyK':
case 'Space':
case 'Enter':
// Pause/Continue
if (player.isPaused()) {
player.play();
} else {
player.pause();
}
event.preventDefault();
break;
case 'KeyM':
// Mute toggle
player.setMute(!player.isMuted());
break;
case 'ArrowUp':
// Volume up
volumeChangeHandler(Math.min(player.getVolume() + volumeIncrement, 1));
break;
case 'ArrowDown':
// Volume down
volumeChangeHandler(Math.max(player.getVolume() - volumeIncrement, 0));
default:
break;
}
});
};

View file

@ -1,10 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="../../assets/fonts/inter.css" />
<link rel="stylesheet" href="./style.css" />
<title>FCast Receiver</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<video id="videoPlayer" autoplay preload="auto"></video>

View file

@ -1,337 +1,15 @@
html {
margin: 0;
padding: 0;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
background-color: black;
color: white;
width: 100vw;
max-width: 100%;
height: 100vh;
max-height: 100%;
}
#videoPlayer {
object-fit: contain;
width: 100%;
height: 100%;
}
*:focus {
outline: none;
box-shadow: none;
}
.container {
position: absolute;
bottom: 0px;
/* height: 100%; */
height: 120px;
width: 100%;
/* background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); */
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.0) 35%);
background-size: 100% 300px;
background-repeat: no-repeat;
background-position: bottom;
opacity: 1;
transition: opacity 0.1s ease-in-out;
}
.volumeContainer {
position: relative;
height: 24px;
width: 92px;
flex-shrink: 0;
user-select: none;
}
.volumeBar {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
/* width: 72px; */
width: 76px;
background-color: #999999;
border-radius: 3px;
pointer-events: none;
}
.volumeBarInteractiveArea {
position: absolute;
left: 0px;
/* left: 8px; */
top: 0px;
height: 24px;
width: 92px;
/* width: 84px; */
cursor: pointer;
}
.volumeBarHandle {
position: absolute;
left: 84px;
top: 4px;
width: 16px;
height: 16px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
box-shadow: 0px 32px 64px 0px rgba(0, 0, 0, 0.56), 0px 2px 21px 0px rgba(0, 0, 0, 0.55);
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.volumeBarProgress {
position: absolute;
/* left: 12px; */
left: 8px;
top: 10px;
height: 4px;
width: 76px;
/* background-color: #ffffff; */
background-color: #c9c9c9;
border-radius: 3px;
pointer-events: none;
}
.progressBarContainer {
position: absolute;
bottom: 60px;
left: 16px;
right: 16px;
height: 4px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
user-select: none;
}
.progressBarInteractiveArea {
position: absolute;
/* bottom: 60px; */
/* left: 24px; */
/* right: 24px; */
height: 4px;
width: 100%;
left: 0px;
bottom: 0px;
padding-top: 10px;
padding-bottom: 10px;
border-radius: 3px;
cursor: pointer;
z-index: 999;
}
.progressBarChapterContainer {
position: absolute;
bottom: 73px;
left: 24px;
right: 24px;
height: 4px;
border-radius: 3px;
cursor: pointer;
}
.progressBar {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
/* right: 24px; */
left: 8px;
width: calc(100% - 16px);
height: 4px;
background-color: #99999945;
border-radius: 3px;
pointer-events: none;
}
.progressBarBuffer {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 4px;
height: 4px;
background-color: #D9D9D945;
border-radius: 3px;
pointer-events: none;
}
.progressBarProgress {
/* position: absolute; */
position: relative;
/* bottom: 70px; */
/* left: 24px; */
left: 8px;
bottom: 8px;
height: 4px;
width: 0px;
background-color: #019BE7;
border-radius: 3px;
pointer-events: none;
}
.progressBarPosition {
display: none;
position: absolute;
bottom: 25px;
padding: 2px 5px;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.5);
}
.progressBarHandle {
position: absolute;
/* bottom: 70px; */
bottom: 10px;
width: 20px;
height: 20px;
margin-left: -8px;
margin-bottom: -8px;
background-color: #019BE7;
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.positionContainer {
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
user-select: text;
}
.position {
margin-right: 10px;
vertical-align: bottom;
color: #c9c9c9;
}
.duration {
opacity: 0.6;
color: #c9c9c9;
}
.liveBadge {
background-color: red;
/* margin-top: -2px; */
/* padding: 5px 5px; */
padding: 2px 5px;
border-radius: 4px;
/* margin-left: 10px; */
margin-right: 10px;
cursor: pointer;
}
.play {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_play.svg");
transition: background-image 0.1s ease-in-out;
}
.play:hover {
background-image: url("../../assets/icons/player/icon24_play_active.svg");
}
.pause {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_pause.svg");
transition: background-image 0.1s ease-in-out;
}
.pause:hover {
background-image: url("../../assets/icons/player/icon24_pause_active.svg");
}
.volume_high {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_volume_more_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_high:hover {
background-image: url("../../assets/icons/player/icon24_volume_more_50pct_active.svg");
}
.volume_low {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_volume_less_50pct.svg");
transition: background-image 0.1s ease-in-out;
}
.volume_low:hover {
background-image: url("../../assets/icons/player/icon24_volume_less_50pct_active.svg");
}
.mute {
width: 24px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
background-image: url("../../assets/icons/player/icon24_mute.svg");
transition: background-image 0.1s ease-in-out;
}
.mute:hover {
background-image: url("../../assets/icons/player/icon24_mute_active.svg");
}
.fullscreen_on {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_fullscreen_on.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_on.svg");
transition: background-image 0.1s ease-in-out;
}
.fullscreen_on:hover {
background-image: url("../../assets/icons/player/icon24_fullscreen_on_active.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_on_active.svg");
}
.fullscreen_off {
@ -339,157 +17,10 @@ body {
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_fullscreen_off.svg");
background-image: url("../assets/icons/player/icon24_fullscreen_off.svg");
transition: background-image 0.1s ease-in-out;
}
.fullscreen_off:hover {
background-image: url("../../assets/icons/player/icon24_fullscreen_off_active.svg");
}
.speed {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_speed.svg");
transition: background-image 0.1s ease-in-out;
}
.speed:hover {
background-image: url("../../assets/icons/player/icon24_speed_active.svg");
}
.captions_off {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_cc_off.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_off:hover {
background-image: url("../../assets/icons/player/icon24_cc_off_active.svg");
}
.captions_on {
width: 24px;
height: 24px;
cursor: pointer;
background-image: url("../../assets/icons/player/icon24_cc_on.svg");
transition: background-image 0.1s ease-in-out;
}
.captions_on:hover {
background-image: url("../../assets/icons/player/icon24_cc_on_active.svg");
}
.leftButtonContainer {
position: absolute;
bottom: 24px;
left: 24px;
height: 24px;
/* width: calc(50% - 24px); */
right: 160px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
overflow: hidden;
user-select: none;
}
.buttonContainer {
position: absolute;
bottom: 24px;
right: 24px;
height: 24px;
/* width: calc(50% - 24px); */
align-items: center;
overflow: hidden;
display: flex;
flex-direction: row-reverse;
gap: 24px;
}
.captionsContainer {
/* display: none; */
position: relative;
/* top: -200px; */
bottom: 160px;
margin: auto;
text-align: center;
font-family: InterVariable;
font-size: 28px;
font-style: normal;
font-weight: 400;
background-color: rgba(0, 0, 0, 0.5);
padding: 0px 5px;
width: fit-content;
transition: bottom 0.2s ease-in-out;
}
.speedMenu {
position: absolute;
bottom: 80px;
right: 60px;
height: calc(55vh);
max-height: 368px;
background-color: #141414;
padding: 12px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
font-family: InterVariable;
font-size: 16px;
font-style: normal;
font-weight: 400;
box-shadow: 0px 1.852px 3.148px 0px rgba(0, 0, 0, 0.06), 0px 8.148px 6.519px 0px rgba(0, 0, 0, 0.10), 0px 20px 13px 0px rgba(0, 0, 0, 0.13), 0px 38.519px 25.481px 0px rgba(0, 0, 0, 0.15), 0px 64.815px 46.852px 0px rgba(0, 0, 0, 0.19), 0px 100px 80px 0px rgba(0, 0, 0, 0.25);
}
.speedMenuTitle {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.speedMenuEntry {
display: flex;
padding: 10px 15px;
}
.speedMenuEntry:hover {
cursor: pointer;
background-color: rgba(255, 255, 255, 0.1);
}
.speedMenuSeparator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.speedMenuEntryEnabled {
width: 20px;
height: 20px;
margin-right: 10px;
background-image: url("../../assets/icons/player/icon24_check_thin.svg");
background-size: cover;
opacity: 0;
background-image: url("../assets/icons/player/icon24_fullscreen_off_active.svg");
}