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

Added toasts and UI update on device connection

This commit is contained in:
Michael Hollister 2025-01-06 20:35:57 -06:00
parent 5328087d64
commit 3142709d7f
17 changed files with 640 additions and 121 deletions

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#019BE7"/>
<path d="M8 12.6L10.5397 15L16 9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.44873 1.9375C7.13805 0.6875 8.86195 0.6875 9.55246 1.9375L15.7599 13.1875C15.9172 13.4725 16 13.7959 16 14.125C16 14.4541 15.9172 14.7774 15.7599 15.0625C15.6027 15.3475 15.3764 15.5842 15.104 15.7488C14.8316 15.9133 14.5226 16 14.2081 16H1.79314C1.47848 16.0002 1.16932 15.9137 0.896742 15.7492C0.624165 15.5848 0.397785 15.3481 0.240368 15.063C0.0829523 14.7779 5.04209e-05 14.4545 2.29922e-08 14.1253C-5.03749e-05 13.7961 0.0827525 13.4726 0.240081 13.1875L6.44873 1.9375Z" fill="#F97066"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09511 5C8.31294 5 8.52184 5.08889 8.67587 5.24713C8.8299 5.40536 8.91643 5.61997 8.91643 5.84375V9.21875C8.91643 9.44253 8.8299 9.65714 8.67587 9.81537C8.52184 9.97361 8.31294 10.0625 8.09511 10.0625C7.87728 10.0625 7.66837 9.97361 7.51434 9.81537C7.36031 9.65714 7.27378 9.44253 7.27378 9.21875V5.84375C7.27378 5.61997 7.36031 5.40536 7.51434 5.24713C7.66837 5.08889 7.87728 5 8.09511 5ZM8.09511 14C8.38555 14 8.66409 13.8815 8.86946 13.6705C9.07483 13.4595 9.19021 13.1734 9.19021 12.875C9.19021 12.5766 9.07483 12.2905 8.86946 12.0795C8.66409 11.8685 8.38555 11.75 8.09511 11.75C7.80467 11.75 7.52612 11.8685 7.32075 12.0795C7.11538 12.2905 7 12.5766 7 12.875C7 13.1734 7.11538 13.4595 7.32075 13.6705C7.52612 13.8815 7.80467 14 8.09511 14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.38608C17.525 2.38608 22 6.86108 22 12.3861C22 17.9111 17.525 22.3861 12 22.3861C6.475 22.3861 2 17.9111 2 12.3861C2 6.86108 6.475 2.38608 12 2.38608ZM13.25 7.38608C13.25 6.69858 12.6875 6.13608 12 6.13608C11.3125 6.13608 10.75 6.69858 10.75 7.38608C10.75 8.07358 11.3125 8.63608 12 8.63608C12.6875 8.63608 13.25 8.07358 13.25 7.38608ZM13.25 18.6361V11.1361H10.75V18.6361H13.25Z" fill="#019BE7"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View file

@ -202,8 +202,15 @@ export class FCastSession {
case Opcode.SetSpeed:
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
break;
case Opcode.Version:
this.emitter.emit("version", JSON.parse(body) as VersionMessage);
break;
case Opcode.Ping:
this.send(Opcode.Pong);
this.emitter.emit("ping");
break;
case Opcode.Pong:
this.emitter.emit("pong");
break;
}
} catch (e) {
@ -228,5 +235,8 @@ export class FCastSession {
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) });
this.emitter.on("version", (body: VersionMessage) => { emitter.emit("version", body) });
this.emitter.on("ping", () => { emitter.emit("ping") });
this.emitter.on("pong", () => { emitter.emit("pong") });
}
}

View file

