diff --git a/receivers/common/assets/icons/app/checked.svg b/receivers/common/assets/icons/app/checked.svg
new file mode 100644
index 0000000..3c90550
--- /dev/null
+++ b/receivers/common/assets/icons/app/checked.svg
@@ -0,0 +1,4 @@
+
diff --git a/receivers/common/assets/icons/app/error.svg b/receivers/common/assets/icons/app/error.svg
new file mode 100644
index 0000000..0b8368e
--- /dev/null
+++ b/receivers/common/assets/icons/app/error.svg
@@ -0,0 +1,4 @@
+
diff --git a/receivers/common/assets/icons/app/info.svg b/receivers/common/assets/icons/app/info.svg
new file mode 100644
index 0000000..98ab77b
--- /dev/null
+++ b/receivers/common/assets/icons/app/info.svg
@@ -0,0 +1,3 @@
+
diff --git a/receivers/common/web/FCastSession.ts b/receivers/common/web/FCastSession.ts
index a489545..011d749 100644
--- a/receivers/common/web/FCastSession.ts
+++ b/receivers/common/web/FCastSession.ts
@@ -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") });
}
}
diff --git a/receivers/common/web/TcpListenerService.ts b/receivers/common/web/TcpListenerService.ts
index 5865b08..e686a0c 100644
--- a/receivers/common/web/TcpListenerService.ts
+++ b/receivers/common/web/TcpListenerService.ts
@@ -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});
diff --git a/receivers/common/web/WebSocketListenerService.ts b/receivers/common/web/WebSocketListenerService.ts
index 3010a20..988de36 100644
--- a/receivers/common/web/WebSocketListenerService.ts
+++ b/receivers/common/web/WebSocketListenerService.ts
@@ -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});
diff --git a/receivers/common/web/components/Toast.ts b/receivers/common/web/components/Toast.ts
new file mode 100644
index 0000000..32c44e9
--- /dev/null
+++ b/receivers/common/web/components/Toast.ts
@@ -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';
+}
diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts
index fdb1d8b..4e84536 100644
--- a/receivers/common/web/main/Preload.ts
+++ b/receivers/common/web/main/Preload.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-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}`);
diff --git a/receivers/common/web/main/Renderer.ts b/receivers/common/web/main/Renderer.ts
index fb341a1..554f270 100644
--- a/receivers/common/web/main/Renderer.ts
+++ b/receivers/common/web/main/Renderer.ts
@@ -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`);
diff --git a/receivers/common/web/main/common.css b/receivers/common/web/main/common.css
index 23a71b0..792a952 100644
--- a/receivers/common/web/main/common.css
+++ b/receivers/common/web/main/common.css
@@ -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;
+ }
+}
diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts
index 6cfff1e..6c71633 100644
--- a/receivers/electron/src/Main.ts
+++ b/receivers/electron/src/Main.ts
@@ -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',
diff --git a/receivers/electron/src/main/Preload.ts b/receivers/electron/src/main/Preload.ts
index 6b90398..509b0b1 100644
--- a/receivers/electron/src/main/Preload.ts
+++ b/receivers/electron/src/main/Preload.ts
@@ -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'),
diff --git a/receivers/electron/src/main/index.html b/receivers/electron/src/main/index.html
index acf8d92..1a6e587 100644
--- a/receivers/electron/src/main/index.html
+++ b/receivers/electron/src/main/index.html
@@ -22,8 +22,9 @@
-
Waiting for a connection
-
+
Waiting for a connection
+
+
@@ -55,6 +56,10 @@
+
App will continue to run as tray app when the window is closed
diff --git a/receivers/webos/fcast-receiver-service/src/Main.ts b/receivers/webos/fcast-receiver-service/src/Main.ts
index 9d3cc65..6ad6b82 100644
--- a/receivers/webos/fcast-receiver-service/src/Main.ts
+++ b/receivers/webos/fcast-receiver-service/src/Main.ts
@@ -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 });
}
diff --git a/receivers/webos/fcast-receiver/src/main/Preload.ts b/receivers/webos/fcast-receiver/src/main/Preload.ts
index 239fcd1..6935679 100644
--- a/receivers/webos/fcast-receiver/src/main/Preload.ts
+++ b/receivers/webos/fcast-receiver/src/main/Preload.ts
@@ -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);
+}
diff --git a/receivers/webos/fcast-receiver/src/main/index.html b/receivers/webos/fcast-receiver/src/main/index.html
index fd06b80..6445500 100644
--- a/receivers/webos/fcast-receiver/src/main/index.html
+++ b/receivers/webos/fcast-receiver/src/main/index.html
@@ -8,7 +8,6 @@
-
@@ -29,8 +28,9 @@
-
Waiting for a connection
-
+
Waiting for a connection
+
+
@@ -46,9 +46,14 @@
+
App will continue to listen for connections when suspended in the background
+
diff --git a/receivers/webos/fcast-receiver/src/main/style.css b/receivers/webos/fcast-receiver/src/main/style.css
index f884474..4bb92bc 100644
--- a/receivers/webos/fcast-receiver/src/main/style.css
+++ b/receivers/webos/fcast-receiver/src/main/style.css
@@ -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;
+}