From 4c1cb6cf8e2344c519a193f85f1ce52bed47d24f Mon Sep 17 00:00:00 2001 From: Michael Hollister Date: Mon, 7 Jul 2025 14:02:19 -0500 Subject: [PATCH] webOS: Initial port of Electron v2.2.0 changes --- receivers/common/web/main/Preload.ts | 20 +- receivers/common/web/main/Renderer.ts | 39 ++- receivers/common/web/player/Preload.ts | 48 +++- receivers/common/web/player/Renderer.ts | 162 ++++++----- receivers/common/web/viewer/Renderer.ts | 38 ++- receivers/electron/src/main/Renderer.ts | 10 + receivers/electron/src/player/Renderer.ts | 25 +- receivers/electron/src/viewer/Renderer.ts | 17 +- receivers/webos/README.md | 2 + receivers/webos/fcast-receiver-service/.npmrc | 1 + .../fcast-receiver-service/package-lock.json | 247 ++++++++++++---- .../webos/fcast-receiver-service/package.json | 10 +- .../webos/fcast-receiver-service/src/Main.ts | 271 +++++++++++------- receivers/webos/fcast-receiver/.npmrc | 1 + receivers/webos/fcast-receiver/appinfo.json | 2 +- receivers/webos/fcast-receiver/lib/common.ts | 98 +++++++ .../webos/fcast-receiver/package-lock.json | 211 +++++++++----- receivers/webos/fcast-receiver/package.json | 12 +- .../webos/fcast-receiver/src/main/Preload.ts | 170 +++++------ .../webos/fcast-receiver/src/main/Renderer.ts | 44 +++ .../webos/fcast-receiver/src/main/index.html | 1 + .../fcast-receiver/src/player/Preload.ts | 142 +++++---- .../fcast-receiver/src/player/Renderer.ts | 35 ++- .../fcast-receiver/src/player/index.html | 7 + .../fcast-receiver/src/viewer/Renderer.ts | 78 +++++ .../fcast-receiver/src/viewer/index.html | 45 +++ .../webos/fcast-receiver/src/viewer/style.css | 1 + .../webos/fcast-receiver/webpack.config.js | 68 ++++- 28 files changed, 1273 insertions(+), 532 deletions(-) create mode 100644 receivers/webos/fcast-receiver-service/.npmrc create mode 100644 receivers/webos/fcast-receiver/.npmrc create mode 100644 receivers/webos/fcast-receiver/lib/common.ts create mode 100644 receivers/webos/fcast-receiver/src/viewer/Renderer.ts create mode 100644 receivers/webos/fcast-receiver/src/viewer/index.html create mode 100644 receivers/webos/fcast-receiver/src/viewer/style.css diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts index b3fe643..0fce9c3 100644 --- a/receivers/common/web/main/Preload.ts +++ b/receivers/common/web/main/Preload.ts @@ -84,11 +84,19 @@ if (TARGET === 'electron') { // @ts-ignore } else if (TARGET === 'webOS' || TARGET === 'tizenOS') { - preloadData = { - onDeviceInfoCb: () => { logger.error('Main: Callback not set while fetching device info'); }, - getSessionsCb: () => { logger.error('Main: Callback not set while calling getSessions'); }, - onConnectCb: (_, value: any) => { logger.error('Main: Callback not set while calling onConnect'); }, - onDisconnectCb: (_, value: any) => { logger.error('Main: Callback not set while calling onDisconnect'); }, + preloadData.onDeviceInfoCb = () => { logger.warn('Main: Callback not set while fetching device info'); }; + preloadData.getSessionsCb = () => { logger.error('Main: Callback not set while calling getSessions'); }; + preloadData.onConnectCb = (_, value: any) => { logger.error('Main: Callback not set while calling onConnect'); }; + preloadData.onDisconnectCb = (_, value: any) => { logger.error('Main: Callback not set while calling onDisconnect'); }; + preloadData.sendEventCb = (message: EventMessage) => { logger.error('Main: Callback not set while calling onSendEventCb'); }; + + preloadData.onEventSubscribedKeysUpdate = (value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = value.keyDown; + preloadData.subscribedKeys.keyUp = value.keyUp; + }; + + preloadData.onToast = (message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) => { + toast(message, icon, duration); }; window.targetAPI = { @@ -102,8 +110,10 @@ if (TARGET === 'electron') { return preloadData.getSessionsCb(); } }, + getSubscribedKeys: () => preloadData.subscribedKeys, onConnect: (callback: (_, value: any) => void) => preloadData.onConnectCb = callback, onDisconnect: (callback: (_, value: any) => void) => preloadData.onDisconnectCb = callback, + sendEvent: (message: EventMessage) => { preloadData.sendEventCb(message); }, logger: loggerInterface, }; } else { diff --git a/receivers/common/web/main/Renderer.ts b/receivers/common/web/main/Renderer.ts index 72949cb..938f3b6 100644 --- a/receivers/common/web/main/Renderer.ts +++ b/receivers/common/web/main/Renderer.ts @@ -1,9 +1,10 @@ import QRCode from 'modules/qrcode'; import * as connectionMonitor from '../ConnectionMonitor'; -import { onQRCodeRendered } from 'src/main/Renderer'; +import { onQRCodeRendered, targetKeyUpEventListener } from 'src/main/Renderer'; import { toast, ToastIcon } from '../components/Toast'; import { EventMessage, EventType, KeyEvent } from 'common/Packets'; +import { targetKeyDownEventListener } from 'src/main/Renderer'; const connectionStatusText = document.getElementById('connection-status-text'); const connectionStatusSpinner = document.getElementById('connection-spinner'); @@ -203,12 +204,40 @@ function renderQRCode(url: string) { } document.addEventListener('keydown', (event: KeyboardEvent) => { - if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, false))); + // logger.info("KeyDown", event); + let result = targetKeyDownEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase))); } }); document.addEventListener('keyup', (event: KeyboardEvent) => { - if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false))); + // logger.info("KeyUp", event); + let result = targetKeyUpEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, key, event.repeat, handledCase))); } }); diff --git a/receivers/common/web/player/Preload.ts b/receivers/common/web/player/Preload.ts index 1847122..6e0877c 100644 --- a/receivers/common/web/player/Preload.ts +++ b/receivers/common/web/player/Preload.ts @@ -75,32 +75,48 @@ if (TARGET === 'electron') { // @ts-ignore } else if (TARGET === 'webOS' || TARGET === 'tizenOS') { - preloadData = { - sendPlaybackErrorCb: () => { logger.error('Player: Callback "send_playback_error" not set'); }, - sendPlaybackUpdateCb: () => { logger.error('Player: Callback "send_playback_update" not set'); }, - sendVolumeUpdateCb: () => { logger.error('Player: Callback "send_volume_update" not set'); }, - // onPlayCb: () => { logger.error('Player: Callback "play" not set'); }, - onPlayCb: undefined, - onPauseCb: () => { logger.error('Player: Callback "pause" not set'); }, - onResumeCb: () => { logger.error('Player: Callback "resume" not set'); }, - onSeekCb: () => { logger.error('Player: Callback "onseek" not set'); }, - onSetVolumeCb: () => { logger.error('Player: Callback "setvolume" not set'); }, - onSetSpeedCb: () => { logger.error('Player: Callback "setspeed" not set'); }, - getSessionsCb: () => { logger.error('Player: Callback "getSessions" not set'); }, - onConnectCb: () => { logger.error('Player: Callback "onConnect" not set'); }, - onDisconnectCb: () => { logger.error('Player: Callback "onDisconnect" not set'); }, + preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => { logger.error('Player: Callback "send_playback_update" not set'); }; + preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => { logger.error('Player: Callback "send_volume_update" not set'); }; + preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { logger.error('Player: Callback "send_playback_error" not set'); }; + preloadData.sendEventCb = (message: EventMessage) => { logger.error('Player: Callback "onSendEventCb" not set'); }; + // preloadData.onPlayCb = () => { logger.error('Player: Callback "play" not set'); }; + preloadData.onPlayCb = undefined; + preloadData.onPauseCb = () => { logger.error('Player: Callback "pause" not set'); }; + preloadData.onResumeCb = () => { logger.error('Player: Callback "resume" not set'); }; + preloadData.onSeekCb = () => { logger.error('Player: Callback "onseek" not set'); }; + preloadData.onSetVolumeCb = () => { logger.error('Player: Callback "setvolume" not set'); }; + preloadData.onSetSpeedCb = () => { logger.error('Player: Callback "setspeed" not set'); }; + preloadData.onSetPlaylistItemCb = () => { logger.error('Player: Callback "onSetPlaylistItem" not set'); }; + + preloadData.sendPlayRequestCb = () => { logger.error('Player: Callback "sendPlayRequest" not set'); }; + preloadData.getSessionsCb = () => { logger.error('Player: Callback "getSessions" not set'); }; + preloadData.onConnectCb = () => { logger.error('Player: Callback "onConnect" not set'); }; + preloadData.onDisconnectCb = () => { logger.error('Player: Callback "onDisconnect" not set'); }; + preloadData.onPlayPlaylistCb = () => { logger.error('Player: Callback "onPlayPlaylist" not set'); }; + + preloadData.onEventSubscribedKeysUpdate = (value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = value.keyDown; + preloadData.subscribedKeys.keyUp = value.keyUp; + }; + + preloadData.onToast = (message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) => { + toast(message, icon, duration); }; window.targetAPI = { - sendPlaybackError: (error: PlaybackErrorMessage) => { preloadData.sendPlaybackErrorCb(error); }, sendPlaybackUpdate: (update: PlaybackUpdateMessage) => { preloadData.sendPlaybackUpdateCb(update); }, sendVolumeUpdate: (update: VolumeUpdateMessage) => { preloadData.sendVolumeUpdateCb(update); }, + sendPlaybackError: (error: PlaybackErrorMessage) => { preloadData.sendPlaybackErrorCb(error); }, + sendEvent: (message: EventMessage) => { preloadData.sendEventCb(message); }, onPlay: (callback: any) => { preloadData.onPlayCb = callback; }, onPause: (callback: any) => { preloadData.onPauseCb = callback; }, onResume: (callback: any) => { preloadData.onResumeCb = callback; }, onSeek: (callback: any) => { preloadData.onSeekCb = callback; }, onSetVolume: (callback: any) => { preloadData.onSetVolumeCb = callback; }, onSetSpeed: (callback: any) => { preloadData.onSetSpeedCb = callback; }, + onSetPlaylistItem: (callback: any) => { preloadData.onSetPlaylistItemCb = callback; }, + + sendPlayRequest: (message: PlayMessage, playlistIndex: number) => { preloadData.sendPlayRequestCb(message, playlistIndex); }, getSessions: (callback?: () => Promise<[any]>) => { if (callback) { preloadData.getSessionsCb = callback; @@ -109,8 +125,10 @@ if (TARGET === 'electron') { return preloadData.getSessionsCb(); } }, + getSubscribedKeys: () => preloadData.subscribedKeys, onConnect: (callback: any) => { preloadData.onConnectCb = callback; }, onDisconnect: (callback: any) => { preloadData.onDisconnectCb = callback; }, + onPlayPlaylist: (callback: any) => { preloadData.onPlayPlaylistCb = callback; }, logger: loggerInterface, }; } else { diff --git a/receivers/common/web/player/Renderer.ts b/receivers/common/web/player/Renderer.ts index 1d945d3..630401e 100644 --- a/receivers/common/web/player/Renderer.ts +++ b/receivers/common/web/player/Renderer.ts @@ -12,7 +12,8 @@ import { targetKeyDownEventListener, captionsBaseHeightCollapsed, captionsBaseHeightExpanded, - captionsLineHeight + captionsLineHeight, + targetKeyUpEventListener } from 'src/player/Renderer'; const logger = window.targetAPI.logger; @@ -912,74 +913,6 @@ document.addEventListener('click', (event: MouseEvent) => { const skipInterval = 10; const volumeIncrement = 0.1; -function keyDownEventListener(event: KeyboardEvent) { - // logger.info("KeyDown", event); - let handledCase = targetKeyDownEventListener(event); - - if (!handledCase) { - switch (event.code) { - case 'ArrowLeft': - skipBack(); - event.preventDefault(); - handledCase = true; - break; - case 'ArrowRight': - skipForward(); - event.preventDefault(); - handledCase = true; - break; - case "Home": - player?.setCurrentTime(0); - event.preventDefault(); - handledCase = true; - break; - case "End": - if (isLive) { - setLivePosition(); - } - else { - player?.setCurrentTime(player?.getDuration()); - } - event.preventDefault(); - handledCase = true; - break; - case 'KeyK': - case 'Space': - case 'Enter': - // Play/pause toggle - if (player?.isPaused()) { - player?.play(); - } else { - player?.pause(); - } - event.preventDefault(); - handledCase = true; - break; - case 'KeyM': - // Mute toggle - player?.setMute(!player?.isMuted()); - handledCase = true; - break; - case 'ArrowUp': - // Volume up - volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); - handledCase = true; - break; - case 'ArrowDown': - // Volume down - volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); - handledCase = true; - break; - default: - break; - } - } - - if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase))); - } -} - function skipBack() { player?.setCurrentTime(Math.max(player?.getCurrentTime() - skipInterval, 0)); } @@ -990,10 +923,94 @@ function skipForward() { } } -document.addEventListener('keydown', keyDownEventListener); +document.addEventListener('keydown', (event: KeyboardEvent) => { + // logger.info("KeyDown", event.key); + let result = targetKeyDownEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key.toLowerCase()) { + case 'arrowleft': + skipBack(); + event.preventDefault(); + handledCase = true; + break; + case 'arrowright': + skipForward(); + event.preventDefault(); + handledCase = true; + break; + case "home": + player?.setCurrentTime(0); + event.preventDefault(); + handledCase = true; + break; + case "end": + if (isLive) { + setLivePosition(); + } + else { + player?.setCurrentTime(player?.getDuration()); + } + event.preventDefault(); + handledCase = true; + break; + case 'k': + case ' ': + case 'enter': + // Play/pause toggle + if (player?.isPaused()) { + player?.play(); + } else { + player?.pause(); + } + event.preventDefault(); + handledCase = true; + break; + case 'm': + // Mute toggle + player?.setMute(!player?.isMuted()); + handledCase = true; + break; + case 'arrowup': + // Volume up + volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); + handledCase = true; + break; + case 'arrowdown': + // Volume down + volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); + handledCase = true; + break; + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase))); + } +}); document.addEventListener('keyup', (event: KeyboardEvent) => { - if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false))); + // logger.info("KeyUp", event); + let result = targetKeyUpEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, key, event.repeat, handledCase))); } }); @@ -1020,6 +1037,7 @@ export { captionsBaseHeight, captionsLineHeight, onPlay, + onPlayPlaylist, playerCtrlStateUpdate, formatDuration, skipBack, diff --git a/receivers/common/web/viewer/Renderer.ts b/receivers/common/web/viewer/Renderer.ts index 74e8435..a8b7785 100644 --- a/receivers/common/web/viewer/Renderer.ts +++ b/receivers/common/web/viewer/Renderer.ts @@ -6,6 +6,7 @@ import { toast, ToastIcon } from 'common/components/Toast'; import { targetPlayerCtrlStateUpdate, targetKeyDownEventListener, + targetKeyUpEventListener, } from 'src/viewer/Renderer'; const logger = window.targetAPI.logger; @@ -328,9 +329,13 @@ document.onmousemove = () => { uiHideTimer.start(); }; -function keyDownEventListener(event: KeyboardEvent) { +document.addEventListener('keydown', (event: KeyboardEvent) => { // logger.info("KeyDown", event); - let handledCase = targetKeyDownEventListener(event); + let result = targetKeyDownEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; if (!handledCase) { switch (event.code) { @@ -371,15 +376,27 @@ function keyDownEventListener(event: KeyboardEvent) { } } - if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase))); + if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase))); } -} - -document.addEventListener('keydown', keyDownEventListener); +}); document.addEventListener('keyup', (event: KeyboardEvent) => { - if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) { - window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false))); + // logger.info("KeyUp", event); + let result = targetKeyUpEventListener(event); + let handledCase = result.handledCase; + + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, key, event.repeat, handledCase))); } }); @@ -389,6 +406,9 @@ export { idleIcon, imageViewer, genericViewer, + playlistIndex, onPlay, + onPlayPlaylist, playerCtrlStateUpdate, + setPlaylistItem, }; diff --git a/receivers/electron/src/main/Renderer.ts b/receivers/electron/src/main/Renderer.ts index 15a61cd..671e9a4 100644 --- a/receivers/electron/src/main/Renderer.ts +++ b/receivers/electron/src/main/Renderer.ts @@ -3,6 +3,16 @@ import 'common/main/Renderer'; const logger = window.targetAPI.logger; export function onQRCodeRendered() {} +export function targetKeyDownEventListener(_event: KeyboardEvent): { handledCase: boolean, key: string } { + // unused in Electron currently + return { handledCase: false, key: '', }; +}; + +export function targetKeyUpEventListener(_event: KeyboardEvent): { handledCase: boolean, key: string } { + // unused in Electron currently + return { handledCase: false, key: '', }; +}; + const updateView = document.getElementById("update-view"); const updateViewTitle = document.getElementById("update-view-title"); const updateText = document.getElementById("update-text"); diff --git a/receivers/electron/src/player/Renderer.ts b/receivers/electron/src/player/Renderer.ts index 6fa2efe..4057a5e 100644 --- a/receivers/electron/src/player/Renderer.ts +++ b/receivers/electron/src/player/Renderer.ts @@ -44,25 +44,17 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean return handledCase; } -export function targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) { - // Currently unused in electron player - switch (event) { - default: - break; - } -} - -export function targetKeyDownEventListener(event: KeyboardEvent) { +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { let handledCase = false; - switch (event.code) { - case 'KeyF': - case 'F11': + switch (event.key.toLowerCase()) { + case 'f': + case 'f11': playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); event.preventDefault(); handledCase = true; break; - case 'Escape': + case 'escape': playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen); event.preventDefault(); handledCase = true; @@ -71,7 +63,12 @@ export function targetKeyDownEventListener(event: KeyboardEvent) { break; } - return handledCase + return { handledCase: handledCase, key: event.key, }; +}; + +export function targetKeyUpEventListener(_event: KeyboardEvent): { handledCase: boolean, key: string } { + // unused in Electron currently + return { handledCase: false, key: '', }; }; export { diff --git a/receivers/electron/src/viewer/Renderer.ts b/receivers/electron/src/viewer/Renderer.ts index 06bf811..365111b 100644 --- a/receivers/electron/src/viewer/Renderer.ts +++ b/receivers/electron/src/viewer/Renderer.ts @@ -40,17 +40,17 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean return handledCase; } -export function targetKeyDownEventListener(event: KeyboardEvent): boolean { +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { let handledCase = false; - switch (event.code) { - case 'KeyF': - case 'F11': + switch (event.key.toLowerCase()) { + case 'f': + case 'f11': playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); event.preventDefault(); handledCase = true; break; - case 'Escape': + case 'escape': playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen); event.preventDefault(); handledCase = true; @@ -59,5 +59,10 @@ export function targetKeyDownEventListener(event: KeyboardEvent): boolean { break; } - return handledCase + return { handledCase: handledCase, key: event.key, }; +}; + +export function targetKeyUpEventListener(_event: KeyboardEvent): { handledCase: boolean, key: string } { + // unused in Electron currently + return { handledCase: false, key: '', }; }; diff --git a/receivers/webos/README.md b/receivers/webos/README.md index 4d93ca8..5cc2654 100644 --- a/receivers/webos/README.md +++ b/receivers/webos/README.md @@ -26,6 +26,8 @@ PASSPHRASE=YOUR_TV_PASSPHRASE This information is found in the development app. +Note that you may have to periodically rebuild the container to keep key information up-to-date with the TV device. + ### Run ```bash docker run --rm -it -w /app/receivers/webos --entrypoint='bash' --network host \ diff --git a/receivers/webos/fcast-receiver-service/.npmrc b/receivers/webos/fcast-receiver-service/.npmrc new file mode 100644 index 0000000..6e4ba02 --- /dev/null +++ b/receivers/webos/fcast-receiver-service/.npmrc @@ -0,0 +1 @@ +@futo:registry=https://gitlab.futo.org/api/v4/projects/305/packages/npm/ diff --git a/receivers/webos/fcast-receiver-service/package-lock.json b/receivers/webos/fcast-receiver-service/package-lock.json index 0bbfc7c..98200fb 100644 --- a/receivers/webos/fcast-receiver-service/package-lock.json +++ b/receivers/webos/fcast-receiver-service/package-lock.json @@ -1,23 +1,26 @@ { "name": "com.futo.fcast.receiver.service", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "com.futo.fcast.receiver.service", - "version": "1.1.0", + "version": "2.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "http": "^0.0.1-security", + "bufferutil": "^4.0.8", + "follow-redirects": "^1.15.9", "log4js": "^6.9.1", + "memfs": "^4.17.2", "url": "^0.11.4", - "uuid": "^11.0.3", + "uuid": "^9.0.1", "ws": "^8.18.0" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@futo/mdns-js": "1.0.3", "@types/jest": "^29.5.11", "@types/mdns": "^0.0.38", "@types/node-forge": "^1.3.10", @@ -29,7 +32,6 @@ "eslint": "^9.25.0", "globals": "^16.0.0", "jest": "^29.7.0", - "mdns-js": "github:mdns-js/node-mdns-js", "patch-package": "^8.0.0", "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", @@ -713,6 +715,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@futo/mdns-js": { + "version": "1.0.3", + "resolved": "https://gitlab.futo.org/api/v4/projects/305/packages/npm/@futo/mdns-js/-/@futo/mdns-js-1.0.3.tgz", + "integrity": "sha1-y25rzWUSYkRu0bhkR472LJLMdcc=", + "dev": true, + "dependencies": { + "debug": "~3.1.0", + "dns-js": "~0.2.1", + "semver": "~5.4.1" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@futo/mdns-js/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@futo/mdns-js/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@futo/mdns-js/node_modules/semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1302,6 +1345,60 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", + "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", + "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", @@ -2521,8 +2618,6 @@ "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", "hasInstallScript": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -3786,6 +3881,26 @@ "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4052,11 +4167,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/http/-/http-0.0.1-security.tgz", - "integrity": "sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==" - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4084,6 +4194,15 @@ "node": ">=10.17.0" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5409,47 +5528,6 @@ "node": ">= 0.4" } }, - "node_modules/mdns-js": { - "version": "1.0.3", - "resolved": "git+ssh://git@github.com/mdns-js/node-mdns-js.git#4fb9220ec8852bae9e2781917f649821b9df539d", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "~3.1.0", - "dns-js": "~0.2.1", - "semver": "~5.4.1" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mdns-js/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/mdns-js/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdns-js/node_modules/semver": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5460,6 +5538,25 @@ "node": ">= 0.8" } }, + "node_modules/memfs": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", + "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -5595,7 +5692,6 @@ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "license": "MIT", - "optional": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -7047,6 +7143,18 @@ "node": ">=8" } }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -7135,6 +7243,22 @@ "node": ">=0.6" } }, + "node_modules/tree-dump": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", + "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/ts-api-utils": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.2.tgz", @@ -7254,6 +7378,12 @@ "node": ">= 8" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7470,16 +7600,15 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/receivers/webos/fcast-receiver-service/package.json b/receivers/webos/fcast-receiver-service/package.json index c8f0723..c1906b6 100644 --- a/receivers/webos/fcast-receiver-service/package.json +++ b/receivers/webos/fcast-receiver-service/package.json @@ -1,6 +1,6 @@ { "name": "com.futo.fcast.receiver.service", - "version": "1.1.0", + "version": "2.0.0", "description": "FCast network service", "author": "FUTO", "license": "MIT", @@ -12,6 +12,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@futo/mdns-js": "1.0.3", "@types/jest": "^29.5.11", "@types/mdns": "^0.0.38", "@types/node-forge": "^1.3.10", @@ -23,7 +24,6 @@ "eslint": "^9.25.0", "globals": "^16.0.0", "jest": "^29.7.0", - "mdns-js": "github:mdns-js/node-mdns-js", "patch-package": "^8.0.0", "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", @@ -33,10 +33,12 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "http": "^0.0.1-security", + "bufferutil": "^4.0.8", + "follow-redirects": "^1.15.9", "log4js": "^6.9.1", + "memfs": "^4.17.2", "url": "^0.11.4", - "uuid": "^11.0.3", + "uuid": "^9.0.1", "ws": "^8.18.0" }, "optionalDependencies": { diff --git a/receivers/webos/fcast-receiver-service/src/Main.ts b/receivers/webos/fcast-receiver-service/src/Main.ts index 5975f42..7e9a285 100644 --- a/receivers/webos/fcast-receiver-service/src/Main.ts +++ b/receivers/webos/fcast-receiver-service/src/Main.ts @@ -5,17 +5,30 @@ const Service = __non_webpack_require__('webos-service'); // const Service = require('webos-service'); -import { Opcode, PlayMessage, PlaybackErrorMessage, PlaybackUpdateMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from 'common/Packets'; +import { EventMessage, EventType, Opcode, PlayMessage, PlayUpdateMessage, PlaybackErrorMessage, PlaybackUpdateMessage, PlaylistContent, SeekMessage, + SetPlaylistItemMessage, SetSpeedMessage, SetVolumeMessage, 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 { ConnectionMonitor } from 'common/ConnectionMonitor'; import { Logger, LoggerType } from 'common/Logger'; +import { MediaCache } from 'common/MediaCache'; +import { preparePlayMessage } from 'common/UtilityBackend'; import * as os from 'os'; import { EventEmitter } from 'events'; import { ToastIcon } from 'common/components/Toast'; const logger = new Logger('Main', LoggerType.BACKEND); +const serviceId = 'com.futo.fcast.receiver.service'; +const service = new Service(serviceId); + +class AppCache { + public interfaces: any = null; + public appName: string = null; + public appVersion: string = null; + public playMessage: PlayMessage = null; + public playerVolume: number = null; + public subscribedKeys = new Set(); +} export class Main { static tcpListenerService: TcpListenerService; @@ -23,14 +36,39 @@ export class Main { static discoveryService: DiscoveryService; static connectionMonitor: ConnectionMonitor; static emitter: EventEmitter; + static cache: AppCache = new AppCache(); + + private static listeners = []; + private static mediaCache: MediaCache = null; + + private static windowVisible: boolean = false; + private static windowType: string = 'main'; + + private static async play(message: PlayMessage) { + Main.listeners.forEach(l => l.send(Opcode.PlayUpdate, new PlayUpdateMessage(Date.now(), message))); + Main.cache.playMessage = message; + const messageInfo = await preparePlayMessage(message, Main.cache.playerVolume, (playMessage: PlaylistContent) => { + Main.mediaCache?.destroy(); + Main.mediaCache = new MediaCache(playMessage); + }); + + Main.emitter.emit('play', messageInfo); + if (!Main.windowVisible) { + const appId = 'com.futo.fcast.receiver'; + service.call("luna://com.webos.applicationManager/launch", { + 'id': appId, + 'params': { timestamp: Date.now(), messageInfo: messageInfo } + }, (response: any) => { + logger.info(`Launch response: ${JSON.stringify(response)}`); + logger.info(`Relaunching FCast Receiver with args: ${messageInfo.rendererEvent} ${JSON.stringify(messageInfo.rendererMessage)}`); + }); + } + } static { try { logger.info(`OS: ${process.platform} ${process.arch}`); - const serviceId = 'com.futo.fcast.receiver.service'; - 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; @@ -38,22 +76,6 @@ export class Main { keepAlive = activity; }); - const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); }; - const objectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); }; - - registerService(service, 'toast', (message: any) => { return objectCb.bind(this, message) }); - - // getDeviceInfo and network-changed handled in frontend - service.register("get_sessions", (message: any) => { - message.respond({ - returnValue: true, - value: [].concat(Main.tcpListenerService.getSenders(), Main.webSocketListenerService.getSessions()) - }); - }); - - registerService(service, 'connect', (message: any) => { return objectCb.bind(this, message) }); - registerService(service, 'disconnect', (message: any) => { return objectCb.bind(this, message) }); - Main.connectionMonitor = new ConnectionMonitor(); Main.discoveryService = new DiscoveryService(); Main.discoveryService.start(); @@ -62,78 +84,35 @@ export class Main { Main.webSocketListenerService = new WebSocketListenerService(); Main.emitter = new EventEmitter(); - let playData: PlayMessage = null; - let playClosureCb = null; - const playCb = (message: any, playMessage: PlayMessage) => { - playData = playMessage; - message.respond({ returnValue: true, value: { playData: playData } }); - }; - - let stopClosureCb: any = null; - const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); }; - const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); }; - const setSpeedCb = (message: any, speedMessage: SetSpeedMessage) => { message.respond({ returnValue: true, value: speedMessage }); }; - - // Note: When logging the `message` object, do NOT use JSON.stringify, you can log messages directly. Seems to be a circular reference causing errors... - service.register('play', (message: any) => { - if (message.isSubscription) { - playClosureCb = playCb.bind(this, message); - Main.emitter.on('play', playClosureCb); - } - - message.respond({ returnValue: true, value: { subscribed: true, playData: playData }}); - }, - (message: any) => { - logger.info('Canceled play service subscriber'); - Main.emitter.removeAllListeners('play'); - message.respond({ returnValue: true, value: message.payload }); - }); + const voidCb = (message: any) => { message.respond({ returnValue: true, value: {} }); }; + const objectCb = (message: any, value: any) => { message.respond({ returnValue: true, value: value }); }; + registerService(service, 'toast', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'connect', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'disconnect', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'play', (message: any) => { return objectCb.bind(this, message) }); registerService(service, 'pause', (message: any) => { return voidCb.bind(this, message) }); registerService(service, 'resume', (message: any) => { return voidCb.bind(this, message) }); + registerService(service, 'stop', (message: any) => { return voidCb.bind(this, message) }); + registerService(service, 'seek', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'setvolume', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'setspeed', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'setplaylistitem', (message: any) => { return objectCb.bind(this, message) }); + registerService(service, 'event_subscribed_keys_update', (message: any) => { return objectCb.bind(this, message) }); - service.register('stop', (message: any) => { - playData = null; - - if (message.isSubscription) { - stopClosureCb = voidCb.bind(this, message); - Main.emitter.on('stop', stopClosureCb); - } - - message.respond({ returnValue: true, value: { subscribed: true }}); - }, - (message: any) => { - logger.info('Canceled stop service subscriber'); - Main.emitter.removeAllListeners('stop'); - message.respond({ returnValue: true, value: message.payload }); - }); - - registerService(service, 'seek', (message: any) => { return seekCb.bind(this, message) }); - registerService(service, 'setvolume', (message: any) => { return setVolumeCb.bind(this, message) }); - registerService(service, 'setspeed', (message: any) => { return setSpeedCb.bind(this, message) }); - - const listeners = [Main.tcpListenerService, Main.webSocketListenerService]; - listeners.forEach(l => { - l.emitter.on("play", async (message) => { - await NetworkService.proxyPlayIfRequired(message); - Main.emitter.emit('play', message); - - const appId = 'com.futo.fcast.receiver'; - service.call("luna://com.webos.applicationManager/launch", { - 'id': appId, - 'params': { timestamp: Date.now(), playData: message } - }, (response: any) => { - logger.info(`Launch response: ${JSON.stringify(response)}`); - logger.info(`Relaunching FCast Receiver with args: ${JSON.stringify(message)}`); - }); - }); + Main.listeners = [Main.tcpListenerService, Main.webSocketListenerService]; + Main.listeners.forEach(l => { + l.emitter.on("play", (message: PlayMessage) => Main.play(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("seek", (message: SeekMessage) => Main.emitter.emit('seek', message)); + l.emitter.on("setvolume", (message: SetVolumeMessage) => { + Main.cache.playerVolume = message.volume; + Main.emitter.emit('setvolume', message); + }); + l.emitter.on("setspeed", (message: SetSpeedMessage) => Main.emitter.emit('setspeed', message)); l.emitter.on('connect', (message) => { ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => { @@ -151,48 +130,117 @@ export class Main { l.emitter.on('pong', (message) => { ConnectionMonitor.onPingPong(message, l instanceof WebSocketListenerService); }); + l.emitter.on('initial', (message) => { + logger.info(`Received 'Initial' message from sender: ${message}`); + }); + l.emitter.on("setplaylistitem", (message: SetPlaylistItemMessage) => Main.emitter.emit('setplaylistitem', message)); + l.emitter.on('subscribeevent', (message) => { + const subscribeData = l.subscribeEvent(message.sessionId, message.body.event); + + if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) { + Main.emitter.emit('event_subscribed_keys_update', subscribeData); + } + }); + l.emitter.on('unsubscribeevent', (message) => { + const unsubscribeData = l.unsubscribeEvent(message.sessionId, message.body.event); + + if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) { + Main.emitter.emit('event_subscribed_keys_update', unsubscribeData); + } + }); l.start(); }); service.register("send_playback_error", (message: any) => { - listeners.forEach(l => { - const value: PlaybackErrorMessage = message.payload.error; - l.send(Opcode.PlaybackError, value); - }); - + const value: PlaybackErrorMessage = message.payload.error; + Main.listeners.forEach(l => l.send(Opcode.PlaybackError, value)); message.respond({ returnValue: true, value: { success: true } }); }); service.register("send_playback_update", (message: any) => { // logger.info("In send_playback_update callback"); - - listeners.forEach(l => { - const value: PlaybackUpdateMessage = message.payload.update; - l.send(Opcode.PlaybackUpdate, value); - }); - + const value: PlaybackUpdateMessage = message.payload.update; + Main.listeners.forEach(l => l.send(Opcode.PlaybackUpdate, value)); message.respond({ returnValue: true, value: { success: true } }); }); service.register("send_volume_update", (message: any) => { - listeners.forEach(l => { - const value: VolumeUpdateMessage = message.payload.update; - l.send(Opcode.VolumeUpdate, value); - }); + const value: VolumeUpdateMessage = message.payload.update; + Main.cache.playerVolume = value.volume; + Main.listeners.forEach(l => l.send(Opcode.VolumeUpdate, value)); + message.respond({ returnValue: true, value: { success: true } }); + }); + + service.register("send_event", (message: any) => { + const value: EventMessage = message.payload.event; + Main.listeners.forEach(l => l.send(Opcode.Event, value)); + message.respond({ returnValue: true, value: { success: true } }); + }); + + service.register("play_request", (message: any) => { + const value: PlayMessage = message.payload.message; + const playlistIndex: number = message.payload.playlistIndex; + + logger.debug(`Received play request for index ${playlistIndex}:`, value); + value.url = Main.mediaCache?.has(playlistIndex) ? Main.mediaCache?.getUrl(playlistIndex) : value.url; + Main.mediaCache?.cacheItems(playlistIndex); + Main.play(value); message.respond({ returnValue: true, value: { success: true } }); }); + + // Having to mix and match session ids and ip addresses until querying websocket remote addresses is fixed + service.register("get_sessions", (message: any) => { + message.respond({ + returnValue: true, + value: [].concat(Main.tcpListenerService.getSenders(), Main.webSocketListenerService.getSessions()) + }); + }); + + service.register("network_changed", (message: any) => { + logger.info('Network interfaces have changed', message); + Main.discoveryService.stop(); + Main.discoveryService.start(); + + if (message.payload.fallback) { + message.respond({ + returnValue: true, + value: getAllIPv4Addresses() + }); + } + else { + message.respond({ returnValue: true, value: {} }); + } + }); + + service.register("visibility_changed", (message: any) => { + logger.info('Window visibility has changed', message.payload); + Main.windowVisible = !message.payload.hidden; + Main.windowType = message.payload.window; + message.respond({ returnValue: true, value: {} }); + }); } catch (err) { logger.error("Error initializing service:", err); Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR }); } - } } export function getComputerName() { - return os.hostname(); + return `FCast-${os.hostname()}`; +} + +export function getAppName() { + return Main.cache.appName; +} + +export function getAppVersion() { + return Main.cache.appVersion; +} + +export function getPlayMessage() { + return Main.cache.playMessage; } export async function errorHandler(error: Error) { @@ -219,3 +267,22 @@ function registerService(service: Service, method: string, callback: (message: a message.respond({ returnValue: true, value: message.payload }); }); } + +// Fallback for simulator or TV devices that don't work with the luna://com.palm.connectionmanager/getStatus method +function 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; +} diff --git a/receivers/webos/fcast-receiver/.npmrc b/receivers/webos/fcast-receiver/.npmrc new file mode 100644 index 0000000..6e4ba02 --- /dev/null +++ b/receivers/webos/fcast-receiver/.npmrc @@ -0,0 +1 @@ +@futo:registry=https://gitlab.futo.org/api/v4/projects/305/packages/npm/ diff --git a/receivers/webos/fcast-receiver/appinfo.json b/receivers/webos/fcast-receiver/appinfo.json index ddc742d..6db255e 100644 --- a/receivers/webos/fcast-receiver/appinfo.json +++ b/receivers/webos/fcast-receiver/appinfo.json @@ -1,6 +1,6 @@ { "id": "com.futo.fcast.receiver", - "version": "1.1.0", + "version": "2.0.0", "vendor": "FUTO", "type": "web", "main": "main_window/index.html", diff --git a/receivers/webos/fcast-receiver/lib/common.ts b/receivers/webos/fcast-receiver/lib/common.ts new file mode 100644 index 0000000..5c9e425 --- /dev/null +++ b/receivers/webos/fcast-receiver/lib/common.ts @@ -0,0 +1,98 @@ +const logger = window.targetAPI.logger; +const serviceId = 'com.futo.fcast.receiver.service'; + +export enum RemoteKeyCode { + Stop = 413, + Rewind = 412, + Play = 415, + Pause = 19, + FastForward = 417, + Back = 461, +} + +export function requestService(method: string, successCb: (message: any) => void, failureCb?: (message: any) => void, onCompleteCb?: (message: any) => void): any { + return window.webOS.service.request(`luna://${serviceId}/`, { + method: method, + parameters: {}, + onSuccess: (message: any) => { + if (message.value?.subscribed === true) { + logger.info(`requestService: Registered ${method} handler with service`); + } + else { + successCb(message); + } + }, + onFailure: (message: any) => { + logger.error(`requestService: ${method} ${JSON.stringify(message)}`); + + if (failureCb) { + failureCb(message); + } + }, + onComplete: (message: any) => { + if (onCompleteCb) { + onCompleteCb(message); + } + }, + subscribe: true, + resubscribe: true + }); +} + +export function callService(method: string, parameters?: any, successCb?: (message: any) => void, failureCb?: (message: any) => void, onCompleteCb?: (message: any) => void) { + return window.webOS.service.request(`luna://${serviceId}/`, { + method: method, + parameters: parameters, + onSuccess: (message: any) => { + if (successCb) { + successCb(message); + } + }, + onFailure: (message: any) => { + logger.error(`callService: ${method} ${JSON.stringify(message)}`); + + if (failureCb) { + failureCb(message); + } + }, + onComplete: (message: any) => { + if (onCompleteCb) { + onCompleteCb(message); + } + }, + subscribe: false, + resubscribe: false + }); +} + +export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + let handledCase = false; + let key = ''; + + // .keyCode instead of alternatives is required to work properly on webOS + switch (event.keyCode) { + // Unhandled cases (used for replacing undefined key codes) + case RemoteKeyCode.Stop: + key = 'Stop'; + break; + case RemoteKeyCode.Rewind: + key = 'Rewind'; + break; + case RemoteKeyCode.Play: + key = 'Play'; + break; + case RemoteKeyCode.Pause: + key = 'Pause'; + break; + case RemoteKeyCode.FastForward: + key = 'FastForward'; + break; + case RemoteKeyCode.Back: + key = 'Back'; + break; + default: + break; + } + + return { handledCase: handledCase, key: key }; +}; diff --git a/receivers/webos/fcast-receiver/package-lock.json b/receivers/webos/fcast-receiver/package-lock.json index c7bd729..dbf4e0d 100644 --- a/receivers/webos/fcast-receiver/package-lock.json +++ b/receivers/webos/fcast-receiver/package-lock.json @@ -1,26 +1,26 @@ { "name": "com.futo.fcast.receiver", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "com.futo.fcast.receiver", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "bufferutil": "^4.0.8", "dashjs": "^4.7.4", "hls.js": "^1.5.15", - "http": "^0.0.1-security", - "https": "^1.0.0", + "log4js": "^6.9.1", "qrcode": "^1.5.3", "url": "^0.11.4", - "uuid": "^11.0.3", + "uuid": "^9.0.1", "ws": "^8.18.0" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@futo/mdns-js": "1.0.3", "@types/jest": "^29.5.11", "@types/mdns": "^0.0.38", "@types/node-forge": "^1.3.10", @@ -32,13 +32,15 @@ "eslint": "^9.25.0", "globals": "^16.0.0", "jest": "^29.7.0", - "mdns-js": "github:mdns-js/node-mdns-js", "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", "typescript": "^5.5.4", "typescript-eslint": "^8.4.0", "webpack": "^5.99.6", "webpack-cli": "^6.0.1" + }, + "optionalDependencies": { + "utf-8-validate": "^6.0.5" } }, "node_modules/@ampproject/remapping": { @@ -837,6 +839,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@futo/mdns-js": { + "version": "1.0.3", + "resolved": "https://gitlab.futo.org/api/v4/projects/305/packages/npm/@futo/mdns-js/-/@futo/mdns-js-1.0.3.tgz", + "integrity": "sha1-y25rzWUSYkRu0bhkR472LJLMdcc=", + "dev": true, + "dependencies": { + "debug": "~3.1.0", + "dns-js": "~0.2.1", + "semver": "~5.4.1" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@futo/mdns-js/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@futo/mdns-js/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@futo/mdns-js/node_modules/semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3045,11 +3088,19 @@ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", "license": "MIT" }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3880,7 +3931,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true, "license": "ISC" }, "node_modules/forwarded": { @@ -3903,6 +3953,20 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4071,7 +4135,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -4134,11 +4197,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/http/-/http-0.0.1-security.tgz", - "integrity": "sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==" - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4156,12 +4214,6 @@ "node": ">= 0.8" } }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", - "license": "ISC" - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5261,6 +5313,15 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5378,6 +5439,22 @@ "dev": true, "license": "MIT" }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5430,47 +5507,6 @@ "node": ">= 0.4" } }, - "node_modules/mdns-js": { - "version": "1.0.3", - "resolved": "git+ssh://git@github.com/mdns-js/node-mdns-js.git#4fb9220ec8852bae9e2781917f649821b9df539d", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "~3.1.0", - "dns-js": "~0.2.1", - "semver": "~5.4.1" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mdns-js/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/mdns-js/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdns-js/node_modules/semver": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5575,7 +5611,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/natural-compare": { @@ -6425,6 +6460,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6840,6 +6881,20 @@ "node": ">= 0.8" } }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -7358,6 +7413,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7428,17 +7492,30 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "license": "MIT" }, + "node_modules/utf-8-validate": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.5.tgz", + "integrity": "sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/receivers/webos/fcast-receiver/package.json b/receivers/webos/fcast-receiver/package.json index 291be41..4b0758d 100644 --- a/receivers/webos/fcast-receiver/package.json +++ b/receivers/webos/fcast-receiver/package.json @@ -1,6 +1,6 @@ { "name": "com.futo.fcast.receiver", - "version": "1.1.0", + "version": "2.0.0", "description": "An application implementing a FCast receiver.", "author": "FUTO", "license": "MIT", @@ -10,6 +10,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@futo/mdns-js": "1.0.3", "@types/jest": "^29.5.11", "@types/mdns": "^0.0.38", "@types/node-forge": "^1.3.10", @@ -21,7 +22,6 @@ "eslint": "^9.25.0", "globals": "^16.0.0", "jest": "^29.7.0", - "mdns-js": "github:mdns-js/node-mdns-js", "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", "typescript": "^5.5.4", @@ -33,11 +33,13 @@ "bufferutil": "^4.0.8", "dashjs": "^4.7.4", "hls.js": "^1.5.15", - "http": "^0.0.1-security", - "https": "^1.0.0", + "log4js": "^6.9.1", "qrcode": "^1.5.3", "url": "^0.11.4", - "uuid": "^11.0.3", + "uuid": "^9.0.1", "ws": "^8.18.0" + }, + "optionalDependencies": { + "utf-8-validate": "^6.0.5" } } diff --git a/receivers/webos/fcast-receiver/src/main/Preload.ts b/receivers/webos/fcast-receiver/src/main/Preload.ts index ba8396f..695534c 100644 --- a/receivers/webos/fcast-receiver/src/main/Preload.ts +++ b/receivers/webos/fcast-receiver/src/main/Preload.ts @@ -1,153 +1,139 @@ /* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { preloadData } from 'common/main/Preload'; -import { toast, ToastIcon } from 'common/components/Toast'; +import { ToastIcon } from 'common/components/Toast'; +import { EventMessage } from 'common/Packets'; +import { callService, requestService } from 'lib/common'; require('lib/webOSTVjs-1.2.10/webOSTV.js'); require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); const logger = window.targetAPI.logger; -enum RemoteKeyCode { - Stop = 413, - Rewind = 412, - Play = 415, - Pause = 19, - FastForward = 417, - Back = 461, -} - try { - let getSessions = null; + const serviceId = 'com.futo.fcast.receiver.service'; + let getSessionsService = null; + let networkChangedService = null; + let visibilityChangedService = null; - const toastService = requestService('toast', (message: any) => { toast(message.value.message, message.value.icon, message.value.duration); }); - const getDeviceInfoService = window.webOS.service.request('luna://com.palm.connectionmanager', { - method: 'getStatus', - parameters: {}, + const toastService = requestService('toast', (message: any) => { preloadData.onToastCb(message.value.message, message.value.icon, message.value.duration); }); + const getDeviceInfoService = window.webOSDev.connection.getStatus({ onSuccess: (message: any) => { - // logger.info('Network info status message', message); + logger.info('Network info status message', message); const deviceName = 'FCast-LGwebOSTV'; const connections = []; + let fallback = true; if (message.wired.state !== 'disconnected') { - connections.push({ type: 'wired', name: 'Ethernet', address: message.wired.ipAddress }) + connections.push({ type: 'wired', name: 'Ethernet', address: message.wired.ipAddress }); + fallback = false; } // wifiDirect never seems to be connected, despite being connected (which is needed for signalLevel...) // if (message.wifiDirect.state !== 'disconnected') { if (message.wifi.state !== 'disconnected') { - connections.push({ type: 'wireless', name: message.wifi.ssid, address: message.wifi.ipAddress, signalLevel: 100 }) + connections.push({ type: 'wireless', name: message.wifi.ssid, address: message.wifi.ipAddress, signalLevel: 100 }); + fallback = false; } - preloadData.deviceInfo = { name: deviceName, interfaces: connections }; - preloadData.onDeviceInfoCb(); + if (fallback) { + networkChangedService = callService('network_changed', { fallback: fallback }, (message: any) => { + logger.info('Fallback network interfaces', message); + for (const ipAddr of message.value) { + connections.push({ type: 'wired', name: 'Ethernet', address: ipAddr }); + } + + preloadData.deviceInfo = { name: deviceName, interfaces: connections }; + preloadData.onDeviceInfoCb(); + }, (message: any) => { + logger.error('Main: preload - error fetching network interfaces', message); + preloadData.onToastCb('Error detecting network interfaces', ToastIcon.ERROR); + }, () => { + networkChangedService = null; + }); + } + else { + networkChangedService = callService('network_changed', { fallback: fallback }, null, null, () => { + networkChangedService = null; + }); + preloadData.deviceInfo = { name: deviceName, interfaces: connections }; + preloadData.onDeviceInfoCb(); + } }, onFailure: (message: any) => { logger.error(`Main: com.palm.connectionmanager/getStatus ${JSON.stringify(message)}`); - toast(`Main: com.palm.connectionmanager/getStatus ${JSON.stringify(message)}`, ToastIcon.ERROR); - + preloadData.onToastCb(`Main: com.palm.connectionmanager/getStatus ${JSON.stringify(message)}`, ToastIcon.ERROR); }, - // onComplete: (message) => {}, subscribe: true, resubscribe: true }); + const onEventSubscribedKeysUpdateService = requestService('event_subscribed_keys_update', (message: any) => { preloadData.onEventSubscribedKeysUpdate(message.value); }); window.targetAPI.getSessions(() => { return new Promise((resolve, reject) => { - getSessions = requestService('get_sessions', (message: any) => resolve(message.value), (message: any) => reject(message), false); + getSessionsService = callService('get_sessions', {}, (message: any) => resolve(message.value), (message: any) => reject(message)); }); }); const onConnectService = requestService('connect', (message: any) => { preloadData.onConnectCb(null, message.value); }); const onDisconnectService = requestService('disconnect', (message: any) => { preloadData.onDisconnectCb(null, message.value); }); - const playService = requestService('play', (message: any) => { - if (message.value !== undefined && message.value.playData !== undefined) { - logger.info(`Main: Playing ${JSON.stringify(message)}`); - sessionStorage.setItem('playData', JSON.stringify(message.value.playData)); - getDeviceInfoService.cancel(); - getSessions?.cancel(); - toastService.cancel(); - onConnectService.cancel(); - onDisconnectService.cancel(); - playService.cancel(); + preloadData.sendEventCb = (event: EventMessage) => { + window.webOS.service.request(`luna://${serviceId}/`, { + method: 'send_event', + parameters: { event }, + onSuccess: () => {}, + onFailure: (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); }, + }); + }; - // 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', '_self'); - } + const playService = requestService('play', (message: any) => { + logger.info(`Main: Playing ${JSON.stringify(message)}`); + play(message.value); }); const launchHandler = () => { const params = window.webOSDev.launchParams(); logger.info(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`); + // WebOS 6.0 and earlier: Timestamp tracking seems to be necessary as launch event is raised regardless if app is in foreground or not const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); - if (params.playData !== undefined && params.timestamp != lastTimestamp) { + if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) { localStorage.setItem('lastTimestamp', params.timestamp); - sessionStorage.setItem('playData', JSON.stringify(params.playData)); - toastService?.cancel(); - getDeviceInfoService?.cancel(); - getSessions?.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', '_self'); + play(params.messageInfo); } }; document.addEventListener('webOSLaunch', launchHandler); document.addEventListener('webOSRelaunch', launchHandler); + document.addEventListener('visibilitychange', () => { + visibilityChangedService = callService('visibility_changed', { hidden: document.hidden, window: 'main' }, null, null, () => { + visibilityChangedService = null; + }) + }); // 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) => { - // logger.info("KeyDown", event); + const play = (messageInfo: any) => { + sessionStorage.setItem('playInfo', JSON.stringify(messageInfo)); - 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; - } - }); + getDeviceInfoService?.cancel(); + onEventSubscribedKeysUpdateService?.cancel(); + getSessionsService?.cancel(); + toastService?.cancel(); + onConnectService?.cancel(); + onDisconnectService?.cancel(); + playService?.cancel(); + networkChangedService?.cancel(); + visibilityChangedService?.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(`../${messageInfo.contentViewer}/index.html`, '_self'); + }; } catch (err) { logger.error(`Main: preload ${JSON.stringify(err)}`); - toast(`Error starting the application (preload): ${JSON.stringify(err)}`, ToastIcon.ERROR); -} - -function requestService(method: string, successCallback: (message: any) => void, failureCallback?: (message: any) => void, subscribe: boolean = true): any { - const serviceId = 'com.futo.fcast.receiver.service'; - - return window.webOS.service.request(`luna://${serviceId}/`, { - method: method, - parameters: {}, - onSuccess: (message: any) => { - if (message.value?.subscribed === true) { - logger.info(`Main: Registered ${method} handler with service`); - } - else { - successCallback(message); - } - }, - onFailure: (message: any) => { - logger.error(`Main: ${method} ${JSON.stringify(message)}`); - - if (failureCallback) { - failureCallback(message); - } - }, - // onComplete: (message) => {}, - subscribe: subscribe, - resubscribe: subscribe - }); + preloadData.onToastCb(`Error starting the application: ${JSON.stringify(err)}`, ToastIcon.ERROR); } diff --git a/receivers/webos/fcast-receiver/src/main/Renderer.ts b/receivers/webos/fcast-receiver/src/main/Renderer.ts index a0a00a1..f3df63b 100644 --- a/receivers/webos/fcast-receiver/src/main/Renderer.ts +++ b/receivers/webos/fcast-receiver/src/main/Renderer.ts @@ -1,4 +1,6 @@ import 'common/main/Renderer'; +import { RemoteKeyCode } from 'lib/common'; +import * as common from 'lib/common'; const backgroundVideo = document.getElementById('video-player'); const loadingScreen = document.getElementById('loading-screen'); @@ -30,3 +32,45 @@ backgroundVideo.onplaying = () => { export function onQRCodeRendered() { qrCodeRendered = true; } + +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + let handledCase = false; + let key = ''; + + switch (event.keyCode) { + // Unhandled cases (used for replacing undefined key codes) + case RemoteKeyCode.Stop: + key = 'Stop'; + break; + case RemoteKeyCode.Rewind: + key = 'Rewind'; + break; + case RemoteKeyCode.Play: + key = 'Play'; + break; + case RemoteKeyCode.Pause: + key = 'Pause'; + break; + case RemoteKeyCode.FastForward: + key = 'FastForward'; + break; + + // Handled cases + + // WebOS 22 and earlier does not work well using the history API, + // so manually handling page navigation... + case RemoteKeyCode.Back: + window.webOS.platformBack(); + handledCase = true; + key = 'Back'; + break; + default: + break; + } + + return { handledCase: handledCase, key: key }; +}; + +export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + return common.targetKeyUpEventListener(event); +}; diff --git a/receivers/webos/fcast-receiver/src/main/index.html b/receivers/webos/fcast-receiver/src/main/index.html index bcf0710..2046724 100644 --- a/receivers/webos/fcast-receiver/src/main/index.html +++ b/receivers/webos/fcast-receiver/src/main/index.html @@ -15,6 +15,7 @@
+ diff --git a/receivers/webos/fcast-receiver/src/player/Preload.ts b/receivers/webos/fcast-receiver/src/player/Preload.ts index 96dc4fb..1e5e174 100644 --- a/receivers/webos/fcast-receiver/src/player/Preload.ts +++ b/receivers/webos/fcast-receiver/src/player/Preload.ts @@ -1,19 +1,21 @@ /* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { preloadData } from 'common/player/Preload'; -import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets'; +import { EventMessage, PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, VolumeUpdateMessage } from 'common/Packets'; +import { callService, requestService } from 'lib/common'; import { toast, ToastIcon } from 'common/components/Toast'; require('lib/webOSTVjs-1.2.10/webOSTV.js'); require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); const logger = window.targetAPI.logger; +const serviceId = 'com.futo.fcast.receiver.service'; try { - const serviceId = 'com.futo.fcast.receiver.service'; let getSessions = null; window.webOSAPI = { - pendingPlay: JSON.parse(sessionStorage.getItem('playData')) + pendingPlay: JSON.parse(sessionStorage.getItem('playInfo')) }; + const contentViewer = window.webOSAPI.pendingPlay?.contentViewer; preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { window.webOS.service.request(`luna://${serviceId}/`, { @@ -48,45 +50,73 @@ try { }, }); }; + preloadData.sendEventCb = (event: EventMessage) => { + window.webOS.service.request(`luna://${serviceId}/`, { + method: 'send_event', + parameters: { event }, + onSuccess: () => {}, + onFailure: (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); }, + }); + }; - const playService = window.webOS.service.request(`luna://${serviceId}/`, { - method:"play", - parameters: {}, - onSuccess: (message: any) => { - // logger.info(JSON.stringify(message)); - if (message.value.subscribed === true) { - logger.info('Player: Registered play handler with service'); - } + const playService = requestService('play', (message: any) => { + if (contentViewer !== message.value.contentViewer) { + playService?.cancel(); + pauseService?.cancel(); + resumeService?.cancel(); + stopService?.cancel(); + seekService?.cancel(); + setVolumeService?.cancel(); + setSpeedService?.cancel(); + onSetPlaylistItemService?.cancel(); + getSessions?.cancel(); + onEventSubscribedKeysUpdateService?.cancel(); + onConnectService?.cancel(); + onDisconnectService?.cancel(); + onPlayPlaylistService?.cancel(); - if (message.value.playData !== null) { + // 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(`../${message.value.contentViewer}/index.html`, '_self'); + } + else { + if (message.value.rendererEvent === 'play-playlist') { if (preloadData.onPlayCb === undefined) { - window.webOSAPI.pendingPlay = message.value.playData; + window.webOSAPI.pendingPlay = message.value; } else { - preloadData.onPlayCb(null, message.value.playData); + preloadData.onPlayPlaylistCb(null, message.value.rendererMessage); } } - }, - onFailure: (message: any) => { - logger.error(`Player: play ${JSON.stringify(message)}`); - }, - subscribe: true, - resubscribe: true + else { + if (preloadData.onPlayCb === undefined) { + window.webOSAPI.pendingPlay = message.value; + } + else { + preloadData.onPlayCb(null, message.value.rendererMessage); + } + } + } + }, (message: any) => { + logger.error(`Player: play ${JSON.stringify(message)}`); }); - const pauseService = requestService('pause', () => { preloadData.onPauseCb(); }); const resumeService = requestService('resume', () => { preloadData.onResumeCb(); }); const stopService = requestService('stop', () => { - playService.cancel(); - pauseService.cancel(); - resumeService.cancel(); - stopService.cancel(); - seekService.cancel(); - setVolumeService.cancel(); - setSpeedService.cancel(); + playService?.cancel(); + pauseService?.cancel(); + resumeService?.cancel(); + stopService?.cancel(); + seekService?.cancel(); + setVolumeService?.cancel(); + setSpeedService?.cancel(); + onSetPlaylistItemService?.cancel(); getSessions?.cancel(); - onConnectService.cancel(); - onDisconnectService.cancel(); + onEventSubscribedKeysUpdateService?.cancel(); + onConnectService?.cancel(); + onDisconnectService?.cancel(); + onPlayPlaylistService?.cancel(); // WebOS 22 and earlier does not work well using the history API, // so manually handling page navigation... @@ -97,25 +127,38 @@ try { const seekService = requestService('seek', (message: any) => { preloadData.onSeekCb(null, message.value); }); const setVolumeService = requestService('setvolume', (message: any) => { preloadData.onSetVolumeCb(null, message.value); }); const setSpeedService = requestService('setspeed', (message: any) => { preloadData.onSetSpeedCb(null, message.value); }); + const onSetPlaylistItemService = requestService('setplaylistitem', (message: any) => { preloadData.onSetPlaylistItemCb(null, message.value); }); + preloadData.sendPlayRequestCb = (message: PlayMessage, playlistIndex: number) => { + window.webOS.service.request(`luna://${serviceId}/`, { + method: 'play_request', + parameters: { message: message, playlistIndex: playlistIndex }, + onSuccess: () => {}, + onFailure: (message: any) => { logger.error(`Player: play_request ${playlistIndex} ${JSON.stringify(message)}`); }, + }); + }; window.targetAPI.getSessions(() => { return new Promise((resolve, reject) => { - getSessions = requestService('get_sessions', (message: any) => resolve(message.value), (message: any) => reject(message), false); + getSessions = callService('get_sessions', {}, (message: any) => resolve(message.value), (message: any) => reject(message)); }); }); + const onEventSubscribedKeysUpdateService = requestService('event_subscribed_keys_update', (message: any) => { preloadData.onEventSubscribedKeysUpdate(message.value); }); const onConnectService = requestService('connect', (message: any) => { preloadData.onConnectCb(null, message.value); }); const onDisconnectService = requestService('disconnect', (message: any) => { preloadData.onDisconnectCb(null, message.value); }); + const onPlayPlaylistService = requestService('play-playlist', (message: any) => { preloadData.onPlayPlaylistCb(null, message.value); }); const launchHandler = () => { // args don't seem to be passed in via event despite what documentation says... const params = window.webOSDev.launchParams(); logger.info(`Player: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`); + // WebOS 6.0 and earlier: Timestamp tracking seems to be necessary as launch event is raised regardless if app is in foreground or not const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); - if (params.playData !== undefined && params.timestamp != lastTimestamp) { + if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) { localStorage.setItem('lastTimestamp', params.timestamp); - sessionStorage.setItem('playData', JSON.stringify(params.playData)); + sessionStorage.setItem('playInfo', JSON.stringify(params.messageInfo)); + playService?.cancel(); pauseService?.cancel(); resumeService?.cancel(); @@ -123,49 +166,26 @@ try { seekService?.cancel(); setVolumeService?.cancel(); setSpeedService?.cancel(); + onSetPlaylistItemService?.cancel(); getSessions?.cancel(); + onEventSubscribedKeysUpdateService?.cancel(); onConnectService?.cancel(); onDisconnectService?.cancel(); + onPlayPlaylistService?.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', '_self'); + window.open(`../${params.messageInfo.contentViewer}/index.html`, '_self'); } }; document.addEventListener('webOSLaunch', launchHandler); document.addEventListener('webOSRelaunch', launchHandler); + document.addEventListener('visibilitychange', () => callService('visibility_changed', { hidden: document.hidden, window: contentViewer })); } catch (err) { logger.error(`Player: preload ${JSON.stringify(err)}`); toast(`Error starting the video player (preload): ${JSON.stringify(err)}`, ToastIcon.ERROR); } - -function requestService(method: string, successCallback: (message: any) => void, failureCallback?: (message: any) => void, subscribe: boolean = true): any { - const serviceId = 'com.futo.fcast.receiver.service'; - - return window.webOS.service.request(`luna://${serviceId}/`, { - method: method, - parameters: {}, - onSuccess: (message: any) => { - if (message.value?.subscribed === true) { - logger.info(`Player: Registered ${method} handler with service`); - } - else { - successCallback(message); - } - }, - onFailure: (message: any) => { - logger.error(`Main: ${method} ${JSON.stringify(message)}`); - - if (failureCallback) { - failureCallback(message); - } - }, - // onComplete: (message) => {}, - subscribe: subscribe, - resubscribe: subscribe - }); -} diff --git a/receivers/webos/fcast-receiver/src/player/Renderer.ts b/receivers/webos/fcast-receiver/src/player/Renderer.ts index 46d84b3..1961153 100644 --- a/receivers/webos/fcast-receiver/src/player/Renderer.ts +++ b/receivers/webos/fcast-receiver/src/player/Renderer.ts @@ -1,6 +1,7 @@ import { isLive, onPlay, + onPlayPlaylist, player, PlayerControlEvent, playerCtrlCaptions, @@ -20,20 +21,13 @@ import { skipBack, skipForward, } from 'common/player/Renderer'; +import { RemoteKeyCode } from 'lib/common'; +import * as common from 'lib/common'; const captionsBaseHeightCollapsed = 150; const captionsBaseHeightExpanded = 320; const captionsLineHeight = 68; -enum RemoteKeyCode { - Stop = 413, - Rewind = 412, - Play = 415, - Pause = 19, - FastForward = 417, - Back = 461, -} - export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean { let handledCase = false; @@ -84,21 +78,23 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean return handledCase; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function targetKeyDownEventListener(event: any): boolean { +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { let handledCase = false; + let key = ''; switch (event.keyCode) { case RemoteKeyCode.Stop: // history.back(); window.open('../main_window/index.html', '_self'); handledCase = true; + key = 'Stop'; break; case RemoteKeyCode.Rewind: skipBack(); event.preventDefault(); handledCase = true; + key = 'Rewind'; break; case RemoteKeyCode.Play: @@ -107,6 +103,7 @@ export function targetKeyDownEventListener(event: any): boolean { } event.preventDefault(); handledCase = true; + key = 'Play'; break; case RemoteKeyCode.Pause: if (!player.isPaused()) { @@ -114,12 +111,14 @@ export function targetKeyDownEventListener(event: any): boolean { } event.preventDefault(); handledCase = true; + key = 'Pause'; break; case RemoteKeyCode.FastForward: skipForward(); event.preventDefault(); handledCase = true; + key = 'FastForward'; break; // WebOS 22 and earlier does not work well using the history API, @@ -129,17 +128,27 @@ export function targetKeyDownEventListener(event: any): boolean { window.open('../main_window/index.html', '_self'); event.preventDefault(); handledCase = true; + key = 'Back'; break; default: break; } - return handledCase; + return { handledCase: handledCase, key: key }; +}; + +export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + return common.targetKeyUpEventListener(event); }; if (window.webOSAPI.pendingPlay !== null) { - onPlay(null, window.webOSAPI.pendingPlay); + if (window.webOSAPI.pendingPlay.rendererEvent === 'play-playlist') { + onPlayPlaylist(null, window.webOSAPI.pendingPlay.rendererMessage); + } + else { + onPlay(null, window.webOSAPI.pendingPlay.rendererMessage); + } } export { diff --git a/receivers/webos/fcast-receiver/src/player/index.html b/receivers/webos/fcast-receiver/src/player/index.html index b53e37a..ac23ed7 100644 --- a/receivers/webos/fcast-receiver/src/player/index.html +++ b/receivers/webos/fcast-receiver/src/player/index.html @@ -10,7 +10,12 @@ +
+
+
+ +
@@ -32,7 +37,9 @@
+
+
diff --git a/receivers/webos/fcast-receiver/src/viewer/Renderer.ts b/receivers/webos/fcast-receiver/src/viewer/Renderer.ts new file mode 100644 index 0000000..d758edf --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/Renderer.ts @@ -0,0 +1,78 @@ +import { PlayerControlEvent, playerCtrlStateUpdate, onPlay, onPlayPlaylist, setPlaylistItem, playlistIndex } from 'common/viewer/Renderer'; +import { RemoteKeyCode } from 'lib/common'; +import * as common from 'lib/common'; + +export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean { + let handledCase = false; + return handledCase; +} + +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + let handledCase = false; + let key = ''; + + switch (event.keyCode) { + case RemoteKeyCode.Stop: + // history.back(); + window.open('../main_window/index.html', '_self'); + event.preventDefault(); + handledCase = true; + key = 'Stop'; + break; + + case RemoteKeyCode.Rewind: + setPlaylistItem(playlistIndex - 1); + event.preventDefault(); + handledCase = true; + key = 'Rewind'; + break; + + case RemoteKeyCode.Play: + playerCtrlStateUpdate(PlayerControlEvent.Play); + event.preventDefault(); + handledCase = true; + key = 'Play'; + break; + case RemoteKeyCode.Pause: + playerCtrlStateUpdate(PlayerControlEvent.Pause); + event.preventDefault(); + handledCase = true; + key = 'Pause'; + break; + + case RemoteKeyCode.FastForward: + setPlaylistItem(playlistIndex + 1); + event.preventDefault(); + handledCase = true; + key = 'FastForward'; + break; + + // WebOS 22 and earlier does not work well using the history API, + // so manually handling page navigation... + case RemoteKeyCode.Back: + // history.back(); + window.open('../main_window/index.html', '_self'); + event.preventDefault(); + handledCase = true; + key = 'Back'; + break; + + default: + break; + } + + return { handledCase: handledCase, key: key }; +}; + +export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + return common.targetKeyUpEventListener(event); +}; + +if (window.webOSAPI.pendingPlay !== null) { + if (window.webOSAPI.pendingPlay.rendererEvent === 'play-playlist') { + onPlayPlaylist(null, window.webOSAPI.pendingPlay.rendererMessage); + } + else { + onPlay(null, window.webOSAPI.pendingPlay.rendererMessage); + } +} diff --git a/receivers/webos/fcast-receiver/src/viewer/index.html b/receivers/webos/fcast-receiver/src/viewer/index.html new file mode 100644 index 0000000..6e36154 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/index.html @@ -0,0 +1,45 @@ + + + + FCast Receiver + + + + + + + + + + +
+
+
+ + +
> + +
+
+
+
+
+ + + + +
+ + +
+ +
+
+
+
+ + + + diff --git a/receivers/webos/fcast-receiver/src/viewer/style.css b/receivers/webos/fcast-receiver/src/viewer/style.css new file mode 100644 index 0000000..f9f22a9 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/style.css @@ -0,0 +1 @@ +/* Stub for future use */ diff --git a/receivers/webos/fcast-receiver/webpack.config.js b/receivers/webos/fcast-receiver/webpack.config.js index af8b4f7..9ce297c 100644 --- a/receivers/webos/fcast-receiver/webpack.config.js +++ b/receivers/webos/fcast-receiver/webpack.config.js @@ -18,13 +18,18 @@ module.exports = [ preload: './src/main/Preload.ts', renderer: './src/main/Renderer.ts', }, - target: 'web', + target: ['web', 'es5'], module: { rules: [ { test: /\.tsx?$/, include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')], use: [{ loader: 'ts-loader' }] + }, + { + test: /\.tsx?$/, + include: [path.resolve(__dirname, 'lib'), path.resolve(__dirname, 'src')], + use: [{ loader: 'ts-loader' }] } ], }, @@ -88,13 +93,18 @@ module.exports = [ preload: './src/player/Preload.ts', renderer: './src/player/Renderer.ts', }, - target: 'web', + target: ['web', 'es5'], module: { rules: [ { test: /\.tsx?$/, include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')], use: [{ loader: 'ts-loader' }] + }, + { + test: /\.tsx?$/, + include: [path.resolve(__dirname, 'lib'), path.resolve(__dirname, 'src')], + use: [{ loader: 'ts-loader' }] } ], }, @@ -130,4 +140,58 @@ module.exports = [ }) ] }, + { + mode: buildMode, + entry: { + // Player preload is intentionally reused + preload: './src/player/Preload.ts', + renderer: './src/viewer/Renderer.ts', + }, + target: ['web', 'es5'], + module: { + rules: [ + { + test: /\.tsx?$/, + include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')], + use: [{ loader: 'ts-loader' }] + }, + { + test: /\.tsx?$/, + include: [path.resolve(__dirname, 'lib'), path.resolve(__dirname, 'src')], + use: [{ loader: 'ts-loader' }] + } + ], + }, + resolve: { + alias: { + 'src': path.resolve(__dirname, 'src'), + 'lib': path.resolve(__dirname, 'lib'), + 'modules': path.resolve(__dirname, 'node_modules'), + 'common': path.resolve(__dirname, '../../common/web'), + }, + extensions: ['.tsx', '.ts', '.js'], + }, + output: { + filename: '[name].js', + path: path.resolve(__dirname, 'dist/viewer'), + }, + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + { + from: '../../common/web/viewer/common.css', + to: '[name][ext]', + }, + { + from: './src/viewer/*', + to: '[name][ext]', + globOptions: { ignore: ['**/*.ts'] } + } + ], + }), + new webpack.DefinePlugin({ + TARGET: JSON.stringify(TARGET) + }) + ] + } ];