@ -2,9 +2,11 @@ import * as net from 'net';
import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'events';
import { Main, errorHandler } from 'src/Main';
import { v4 as uuidv4 } from 'modules/uuid';
export class TcpListenerService {
public static PORT = 46899;
private static TIMEOUT = 2500;
emitter = new EventEmitter();
@ -56,6 +58,24 @@ export class TcpListenerService {
session.bindEvents(this.emitter);
this.sessions.push(session);
const connectionId = uuidv4();
let heartbeatRetries = 0;
socket.setTimeout(TcpListenerService.TIMEOUT);
socket.on('timeout', () => {
try {
if (heartbeatRetries > 3) {
Main.logger.warn(`Could not ping device ${socket.remoteAddress}:${socket.remotePort}. Disconnecting...`);
socket.destroy();
}
heartbeatRetries += 1;
session.send(Opcode.Ping);
} catch (e) {
Main.logger.warn(`Error while pinging sender device ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.destroy();
}
});
socket.on("error", (err) => {
Main.logger.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
socket.destroy();
@ -63,6 +83,7 @@ export class TcpListenerService {
socket.on("data", buffer => {
try {
heartbeatRetries = 0;
session.processBytes(buffer);
} catch (e) {
Main.logger.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
@ -75,8 +96,11 @@ export class TcpListenerService {
if (index != -1) {
this.sessions.splice(index, 1);
}
this.emitter.emit('disconnect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
});
this.emitter.emit('connect', { id: connectionId, type: 'tcp', data: { address: socket.remoteAddress, port: socket.remotePort }});
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});

View file

@ -2,9 +2,11 @@ import { FCastSession, Opcode } from 'common/FCastSession';
import { EventEmitter } from 'events';
import { WebSocket, WebSocketServer } from 'modules/ws';
import { Main, errorHandler } from 'src/Main';
import { v4 as uuidv4 } from 'modules/uuid';
export class WebSocketListenerService {
public static PORT = 46898;
private static TIMEOUT = 2500;
emitter = new EventEmitter();
@ -54,6 +56,24 @@ export class WebSocketListenerService {
session.bindEvents(this.emitter);
this.sessions.push(session);
const connectionId = uuidv4();
let heartbeatRetries = 0;
socket.setTimeout(WebSocketListenerService.TIMEOUT);
socket.on('timeout', () => {
try {
if (heartbeatRetries > 3) {
Main.logger.warn(`Could not ping device ${socket.remoteAddress}:${socket.remotePort}. Disconnecting...`);
socket.destroy();
}
heartbeatRetries += 1;
session.send(Opcode.Ping);
} catch (e) {
Main.logger.warn(`Error while pinging sender device ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.destroy();
}
});
socket.on("error", (err) => {
Main.logger.warn(`Error.`, err);
session.close();
@ -61,6 +81,7 @@ export class WebSocketListenerService {
socket.on('message', data => {
try {
heartbeatRetries = 0;
if (data instanceof Buffer) {
session.processBytes(data);
} else {
@ -79,8 +100,11 @@ export class WebSocketListenerService {
if (index != -1) {
this.sessions.splice(index, 1);
}
this.emitter.emit('disconnect', { id: connectionId, type: 'ws', data: { url: socket.url() }});
});
this.emitter.emit('connect', { id: connectionId, type: 'ws', data: { url: socket.url() }});
try {
Main.logger.info('Sending version');
session.send(Opcode.Version, {version: 2});

View file

@ -0,0 +1,54 @@
export enum ToastIcon {
INFO,
ERROR,
}
const toastQueue = []
export function toast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
toastQueue.push({ message: message, icon: icon, duration: duration });
if (toastQueue.length === 1) {
renderToast(message, icon, duration);
}
}
function renderToast(message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) {
const toastNotification = document.getElementById('toast-notification');
const toastIcon = document.getElementById('toast-icon');
const toastText = document.getElementById('toast-text');
if (!(toastNotification && toastIcon && toastText)) {
throw 'Toast component could not be initialized';
}
window.setTimeout(() => {
toastNotification.className = 'toast-fade-out';
toastNotification.style.opacity = '0';
toastQueue.shift();
if (toastQueue.length > 0) {
window.setTimeout(() => {
let toast = toastQueue[0];
renderToast(toast.message, toast.icon, toast.duration);
}, 1000);
}
}, duration);
switch (icon) {
case ToastIcon.INFO:
toastIcon.style.backgroundImage = 'url(../assets/icons/app/info.svg)';
break;
case ToastIcon.ERROR:
toastIcon.style.backgroundImage = 'url(../assets/icons/app/error.svg)';
break;
default:
break;
}
toastText.textContent = message;
toastNotification.className = 'toast-fade-in';
toastNotification.style.opacity = '1';
}

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast, ToastIcon } from '../components/Toast';
declare global {
interface Window {
@ -19,45 +20,69 @@ if (TARGET === 'electron') {
// @ts-ignore
const electronAPI = __non_webpack_require__('electron');
electronAPI.ipcRenderer.on("device-info", (_event, value) => {
// Since event is sent async during window startup, could fire off before or after renderer.js is loaded
electronAPI.ipcRenderer.on('startup-storage-clear', () => {
localStorage.clear();
});
electronAPI.ipcRenderer.on("device-info", (_event, value: any) => {
deviceInfo = value;
})
electronAPI.contextBridge.exposeInMainWorld('targetAPI', {
onStartupStorageClear: (callback: any) => electronAPI.ipcRenderer.on('startup-storage-clear', callback),
onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback),
onConnect: (callback: any) => electronAPI.ipcRenderer.on("connect", callback),
onDisconnect: (callback: any) => electronAPI.ipcRenderer.on("disconnect", callback),
getDeviceInfo: () => deviceInfo,
});
// @ts-ignore
} else if (TARGET === 'webOS') {
require('lib/webOSTVjs-1.2.10/webOSTV.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
const serviceId = 'com.futo.fcast.receiver.service';
let onDeviceInfoCb = () => { console.log('Main: Callback not set while fetching device info'); };
try {
require('lib/webOSTVjs-1.2.10/webOSTV.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
const serviceId = 'com.futo.fcast.receiver.service';
let onStartupStorageClearCb = () => { localStorage.clear(); };
let onDeviceInfoCb = () => { console.log('Main: Callback not set while fetching device info'); };
let onConnectCb = (_, value: any) => { console.log('Main: Callback not set while calling onConnect'); };
let onDisconnectCb = (_, value: any) => { console.log('Main: Callback not set while calling onDisconnect'); };
const getDeviceInfoService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"getDeviceInfo",
parameters: {},
onSuccess: (message: any) => {
console.log(`Main: getDeviceInfo ${JSON.stringify(message)}`);
const getDeviceInfoService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"getDeviceInfo",
parameters: {},
onSuccess: (message: any) => {
console.log(`Main: getDeviceInfo ${JSON.stringify(message)}`);
deviceInfo = message.value;
onDeviceInfoCb();
},
onFailure: (message: any) => {
console.error(`Main: getDeviceInfo ${JSON.stringify(message)}`);
},
// onComplete: (message) => {},
});
deviceInfo = message.value;
onDeviceInfoCb();
},
onFailure: (message: any) => {
console.error(`Main: getDeviceInfo ${JSON.stringify(message)}`);
toast(`Main: getDeviceInfo ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
// onComplete: (message) => {},
});
window.targetAPI = {
onDeviceInfo: (callback: () => void) => onDeviceInfoCb = callback,
getDeviceInfo: () => deviceInfo,
};
window.targetAPI = {
onStartupStorageClear: (callback: () => void) => onStartupStorageClearCb = callback,
onDeviceInfo: (callback: () => void) => onDeviceInfoCb = callback,
onConnect: (callback: () => void) => onConnectCb = callback,
onDisconnect: (callback: () => void) => onDisconnectCb = callback,
getDeviceInfo: () => deviceInfo,
};
preloadData = {
getDeviceInfoService: getDeviceInfoService,
};
preloadData = {
getDeviceInfoService: getDeviceInfoService,
onStartupStorageClearCb: onStartupStorageClearCb,
onConnectCb: onConnectCb,
onDisconnectCb: onDisconnectCb,
};
}
catch (err) {
console.error(`Main: preload ${JSON.stringify(err)}`);
toast(`Main: preload ${JSON.stringify(err)}`, ToastIcon.ERROR);
}
} else {
// @ts-ignore
console.log(`Attempting to run FCast player on unsupported target: ${TARGET}`);

View file

@ -1,14 +1,57 @@
import QRCode from 'modules/qrcode';
import { onQRCodeRendered } from 'src/main/Renderer';
import { toast, ToastIcon } from '../components/Toast';
const connectionStatusText = document.getElementById("connection-status-text");
const connectionStatusSpinner = document.getElementById("connection-spinner");
const connectionStatusCheck = document.getElementById("connection-check");
let connections = JSON.parse(localStorage.getItem('connections')) ?? [];
if (connections.length > 0) {
connections.forEach(connection => {
onConnect(connection);
});
}
window.targetAPI.onStartupStorageClear((_event, value: any) => {
localStorage.clear();
localStorage.setItem('connections', JSON.stringify(connections));
});
window.targetAPI.onDeviceInfo(renderIPsAndQRCode);
window.targetAPI.onConnect((_event, value: any) => {
connections.push(value.id);
localStorage.setItem('connections', JSON.stringify(connections));
onConnect(value);
});
window.targetAPI.onDisconnect((_event, value: any) => {
console.log(`Device disconnected: ${JSON.stringify(value)}`);
const index = connections.indexOf(value.id);
if (index != -1) {
connections.splice(index, 1);
localStorage.setItem('connections', JSON.stringify(connections));
}
if (connections.length === 0) {
connectionStatusText.textContent = 'Waiting for a connection';
connectionStatusSpinner.style.display = 'inline-block';
connectionStatusCheck.style.display = 'none';
toast("Device disconnected", ToastIcon.INFO);
}
});
if(window.targetAPI.getDeviceInfo()) {
console.log("device info already present");
renderIPsAndQRCode();
}
function onConnect(value: any) {
console.log(`Device connected: ${JSON.stringify(value)}`);
connectionStatusText.textContent = 'Connected: Ready to cast';
connectionStatusSpinner.style.display = 'none';
connectionStatusCheck.style.display = 'inline-block';
}
function renderIPsAndQRCode() {
const value = window.targetAPI.getDeviceInfo();
console.log("device info", value);
@ -46,6 +89,7 @@ function renderIPsAndQRCode() {
(err) => {
if (err) {
console.error(`Error rendering QR Code: ${err}`);
toast(`Error rendering QR Code: ${err}`, ToastIcon.ERROR);
}
else {
console.log(`Rendered QR Code`);

View file

@ -134,11 +134,11 @@ body, html {
font-weight: bold;
}
#waiting-for-connection, #ips, #automatic-discovery {
#connection-status-text, #ips, #automatic-discovery {
margin-top: 20px;
}
#spinner {
#connection-spinner {
padding: 20px;
}
@ -189,3 +189,108 @@ body, html {
transform: rotate(360deg);
}
}
#connection-check {
/* display: inline-block; */
display: none;
position: relative;
width: 64px;
height: 64px;
margin: 18px;
padding: 10px;
background-color: #019BE7;
border-radius: 50%;
z-index: 0;
}
#connection-check-mark {
position: relative;
top: -10px;
left: -10px;
width: 100%;
height: 100%;
padding: 10px;
animation: check 0.5s cubic-bezier(0.5, 0, 0.5, 1) 1;
background-image: url(../assets/icons/app/checked.svg);
background-size: cover;
background-color: #019BE7;
border-radius: 50%;
z-index: 1;
}
@keyframes check {
0% {
clip-path: inset(0px 64px 0px 0px);
}
100% {
clip-path: inset(0px 0px 0px 0px);
}
}
#toast-notification {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px 20px;
gap: 12px;
position: relative;
top: -200px;
max-width: 70%;
background: #F0F0F0;
border: 3px solid rgba(0, 0, 0, 0.08);
box-shadow: 0px 100px 80px rgba(0, 0, 0, 0.33), 0px 64.8148px 46.8519px rgba(0, 0, 0, 0.250556), 0px 38.5185px 25.4815px rgba(0, 0, 0, 0.200444), 0px 20px 13px rgba(0, 0, 0, 0.165), 0px 8.14815px 6.51852px rgba(0, 0, 0, 0.129556), 0px 1.85185px 3.14815px rgba(0, 0, 0, 0.0794444);
border-radius: 12px;
opacity: 0;
}
#toast-icon {
width: 48px;
height: 48px;
background-image: url(../assets/icons/app/info.svg);
background-size: cover;
flex-shrink: 0;
}
#toast-text {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
font-family: InterVariable;
font-size: 20px;
font-style: normal;
font-weight: 400;
}
.toast-fade-in {
animation: toast-fade-in 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
}
.toast-fade-out {
animation: toast-fade-out 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1;
}
@keyframes toast-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes toast-fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View file

@ -11,6 +11,7 @@ import * as path from 'path';
import * as log4js from "log4js";
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { ToastIcon } from 'common/components/Toast';
const cp = require('child_process');
export class Main {
@ -24,6 +25,7 @@ export class Main {
static discoveryService: DiscoveryService;
static tray: Tray;
static logger: log4js.Logger;
private static startupStorageClear = true;
private static toggleMainWindow() {
if (Main.mainWindow) {
@ -189,6 +191,9 @@ export class Main {
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('connect', (message) => Main.mainWindow?.webContents?.send('connect', message));
l.emitter.on('disconnect', (message) => Main.mainWindow?.webContents?.send('disconnect', message));
l.start();
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
@ -293,6 +298,11 @@ export class Main {
}
});
if (Main.startupStorageClear) {
Main.mainWindow.webContents.send('startup-storage-clear');
Main.startupStorageClear = false;
}
Main.mainWindow.loadFile(path.join(__dirname, 'main/index.html'));
Main.mainWindow.on('closed', () => {
Main.mainWindow = null;
@ -393,6 +403,7 @@ export function getComputerName() {
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application error:", err);
Main.mainWindow.webContents.send("toast", { message: err, icon: ToastIcon.ERROR });
const restartPrompt = await dialog.showMessageBox({
type: 'error',

View file

@ -1,6 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { contextBridge, ipcRenderer } from 'electron';
import 'common/main/Preload';
import { toast } from 'common/components/Toast';
ipcRenderer.on("toast", (_event, value: any) => {
toast(value.message, value.icon, value.duration);
});
contextBridge.exposeInMainWorld('electronAPI', {
updaterProgress: () => ipcRenderer.invoke('updater-progress'),

View file

@ -22,8 +22,9 @@
</div>
<div id="connection-status">
<div id="waiting-for-connection" class="non-selectable">Waiting for a connection</div>
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-status-text" class="non-selectable">Waiting for a connection</div>
<div id="connection-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-check"><div id="connection-check-mark"></div></div>
</div>
<div id="update-view" class="card">
@ -55,6 +56,10 @@
</div>
</div>
<div id="toast-notification">
<div id="toast-icon"></div>
<div id="toast-text"></div>
</div>
<div id="window-can-be-closed" class="non-selectable">App will continue to run as tray app when the window is closed</div>
</div>
</div>

View file

@ -14,12 +14,14 @@ import { Opcode } from 'common/FCastSession';
import * as os from 'os';
import * as log4js from "log4js";
import { EventEmitter } from 'events';
import { ToastIcon } from 'common/components/Toast';
export class Main {
static tcpListenerService: TcpListenerService;
static webSocketListenerService: WebSocketListenerService;
static discoveryService: DiscoveryService;
static logger: log4js.Logger;
static emitter: EventEmitter;
static {
try {
@ -38,11 +40,45 @@ export class Main {
const service = new Service(serviceId);
// Service will timeout and casting will disconnect if not forced to be kept alive
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let keepAlive;
service.activityManager.create("keepAlive", function(activity) {
keepAlive = activity;
});
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
let startupStorageClearClosureCb = null;
service.register("startup-storage-clear", (message: any) => {
if (message.isSubscription) {
startupStorageClearClosureCb = voidCb.bind(this, message);
Main.emitter.on('startup-storage-clear', startupStorageClearClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled startup-storage-clear service subscriber');
Main.emitter.off('startup-storage-clear', startupStorageClearClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
let toastClosureCb = null;
const toastCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); };
service.register("toast", (message: any) => {
if (message.isSubscription) {
toastClosureCb = toastCb.bind(this, message);
Main.emitter.on('toast', toastClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled toast service subscriber');
Main.emitter.off('toast', toastClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("getDeviceInfo", (message: any) => {
Main.logger.info("In getDeviceInfo callback");
@ -52,13 +88,45 @@ export class Main {
});
});
let connectClosureCb = null;
const connectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); };
service.register("connect", (message: any) => {
if (message.isSubscription) {
connectClosureCb = connectCb.bind(this, message);
Main.emitter.on('connect', connectClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled connect service subscriber');
Main.emitter.off('connect', connectClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
let disconnectClosureCb = null;
const disconnectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); };
service.register("disconnect", (message: any) => {
if (message.isSubscription) {
disconnectClosureCb = disconnectCb.bind(this, message);
Main.emitter.on('disconnect', disconnectClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled disconnect service subscriber');
Main.emitter.off('disconnect', disconnectClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
Main.discoveryService = new DiscoveryService();
Main.discoveryService.start();
Main.tcpListenerService = new TcpListenerService();
Main.webSocketListenerService = new WebSocketListenerService();
const emitter = new EventEmitter();
Main.emitter = new EventEmitter();
let playData: PlayMessage = null;
let playClosureCb = null;
@ -70,7 +138,6 @@ export class Main {
let pauseClosureCb: any = null;
let resumeClosureCb: any = null;
let stopClosureCb: any = null;
const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); };
let seekClosureCb = null;
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
@ -86,42 +153,42 @@ export class Main {
service.register("play", (message: any) => {
if (message.isSubscription) {
playClosureCb = playCb.bind(this, message);
emitter.on('play', playClosureCb);
Main.emitter.on('play', playClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true, playData: playData }});
},
(message: any) => {
Main.logger.info('Canceled play service subscriber');
emitter.off('play', playClosureCb);
Main.emitter.off('play', playClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("pause", (message: any) => {
if (message.isSubscription) {
pauseClosureCb = voidCb.bind(this, message);
emitter.on('pause', pauseClosureCb);
Main.emitter.on('pause', pauseClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled pause service subscriber');
emitter.off('pause', pauseClosureCb);
Main.emitter.off('pause', pauseClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("resume", (message: any) => {
if (message.isSubscription) {
resumeClosureCb = voidCb.bind(this, message);
emitter.on('resume', resumeClosureCb);
Main.emitter.on('resume', resumeClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled resume service subscriber');
emitter.off('resume', resumeClosureCb);
Main.emitter.off('resume', resumeClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
@ -130,56 +197,56 @@ export class Main {
if (message.isSubscription) {
stopClosureCb = voidCb.bind(this, message);
emitter.on('stop', stopClosureCb);
Main.emitter.on('stop', stopClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled stop service subscriber');
emitter.off('stop', stopClosureCb);
Main.emitter.off('stop', stopClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("seek", (message: any) => {
if (message.isSubscription) {
seekClosureCb = seekCb.bind(this, message);
emitter.on('seek', seekClosureCb);
Main.emitter.on('seek', seekClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled seek service subscriber');
emitter.off('seek', seekClosureCb);
Main.emitter.off('seek', seekClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("setvolume", (message: any) => {
if (message.isSubscription) {
setVolumeClosureCb = setVolumeCb.bind(this, message);
emitter.on('setvolume', setVolumeClosureCb);
Main.emitter.on('setvolume', setVolumeClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled setvolume service subscriber');
emitter.off('setvolume', setVolumeClosureCb);
Main.emitter.off('setvolume', setVolumeClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
service.register("setspeed", (message: any) => {
if (message.isSubscription) {
setSpeedClosureCb = setSpeedCb.bind(this, message);
emitter.on('setspeed', setSpeedClosureCb);
Main.emitter.on('setspeed', setSpeedClosureCb);
}
message.respond({ returnValue: true, value: { subscribed: true }});
},
(message: any) => {
Main.logger.info('Canceled setspeed service subscriber');
emitter.off('setspeed', setSpeedClosureCb);
Main.emitter.off('setspeed', setSpeedClosureCb);
message.respond({ returnValue: true, value: message.payload });
});
@ -187,7 +254,7 @@ export class Main {
listeners.forEach(l => {
l.emitter.on("play", async (message) => {
await NetworkService.proxyPlayIfRequired(message);
emitter.emit('play', message);
Main.emitter.emit('play', message);
const appId = 'com.futo.fcast.receiver';
service.call("luna://com.webos.applicationManager/launch", {
@ -198,12 +265,15 @@ export class Main {
Main.logger.info(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`);
});
});
l.emitter.on("pause", () => emitter.emit('pause'));
l.emitter.on("resume", () => emitter.emit('resume'));
l.emitter.on("stop", () => emitter.emit('stop'));
l.emitter.on("seek", (message) => emitter.emit('seek', message));
l.emitter.on("setvolume", (message) => emitter.emit('setvolume', message));
l.emitter.on("setspeed", (message) => emitter.emit('setspeed', message));
l.emitter.on("pause", () => Main.emitter.emit('pause'));
l.emitter.on("resume", () => Main.emitter.emit('resume'));
l.emitter.on("stop", () => Main.emitter.emit('stop'));
l.emitter.on("seek", (message) => Main.emitter.emit('seek', message));
l.emitter.on("setvolume", (message) => Main.emitter.emit('setvolume', message));
l.emitter.on("setspeed", (message) => Main.emitter.emit('setspeed', message));
l.emitter.on('connect', (message) => Main.emitter.emit('connect', message));
l.emitter.on('disconnect', (message) => Main.emitter.emit('disconnect', message));
l.start();
});
@ -235,9 +305,12 @@ export class Main {
message.respond({ returnValue: true, value: { success: true } });
});
this.emitter.emit('startup-storage-clear');
}
catch (err) {
Main.logger.error("Error initializing service:", err);
Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR });
}
}
@ -249,4 +322,5 @@ export function getComputerName() {
export async function errorHandler(err: NodeJS.ErrnoException) {
Main.logger.error("Application error:", err);
Main.emitter.emit('toast', { message: err, icon: ToastIcon.ERROR });
}

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { preloadData } from 'common/main/Preload';
import { toast, ToastIcon } from 'common/components/Toast';
enum RemoteKeyCode {
Stop = 413,
@ -10,75 +11,175 @@ enum RemoteKeyCode {
Back = 461,
}
const serviceId = 'com.futo.fcast.receiver.service';
try {
const serviceId = 'com.futo.fcast.receiver.service';
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"play",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered play handler with service');
}
else {
if (message.value !== undefined && message.value.playData !== undefined) {
console.log(`Main: Playing ${JSON.stringify(message)}`);
preloadData.getDeviceInfoService.cancel();
playService.cancel();
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
// history.pushState({}, '', '../main_window/index.html');
window.open('../player/index.html');
const startupStorageClearService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"startup-storage-clear",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered startup-storage-clear handler with service');
}
else {
preloadData.onStartupStorageClearCb();
}
},
onFailure: (message: any) => {
console.error(`Main: startup-storage-clear ${JSON.stringify(message)}`);
toast(`Main: startup-storage-clear ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
subscribe: true,
resubscribe: true
});
const toastService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"toast",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered toast handler with service');
}
else {
toast(message.value.message, message.value.icon, message.value.duration);
}
},
onFailure: (message: any) => {
console.error(`Main: toast ${JSON.stringify(message)}`);
toast(`Main: toast ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
subscribe: true,
resubscribe: true
});
const onConnectService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"connect",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered connect handler with service');
}
else {
preloadData.onConnectCb(null, message.value);
}
},
onFailure: (message: any) => {
console.error(`Main: connect ${JSON.stringify(message)}`);
toast(`Main: connect ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
subscribe: true,
resubscribe: true
});
const onDisconnectService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"disconnect",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered disconnect handler with service');
}
else {
preloadData.onDisconnectCb(null, message.value);
}
},
onFailure: (message: any) => {
console.error(`Main: disconnect ${JSON.stringify(message)}`);
toast(`Main: disconnect ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
subscribe: true,
resubscribe: true
});
const playService = window.webOS.service.request(`luna://${serviceId}/`, {
method:"play",
parameters: {},
onSuccess: (message: any) => {
if (message.value.subscribed === true) {
console.log('Main: Registered play handler with service');
}
else {
if (message.value !== undefined && message.value.playData !== undefined) {
console.log(`Main: Playing ${JSON.stringify(message)}`);
preloadData.getDeviceInfoService.cancel();
startupStorageClearService.cancel();
toastService.cancel();
onConnectService.cancel();
onDisconnectService.cancel();
playService.cancel();
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
// history.pushState({}, '', '../main_window/index.html');
window.open('../player/index.html');
}
}
},
onFailure: (message: any) => {
console.error(`Main: play ${JSON.stringify(message)}`);
toast(`Main: play ${JSON.stringify(message)}`, ToastIcon.ERROR);
},
subscribe: true,
resubscribe: true
});
const launchHandler = (args: any) => {
// args don't seem to be passed in via event despite what documentation says...
const params = window.webOSDev.launchParams();
console.log(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`);
const lastTimestamp = localStorage.getItem('lastTimestamp');
if (params.playData !== undefined && params.timestamp != lastTimestamp) {
localStorage.setItem('lastTimestamp', params.timestamp);
if (preloadData.getDeviceInfoService !== undefined) {
preloadData.getDeviceInfoService.cancel();
}
if (startupStorageClearService !== undefined) {
startupStorageClearService.cancel();
}
if (toastService !== undefined) {
toastService.cancel();
}
if (onConnectService !== undefined) {
onConnectService.cancel();
}
if (onDisconnectService !== undefined) {
onDisconnectService.cancel();
}
if (playService !== undefined) {
playService.cancel();
}
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
// history.pushState({}, '', '../main_window/index.html');
window.open('../player/index.html');
}
},
onFailure: (message: any) => {
console.error(`Main: play ${JSON.stringify(message)}`);
},
subscribe: true,
resubscribe: true
});
};
const launchHandler = (args: any) => {
// args don't seem to be passed in via event despite what documentation says...
const params = window.webOSDev.launchParams();
console.log(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`);
document.addEventListener('webOSLaunch', (ags) => { launchHandler(ags)});
document.addEventListener('webOSRelaunch', (ags) => { launchHandler(ags)});
const lastTimestamp = localStorage.getItem('lastTimestamp');
if (params.playData !== undefined && params.timestamp != lastTimestamp) {
localStorage.setItem('lastTimestamp', params.timestamp);
if (preloadData.getDeviceInfoService !== undefined) {
preloadData.getDeviceInfoService.cancel();
// Cannot go back to a state where user was previously casting a video, so exit.
// window.onpopstate = () => {
// window.webOS.platformBack();
// };
document.addEventListener('keydown', (event: any) => {
// console.log("KeyDown", event);
switch (event.keyCode) {
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
case RemoteKeyCode.Back:
window.webOS.platformBack();
break;
default:
break;
}
if (playService !== undefined) {
playService.cancel();
}
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
// history.pushState({}, '', '../main_window/index.html');
window.open('../player/index.html');
}
};
document.addEventListener('webOSLaunch', (ags) => { console.log('lunch'); launchHandler(ags)});
document.addEventListener('webOSRelaunch', (ags) => { console.log('relun'); launchHandler(ags)});
// Cannot go back to a state where user was previously casting a video, so exit.
// window.onpopstate = () => {
// window.webOS.platformBack();
// };
document.addEventListener('keydown', (event: any) => {
// console.log("KeyDown", event);
switch (event.keyCode) {
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
case RemoteKeyCode.Back:
window.webOS.platformBack();
break;
default:
break;
}
});
});
}
catch (err) {
console.error(`Main: preload ${JSON.stringify(err)}`);
toast(`Main: preload ${JSON.stringify(err)}`, ToastIcon.ERROR);
}

View file

@ -8,7 +8,6 @@
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
<script src="./preload.js"></script>
</head>
<body>
@ -29,8 +28,9 @@
</div>
<div id="connection-status">
<div id="waiting-for-connection" class="non-selectable">Waiting for a connection</div>
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-status-text" class="non-selectable">Waiting for a connection</div>
<div id="connection-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="connection-check"><div id="connection-check-mark"></div></div>
</div>
</div>
<div id="detail-view" class="card">
@ -46,9 +46,14 @@
</div>
</div>
<div id="toast-notification">
<div id="toast-icon"></div>
<div id="toast-text"></div>
</div>
<div id="window-can-be-closed" class="non-selectable">App will continue to listen for connections when suspended in the background</div>
</div>
</div>
<script src="./preload.js"></script>
<script src="./renderer.js"></script>
</body>

View file

@ -68,3 +68,24 @@
color: white;
padding-right: 20px;
}
#connection-check {
width: 104px;
height: 104px;
}
#toast-notification {
gap: unset;
top: -250px;
}
#toast-icon {
width: 88px;
height: 88px;
margin-right: 20px;
}
#toast-text {
font-family: InterRegular;
font-size: 28px;
}