diff --git a/receivers/common/web/FCastSession.ts b/receivers/common/web/FCastSession.ts index b2a8d4b..541e6ff 100644 --- a/receivers/common/web/FCastSession.ts +++ b/receivers/common/web/FCastSession.ts @@ -61,31 +61,7 @@ export class FCastSession { const size = 1 + data.length; const header = Buffer.alloc(4 + 1); - - // webOS 22 and earlier node versions do not support `writeUint32LE`, - // so manually checking endianness and writing as LE - // @ts-ignore - if (TARGET === 'webOS') { - let uInt32 = new Uint32Array([0x11223344]); - let uInt8 = new Uint8Array(uInt32.buffer); - - if(uInt8[0] === 0x44) { - // LE - header[0] = size & 0xFF; - header[1] = size & 0xFF00; - header[2] = size & 0xFF0000; - header[3] = size & 0xFF000000; - } else if (uInt8[0] === 0x11) { - // BE - header[0] = size & 0xFF000000; - header[1] = size & 0xFF0000; - header[2] = size & 0xFF00; - header[3] = size & 0xFF; - } - } else { - header.writeUint32LE(size, 0); - } - + header.writeUint32LE(size, 0); header[4] = opcode; let packet: Buffer; diff --git a/receivers/common/web/UtilityFrontend.ts b/receivers/common/web/UtilityFrontend.ts index df29f9d..8daa802 100644 --- a/receivers/common/web/UtilityFrontend.ts +++ b/receivers/common/web/UtilityFrontend.ts @@ -22,6 +22,8 @@ export class Timer { private delay: number; private startTime: number; private remainingTime: number; + private enabled: boolean; + public started: boolean; constructor(callback: () => void, delay: number, autoStart: boolean = true) { @@ -29,6 +31,7 @@ export class Timer { this.callback = callback; this.delay = delay; this.started = false; + this.enabled = true; if (autoStart) { this.start(); @@ -36,20 +39,22 @@ export class Timer { } public start(delay?: number) { - this.delay = delay ? delay : this.delay; + if (this.enabled) { + this.delay = delay ? delay : this.delay; - if (this.handle) { - window.clearTimeout(this.handle); + if (this.handle) { + window.clearTimeout(this.handle); + } + + this.started = true; + this.startTime = Date.now(); + this.remainingTime = null; + this.handle = window.setTimeout(this.callback, this.delay); } - - this.started = true; - this.startTime = Date.now(); - this.remainingTime = null; - this.handle = window.setTimeout(this.callback, this.delay); } public pause() { - if (this.handle) { + if (this.enabled && this.handle) { window.clearTimeout(this.handle); this.handle = null; this.remainingTime = this.delay - (Date.now() - this.startTime); @@ -57,7 +62,7 @@ export class Timer { } public resume() { - if (this.remainingTime) { + if (this.enabled && this.remainingTime) { this.start(this.remainingTime); } } @@ -70,4 +75,32 @@ export class Timer { this.started = false; } } + + public end() { + this.stop(); + this.callback(); + } + + public enable() { + this.enabled = true; + } + + public disable() { + this.enabled = false; + this.stop(); + } + + public setDelay(delay: number) { + this.stop(); + this.delay = delay; + } + + public setCallback(callback: () => void) { + this.stop(); + this.callback = callback; + } + + public isPaused(): boolean { + return this.remainingTime !== null; + } } diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts index b3fe643..843a879 100644 --- a/receivers/common/web/main/Preload.ts +++ b/receivers/common/web/main/Preload.ts @@ -84,11 +84,20 @@ 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.initializeSubscribedKeysCb = () => { logger.error('Main: Callback not set while calling initializeSubscribedKeys'); }; + 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: string[], keyUp: string[] }) => { + preloadData.subscribedKeys.keyDown = new Set(value.keyDown); + preloadData.subscribedKeys.keyUp = new Set(value.keyUp); + }; + + preloadData.onToast = (message: string, icon: ToastIcon = ToastIcon.INFO, duration: number = 5000) => { + toast(message, icon, duration); }; window.targetAPI = { @@ -102,8 +111,21 @@ if (TARGET === 'electron') { return preloadData.getSessionsCb(); } }, + initializeSubscribedKeys: (callback?: () => Promise<{ keyDown: string[], keyUp: string[] }>) => { + if (callback) { + preloadData.initializeSubscribedKeysCb = callback; + } + else { + preloadData.initializeSubscribedKeysCb().then((value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = new Set(value.keyDown); + preloadData.subscribedKeys.keyUp = new Set(value.keyUp); + }); + } + }, + 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..f8b737e 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'); @@ -202,13 +203,45 @@ function renderQRCode(url: string) { onQRCodeRendered(); } -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))); +export function keyDownEventHandler(event: KeyboardEvent) { + // 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.toLowerCase()) { + default: + break; + } } -}); -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))); + + if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase))); } -}); +} + +export function keyUpEventHandler(event: KeyboardEvent) { + // 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.toLowerCase()) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, key, event.repeat, handledCase))); + } +} + +document.addEventListener('keydown', keyDownEventHandler); +document.addEventListener('keyup', keyUpEventHandler); diff --git a/receivers/common/web/player/Player.ts b/receivers/common/web/player/Player.ts index c7f62fd..c0a13af 100644 --- a/receivers/common/web/player/Player.ts +++ b/receivers/common/web/player/Player.ts @@ -14,6 +14,8 @@ export class Player { private player: HTMLVideoElement; private playMessage: PlayMessage; private source: string; + private playCb: any; + private pauseCb: any; // Todo: use a common event handler interface instead of exposing internal players public playerType: PlayerType; @@ -23,6 +25,8 @@ export class Player { constructor(player: HTMLVideoElement, message: PlayMessage) { this.player = player; this.playMessage = message; + this.playCb = null; + this.pauseCb = null; if (message.container === 'application/dash+xml') { this.playerType = PlayerType.Dash; @@ -110,6 +114,8 @@ export class Player { this.hlsPlayer = null; this.playMessage = null; this.source = null; + this.playCb = null; + this.pauseCb = null; } /** @@ -143,6 +149,10 @@ export class Player { } else { // HLS, HTML this.player.play(); } + + if (this.playCb) { + this.playCb(); + } } public isPaused(): boolean { @@ -161,6 +171,15 @@ export class Player { } else { // HLS, HTML this.player.pause(); } + + if (this.pauseCb) { + this.pauseCb(); + } + } + + public setPlayPauseCallback(playCallback: (() => void), pauseCallback: (() => void)) { + this.playCb = playCallback; + this.pauseCb = pauseCallback; } public stop() { diff --git a/receivers/common/web/player/Preload.ts b/receivers/common/web/player/Preload.ts index 1847122..3d44b5c 100644 --- a/receivers/common/web/player/Preload.ts +++ b/receivers/common/web/player/Preload.ts @@ -19,7 +19,6 @@ declare global { interface Window { electronAPI: any; tizenOSAPI: any; - webOSAPI: any; webOS: any; targetAPI: any; } @@ -75,32 +74,49 @@ 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.initializeSubscribedKeysCb = () => { logger.error('Player: Callback "initializeSubscribedKeys" not set'); }; + preloadData.onConnectCb = () => { logger.warn('Player: Callback "onConnect" not set'); }; + preloadData.onDisconnectCb = () => { logger.warn('Player: Callback "onDisconnect" not set'); }; + preloadData.onPlayPlaylistCb = () => { logger.error('Player: Callback "onPlayPlaylist" not set'); }; + + preloadData.onEventSubscribedKeysUpdate = (value: { keyDown: string[], keyUp: string[] }) => { + preloadData.subscribedKeys.keyDown = new Set(value.keyDown); + preloadData.subscribedKeys.keyUp = new Set(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,21 @@ if (TARGET === 'electron') { return preloadData.getSessionsCb(); } }, + initializeSubscribedKeys: (callback?: () => Promise<{ keyDown: string[], keyUp: string[] }>) => { + if (callback) { + preloadData.initializeSubscribedKeysCb = callback; + } + else { + preloadData.initializeSubscribedKeysCb().then((value: { keyDown: Set, keyUp: Set }) => { + preloadData.subscribedKeys.keyDown = new Set(value.keyDown); + preloadData.subscribedKeys.keyUp = new Set(value.keyUp); + }); + } + }, + 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..ec083b3 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; @@ -39,7 +40,7 @@ const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer") const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress"); const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition"); const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle"); -const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea"); +const playerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea"); const playerCtrlVolumeBar = document.getElementById("volumeBar"); const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress"); @@ -79,10 +80,7 @@ let playlistIndex = 0; let isMediaItem = false; let playItemCached = false; -let uiHideTimer = new Timer(() => { - uiVisible = false; - playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); -}, 3000); +let uiHideTimer = new Timer(() => { playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); }, 3000); let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false); let showDurationTimer = new Timer(mediaEndHandler, 0, false); let mediaTitleShowTimer = new Timer(() => { mediaTitle.style.display = 'none'; }, 5000); @@ -566,6 +564,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) { } case PlayerControlEvent.UiFadeOut: { + uiVisible = false; document.body.style.cursor = "none"; playerControls.style.opacity = '0'; captionsBaseHeight = captionsBaseHeightCollapsed; @@ -581,6 +580,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) { } case PlayerControlEvent.UiFadeIn: { + uiVisible = true; document.body.style.cursor = "default"; playerControls.style.opacity = '1'; captionsBaseHeight = captionsBaseHeightExpanded; @@ -643,7 +643,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) { function scrubbingMouseUIHandler(e: MouseEvent) { const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft; - const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2); + const progressBarWidth = playerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2); let time = isLive ? Math.round((1 - (progressBarOffset / progressBarWidth)) * player?.getDuration()) : Math.round((progressBarOffset / progressBarWidth) * player?.getDuration()); time = Math.min(player?.getDuration(), Math.max(0.0, time)); @@ -656,7 +656,7 @@ function scrubbingMouseUIHandler(e: MouseEvent) { playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time); let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2); - offset = Math.min(PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset)); + offset = Math.min(playerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBarPosition.offsetWidth / 1), Math.max(8, offset)); playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`); } @@ -673,21 +673,21 @@ playerCtrlPlayPrevious.onclick = () => { setPlaylistItem(playlistIndex - 1); } playerCtrlPlayNext.onclick = () => { setPlaylistItem(playlistIndex + 1); } playerCtrlVolume.onclick = () => { player?.setMute(!player?.isMuted()); }; -PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) }; -PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; }; -PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => { +playerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) }; +playerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; }; +playerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => { if (e.buttons === 0) { volumeChanging = false; } scrubbingMouseUIHandler(e); }; -PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); }; -PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) }; +playerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); }; +playerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) }; function scrubbingMouseHandler(e: MouseEvent) { const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft; - const progressBarWidth = PlayerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2); + const progressBarWidth = playerCtrlProgressBarInteractiveArea.offsetWidth - (playerCtrlProgressBar.offsetLeft * 2); let time = Math.round((progressBarOffset / progressBarWidth) * player?.getDuration()); time = Math.min(player?.getDuration(), Math.max(0.0, time)); @@ -879,17 +879,11 @@ function stopUiHideTimer() { uiHideTimer.stop(); if (!uiVisible) { - uiVisible = true; playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); } } -document.onmouseout = () => { - uiHideTimer.stop(); - uiVisible = false; - playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); -} - +document.onmouseout = () => { uiHideTimer.end(); } document.onmousemove = () => { stopUiHideTimer(); @@ -909,31 +903,91 @@ document.addEventListener('click', (event: MouseEvent) => { }); // Add the keydown event listener to the document -const skipInterval = 10; +const minSkipInterval = 10; const volumeIncrement = 0.1; -function keyDownEventListener(event: KeyboardEvent) { - // logger.info("KeyDown", event); - let handledCase = targetKeyDownEventListener(event); +let skipBackRepeat = false; +let skipBackInterval = minSkipInterval; +let skipBackIntervalIncrease = false; +let skipBackTimer = new Timer(() => { skipBackIntervalIncrease = true; }, 2000, false); + +let skipForwardRepeat = false; +let skipForwardInterval = minSkipInterval; +let skipForwardIntervalIncrease = false; +let skipForwardTimer = new Timer(() => { skipForwardIntervalIncrease = true; }, 2000, false); + +function skipBack(repeat: boolean = false) { + if (!skipBackRepeat && repeat) { + skipBackRepeat = true; + skipBackTimer.start(); + } + else if (skipBackRepeat && skipBackIntervalIncrease && repeat) { + skipBackInterval = skipBackInterval === 10 ? 30 : Math.min(skipBackInterval + 30, 300); + skipBackIntervalIncrease = false; + skipBackTimer.start(); + } + else if (!repeat) { + skipBackTimer.stop(); + skipBackRepeat = false; + skipBackIntervalIncrease = false; + skipBackInterval = minSkipInterval; + } + + player?.setCurrentTime(Math.max(player?.getCurrentTime() - skipBackInterval, 0)); + // Force time update since player triggered update only occurs in real-time if skipping within loaded buffer + playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); +} + +function skipForward(repeat: boolean = false) { + if (!skipForwardRepeat && repeat) { + skipForwardRepeat = true; + skipForwardTimer.start(); + } + else if (skipForwardRepeat && skipForwardIntervalIncrease && repeat) { + skipForwardInterval = skipForwardInterval === 10 ? 30 : Math.min(skipForwardInterval + 30, 300); + skipForwardIntervalIncrease = false; + skipForwardTimer.start(); + } + else if (!repeat) { + skipForwardTimer.stop(); + skipForwardRepeat = false; + skipForwardIntervalIncrease = false; + skipForwardInterval = minSkipInterval; + } + + if (!isLivePosition) { + player?.setCurrentTime(Math.min(player?.getCurrentTime() + skipForwardInterval, player?.getDuration())); + // Force time update since player triggered update only occurs in real-time if skipping within loaded buffer + playerCtrlStateUpdate(PlayerControlEvent.TimeUpdate); + } +} + +function keyDownEventHandler(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.code) { - case 'ArrowLeft': - skipBack(); + switch (event.key.toLowerCase()) { + case 'arrowleft': + skipBack(event.repeat); event.preventDefault(); handledCase = true; break; - case 'ArrowRight': - skipForward(); + case 'arrowright': + skipForward(event.repeat); event.preventDefault(); handledCase = true; break; - case "Home": + case "home": player?.setCurrentTime(0); event.preventDefault(); handledCase = true; break; - case "End": + case "end": if (isLive) { setLivePosition(); } @@ -943,9 +997,9 @@ function keyDownEventListener(event: KeyboardEvent) { event.preventDefault(); handledCase = true; break; - case 'KeyK': - case 'Space': - case 'Enter': + case 'k': + case ' ': + case 'enter': // Play/pause toggle if (player?.isPaused()) { player?.play(); @@ -955,17 +1009,17 @@ function keyDownEventListener(event: KeyboardEvent) { event.preventDefault(); handledCase = true; break; - case 'KeyM': + case 'm': // Mute toggle player?.setMute(!player?.isMuted()); handledCase = true; break; - case 'ArrowUp': + case 'arrowup': // Volume up volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); handledCase = true; break; - case 'ArrowDown': + case 'arrowdown': // Volume down volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); handledCase = true; @@ -975,27 +1029,33 @@ 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))); } } -function skipBack() { - player?.setCurrentTime(Math.max(player?.getCurrentTime() - skipInterval, 0)); -} +function keyUpEventHandler(event: KeyboardEvent) { + // logger.info("KeyUp", event); + let result = targetKeyUpEventListener(event); + let handledCase = result.handledCase; -function skipForward() { - if (!isLivePosition) { - player?.setCurrentTime(Math.min(player?.getCurrentTime() + skipInterval, player?.getDuration())); + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase) { + switch (event.key.toLowerCase()) { + default: + break; + } + } + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, 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))); - } -}); +document.addEventListener('keydown', keyDownEventHandler); +document.addEventListener('keyup', keyUpEventHandler); export { PlayerControlEvent, @@ -1004,24 +1064,20 @@ export { idleIcon, videoElement, videoCaptions, - playerCtrlProgressBar, - playerCtrlProgressBarBuffer, - playerCtrlProgressBarProgress, playerCtrlProgressBarHandle, - playerCtrlVolumeBar, - playerCtrlVolumeBarProgress, - playerCtrlVolumeBarHandle, - playerCtrlLiveBadge, - playerCtrlPosition, - playerCtrlDuration, playerCtrlCaptions, player, + uiHideTimer, isLive, + playlistIndex, captionsBaseHeight, captionsLineHeight, onPlay, + onPlayPlaylist, + setPlaylistItem, playerCtrlStateUpdate, - formatDuration, skipBack, skipForward, + keyDownEventHandler, + keyUpEventHandler, }; diff --git a/receivers/common/web/player/common.css b/receivers/common/web/player/common.css index 953eed7..cc94268 100644 --- a/receivers/common/web/player/common.css +++ b/receivers/common/web/player/common.css @@ -417,7 +417,6 @@ body { .captions_off { cursor: pointer; - display: none; background-image: url("../assets/icons/player/icon24_cc_off.svg"); transition: background-image 0.1s ease-in-out; @@ -429,7 +428,6 @@ body { .captions_on { cursor: pointer; - display: none; background-image: url("../assets/icons/player/icon24_cc_on.svg"); transition: background-image 0.1s ease-in-out; diff --git a/receivers/common/web/viewer/Renderer.ts b/receivers/common/web/viewer/Renderer.ts index 74e8435..1ec790a 100644 --- a/receivers/common/web/viewer/Renderer.ts +++ b/receivers/common/web/viewer/Renderer.ts @@ -5,7 +5,9 @@ import * as connectionMonitor from 'common/ConnectionMonitor'; import { toast, ToastIcon } from 'common/components/Toast'; import { targetPlayerCtrlStateUpdate, + targetPlayerCtrlPostStateUpdate, targetKeyDownEventListener, + targetKeyUpEventListener, } from 'src/viewer/Renderer'; const logger = window.targetAPI.logger; @@ -33,10 +35,7 @@ let isMediaItem = false; let playItemCached = false; let imageViewerPlaybackState: PlaybackState = PlaybackState.Idle; -let uiHideTimer = new Timer(() => { - uiVisible = false; - playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); -}, 3000); +let uiHideTimer = new Timer(() => { playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); }, 3000); let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false); let showDurationTimer = new Timer(() => { @@ -277,12 +276,14 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) { break; case PlayerControlEvent.UiFadeOut: { + uiVisible = false; document.body.style.cursor = "none"; playerControls.style.opacity = '0'; break; } case PlayerControlEvent.UiFadeIn: { + uiVisible = true; document.body.style.cursor = "default"; playerControls.style.opacity = '1'; break; @@ -291,6 +292,8 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) { default: break; } + + targetPlayerCtrlPostStateUpdate(event); } // Receiver generated event handlers @@ -312,57 +315,58 @@ function stopUiHideTimer() { uiHideTimer.stop(); if (!uiVisible) { - uiVisible = true; playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); } } -document.onmouseout = () => { - uiHideTimer.stop(); - uiVisible = false; - playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); -} - +document.onmouseout = () => { uiHideTimer.end(); } document.onmousemove = () => { stopUiHideTimer(); uiHideTimer.start(); }; -function keyDownEventListener(event: KeyboardEvent) { +function keyDownEventHandler(event: KeyboardEvent) { // logger.info("KeyDown", event); - let handledCase = targetKeyDownEventListener(event); + let result = targetKeyDownEventListener(event); + let handledCase = result.handledCase; - if (!handledCase) { - switch (event.code) { - case 'ArrowLeft': + // @ts-ignore + let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key; + + if (!handledCase && isMediaItem) { + switch (event.key.toLowerCase()) { + case 'arrowleft': setPlaylistItem(playlistIndex - 1); event.preventDefault(); handledCase = true; break; - case 'ArrowRight': + case 'arrowright': setPlaylistItem(playlistIndex + 1); event.preventDefault(); handledCase = true; break; - case "Home": + case "home": setPlaylistItem(0); event.preventDefault(); handledCase = true; break; - case "End": + case "end": setPlaylistItem(cachedPlaylist.items.length - 1); event.preventDefault(); handledCase = true; break; - case 'KeyK': - case 'Space': - case 'Enter': + case 'k': + case ' ': + case 'enter': // Play/pause toggle - if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) { - playerCtrlStateUpdate(PlayerControlEvent.Play); - } else { - playerCtrlStateUpdate(PlayerControlEvent.Pause); + if (cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) { + if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) { + playerCtrlStateUpdate(PlayerControlEvent.Play); + } else { + playerCtrlStateUpdate(PlayerControlEvent.Pause); + } } + event.preventDefault(); handledCase = true; break; @@ -371,17 +375,33 @@ 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))); +function keyUpEventHandler(event: KeyboardEvent) { + // 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.toLowerCase()) { + default: + break; + } } -}); + + if (window.targetAPI.getSubscribedKeys().keyUp.has(key)) { + window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, key, event.repeat, handledCase))); + } +} + +document.addEventListener('keydown', keyDownEventHandler); +document.addEventListener('keyup', keyUpEventHandler); export { PlayerControlEvent, @@ -389,6 +409,16 @@ export { idleIcon, imageViewer, genericViewer, + uiHideTimer, + showDurationTimer, + isMediaItem, + playlistIndex, + cachedPlayMediaItem, + imageViewerPlaybackState, onPlay, + onPlayPlaylist, playerCtrlStateUpdate, + setPlaylistItem, + keyDownEventHandler, + keyUpEventHandler, }; 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..2ddb3c9 100644 --- a/receivers/electron/src/player/Renderer.ts +++ b/receivers/electron/src/player/Renderer.ts @@ -52,17 +52,17 @@ export function targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) { } } -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 +71,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..a7d62ed 100644 --- a/receivers/electron/src/viewer/Renderer.ts +++ b/receivers/electron/src/viewer/Renderer.ts @@ -40,17 +40,25 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean return handledCase; } -export function targetKeyDownEventListener(event: KeyboardEvent): boolean { +export function targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) { + // Currently unused in electron player + switch (event) { + default: + break; + } +} + +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 +67,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..bae5f8c 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,57 @@ 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 serviceChannelEvents = [ + 'toast', + 'connect', + 'disconnect', + 'play', + 'pause', + 'resume', + 'stop', + 'seek', + 'setvolume', + 'setspeed', + 'setplaylistitem', + 'event_subscribed_keys_update' + ]; + private static serviceChannelEventTimestamps: Map = new Map(); + + 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)}`); + }); + + Main.windowVisible = true; + Main.windowType = 'player'; + } + } 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,102 +94,146 @@ 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(); Main.tcpListenerService = new TcpListenerService(); 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) => { + service.register('service_channel', (message: any) => { if (message.isSubscription) { - playClosureCb = playCb.bind(this, message); - Main.emitter.on('play', playClosureCb); - } + Main.serviceChannelEvents.forEach((event) => { + Main.emitter.on(event, (value) => { + const timestamp = Date.now(); + const lastTimestamp = Main.serviceChannelEventTimestamps.get(event) ? Main.serviceChannelEventTimestamps.get(event) : -1; - 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 }); - }); - - registerService(service, 'pause', (message: any) => { return voidCb.bind(this, message) }); - registerService(service, 'resume', (message: any) => { return voidCb.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)}`); + if (lastTimestamp < timestamp) { + Main.serviceChannelEventTimestamps.set(event, timestamp); + message.respond({ returnValue: true, subscriptionId: message.payload.subscriptionId, timestamp: timestamp, event: event, value: value }); + } + }); }); + } + + message.respond({ returnValue: true, subscriptionId: message.payload.subscriptionId, timestamp: Date.now(), event: 'register', value: { subscribed: true }}); + }, + (message: any) => { + logger.info(`Canceled 'service_channel' service subscriber`); + Main.serviceChannelEvents.forEach((event) => { + Main.emitter.removeAllListeners(event); + }); + + message.respond({ returnValue: true, value: {} }); + }); + + service.register('app_channel', (message: any) => { + switch (message.payload.event) { + case 'send_playback_error': { + const value: PlaybackErrorMessage = message.payload.value; + Main.listeners.forEach(l => l.send(Opcode.PlaybackError, value)); + break; + } + + case 'send_playback_update': { + const value: PlaybackUpdateMessage = message.payload.value; + Main.listeners.forEach(l => l.send(Opcode.PlaybackUpdate, value)); + break; + } + + case 'send_volume_update': { + const value: VolumeUpdateMessage = message.payload.value; + Main.cache.playerVolume = value.volume; + Main.listeners.forEach(l => l.send(Opcode.VolumeUpdate, value)); + break; + } + + case 'send_event': { + const value: EventMessage = message.payload.value; + Main.listeners.forEach(l => l.send(Opcode.Event, value)); + break; + } + + case 'play_request': { + const value: PlayMessage = message.payload.value.message; + const playlistIndex: number = message.payload.value.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); + break; + } + + case 'get_sessions': { + // Having to mix and match session ids and ip addresses until querying websocket remote addresses is fixed + message.respond({ + returnValue: true, + value: [].concat(Main.tcpListenerService.getSenders(), Main.webSocketListenerService.getSessions()) + }); + return; + } + + case 'get_subscribed_keys': { + const tcpListenerSubscribedKeys = Main.tcpListenerService.getAllSubscribedKeys(); + const webSocketListenerSubscribedKeys = Main.webSocketListenerService.getAllSubscribedKeys(); + // webOS compatibility: Need to convert set objects to array objects since data needs to be JSON compatible + const subscribeData = { + keyDown: Array.from(new Set([...tcpListenerSubscribedKeys.keyDown, ...webSocketListenerSubscribedKeys.keyDown])), + keyUp: Array.from(new Set([...tcpListenerSubscribedKeys.keyUp, ...webSocketListenerSubscribedKeys.keyUp])), + }; + + message.respond({ + returnValue: true, + value: subscribeData + }); + return; + } + + case 'network_changed': { + logger.info('Network interfaces have changed', message); + Main.discoveryService.stop(); + Main.discoveryService.start(); + + if (message.payload.value.fallback) { + message.respond({ + returnValue: true, + value: getAllIPv4Addresses() + }); + } + else { + message.respond({ returnValue: true, value: {} }); + } + return; + } + + case 'visibility_changed': { + logger.info('Window visibility has changed', message.payload.value); + Main.windowVisible = !message.payload.value.hidden; + Main.windowType = message.payload.value.window; + break; + } + + default: + break; + } + + message.respond({ returnValue: true, value: { success: true } }); + }); + + 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: 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("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('setspeed', (message: SetSpeedMessage) => Main.emitter.emit('setspeed', message)); l.emitter.on('connect', (message) => { ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => { @@ -151,48 +251,65 @@ 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) => { + l.subscribeEvent(message.sessionId, message.body.event); + + if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) { + const tcpListenerSubscribedKeys = Main.tcpListenerService.getAllSubscribedKeys(); + const webSocketListenerSubscribedKeys = Main.webSocketListenerService.getAllSubscribedKeys(); + // webOS compatibility: Need to convert set objects to array objects since data needs to be JSON compatible + const subscribeData = { + keyDown: Array.from(new Set([...tcpListenerSubscribedKeys.keyDown, ...webSocketListenerSubscribedKeys.keyDown])), + keyUp: Array.from(new Set([...tcpListenerSubscribedKeys.keyUp, ...webSocketListenerSubscribedKeys.keyUp])), + }; + + console.log('emitting set info ON SUBSCRIBE ONLY', subscribeData) + Main.emitter.emit('event_subscribed_keys_update', subscribeData); + } + }); + l.emitter.on('unsubscribeevent', (message) => { + l.unsubscribeEvent(message.sessionId, message.body.event); + + if (message.body.event.type === EventType.KeyDown.valueOf() || message.body.event.type === EventType.KeyUp.valueOf()) { + const tcpListenerSubscribedKeys = Main.tcpListenerService.getAllSubscribedKeys(); + const webSocketListenerSubscribedKeys = Main.webSocketListenerService.getAllSubscribedKeys(); + // webOS compatibility: Need to convert set objects to array objects since data needs to be JSON compatible + const subscribeData = { + keyDown: Array.from(new Set([...tcpListenerSubscribedKeys.keyDown, ...webSocketListenerSubscribedKeys.keyDown])), + keyUp: Array.from(new Set([...tcpListenerSubscribedKeys.keyUp, ...webSocketListenerSubscribedKeys.keyUp])), + }; + + Main.emitter.emit('event_subscribed_keys_update', subscribeData); + } + }); l.start(); }); - - service.register("send_playback_error", (message: any) => { - listeners.forEach(l => { - const value: PlaybackErrorMessage = message.payload.error; - 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); - }); - - 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); - }); - - message.respond({ returnValue: true, value: { success: true } }); - }); } 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) { @@ -203,19 +320,21 @@ export async function errorHandler(error: Error) { Main.emitter.emit('toast', { message: error, icon: ToastIcon.ERROR }); } -function registerService(service: Service, method: string, callback: (message: any) => any) { - let callbackRef = null; - service.register(method, (message: any) => { - if (message.isSubscription) { - callbackRef = callback(message); - Main.emitter.on(method, callbackRef); - } +// 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[] = []; - message.respond({ returnValue: true, value: { subscribed: true }}); - }, - (message: any) => { - logger.info(`Canceled ${method} service subscriber`); - Main.emitter.removeAllListeners(method); - message.respond({ returnValue: true, value: message.payload }); - }); + 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..b18bcd1 100644 --- a/receivers/webos/fcast-receiver/appinfo.json +++ b/receivers/webos/fcast-receiver/appinfo.json @@ -1,9 +1,9 @@ { "id": "com.futo.fcast.receiver", - "version": "1.1.0", + "version": "2.0.0", "vendor": "FUTO", "type": "web", - "main": "main_window/index.html", + "main": "index.html", "title": "FCast Receiver", "appDescription": "FCast Receiver", "icon": "assets/icons/icon.png", diff --git a/receivers/webos/fcast-receiver/assets/images/splash.png b/receivers/webos/fcast-receiver/assets/images/splash.png index cf798d4..ae228b9 100644 Binary files a/receivers/webos/fcast-receiver/assets/images/splash.png and b/receivers/webos/fcast-receiver/assets/images/splash.png differ diff --git a/receivers/webos/fcast-receiver/lib/common.ts b/receivers/webos/fcast-receiver/lib/common.ts new file mode 100644 index 0000000..ae945db --- /dev/null +++ b/receivers/webos/fcast-receiver/lib/common.ts @@ -0,0 +1,173 @@ +import { v4 as uuidv4 } from 'modules/uuid'; +import { Logger, LoggerType } from 'common/Logger'; +require('lib/webOSTVjs-1.2.10/webOSTV.js'); +require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); + +const logger = new Logger('Common', LoggerType.FRONTEND); +const serviceId = 'com.futo.fcast.receiver.service'; + +export enum RemoteKeyCode { + Stop = 413, + Rewind = 412, + Play = 415, + Pause = 19, + FastForward = 417, + Back = 461, +} + +export enum KeyCode { + ArrowUp = 38, + ArrowDown = 40, + ArrowLeft = 37, + ArrowRight = 39, + KeyK = 75, + Space = 32, + Enter = 13, +} + +export enum ControlBarMode { + KeyboardMouse, + Remote +} + +export class ServiceManager { + private static serviceChannelSuccessCbHandler?: (message: any) => void; + private static serviceChannelFailureCbHandler?: (message: any) => void; + private static serviceChannelCompleteCbHandler?: (message: any) => void; + + constructor() { + // @ts-ignore + window.webOS.service.request(`luna://${serviceId}/`, { + method: 'service_channel', + parameters: { subscriptionId: uuidv4() }, + onSuccess: (message: any) => { + if (message.value?.subscribed === true) { + logger.info(`requestService: Registered 'service_channel' handler with service`); + } + else if (ServiceManager.serviceChannelSuccessCbHandler) { + ServiceManager.serviceChannelSuccessCbHandler(message); + } + }, + onFailure: (message: any) => { + logger.error('Error subscribing to the service_channel:', message); + + if (ServiceManager.serviceChannelFailureCbHandler) { + ServiceManager.serviceChannelFailureCbHandler(message); + } + }, + onComplete: (message: any) => { + if (ServiceManager.serviceChannelCompleteCbHandler) { + ServiceManager.serviceChannelCompleteCbHandler(message); + } + }, + subscribe: true, + resubscribe: true + }); + } + + + public subscribeToServiceChannel(successCb: (message: any) => void, failureCb?: (message: any) => void, onCompleteCb?: (message: any) => void) { + ServiceManager.serviceChannelSuccessCbHandler = successCb; + ServiceManager.serviceChannelFailureCbHandler = failureCb; + ServiceManager.serviceChannelCompleteCbHandler = onCompleteCb; + } + + public call(method: string, parameters?: any, successCb?: (message: any) => void, failureCb?: (message: any) => void, onCompleteCb?: (message: any) => void) { + // @ts-ignore + const service = window.webOS.service.request(`luna://${serviceId}/`, { + method: 'app_channel', + parameters: { event: method, value: 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 + }); + + return service; + } +} + +// CSS media queries do not work on older webOS versions... +export function initializeWindowSizeStylesheet() { + if (window.innerWidth !== 0 && window.innerHeight !== 0) { + if (window.innerWidth >= 1920 && window.innerHeight >= 1080) { + document.head.insertAdjacentHTML('beforeend', ''); + } + else { + document.head.insertAdjacentHTML('beforeend', ''); + } + } + else { + const resolution = sessionStorage.getItem('resolution'); + + if (resolution) { + window.onload = () => { + if (resolution == '1920x1080') { + document.head.insertAdjacentHTML('beforeend', ''); + } + else { + document.head.insertAdjacentHTML('beforeend', ''); + } + } + } + else { + window.onresize = () => { + if (window.innerWidth >= 1920 && window.innerHeight >= 1080) { + sessionStorage.setItem('resolution', '1920x1080'); + document.head.insertAdjacentHTML('beforeend', ''); + } + else { + sessionStorage.setItem('resolution', '1280x720'); + document.head.insertAdjacentHTML('beforeend', ''); + } + }; + } + } +} + +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..932a190 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", @@ -29,15 +29,18 @@ "webpack": "^5.99.6", "webpack-cli": "^6.0.1" }, + "@comment dependencies.uuid": "Versions > 9.0.1 are broken on webOS", "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" + }, + "optionalDependencies": { + "utf-8-validate": "^6.0.5" } } diff --git a/receivers/webos/fcast-receiver/src/Main.ts b/receivers/webos/fcast-receiver/src/Main.ts new file mode 100644 index 0000000..897c651 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/Main.ts @@ -0,0 +1,59 @@ +import { Logger, LoggerType } from 'common/Logger'; +import { ServiceManager } from 'lib/common'; +require('lib/webOSTVjs-1.2.10/webOSTV.js'); +require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); + +declare global { + interface Window { + webOSApp: any; + } +} + +const logger = new Logger('Main', LoggerType.FRONTEND); +const webPage: HTMLIFrameElement = document.getElementById('page') as HTMLIFrameElement; +let launchHandlerCallback = () => { logger.warn('No (re)launch handler set'); }; +let keyDownEventHandler = () => { logger.warn('No keyDown event handler set'); }; +let keyUpEventHandler = () => { logger.warn('No keyUp event handler set'); }; + +function loadPage(path: string) { + // @ts-ignore + webPage.src = path; +} + +// We are embedding iframe element and using that for page navigation. This preserves a global JS context +// so bugs related to oversubscribing/canceling services are worked around by only subscribing once to +// required services +logger.info('Starting webOS application') +window.webOS.deviceInfo((info) => { logger.info('Device info:', info); }); + +window.webOSApp = { + serviceManager: new ServiceManager(), + setLaunchHandler: (callback: () => void) => { + document.removeEventListener('webOSLaunch', launchHandlerCallback); + document.removeEventListener('webOSRelaunch', launchHandlerCallback); + + launchHandlerCallback = callback; + document.addEventListener('webOSLaunch', launchHandlerCallback); + document.addEventListener('webOSRelaunch', launchHandlerCallback); + }, + setKeyDownHandler: (callback: () => void) => { + document.removeEventListener('keydown', keyDownEventHandler); + + keyDownEventHandler = callback; + document.addEventListener('keydown', keyDownEventHandler); + }, + setKeyUpHandler: (callback: () => void) => { + document.removeEventListener('keyup', keyUpEventHandler); + + keyUpEventHandler = callback; + document.addEventListener('keyup', keyUpEventHandler); + }, + loadPage: loadPage, + pendingPlay: null, +}; + +document.addEventListener('webOSLaunch', launchHandlerCallback); +document.addEventListener('webOSRelaunch', launchHandlerCallback); +document.addEventListener('keydown', keyDownEventHandler); +document.addEventListener('keyup', keyUpEventHandler); +loadPage('./main_window/index.html'); diff --git a/receivers/webos/fcast-receiver/src/index.html b/receivers/webos/fcast-receiver/src/index.html new file mode 100644 index 0000000..abeb5b0 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/receivers/webos/fcast-receiver/src/main/1280x720.css b/receivers/webos/fcast-receiver/src/main/1280x720.css new file mode 100644 index 0000000..c7721e2 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/main/1280x720.css @@ -0,0 +1,107 @@ +/* @media only screen and ((max-width: 1919px) or (max-height: 1079px)) { */ + .card { + padding: 15px; + } + + .card-title { + line-height: 20px; + margin: 5px; + margin-bottom: 10px; + } + + .card-title-separator { + margin: 3px 0px; + } + + .iconSize { + width: 32px; + height: 32px; + } + + #overlay { + gap: unset; + /* gap: 12.5vw; */ + font-size: 20px; + } + + #main-view { + margin-right: 12.5vw; + } + + #title-text { + font-size: 100px; + } + + #title-icon { + width: 84px; + height: 84px; + margin-right: 15px; + } + + #connection-status { + padding: 15px; + } + + #connection-error-icon { + margin-top: 10px; + } + + #connection-information-loading-text { + margin: 10px; + } + + #scan-to-connect { + margin-top: 10px; + } + + #qr-code { + width: 192px; + height: 192px; + margin: 15px auto; + padding: 12px; + } + + #ips { + margin-top: 10px; + } + + .ip-entry-text { + margin-top: 4px; + margin-bottom: 4px; + } + + #window-can-be-closed { + margin-bottom: 15px; + font-size: 18px; + } + + .lds-ring { + width: 100px; + height: 100px; + } + .lds-ring div { + width: 84px; + height: 84px; + } + + #connection-check { + width: 84px; + height: 84px; + margin: 24px; + } + + #toast-notification { + padding: 8px; + top: -140px; + } + + #toast-icon { + width: 60px; + height: 60px; + margin-right: 15px; + } + + #toast-text { + font-size: 20px; + } +/* } */ \ No newline at end of file diff --git a/receivers/webos/fcast-receiver/src/main/1920x1080.css b/receivers/webos/fcast-receiver/src/main/1920x1080.css new file mode 100644 index 0000000..4fb2533 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/main/1920x1080.css @@ -0,0 +1,112 @@ +/* @media only screen and ((max-width: 2559px) or (max-height: 1439px)) { */ + .card { + padding: 25px; + } + + .card-title { + line-height: 24px; + margin: 10px; + } + + .card-title-separator { + margin: 3px 0px; + } + + .iconSize { + width: 48px; + height: 48px; + } + + #overlay { + gap: unset; + /* gap: 15vw; */ + font-size: 28px; + } + + #main-view { + margin-right: 15vw; + } + + #title-text { + font-size: 140px; + } + + #title-icon { + width: 124px; + height: 124px; + margin-right: 25px; + } + + #connection-status { + padding: 25px; + } + + #connection-error-icon { + margin-top: 20px; + } + + #connection-information-loading-text { + margin: 20px; + } + + #scan-to-connect { + margin-top: 20px; + } + + #qr-code { + width: 256px; + height: 256px; + margin: 20px auto; + padding: 16px; + } + + #connection-details-separator { + margin-top: 15px; + } + + #ips { + margin-top: 20px; + gap: 15px; + } + + .ip-entry-text { + margin-top: 7px; + margin-bottom: 7px; + } + + #window-can-be-closed { + margin-bottom: 20px; + font-size: 24px; + } + + .lds-ring { + width: 120px; + height: 120px; + } + .lds-ring div { + width: 104px; + height: 104px; + } + + #connection-check { + width: 104px; + height: 104px; + margin: 28px; + } + + #toast-notification { + padding: 12px; + top: -175px; + } + + #toast-icon { + width: 70px; + height: 70px; + margin: 5px 10px; + margin-right: 15px; + } + + #toast-text { + font-size: 28px; + } +/* } */ diff --git a/receivers/webos/fcast-receiver/src/main/Preload.ts b/receivers/webos/fcast-receiver/src/main/Preload.ts index ba8396f..173a227 100644 --- a/receivers/webos/fcast-receiver/src/main/Preload.ts +++ b/receivers/webos/fcast-receiver/src/main/Preload.ts @@ -1,153 +1,143 @@ /* 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 { ServiceManager, initializeWindowSizeStylesheet } 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, +declare global { + interface Window { + targetAPI: any; + webOSApp: any; + } } -try { - let getSessions = null; +const logger = window.targetAPI.logger; - 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: {}, +try { + initializeWindowSizeStylesheet(); + + const serviceManager: ServiceManager = window.parent.webOSApp.serviceManager; + serviceManager.subscribeToServiceChannel((message: any) => { + switch (message.event) { + case 'toast': + preloadData.onToastCb(message.value.message, message.value.icon, message.value.duration); + break; + + case 'event_subscribed_keys_update': + preloadData.onEventSubscribedKeysUpdate(message.value); + break; + + case 'connect': + preloadData.onConnectCb(null, message.value); + break; + + case 'disconnect': + preloadData.onDisconnectCb(null, message.value); + break; + + case 'play': + logger.info(`Main: Playing ${JSON.stringify(message)}`); + play(message.value); + break; + + default: + break; + } + }); + + 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 = []; + const connections: any[] = []; + 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) { + const ipsIfaceName = document.getElementById('ips-iface-name'); + ipsIfaceName.style.display = 'none'; + + serviceManager.call('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); + }); + } + else { + serviceManager.call('network_changed', { fallback: fallback }); + 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 }); window.targetAPI.getSessions(() => { return new Promise((resolve, reject) => { - getSessions = requestService('get_sessions', (message: any) => resolve(message.value), (message: any) => reject(message), false); + serviceManager.call('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(); + window.targetAPI.initializeSubscribedKeys(() => { + return new Promise((resolve, reject) => { + serviceManager.call('get_subscribed_keys', {}, (message: any) => resolve(message.value), (message: any) => reject(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'); - } - }); + preloadData.sendEventCb = (event: EventMessage) => { + serviceManager.call('send_event', event, null, (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); }); + }; const launchHandler = () => { + // Launch handler not supported in simulator due to JSON parsing errors of launch parameters const params = window.webOSDev.launchParams(); logger.info(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`); - const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); - if (params.playData !== 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'); + // 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(sessionStorage.getItem('lastTimestamp')); + if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) { + sessionStorage.setItem('lastTimestamp', params.timestamp); + play(params.messageInfo); } }; - document.addEventListener('webOSLaunch', launchHandler); - document.addEventListener('webOSRelaunch', launchHandler); + window.parent.webOSApp.setLaunchHandler(launchHandler); + document.addEventListener('visibilitychange', () => { serviceManager.call('visibility_changed', { hidden: document.hidden, window: 'main' }); }); - // Cannot go back to a state where user was previously casting a video, so exit. - // window.onpopstate = () => { - // window.webOS.platformBack(); - // }; + const play = (messageInfo: any) => { + sessionStorage.setItem('playInfo', JSON.stringify(messageInfo)); + getDeviceInfoService?.cancel(); - document.addEventListener('keydown', (event: any) => { - // logger.info("KeyDown", event); - - switch (event.keyCode) { - // WebOS 22 and earlier does not work well using the history API, - // so manually handling page navigation... - case RemoteKeyCode.Back: - window.webOS.platformBack(); - break; - default: - break; - } - }); + window.parent.webOSApp.loadPage(`${messageInfo.contentViewer}/index.html`); + }; } 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 - }); + logger.error(`Main: preload`, err); + 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..d1e086a 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 { keyDownEventHandler, keyUpEventHandler } from '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'); @@ -27,6 +29,51 @@ backgroundVideo.onplaying = () => { backgroundVideo.onplaying = null; }; +window.parent.webOSApp.setKeyDownHandler(keyDownEventHandler); +window.parent.webOSApp.setKeyUpHandler(keyUpEventHandler); + 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/main/style.css b/receivers/webos/fcast-receiver/src/main/style.css index 1c92bb3..30d13f5 100644 --- a/receivers/webos/fcast-receiver/src/main/style.css +++ b/receivers/webos/fcast-receiver/src/main/style.css @@ -1,17 +1,15 @@ +/* WebOS custom player styles */ + +html { + overflow: hidden; +} + .card-title { font-family: InterBold; } #overlay { font-family: InterRegular; - /* gap not supported in WebOS 6.0 */ - gap: unset; -} - -#main-view { - /* gap not supported in WebOS 6.0 */ - gap: unset; - margin-right: 15vw; } #title-text { @@ -27,6 +25,15 @@ font-family: InterBold; } +/* gap not supported in WebOS 6.0 */ +#ips { + gap: unset; +} + +#ips-iface-icon { + margin-right: 15px; +} + #window-can-be-closed { font-family: InterRegular; } diff --git a/receivers/webos/fcast-receiver/src/player/1280x720.css b/receivers/webos/fcast-receiver/src/player/1280x720.css new file mode 100644 index 0000000..65bcd41 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/player/1280x720.css @@ -0,0 +1,30 @@ +/* @media only screen and ((max-width: 1919px) or (max-height: 1079px)) { */ + #title-icon { + width: 84px; + height: 84px; + } + + .lds-ring { + width: 100px; + height: 100px; + } + .lds-ring div { + width: 84px; + height: 84px; + } + + #toast-notification { + padding: 8px; + } + + #toast-icon { + width: 40px; + height: 40px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 16px; + } +/* } */ \ No newline at end of file diff --git a/receivers/webos/fcast-receiver/src/player/1920x1080.css b/receivers/webos/fcast-receiver/src/player/1920x1080.css new file mode 100644 index 0000000..bc2b3f3 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/player/1920x1080.css @@ -0,0 +1,30 @@ +/* @media only screen and ((max-width: 2559px) or (max-height: 1439px)) { */ + #title-icon { + width: 124px; + height: 124px; + } + + .lds-ring { + width: 120px; + height: 120px; + } + .lds-ring div { + width: 104px; + height: 104px; + } + + #toast-notification { + padding: 12px; + } + + #toast-icon { + width: 60px; + height: 60px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 22px; + } +/* } */ diff --git a/receivers/webos/fcast-receiver/src/player/Preload.ts b/receivers/webos/fcast-receiver/src/player/Preload.ts index 96dc4fb..4845cde 100644 --- a/receivers/webos/fcast-receiver/src/player/Preload.ts +++ b/receivers/webos/fcast-receiver/src/player/Preload.ts @@ -1,171 +1,153 @@ /* 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 { ServiceManager, initializeWindowSizeStylesheet } 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'); + +declare global { + interface Window { + targetAPI: any; + webOSApp: any; + } +} + const logger = window.targetAPI.logger; try { - const serviceId = 'com.futo.fcast.receiver.service'; - let getSessions = null; + initializeWindowSizeStylesheet(); - window.webOSAPI = { - pendingPlay: JSON.parse(sessionStorage.getItem('playData')) - }; + window.parent.webOSApp.pendingPlay = JSON.parse(sessionStorage.getItem('playInfo')); + const contentViewer = window.parent.webOSApp.pendingPlay?.contentViewer; - preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { - window.webOS.service.request(`luna://${serviceId}/`, { - method: 'send_playback_error', - parameters: { error }, - onSuccess: () => {}, - onFailure: (message: any) => { - logger.error(`Player: send_playback_error ${JSON.stringify(message)}`); - }, - }); - }; - preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => { - window.webOS.service.request(`luna://${serviceId}/`, { - method: 'send_playback_update', - parameters: { update }, - // onSuccess: (message: any) => { - // logger.info(`Player: send_playback_update ${JSON.stringify(message)}`); - // }, - onSuccess: () => {}, - onFailure: (message: any) => { - logger.error(`Player: send_playback_update ${JSON.stringify(message)}`); - }, - }); - }; - preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => { - window.webOS.service.request(`luna://${serviceId}/`, { - method: 'send_volume_update', - parameters: { update }, - onSuccess: () => {}, - onFailure: (message: any) => { - logger.error(`Player: send_volume_update ${JSON.stringify(message)}`); - }, - }); - }; + const serviceManager: ServiceManager = window.parent.webOSApp.serviceManager; + serviceManager.subscribeToServiceChannel((message: any) => { + switch (message.event) { + case 'toast': + preloadData.onToastCb(message.value.message, message.value.icon, message.value.duration); + break; - 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'); - } - - if (message.value.playData !== null) { - if (preloadData.onPlayCb === undefined) { - window.webOSAPI.pendingPlay = message.value.playData; + case 'play': { + if (contentViewer !== message.value.contentViewer) { + sessionStorage.setItem('playInfo', JSON.stringify(message.value)); + window.parent.webOSApp.loadPage(`${message.value.contentViewer}/index.html`); } else { - preloadData.onPlayCb(null, message.value.playData); + if (message.value.rendererEvent === 'play-playlist') { + if (preloadData.onPlayCb === undefined) { + window.parent.webOSApp.pendingPlay = message.value; + } + else { + preloadData.onPlayPlaylistCb(null, message.value.rendererMessage); + } + } + else { + if (preloadData.onPlayCb === undefined) { + window.parent.webOSApp.pendingPlay = message.value; + } + else { + preloadData.onPlayCb(null, message.value.rendererMessage); + } + } } + break; } - }, - onFailure: (message: any) => { - logger.error(`Player: play ${JSON.stringify(message)}`); - }, - subscribe: true, - resubscribe: true + + case 'pause': + preloadData.onPauseCb(); + break; + + case 'resume': + preloadData.onResumeCb(); + break; + + case 'stop': + window.parent.webOSApp.loadPage('main_window/index.html'); + break; + + case 'seek': + preloadData.onSeekCb(null, message.value); + break; + + case 'setvolume': + preloadData.onSetVolumeCb(null, message.value); + break; + + case 'setspeed': + preloadData.onSetSpeedCb(null, message.value); + break; + + case 'setplaylistitem': + preloadData.onSetPlaylistItemCb(null, message.value); + break; + + case 'event_subscribed_keys_update': + preloadData.onEventSubscribedKeysUpdate(message.value); + break; + + case 'connect': + preloadData.onConnectCb(null, message.value); + break; + + case 'disconnect': + preloadData.onDisconnectCb(null, message.value); + break; + + // 'play-playlist' is handled in the 'play' message for webOS + + default: + break; + } }); - 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(); - getSessions?.cancel(); - onConnectService.cancel(); - onDisconnectService.cancel(); - - // WebOS 22 and earlier does not work well using the history API, - // so manually handling page navigation... - // history.back(); - window.open('../main_window/index.html', '_self'); - }); - - 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); }); + preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { + serviceManager.call('send_playback_error', error, null, (message: any) => { logger.error(`Player: send_playback_error ${JSON.stringify(message)}`); }); + }; + preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => { + serviceManager.call('send_playback_update', update, null, (message: any) => { logger.error(`Player: send_playback_update ${JSON.stringify(message)}`); }); + }; + preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => { + serviceManager.call('send_volume_update', update, null, (message: any) => { logger.error(`Player: send_volume_update ${JSON.stringify(message)}`); }); + }; + preloadData.sendEventCb = (event: EventMessage) => { + serviceManager.call('send_event', event, null, (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); }); + }; + preloadData.sendPlayRequestCb = (message: PlayMessage, playlistIndex: number) => { + serviceManager.call('play_request', { message: message, playlistIndex: playlistIndex }, null, (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); + serviceManager.call('get_sessions', {}, (message: any) => resolve(message.value), (message: any) => reject(message)); + }); + }); + window.targetAPI.initializeSubscribedKeys(() => { + return new Promise((resolve, reject) => { + serviceManager.call('get_subscribed_keys', {}, (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 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)}`); - const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); - if (params.playData !== undefined && params.timestamp != lastTimestamp) { - localStorage.setItem('lastTimestamp', params.timestamp); - sessionStorage.setItem('playData', JSON.stringify(params.playData)); - playService?.cancel(); - pauseService?.cancel(); - resumeService?.cancel(); - stopService?.cancel(); - seekService?.cancel(); - setVolumeService?.cancel(); - setSpeedService?.cancel(); - getSessions?.cancel(); - onConnectService?.cancel(); - onDisconnectService?.cancel(); + // 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(sessionStorage.getItem('lastTimestamp')); + if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) { + sessionStorage.setItem('lastTimestamp', params.timestamp); + sessionStorage.setItem('playInfo', JSON.stringify(params.messageInfo)); - // 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.parent.webOSApp.loadPage(`${params.messageInfo.contentViewer}/index.html`); } }; - document.addEventListener('webOSLaunch', launchHandler); - document.addEventListener('webOSRelaunch', launchHandler); - + window.parent.webOSApp.setLaunchHandler(launchHandler); + document.addEventListener('visibilitychange', () => serviceManager.call('visibility_changed', { hidden: document.hidden, window: contentViewer })); } catch (err) { - logger.error(`Player: preload ${JSON.stringify(err)}`); + logger.error(`Player: preload`, 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..47d69f9 100644 --- a/receivers/webos/fcast-receiver/src/player/Renderer.ts +++ b/receivers/webos/fcast-receiver/src/player/Renderer.ts @@ -1,82 +1,125 @@ import { - isLive, onPlay, + onPlayPlaylist, + setPlaylistItem, + playerCtrlStateUpdate, + playlistIndex, player, + uiHideTimer, PlayerControlEvent, playerCtrlCaptions, - playerCtrlDuration, - playerCtrlLiveBadge, - playerCtrlPosition, - playerCtrlProgressBar, - playerCtrlProgressBarBuffer, - playerCtrlProgressBarHandle, - playerCtrlProgressBarProgress, - playerCtrlStateUpdate, - playerCtrlVolumeBar, - playerCtrlVolumeBarHandle, - playerCtrlVolumeBarProgress, videoCaptions, - formatDuration, skipBack, skipForward, + keyDownEventHandler, + keyUpEventHandler, + playerCtrlProgressBarHandle, } from 'common/player/Renderer'; +import { KeyCode, RemoteKeyCode, ControlBarMode } from 'lib/common'; +import * as common from 'lib/common'; +const logger = window.targetAPI.logger; const captionsBaseHeightCollapsed = 150; const captionsBaseHeightExpanded = 320; const captionsLineHeight = 68; -enum RemoteKeyCode { - Stop = 413, - Rewind = 412, - Play = 415, - Pause = 19, - FastForward = 417, - Back = 461, +const playPreviousContainer = document.getElementById('playPreviousContainer'); +const actionContainer = document.getElementById('actionContainer'); +const playNextContainer = document.getElementById('playNextContainer'); + +const playPrevious = document.getElementById('playPrevious'); +const playNext = document.getElementById('playNext'); + +enum ControlFocus { + ProgressBar, + Action, + PlayPrevious, + PlayNext, +} + +let controlMode = ControlBarMode.KeyboardMouse; +let controlFocus = ControlFocus.ProgressBar; + +// Hide +// [<<][>][>>] +// [|<][>][>|] +// Hide +let locationMap = { + ProgressBar: playerCtrlProgressBarHandle, + Action: actionContainer, + PlayPrevious: playPreviousContainer, + PlayNext: playNextContainer, +}; + +window.parent.webOSApp.setKeyDownHandler(keyDownEventHandler); +window.parent.webOSApp.setKeyUpHandler(keyUpEventHandler); + +uiHideTimer.setDelay(5000); +uiHideTimer.setCallback(() => { + if (!player?.isPaused()) { + controlMode = ControlBarMode.KeyboardMouse; + removeFocus(controlFocus); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); + } +}); + +// Leave control bar on screen if magic remote cursor leaves window +document.onmouseout = () => { + if (controlMode === ControlBarMode.KeyboardMouse) { + uiHideTimer.end(); + } +} + +function addFocus(location: ControlFocus) { + if (location === ControlFocus.ProgressBar) { + locationMap[ControlFocus[location]].classList.remove('progressBarHandleHide'); + } + else { + locationMap[ControlFocus[location]].classList.add('buttonFocus'); + } +} + +function removeFocus(location: ControlFocus) { + if (location === ControlFocus.ProgressBar) { + locationMap[ControlFocus[location]].classList.add('progressBarHandleHide'); + } + else { + locationMap[ControlFocus[location]].classList.remove('buttonFocus'); + } +} + +function remoteNavigateTo(location: ControlFocus) { + // Issues with using standard focus, so manually managing styles + removeFocus(controlFocus); + controlFocus = location; + addFocus(controlFocus); +} + +function setControlMode(mode: ControlBarMode, immediateHide: boolean = true) { + if (mode === ControlBarMode.KeyboardMouse) { + uiHideTimer.enable(); + + if (immediateHide) { + removeFocus(controlFocus); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); + } + else { + uiHideTimer.start(); + } + } + else { + remoteNavigateTo(ControlFocus.ProgressBar); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); + uiHideTimer.start(); + } + + controlMode = mode; } export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean { let handledCase = false; switch (event) { - case PlayerControlEvent.Load: { - playerCtrlProgressBarBuffer.setAttribute("style", "width: 0px"); - playerCtrlProgressBarProgress.setAttribute("style", "width: 0px"); - playerCtrlProgressBarHandle.setAttribute("style", `left: ${playerCtrlProgressBar.offsetLeft}px`); - - const volume = Math.round(player.getVolume() * playerCtrlVolumeBar.offsetWidth); - playerCtrlVolumeBarProgress.setAttribute("style", `width: ${volume}px`); - playerCtrlVolumeBarHandle.setAttribute("style", `left: ${volume + 8}px`); - - if (isLive) { - playerCtrlLiveBadge.setAttribute("style", "display: block"); - playerCtrlPosition.setAttribute("style", "display: none"); - playerCtrlDuration.setAttribute("style", "display: none"); - } - else { - playerCtrlLiveBadge.setAttribute("style", "display: none"); - playerCtrlPosition.setAttribute("style", "display: block"); - playerCtrlDuration.setAttribute("style", "display: block"); - playerCtrlPosition.textContent = formatDuration(player.getCurrentTime()); - playerCtrlDuration.innerHTML = formatDuration(player.getDuration()); - } - - if (player.isCaptionsSupported()) { - // Disabling receiver captions control on TV players - playerCtrlCaptions.setAttribute("style", "display: none"); - // playerCtrlCaptions.setAttribute("style", "display: block"); - videoCaptions.setAttribute("style", "display: block"); - } - else { - playerCtrlCaptions.setAttribute("style", "display: none"); - videoCaptions.setAttribute("style", "display: none"); - player.enableCaptions(false); - } - playerCtrlStateUpdate(PlayerControlEvent.SetCaptions); - - handledCase = true; - break; - } - default: break; } @@ -84,22 +127,168 @@ 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 targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) { + switch (event) { + case PlayerControlEvent.Load: { + player.setPlayPauseCallback(() => { + uiHideTimer.enable(); + uiHideTimer.start(); + }, () => { + uiHideTimer.disable(); + }); + + if (player.isCaptionsSupported()) { + // Disabling receiver captions control on TV players + // playerCtrlCaptions.style.display = 'block'; + playerCtrlCaptions.style.display = 'none'; + videoCaptions.style.display = 'block'; + } + else { + playerCtrlCaptions.style.display = 'none'; + videoCaptions.style.display = 'none'; + player.enableCaptions(false); + } + + break; + } + + default: + break; + } +} + +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + // logger.info("KeyDown", event.keyCode); let handledCase = false; + let key = ''; switch (event.keyCode) { - case RemoteKeyCode.Stop: - // history.back(); - window.open('../main_window/index.html', '_self'); + case KeyCode.KeyK: + case KeyCode.Space: + // Play/pause toggle + if (player?.isPaused()) { + player?.play(); + } else { + player?.pause(); + } + event.preventDefault(); handledCase = true; break; - case RemoteKeyCode.Rewind: - skipBack(); + case KeyCode.Enter: + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.ProgressBar || controlFocus === ControlFocus.Action) { + // Play/pause toggle + if (player?.isPaused()) { + player?.play(); + } else { + player?.pause(); + } + } + else if (controlFocus === ControlFocus.PlayPrevious) { + setPlaylistItem(playlistIndex - 1); + } + else if (controlFocus === ControlFocus.PlayNext) { + setPlaylistItem(playlistIndex + 1); + } + } + event.preventDefault(); handledCase = true; break; + case KeyCode.ArrowUp: + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.ProgressBar) { + setControlMode(ControlBarMode.KeyboardMouse); + } + else { + remoteNavigateTo(ControlFocus.ProgressBar); + } + } + + event.preventDefault(); + handledCase = true; + break; + case KeyCode.ArrowDown: + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.ProgressBar) { + remoteNavigateTo(ControlFocus.Action); + } + else { + setControlMode(ControlBarMode.KeyboardMouse); + } + } + + event.preventDefault(); + handledCase = true; + break; + case KeyCode.ArrowLeft: + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.ProgressBar || playPrevious?.style.display === 'none') { + // Note that skip repeat does not trigger in simulator + skipBack(event.repeat); + } + else { + if (controlFocus === ControlFocus.Action) { + remoteNavigateTo(ControlFocus.PlayPrevious); + } + else if (controlFocus === ControlFocus.PlayNext) { + remoteNavigateTo(ControlFocus.Action); + } + } + } + + event.preventDefault(); + handledCase = true; + break; + case KeyCode.ArrowRight: + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.ProgressBar || playNext?.style.display === 'none') { + // Note that skip repeat does not trigger in simulator + skipForward(event.repeat); + } + else { + if (controlFocus === ControlFocus.Action) { + remoteNavigateTo(ControlFocus.PlayNext); + } + else if (controlFocus === ControlFocus.PlayPrevious) { + remoteNavigateTo(ControlFocus.Action); + } + } + } + + event.preventDefault(); + handledCase = true; + break; + + case RemoteKeyCode.Stop: + window.parent.webOSApp.loadPage('main_window/index.html'); + handledCase = true; + key = 'Stop'; + break; + + // Note that in simulator rewind and fast forward key codes are sent twice... + case RemoteKeyCode.Rewind: + skipBack(event.repeat); + event.preventDefault(); + handledCase = true; + key = 'Rewind'; + break; case RemoteKeyCode.Play: if (player.isPaused()) { @@ -107,6 +296,7 @@ export function targetKeyDownEventListener(event: any): boolean { } event.preventDefault(); handledCase = true; + key = 'Play'; break; case RemoteKeyCode.Pause: if (!player.isPaused()) { @@ -114,32 +304,42 @@ export function targetKeyDownEventListener(event: any): boolean { } event.preventDefault(); handledCase = true; + key = 'Pause'; break; + // Note that in simulator rewind and fast forward key codes are sent twice... case RemoteKeyCode.FastForward: - skipForward(); + skipForward(event.repeat); 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'); + window.parent.webOSApp.loadPage('main_window/index.html'); event.preventDefault(); handledCase = true; + key = 'Back'; break; default: break; } - return handledCase; + return { handledCase: handledCase, key: key }; }; -if (window.webOSAPI.pendingPlay !== null) { - onPlay(null, window.webOSAPI.pendingPlay); +export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + return common.targetKeyUpEventListener(event); +}; + +if (window.parent.webOSApp.pendingPlay !== null) { + if (window.parent.webOSApp.pendingPlay.rendererEvent === 'play-playlist') { + onPlayPlaylist(null, window.parent.webOSApp.pendingPlay.rendererMessage); + } + else { + onPlay(null, window.parent.webOSApp.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..2fe5613 100644 --- a/receivers/webos/fcast-receiver/src/player/index.html +++ b/receivers/webos/fcast-receiver/src/player/index.html @@ -10,7 +10,12 @@ +
+
+
+ +
@@ -20,7 +25,7 @@
-
+
@@ -32,7 +37,9 @@
-
+
+
+
@@ -52,7 +59,7 @@
-
+
00:00
diff --git a/receivers/webos/fcast-receiver/src/player/style.css b/receivers/webos/fcast-receiver/src/player/style.css index 71f2198..ed70369 100644 --- a/receivers/webos/fcast-receiver/src/player/style.css +++ b/receivers/webos/fcast-receiver/src/player/style.css @@ -1,7 +1,12 @@ /* WebOS custom player styles */ +html { + overflow: hidden; +} + .container { height: 240px; + background: linear-gradient(to top, rgba(0, 0, 0, 1.0) 0%, rgba(0, 0, 0, 0.0) 80%); } .iconSize { @@ -15,39 +20,9 @@ } .volumeContainer { - height: 48px; - width: 184px; - display: none; } -.volumeBar { - left: 16px; - top: 20px; - height: 8px; - width: 152px; -} - -.volumeBarInteractiveArea { - height: 48px; - width: 184px; -} - -.volumeBarHandle { - left: 168px; - top: 8px; - width: 32px; - height: 32px; - box-shadow: 0px 64px 128px 0px rgba(0, 0, 0, 0.56), 0px 4px 42px 0px rgba(0, 0, 0, 0.55); -} - -.volumeBarProgress { - left: 16px; - top: 20px; - height: 8px; - width: 152px; -} - .progressBarContainer { bottom: 120px; left: 32px; @@ -134,21 +109,42 @@ } .leftButtonContainer { - bottom: 48px; + bottom: 24px; left: 48px; - height: 48px; - /* right: 320px; */ - right: 32px; - gap: 48px; + height: 96px; + right: 48px; + /* gap: 48px; */ + gap: unset; justify-content: center; } +.buttonFocusContainer { + margin-right: 28px; + padding: 20px; + border-radius: 20px; +} + +.buttonFocus { + /* background-image: linear-gradient(to bottom, #008BD7 35%, #0069AA); */ + background-image: linear-gradient(to bottom, #808080 35%, #202020); + border: 1px solid #4E4E4E; +} + +.progressBarHandle { + border: 1px solid #4E4E4E; +} + +.progressBarHandleHide { + display: none; +} + .buttonContainer { bottom: 48px; right: 48px; height: 48px; - gap: 48px; + /* gap: 48px; */ + gap: unset; } .captionsContainer { @@ -180,3 +176,11 @@ #captions { display: none; } + +#toast-notification { + gap: unset; +} + +#toast-text { + font-family: InterRegular; +} diff --git a/receivers/webos/fcast-receiver/src/viewer/1280x720.css b/receivers/webos/fcast-receiver/src/viewer/1280x720.css new file mode 100644 index 0000000..63ab28a --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/1280x720.css @@ -0,0 +1,30 @@ +/* @media only screen and ((max-width: 1919px) or (max-height: 1079px)) { */ + #titleIcon { + width: 84px; + height: 84px; + } + + .lds-ring { + width: 100px; + height: 100px; + } + .lds-ring div { + width: 84px; + height: 84px; + } + + #toast-notification { + padding: 8px; + } + + #toast-icon { + width: 40px; + height: 40px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 16px; + } +/* } */ diff --git a/receivers/webos/fcast-receiver/src/viewer/1920x1080.css b/receivers/webos/fcast-receiver/src/viewer/1920x1080.css new file mode 100644 index 0000000..2293970 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/1920x1080.css @@ -0,0 +1,30 @@ +/* @media only screen and ((max-width: 2559px) or (max-height: 1439px)) { */ + #titleIcon { + width: 124px; + height: 124px; + } + + .lds-ring { + width: 120px; + height: 120px; + } + .lds-ring div { + width: 104px; + height: 104px; + } + + #toast-notification { + padding: 12px; + } + + #toast-icon { + width: 60px; + height: 60px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 22px; + } +/* } */ \ No newline at end of file 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..0730703 --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/Renderer.ts @@ -0,0 +1,295 @@ +import { + PlayerControlEvent, + playerCtrlStateUpdate, + onPlay, + onPlayPlaylist, + setPlaylistItem, + playlistIndex, + uiHideTimer, + showDurationTimer, + isMediaItem, + cachedPlayMediaItem, + imageViewerPlaybackState, + keyDownEventHandler, + keyUpEventHandler +} from 'common/viewer/Renderer'; +import { KeyCode, RemoteKeyCode, ControlBarMode } from 'lib/common'; +import * as common from 'lib/common'; +import { PlaybackState } from 'common/Packets'; + +const logger = window.targetAPI.logger; + +const playPreviousContainer = document.getElementById('playPreviousContainer'); +const actionContainer = document.getElementById('actionContainer'); +const playNextContainer = document.getElementById('playNextContainer'); +const action = document.getElementById('action'); + +enum ControlFocus { + Action, + PlayPrevious, + PlayNext, +} + +let controlMode = ControlBarMode.KeyboardMouse; +let controlFocus = ControlFocus.Action; + +// Hide +// [|<][>][>|] +// Hide +let locationMap = { + Action: actionContainer, + PlayPrevious: playPreviousContainer, + PlayNext: playNextContainer, +}; + + +window.parent.webOSApp.setKeyDownHandler(keyDownEventHandler); +window.parent.webOSApp.setKeyUpHandler(keyUpEventHandler); + +uiHideTimer.setDelay(5000); +uiHideTimer.setCallback(() => { + if (controlMode === ControlBarMode.KeyboardMouse || !showDurationTimer.isPaused()) { + controlMode = ControlBarMode.KeyboardMouse; + locationMap[ControlFocus[controlFocus]].classList.remove('buttonFocus'); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); + } +}); + +// Leave control bar on screen if magic remote cursor leaves window +document.onmouseout = () => { + if (controlMode === ControlBarMode.KeyboardMouse) { + uiHideTimer.end(); + } +} + +function remoteNavigateTo(location: ControlFocus) { + // Issues with using standard focus, so manually managing styles + locationMap[ControlFocus[controlFocus]].classList.remove('buttonFocus'); + controlFocus = location; + locationMap[ControlFocus[controlFocus]].classList.add('buttonFocus'); +} + +function setControlMode(mode: ControlBarMode, immediateHide: boolean = true) { + if (mode === ControlBarMode.KeyboardMouse) { + uiHideTimer.enable(); + + if (immediateHide) { + locationMap[ControlFocus[controlFocus]].classList.remove('buttonFocus'); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); + } + else { + uiHideTimer.start(); + } + } + else { + const focus = action?.style.display === 'none' ? ControlFocus.PlayNext : ControlFocus.Action; + remoteNavigateTo(focus); + playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); + uiHideTimer.start(); + } + + controlMode = mode; +} + +export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean { + let handledCase = false; + + switch (event) { + default: + break; + } + + return handledCase; +} + +export function targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) { + switch (event) { + case PlayerControlEvent.Load: { + if (!isMediaItem && controlMode === ControlBarMode.Remote) { + setControlMode(ControlBarMode.KeyboardMouse); + } + if (action?.style.display === 'none') { + actionContainer.style.display = 'none'; + } + else { + actionContainer.style.display = 'block'; + } + break; + } + + default: + break; + } +} + +export function targetKeyDownEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } { + let handledCase = false; + let key = ''; + + switch (event.keyCode) { + case KeyCode.KeyK: + case KeyCode.Space: + if (isMediaItem) { + // Play/pause toggle + if (cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) { + if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) { + playerCtrlStateUpdate(PlayerControlEvent.Play); + } else { + playerCtrlStateUpdate(PlayerControlEvent.Pause); + } + } + + event.preventDefault(); + handledCase = true; + } + break; + + case KeyCode.Enter: + if (isMediaItem) { + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + if (controlFocus === ControlFocus.Action) { + // Play/pause toggle + if (cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) { + if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) { + playerCtrlStateUpdate(PlayerControlEvent.Play); + } else { + playerCtrlStateUpdate(PlayerControlEvent.Pause); + } + } + } + else if (controlFocus === ControlFocus.PlayPrevious) { + setPlaylistItem(playlistIndex - 1); + } + else if (controlFocus === ControlFocus.PlayNext) { + setPlaylistItem(playlistIndex + 1); + } + } + + event.preventDefault(); + handledCase = true; + } + break; + case KeyCode.ArrowUp: + case KeyCode.ArrowDown: + if (isMediaItem) { + if (controlMode === ControlBarMode.KeyboardMouse) { + setControlMode(ControlBarMode.Remote); + } + else { + setControlMode(ControlBarMode.KeyboardMouse); + } + + event.preventDefault(); + handledCase = true; + } + break; + case KeyCode.ArrowLeft: + if (isMediaItem) { + if (controlMode === ControlBarMode.KeyboardMouse) { + setPlaylistItem(playlistIndex - 1); + } + else { + if (controlFocus === ControlFocus.Action || action?.style.display === 'none') { + remoteNavigateTo(ControlFocus.PlayPrevious); + } + else if (controlFocus === ControlFocus.PlayNext) { + remoteNavigateTo(ControlFocus.Action); + } + } + + event.preventDefault(); + handledCase = true; + } + break; + case KeyCode.ArrowRight: + if (isMediaItem) { + if (controlMode === ControlBarMode.KeyboardMouse) { + setPlaylistItem(playlistIndex + 1); + } + else { + if (controlFocus === ControlFocus.Action || action?.style.display === 'none') { + remoteNavigateTo(ControlFocus.PlayNext); + } + else if (controlFocus === ControlFocus.PlayPrevious) { + remoteNavigateTo(ControlFocus.Action); + } + } + + event.preventDefault(); + handledCase = true; + } + break; + + case RemoteKeyCode.Stop: + window.parent.webOSApp.loadPage('main_window/index.html'); + event.preventDefault(); + handledCase = true; + key = 'Stop'; + break; + + // Note that in simulator rewind and fast forward key codes are sent twice... + case RemoteKeyCode.Rewind: + if (isMediaItem) { + setPlaylistItem(playlistIndex - 1); + event.preventDefault(); + handledCase = true; + key = 'Rewind'; + } + break; + + case RemoteKeyCode.Play: + if (isMediaItem) { + playerCtrlStateUpdate(PlayerControlEvent.Play); + event.preventDefault(); + handledCase = true; + key = 'Play'; + } + break; + case RemoteKeyCode.Pause: + if (isMediaItem) { + playerCtrlStateUpdate(PlayerControlEvent.Pause); + event.preventDefault(); + handledCase = true; + key = 'Pause'; + } + break; + + // Note that in simulator rewind and fast forward key codes are sent twice... + case RemoteKeyCode.FastForward: + if (isMediaItem) { + setPlaylistItem(playlistIndex + 1); + event.preventDefault(); + handledCase = true; + key = 'FastForward'; + } + break; + + case RemoteKeyCode.Back: + window.parent.webOSApp.loadPage('main_window/index.html'); + 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.parent.webOSApp.pendingPlay !== null) { + if (window.parent.webOSApp.pendingPlay.rendererEvent === 'play-playlist') { + onPlayPlaylist(null, window.parent.webOSApp.pendingPlay.rendererMessage); + } + else { + onPlay(null, window.parent.webOSApp.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..1aef77c --- /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..2654ede --- /dev/null +++ b/receivers/webos/fcast-receiver/src/viewer/style.css @@ -0,0 +1,59 @@ +/* WebOS custom player styles */ + +html { + overflow: hidden; +} + +.container { + height: 140px; + background: linear-gradient(to top, rgba(0, 0, 0, 1.0) 0%, rgba(0, 0, 0, 0.0) 100%); +} + +.iconSize { + width: 48px; + height: 48px; + background-size: cover; +} + +#leftButtonContainer { + left: 48px; + + font-family: InterRegular; + font-size: 28px; +} + +#centerButtonContainer { + font-family: InterRegular; + font-size: 28px; +} + +#playlistLength { + margin-right: 28px; +} + +.buttonContainer { + bottom: 24px; + height: 96px; + /* gap: 48px; */ + gap: unset; +} + +.buttonFocusContainer { + margin-right: 28px; + padding: 20px; + border-radius: 20px; +} + +.buttonFocus { + /* background-image: linear-gradient(to bottom, #008BD7 35%, #0069AA); */ + background-image: linear-gradient(to bottom, #808080 35%, #202020); + border: 1px solid #4E4E4E; +} + +#toast-notification { + gap: unset; +} + +#toast-text { + font-family: InterRegular; +} diff --git a/receivers/webos/fcast-receiver/webpack.config.js b/receivers/webos/fcast-receiver/webpack.config.js index af8b4f7..e5aa042 100644 --- a/receivers/webos/fcast-receiver/webpack.config.js +++ b/receivers/webos/fcast-receiver/webpack.config.js @@ -15,16 +15,86 @@ module.exports = [ { mode: buildMode, entry: { - preload: './src/main/Preload.ts', - renderer: './src/main/Renderer.ts', + main: './src/Main.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' }] + } + ], + }, + 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'), + }, + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + // Common assets + { + from: '../common/assets/**', + to: '[path][name][ext]', + context: path.resolve(__dirname, '..', '..', 'common'), + globOptions: { ignore: ['**/*.txt'] } + }, + // Target assets + { from: 'appinfo.json', to: '[name][ext]' }, + { + from: '**', + to: 'assets/[path][name][ext]', + context: path.resolve(__dirname, 'assets'), + globOptions: { ignore: ['**/*.svg'] } + }, + { + from: '**', + to: 'lib/[name][ext]', + context: path.resolve(__dirname, 'lib'), + globOptions: { ignore: ['**/*.txt'] } + }, + { from: './src/index.html', to: '[name][ext]' } + ], + }), + new webpack.DefinePlugin({ + TARGET: JSON.stringify(TARGET) + }) + ] + }, + { + mode: buildMode, + entry: { + preload: './src/main/Preload.ts', + renderer: './src/main/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' }] } ], }, @@ -45,31 +115,10 @@ module.exports = [ plugins: [ new CopyWebpackPlugin({ patterns: [ - // Common assets - { - from: '../common/assets/**', - to: '../[path][name][ext]', - context: path.resolve(__dirname, '..', '..', 'common'), - globOptions: { ignore: ['**/*.txt'] } - }, { from: '../../common/web/main/common.css', to: '[name][ext]', }, - // Target assets - { from: 'appinfo.json', to: '../[name][ext]' }, - { - from: '**', - to: '../assets/[path][name][ext]', - context: path.resolve(__dirname, 'assets'), - globOptions: { ignore: ['**/*.svg'] } - }, - { - from: '**', - to: '../lib/[name][ext]', - context: path.resolve(__dirname, 'lib'), - globOptions: { ignore: ['**/*.txt'] } - }, { from: './src/main/*', to: '[name][ext]', @@ -88,13 +137,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 +184,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) + }) + ] + } ]; diff --git a/receivers/webos/scripts/reinstall_vm.sh b/receivers/webos/scripts/reinstall_vm.sh new file mode 100755 index 0000000..aca046f --- /dev/null +++ b/receivers/webos/scripts/reinstall_vm.sh @@ -0,0 +1,8 @@ +#!/bin/bash +version=$1 + +~/webOS_SDK/TV/Emulator/${version}/vm_remove.sh +rm -rf ~/webOS_SDK/TV/Emulator/${version} +unzip ~/webOS_SDK/TV/Emulator_tv_linux_${version}.zip -d ~/webOS_SDK/TV/ +chmod 755 ~/webOS_SDK/TV/Emulator/${version}/* +~/webOS_SDK/TV/Emulator/${version}/vm_register.sh