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

Merge branch 'michael/webos' into 'master'

WebOS Receiver 2.0.0

See merge request videostreaming/fcast!18
This commit is contained in:
Michael Hollister 2025-07-21 14:34:05 -05:00
commit 64354001c5
44 changed files with 2731 additions and 832 deletions

View file

@ -61,31 +61,7 @@ export class FCastSession {
const size = 1 + data.length; const size = 1 + data.length;
const header = Buffer.alloc(4 + 1); const header = Buffer.alloc(4 + 1);
header.writeUint32LE(size, 0);
// 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[4] = opcode; header[4] = opcode;
let packet: Buffer; let packet: Buffer;

View file

@ -22,6 +22,8 @@ export class Timer {
private delay: number; private delay: number;
private startTime: number; private startTime: number;
private remainingTime: number; private remainingTime: number;
private enabled: boolean;
public started: boolean; public started: boolean;
constructor(callback: () => void, delay: number, autoStart: boolean = true) { constructor(callback: () => void, delay: number, autoStart: boolean = true) {
@ -29,6 +31,7 @@ export class Timer {
this.callback = callback; this.callback = callback;
this.delay = delay; this.delay = delay;
this.started = false; this.started = false;
this.enabled = true;
if (autoStart) { if (autoStart) {
this.start(); this.start();
@ -36,20 +39,22 @@ export class Timer {
} }
public start(delay?: number) { public start(delay?: number) {
this.delay = delay ? delay : this.delay; if (this.enabled) {
this.delay = delay ? delay : this.delay;
if (this.handle) { if (this.handle) {
window.clearTimeout(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() { public pause() {
if (this.handle) { if (this.enabled && this.handle) {
window.clearTimeout(this.handle); window.clearTimeout(this.handle);
this.handle = null; this.handle = null;
this.remainingTime = this.delay - (Date.now() - this.startTime); this.remainingTime = this.delay - (Date.now() - this.startTime);
@ -57,7 +62,7 @@ export class Timer {
} }
public resume() { public resume() {
if (this.remainingTime) { if (this.enabled && this.remainingTime) {
this.start(this.remainingTime); this.start(this.remainingTime);
} }
} }
@ -70,4 +75,32 @@ export class Timer {
this.started = false; 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;
}
} }

View file

@ -84,11 +84,20 @@ if (TARGET === 'electron') {
// @ts-ignore // @ts-ignore
} else if (TARGET === 'webOS' || TARGET === 'tizenOS') { } else if (TARGET === 'webOS' || TARGET === 'tizenOS') {
preloadData = { preloadData.onDeviceInfoCb = () => { logger.warn('Main: Callback not set while fetching device info'); };
onDeviceInfoCb: () => { logger.error('Main: Callback not set while fetching device info'); }, preloadData.getSessionsCb = () => { logger.error('Main: Callback not set while calling getSessions'); };
getSessionsCb: () => { logger.error('Main: Callback not set while calling getSessions'); }, preloadData.initializeSubscribedKeysCb = () => { logger.error('Main: Callback not set while calling initializeSubscribedKeys'); };
onConnectCb: (_, value: any) => { logger.error('Main: Callback not set while calling onConnect'); }, preloadData.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.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 = { window.targetAPI = {
@ -102,8 +111,21 @@ if (TARGET === 'electron') {
return preloadData.getSessionsCb(); return preloadData.getSessionsCb();
} }
}, },
initializeSubscribedKeys: (callback?: () => Promise<{ keyDown: string[], keyUp: string[] }>) => {
if (callback) {
preloadData.initializeSubscribedKeysCb = callback;
}
else {
preloadData.initializeSubscribedKeysCb().then((value: { keyDown: Set<string>, keyUp: Set<string> }) => {
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, onConnect: (callback: (_, value: any) => void) => preloadData.onConnectCb = callback,
onDisconnect: (callback: (_, value: any) => void) => preloadData.onDisconnectCb = callback, onDisconnect: (callback: (_, value: any) => void) => preloadData.onDisconnectCb = callback,
sendEvent: (message: EventMessage) => { preloadData.sendEventCb(message); },
logger: loggerInterface, logger: loggerInterface,
}; };
} else { } else {

View file

@ -1,9 +1,10 @@
import QRCode from 'modules/qrcode'; import QRCode from 'modules/qrcode';
import * as connectionMonitor from '../ConnectionMonitor'; 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 { toast, ToastIcon } from '../components/Toast';
import { EventMessage, EventType, KeyEvent } from 'common/Packets'; import { EventMessage, EventType, KeyEvent } from 'common/Packets';
import { targetKeyDownEventListener } from 'src/main/Renderer';
const connectionStatusText = document.getElementById('connection-status-text'); const connectionStatusText = document.getElementById('connection-status-text');
const connectionStatusSpinner = document.getElementById('connection-spinner'); const connectionStatusSpinner = document.getElementById('connection-spinner');
@ -202,13 +203,45 @@ function renderQRCode(url: string) {
onQRCodeRendered(); onQRCodeRendered();
} }
document.addEventListener('keydown', (event: KeyboardEvent) => { export function keyDownEventHandler(event: KeyboardEvent) {
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { // logger.info("KeyDown", event);
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, false))); 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().keyDown.has(key)) {
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) { window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase)));
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
} }
}); }
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);

View file

@ -14,6 +14,8 @@ export class Player {
private player: HTMLVideoElement; private player: HTMLVideoElement;
private playMessage: PlayMessage; private playMessage: PlayMessage;
private source: string; private source: string;
private playCb: any;
private pauseCb: any;
// Todo: use a common event handler interface instead of exposing internal players // Todo: use a common event handler interface instead of exposing internal players
public playerType: PlayerType; public playerType: PlayerType;
@ -23,6 +25,8 @@ export class Player {
constructor(player: HTMLVideoElement, message: PlayMessage) { constructor(player: HTMLVideoElement, message: PlayMessage) {
this.player = player; this.player = player;
this.playMessage = message; this.playMessage = message;
this.playCb = null;
this.pauseCb = null;
if (message.container === 'application/dash+xml') { if (message.container === 'application/dash+xml') {
this.playerType = PlayerType.Dash; this.playerType = PlayerType.Dash;
@ -110,6 +114,8 @@ export class Player {
this.hlsPlayer = null; this.hlsPlayer = null;
this.playMessage = null; this.playMessage = null;
this.source = null; this.source = null;
this.playCb = null;
this.pauseCb = null;
} }
/** /**
@ -143,6 +149,10 @@ export class Player {
} else { // HLS, HTML } else { // HLS, HTML
this.player.play(); this.player.play();
} }
if (this.playCb) {
this.playCb();
}
} }
public isPaused(): boolean { public isPaused(): boolean {
@ -161,6 +171,15 @@ export class Player {
} else { // HLS, HTML } else { // HLS, HTML
this.player.pause(); this.player.pause();
} }
if (this.pauseCb) {
this.pauseCb();
}
}
public setPlayPauseCallback(playCallback: (() => void), pauseCallback: (() => void)) {
this.playCb = playCallback;
this.pauseCb = pauseCallback;
} }
public stop() { public stop() {

View file

@ -19,7 +19,6 @@ declare global {
interface Window { interface Window {
electronAPI: any; electronAPI: any;
tizenOSAPI: any; tizenOSAPI: any;
webOSAPI: any;
webOS: any; webOS: any;
targetAPI: any; targetAPI: any;
} }
@ -75,32 +74,49 @@ if (TARGET === 'electron') {
// @ts-ignore // @ts-ignore
} else if (TARGET === 'webOS' || TARGET === 'tizenOS') { } else if (TARGET === 'webOS' || TARGET === 'tizenOS') {
preloadData = { preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => { logger.error('Player: Callback "send_playback_update" not set'); };
sendPlaybackErrorCb: () => { logger.error('Player: Callback "send_playback_error" not set'); }, preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => { logger.error('Player: Callback "send_volume_update" not set'); };
sendPlaybackUpdateCb: () => { logger.error('Player: Callback "send_playback_update" not set'); }, preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { logger.error('Player: Callback "send_playback_error" not set'); };
sendVolumeUpdateCb: () => { logger.error('Player: Callback "send_volume_update" not set'); }, preloadData.sendEventCb = (message: EventMessage) => { logger.error('Player: Callback "onSendEventCb" not set'); };
// onPlayCb: () => { logger.error('Player: Callback "play" not set'); }, // preloadData.onPlayCb = () => { logger.error('Player: Callback "play" not set'); };
onPlayCb: undefined, preloadData.onPlayCb = undefined;
onPauseCb: () => { logger.error('Player: Callback "pause" not set'); }, preloadData.onPauseCb = () => { logger.error('Player: Callback "pause" not set'); };
onResumeCb: () => { logger.error('Player: Callback "resume" not set'); }, preloadData.onResumeCb = () => { logger.error('Player: Callback "resume" not set'); };
onSeekCb: () => { logger.error('Player: Callback "onseek" not set'); }, preloadData.onSeekCb = () => { logger.error('Player: Callback "onseek" not set'); };
onSetVolumeCb: () => { logger.error('Player: Callback "setvolume" not set'); }, preloadData.onSetVolumeCb = () => { logger.error('Player: Callback "setvolume" not set'); };
onSetSpeedCb: () => { logger.error('Player: Callback "setspeed" not set'); }, preloadData.onSetSpeedCb = () => { logger.error('Player: Callback "setspeed" not set'); };
getSessionsCb: () => { logger.error('Player: Callback "getSessions" not set'); }, preloadData.onSetPlaylistItemCb = () => { logger.error('Player: Callback "onSetPlaylistItem" not set'); };
onConnectCb: () => { logger.error('Player: Callback "onConnect" not set'); },
onDisconnectCb: () => { logger.error('Player: Callback "onDisconnect" 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 = { window.targetAPI = {
sendPlaybackError: (error: PlaybackErrorMessage) => { preloadData.sendPlaybackErrorCb(error); },
sendPlaybackUpdate: (update: PlaybackUpdateMessage) => { preloadData.sendPlaybackUpdateCb(update); }, sendPlaybackUpdate: (update: PlaybackUpdateMessage) => { preloadData.sendPlaybackUpdateCb(update); },
sendVolumeUpdate: (update: VolumeUpdateMessage) => { preloadData.sendVolumeUpdateCb(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; }, onPlay: (callback: any) => { preloadData.onPlayCb = callback; },
onPause: (callback: any) => { preloadData.onPauseCb = callback; }, onPause: (callback: any) => { preloadData.onPauseCb = callback; },
onResume: (callback: any) => { preloadData.onResumeCb = callback; }, onResume: (callback: any) => { preloadData.onResumeCb = callback; },
onSeek: (callback: any) => { preloadData.onSeekCb = callback; }, onSeek: (callback: any) => { preloadData.onSeekCb = callback; },
onSetVolume: (callback: any) => { preloadData.onSetVolumeCb = callback; }, onSetVolume: (callback: any) => { preloadData.onSetVolumeCb = callback; },
onSetSpeed: (callback: any) => { preloadData.onSetSpeedCb = 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]>) => { getSessions: (callback?: () => Promise<[any]>) => {
if (callback) { if (callback) {
preloadData.getSessionsCb = callback; preloadData.getSessionsCb = callback;
@ -109,8 +125,21 @@ if (TARGET === 'electron') {
return preloadData.getSessionsCb(); return preloadData.getSessionsCb();
} }
}, },
initializeSubscribedKeys: (callback?: () => Promise<{ keyDown: string[], keyUp: string[] }>) => {
if (callback) {
preloadData.initializeSubscribedKeysCb = callback;
}
else {
preloadData.initializeSubscribedKeysCb().then((value: { keyDown: Set<string>, keyUp: Set<string> }) => {
preloadData.subscribedKeys.keyDown = new Set(value.keyDown);
preloadData.subscribedKeys.keyUp = new Set(value.keyUp);
});
}
},
getSubscribedKeys: () => preloadData.subscribedKeys,
onConnect: (callback: any) => { preloadData.onConnectCb = callback; }, onConnect: (callback: any) => { preloadData.onConnectCb = callback; },
onDisconnect: (callback: any) => { preloadData.onDisconnectCb = callback; }, onDisconnect: (callback: any) => { preloadData.onDisconnectCb = callback; },
onPlayPlaylist: (callback: any) => { preloadData.onPlayPlaylistCb = callback; },
logger: loggerInterface, logger: loggerInterface,
}; };
} else { } else {

View file

@ -12,7 +12,8 @@ import {
targetKeyDownEventListener, targetKeyDownEventListener,
captionsBaseHeightCollapsed, captionsBaseHeightCollapsed,
captionsBaseHeightExpanded, captionsBaseHeightExpanded,
captionsLineHeight captionsLineHeight,
targetKeyUpEventListener
} from 'src/player/Renderer'; } from 'src/player/Renderer';
const logger = window.targetAPI.logger; const logger = window.targetAPI.logger;
@ -39,7 +40,7 @@ const playerCtrlProgressBarBuffer = document.getElementById("progressBarBuffer")
const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress"); const playerCtrlProgressBarProgress = document.getElementById("progressBarProgress");
const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition"); const playerCtrlProgressBarPosition = document.getElementById("progressBarPosition");
const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle"); const playerCtrlProgressBarHandle = document.getElementById("progressBarHandle");
const PlayerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea"); const playerCtrlProgressBarInteractiveArea = document.getElementById("progressBarInteractiveArea");
const playerCtrlVolumeBar = document.getElementById("volumeBar"); const playerCtrlVolumeBar = document.getElementById("volumeBar");
const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress"); const playerCtrlVolumeBarProgress = document.getElementById("volumeBarProgress");
@ -79,10 +80,7 @@ let playlistIndex = 0;
let isMediaItem = false; let isMediaItem = false;
let playItemCached = false; let playItemCached = false;
let uiHideTimer = new Timer(() => { let uiHideTimer = new Timer(() => { playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); }, 3000);
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}, 3000);
let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false); let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false);
let showDurationTimer = new Timer(mediaEndHandler, 0, false); let showDurationTimer = new Timer(mediaEndHandler, 0, false);
let mediaTitleShowTimer = new Timer(() => { mediaTitle.style.display = 'none'; }, 5000); let mediaTitleShowTimer = new Timer(() => { mediaTitle.style.display = 'none'; }, 5000);
@ -566,6 +564,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
} }
case PlayerControlEvent.UiFadeOut: { case PlayerControlEvent.UiFadeOut: {
uiVisible = false;
document.body.style.cursor = "none"; document.body.style.cursor = "none";
playerControls.style.opacity = '0'; playerControls.style.opacity = '0';
captionsBaseHeight = captionsBaseHeightCollapsed; captionsBaseHeight = captionsBaseHeightCollapsed;
@ -581,6 +580,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
} }
case PlayerControlEvent.UiFadeIn: { case PlayerControlEvent.UiFadeIn: {
uiVisible = true;
document.body.style.cursor = "default"; document.body.style.cursor = "default";
playerControls.style.opacity = '1'; playerControls.style.opacity = '1';
captionsBaseHeight = captionsBaseHeightExpanded; captionsBaseHeight = captionsBaseHeightExpanded;
@ -643,7 +643,7 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
function scrubbingMouseUIHandler(e: MouseEvent) { function scrubbingMouseUIHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft; 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()); 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)); 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); playerCtrlProgressBarPosition.textContent = isLive ? `${livePrefix}${formatDuration(time)}` : formatDuration(time);
let offset = e.offsetX - (playerCtrlProgressBarPosition.offsetWidth / 2); 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`); playerCtrlProgressBarPosition.setAttribute("style", `display: block; left: ${offset}px`);
} }
@ -673,21 +673,21 @@ playerCtrlPlayPrevious.onclick = () => { setPlaylistItem(playlistIndex - 1); }
playerCtrlPlayNext.onclick = () => { setPlaylistItem(playlistIndex + 1); } playerCtrlPlayNext.onclick = () => { setPlaylistItem(playlistIndex + 1); }
playerCtrlVolume.onclick = () => { player?.setMute(!player?.isMuted()); }; playerCtrlVolume.onclick = () => { player?.setMute(!player?.isMuted()); };
PlayerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) }; playerCtrlProgressBarInteractiveArea.onmousedown = (e: MouseEvent) => { scrubbing = true; scrubbingMouseHandler(e) };
PlayerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; }; playerCtrlProgressBarInteractiveArea.onmouseup = () => { scrubbing = false; };
PlayerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => { playerCtrlProgressBarInteractiveArea.onmouseenter = (e: MouseEvent) => {
if (e.buttons === 0) { if (e.buttons === 0) {
volumeChanging = false; volumeChanging = false;
} }
scrubbingMouseUIHandler(e); scrubbingMouseUIHandler(e);
}; };
PlayerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); }; playerCtrlProgressBarInteractiveArea.onmouseleave = () => { playerCtrlProgressBarPosition.setAttribute("style", "display: none"); };
PlayerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) }; playerCtrlProgressBarInteractiveArea.onmousemove = (e: MouseEvent) => { scrubbingMouseHandler(e) };
function scrubbingMouseHandler(e: MouseEvent) { function scrubbingMouseHandler(e: MouseEvent) {
const progressBarOffset = e.offsetX - playerCtrlProgressBar.offsetLeft; 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()); let time = Math.round((progressBarOffset / progressBarWidth) * player?.getDuration());
time = Math.min(player?.getDuration(), Math.max(0.0, time)); time = Math.min(player?.getDuration(), Math.max(0.0, time));
@ -879,17 +879,11 @@ function stopUiHideTimer() {
uiHideTimer.stop(); uiHideTimer.stop();
if (!uiVisible) { if (!uiVisible) {
uiVisible = true;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
} }
} }
document.onmouseout = () => { document.onmouseout = () => { uiHideTimer.end(); }
uiHideTimer.stop();
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}
document.onmousemove = () => { document.onmousemove = () => {
stopUiHideTimer(); stopUiHideTimer();
@ -909,31 +903,91 @@ document.addEventListener('click', (event: MouseEvent) => {
}); });
// Add the keydown event listener to the document // Add the keydown event listener to the document
const skipInterval = 10; const minSkipInterval = 10;
const volumeIncrement = 0.1; const volumeIncrement = 0.1;
function keyDownEventListener(event: KeyboardEvent) { let skipBackRepeat = false;
// logger.info("KeyDown", event); let skipBackInterval = minSkipInterval;
let handledCase = targetKeyDownEventListener(event); 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) { if (!handledCase) {
switch (event.code) { switch (event.key.toLowerCase()) {
case 'ArrowLeft': case 'arrowleft':
skipBack(); skipBack(event.repeat);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'ArrowRight': case 'arrowright':
skipForward(); skipForward(event.repeat);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case "Home": case "home":
player?.setCurrentTime(0); player?.setCurrentTime(0);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case "End": case "end":
if (isLive) { if (isLive) {
setLivePosition(); setLivePosition();
} }
@ -943,9 +997,9 @@ function keyDownEventListener(event: KeyboardEvent) {
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'KeyK': case 'k':
case 'Space': case ' ':
case 'Enter': case 'enter':
// Play/pause toggle // Play/pause toggle
if (player?.isPaused()) { if (player?.isPaused()) {
player?.play(); player?.play();
@ -955,17 +1009,17 @@ function keyDownEventListener(event: KeyboardEvent) {
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'KeyM': case 'm':
// Mute toggle // Mute toggle
player?.setMute(!player?.isMuted()); player?.setMute(!player?.isMuted());
handledCase = true; handledCase = true;
break; break;
case 'ArrowUp': case 'arrowup':
// Volume up // Volume up
volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1)); volumeChangeHandler(Math.min(player?.getVolume() + volumeIncrement, 1));
handledCase = true; handledCase = true;
break; break;
case 'ArrowDown': case 'arrowdown':
// Volume down // Volume down
volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0)); volumeChangeHandler(Math.max(player?.getVolume() - volumeIncrement, 0));
handledCase = true; handledCase = true;
@ -975,27 +1029,33 @@ function keyDownEventListener(event: KeyboardEvent) {
} }
} }
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) {
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase))); window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase)));
} }
} }
function skipBack() { function keyUpEventHandler(event: KeyboardEvent) {
player?.setCurrentTime(Math.max(player?.getCurrentTime() - skipInterval, 0)); // logger.info("KeyUp", event);
} let result = targetKeyUpEventListener(event);
let handledCase = result.handledCase;
function skipForward() { // @ts-ignore
if (!isLivePosition) { let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key;
player?.setCurrentTime(Math.min(player?.getCurrentTime() + skipInterval, player?.getDuration()));
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('keydown', keyDownEventHandler);
document.addEventListener('keyup', (event: KeyboardEvent) => { document.addEventListener('keyup', keyUpEventHandler);
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) {
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false)));
}
});
export { export {
PlayerControlEvent, PlayerControlEvent,
@ -1004,24 +1064,20 @@ export {
idleIcon, idleIcon,
videoElement, videoElement,
videoCaptions, videoCaptions,
playerCtrlProgressBar,
playerCtrlProgressBarBuffer,
playerCtrlProgressBarProgress,
playerCtrlProgressBarHandle, playerCtrlProgressBarHandle,
playerCtrlVolumeBar,
playerCtrlVolumeBarProgress,
playerCtrlVolumeBarHandle,
playerCtrlLiveBadge,
playerCtrlPosition,
playerCtrlDuration,
playerCtrlCaptions, playerCtrlCaptions,
player, player,
uiHideTimer,
isLive, isLive,
playlistIndex,
captionsBaseHeight, captionsBaseHeight,
captionsLineHeight, captionsLineHeight,
onPlay, onPlay,
onPlayPlaylist,
setPlaylistItem,
playerCtrlStateUpdate, playerCtrlStateUpdate,
formatDuration,
skipBack, skipBack,
skipForward, skipForward,
keyDownEventHandler,
keyUpEventHandler,
}; };

View file

@ -417,7 +417,6 @@ body {
.captions_off { .captions_off {
cursor: pointer; cursor: pointer;
display: none;
background-image: url("../assets/icons/player/icon24_cc_off.svg"); background-image: url("../assets/icons/player/icon24_cc_off.svg");
transition: background-image 0.1s ease-in-out; transition: background-image 0.1s ease-in-out;
@ -429,7 +428,6 @@ body {
.captions_on { .captions_on {
cursor: pointer; cursor: pointer;
display: none;
background-image: url("../assets/icons/player/icon24_cc_on.svg"); background-image: url("../assets/icons/player/icon24_cc_on.svg");
transition: background-image 0.1s ease-in-out; transition: background-image 0.1s ease-in-out;

View file

@ -5,7 +5,9 @@ import * as connectionMonitor from 'common/ConnectionMonitor';
import { toast, ToastIcon } from 'common/components/Toast'; import { toast, ToastIcon } from 'common/components/Toast';
import { import {
targetPlayerCtrlStateUpdate, targetPlayerCtrlStateUpdate,
targetPlayerCtrlPostStateUpdate,
targetKeyDownEventListener, targetKeyDownEventListener,
targetKeyUpEventListener,
} from 'src/viewer/Renderer'; } from 'src/viewer/Renderer';
const logger = window.targetAPI.logger; const logger = window.targetAPI.logger;
@ -33,10 +35,7 @@ let isMediaItem = false;
let playItemCached = false; let playItemCached = false;
let imageViewerPlaybackState: PlaybackState = PlaybackState.Idle; let imageViewerPlaybackState: PlaybackState = PlaybackState.Idle;
let uiHideTimer = new Timer(() => { let uiHideTimer = new Timer(() => { playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut); }, 3000);
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}, 3000);
let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false); let loadingTimer = new Timer(() => { loadingSpinner.style.display = 'block'; }, 100, false);
let showDurationTimer = new Timer(() => { let showDurationTimer = new Timer(() => {
@ -277,12 +276,14 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
break; break;
case PlayerControlEvent.UiFadeOut: { case PlayerControlEvent.UiFadeOut: {
uiVisible = false;
document.body.style.cursor = "none"; document.body.style.cursor = "none";
playerControls.style.opacity = '0'; playerControls.style.opacity = '0';
break; break;
} }
case PlayerControlEvent.UiFadeIn: { case PlayerControlEvent.UiFadeIn: {
uiVisible = true;
document.body.style.cursor = "default"; document.body.style.cursor = "default";
playerControls.style.opacity = '1'; playerControls.style.opacity = '1';
break; break;
@ -291,6 +292,8 @@ function playerCtrlStateUpdate(event: PlayerControlEvent) {
default: default:
break; break;
} }
targetPlayerCtrlPostStateUpdate(event);
} }
// Receiver generated event handlers // Receiver generated event handlers
@ -312,57 +315,58 @@ function stopUiHideTimer() {
uiHideTimer.stop(); uiHideTimer.stop();
if (!uiVisible) { if (!uiVisible) {
uiVisible = true;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn); playerCtrlStateUpdate(PlayerControlEvent.UiFadeIn);
} }
} }
document.onmouseout = () => { document.onmouseout = () => { uiHideTimer.end(); }
uiHideTimer.stop();
uiVisible = false;
playerCtrlStateUpdate(PlayerControlEvent.UiFadeOut);
}
document.onmousemove = () => { document.onmousemove = () => {
stopUiHideTimer(); stopUiHideTimer();
uiHideTimer.start(); uiHideTimer.start();
}; };
function keyDownEventListener(event: KeyboardEvent) { function keyDownEventHandler(event: KeyboardEvent) {
// logger.info("KeyDown", event); // logger.info("KeyDown", event);
let handledCase = targetKeyDownEventListener(event); let result = targetKeyDownEventListener(event);
let handledCase = result.handledCase;
if (!handledCase) { // @ts-ignore
switch (event.code) { let key = (TARGET === 'webOS' && result.key !== '') ? result.key : event.key;
case 'ArrowLeft':
if (!handledCase && isMediaItem) {
switch (event.key.toLowerCase()) {
case 'arrowleft':
setPlaylistItem(playlistIndex - 1); setPlaylistItem(playlistIndex - 1);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'ArrowRight': case 'arrowright':
setPlaylistItem(playlistIndex + 1); setPlaylistItem(playlistIndex + 1);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case "Home": case "home":
setPlaylistItem(0); setPlaylistItem(0);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case "End": case "end":
setPlaylistItem(cachedPlaylist.items.length - 1); setPlaylistItem(cachedPlaylist.items.length - 1);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'KeyK': case 'k':
case 'Space': case ' ':
case 'Enter': case 'enter':
// Play/pause toggle // Play/pause toggle
if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) { if (cachedPlayMediaItem.showDuration && cachedPlayMediaItem.showDuration > 0) {
playerCtrlStateUpdate(PlayerControlEvent.Play); if (imageViewerPlaybackState === PlaybackState.Paused || imageViewerPlaybackState === PlaybackState.Idle) {
} else { playerCtrlStateUpdate(PlayerControlEvent.Play);
playerCtrlStateUpdate(PlayerControlEvent.Pause); } else {
playerCtrlStateUpdate(PlayerControlEvent.Pause);
}
} }
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
@ -371,17 +375,33 @@ function keyDownEventListener(event: KeyboardEvent) {
} }
} }
if (window.targetAPI.getSubscribedKeys().keyDown.has(event.key)) { if (window.targetAPI.getSubscribedKeys().keyDown.has(key)) {
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, event.key, event.repeat, handledCase))); window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyDown, key, event.repeat, handledCase)));
} }
} }
document.addEventListener('keydown', keyDownEventListener); function keyUpEventHandler(event: KeyboardEvent) {
document.addEventListener('keyup', (event: KeyboardEvent) => { // logger.info("KeyUp", event);
if (window.targetAPI.getSubscribedKeys().keyUp.has(event.key)) { let result = targetKeyUpEventListener(event);
window.targetAPI.sendEvent(new EventMessage(Date.now(), new KeyEvent(EventType.KeyUp, event.key, event.repeat, false))); 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 { export {
PlayerControlEvent, PlayerControlEvent,
@ -389,6 +409,16 @@ export {
idleIcon, idleIcon,
imageViewer, imageViewer,
genericViewer, genericViewer,
uiHideTimer,
showDurationTimer,
isMediaItem,
playlistIndex,
cachedPlayMediaItem,
imageViewerPlaybackState,
onPlay, onPlay,
onPlayPlaylist,
playerCtrlStateUpdate, playerCtrlStateUpdate,
setPlaylistItem,
keyDownEventHandler,
keyUpEventHandler,
}; };

View file

@ -3,6 +3,16 @@ import 'common/main/Renderer';
const logger = window.targetAPI.logger; const logger = window.targetAPI.logger;
export function onQRCodeRendered() {} 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 updateView = document.getElementById("update-view");
const updateViewTitle = document.getElementById("update-view-title"); const updateViewTitle = document.getElementById("update-view-title");
const updateText = document.getElementById("update-text"); const updateText = document.getElementById("update-text");

View file

@ -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; let handledCase = false;
switch (event.code) { switch (event.key.toLowerCase()) {
case 'KeyF': case 'f':
case 'F11': case 'f11':
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'Escape': case 'escape':
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen); playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
@ -71,7 +71,12 @@ export function targetKeyDownEventListener(event: KeyboardEvent) {
break; 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 { export {

View file

@ -40,17 +40,25 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean
return handledCase; 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; let handledCase = false;
switch (event.code) { switch (event.key.toLowerCase()) {
case 'KeyF': case 'f':
case 'F11': case 'f11':
playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen); playerCtrlStateUpdate(PlayerControlEvent.ToggleFullscreen);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case 'Escape': case 'escape':
playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen); playerCtrlStateUpdate(PlayerControlEvent.ExitFullscreen);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
@ -59,5 +67,10 @@ export function targetKeyDownEventListener(event: KeyboardEvent): boolean {
break; 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: '', };
}; };

View file

@ -26,6 +26,8 @@ PASSPHRASE=YOUR_TV_PASSPHRASE
This information is found in the development app. 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 ### Run
```bash ```bash
docker run --rm -it -w /app/receivers/webos --entrypoint='bash' --network host \ docker run --rm -it -w /app/receivers/webos --entrypoint='bash' --network host \

View file

@ -0,0 +1 @@
@futo:registry=https://gitlab.futo.org/api/v4/projects/305/packages/npm/

View file

@ -1,23 +1,26 @@
{ {
"name": "com.futo.fcast.receiver.service", "name": "com.futo.fcast.receiver.service",
"version": "1.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "com.futo.fcast.receiver.service", "name": "com.futo.fcast.receiver.service",
"version": "1.1.0", "version": "2.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"http": "^0.0.1-security", "bufferutil": "^4.0.8",
"follow-redirects": "^1.15.9",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"memfs": "^4.17.2",
"url": "^0.11.4", "url": "^0.11.4",
"uuid": "^11.0.3", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@futo/mdns-js": "1.0.3",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38", "@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10", "@types/node-forge": "^1.3.10",
@ -29,7 +32,6 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
@ -713,6 +715,47 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1302,6 +1345,60 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
@ -2521,8 +2618,6 @@
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"node-gyp-build": "^4.3.0" "node-gyp-build": "^4.3.0"
}, },
@ -3786,6 +3881,26 @@
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"license": "ISC" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -4052,11 +4167,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -4084,6 +4194,15 @@
"node": ">=10.17.0" "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": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -5409,47 +5528,6 @@
"node": ">= 0.4" "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": { "node_modules/media-typer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@ -5460,6 +5538,25 @@
"node": ">= 0.8" "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": { "node_modules/merge-descriptors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"node-gyp-build": "bin.js", "node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js", "node-gyp-build-optional": "optional.js",
@ -7047,6 +7143,18 @@
"node": ">=8" "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": { "node_modules/tinyglobby": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@ -7135,6 +7243,22 @@
"node": ">=0.6" "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": { "node_modules/ts-api-utils": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.2.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.2.tgz",
@ -7254,6 +7378,12 @@
"node": ">= 8" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7470,16 +7600,15 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT",
"bin": { "bin": {
"uuid": "dist/esm/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {

View file

@ -1,6 +1,6 @@
{ {
"name": "com.futo.fcast.receiver.service", "name": "com.futo.fcast.receiver.service",
"version": "1.1.0", "version": "2.0.0",
"description": "FCast network service", "description": "FCast network service",
"author": "FUTO", "author": "FUTO",
"license": "MIT", "license": "MIT",
@ -12,6 +12,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@futo/mdns-js": "1.0.3",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38", "@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10", "@types/node-forge": "^1.3.10",
@ -23,7 +24,6 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
@ -33,10 +33,12 @@
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
}, },
"dependencies": { "dependencies": {
"http": "^0.0.1-security", "bufferutil": "^4.0.8",
"follow-redirects": "^1.15.9",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"memfs": "^4.17.2",
"url": "^0.11.4", "url": "^0.11.4",
"uuid": "^11.0.3", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"optionalDependencies": { "optionalDependencies": {

View file

@ -5,17 +5,30 @@
const Service = __non_webpack_require__('webos-service'); const Service = __non_webpack_require__('webos-service');
// const Service = 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 { DiscoveryService } from 'common/DiscoveryService';
import { TcpListenerService } from 'common/TcpListenerService'; import { TcpListenerService } from 'common/TcpListenerService';
import { WebSocketListenerService } from 'common/WebSocketListenerService'; import { WebSocketListenerService } from 'common/WebSocketListenerService';
import { NetworkService } from 'common/NetworkService';
import { ConnectionMonitor } from 'common/ConnectionMonitor'; import { ConnectionMonitor } from 'common/ConnectionMonitor';
import { Logger, LoggerType } from 'common/Logger'; import { Logger, LoggerType } from 'common/Logger';
import { MediaCache } from 'common/MediaCache';
import { preparePlayMessage } from 'common/UtilityBackend';
import * as os from 'os'; import * as os from 'os';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { ToastIcon } from 'common/components/Toast'; import { ToastIcon } from 'common/components/Toast';
const logger = new Logger('Main', LoggerType.BACKEND); 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<string>();
}
export class Main { export class Main {
static tcpListenerService: TcpListenerService; static tcpListenerService: TcpListenerService;
@ -23,14 +36,57 @@ export class Main {
static discoveryService: DiscoveryService; static discoveryService: DiscoveryService;
static connectionMonitor: ConnectionMonitor; static connectionMonitor: ConnectionMonitor;
static emitter: EventEmitter; 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<string, number> = 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 { static {
try { try {
logger.info(`OS: ${process.platform} ${process.arch}`); 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 // Service will timeout and casting will disconnect if not forced to be kept alive
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
let keepAlive; let keepAlive;
@ -38,102 +94,146 @@ export class Main {
keepAlive = activity; 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.connectionMonitor = new ConnectionMonitor();
Main.discoveryService = new DiscoveryService(); Main.discoveryService = new DiscoveryService();
Main.discoveryService.start(); Main.discoveryService.start();
Main.tcpListenerService = new TcpListenerService(); Main.tcpListenerService = new TcpListenerService();
Main.webSocketListenerService = new WebSocketListenerService(); Main.webSocketListenerService = new WebSocketListenerService();
Main.emitter = new EventEmitter(); Main.emitter = new EventEmitter();
let playData: PlayMessage = null;
let playClosureCb = null; service.register('service_channel', (message: any) => {
const playCb = (message: any, playMessage: PlayMessage) => {
playData = playMessage;
message.respond({ returnValue: true, value: { playData: playData } });
};
let stopClosureCb: any = null;
const seekCb = (message: any, seekMessage: SeekMessage) => { message.respond({ returnValue: true, value: seekMessage }); };
const setVolumeCb = (message: any, volumeMessage: SetVolumeMessage) => { message.respond({ returnValue: true, value: volumeMessage }); };
const setSpeedCb = (message: any, speedMessage: SetSpeedMessage) => { message.respond({ returnValue: true, value: speedMessage }); };
// Note: When logging the `message` object, do NOT use JSON.stringify, you can log messages directly. Seems to be a circular reference causing errors...
service.register('play', (message: any) => {
if (message.isSubscription) { if (message.isSubscription) {
playClosureCb = playCb.bind(this, message); Main.serviceChannelEvents.forEach((event) => {
Main.emitter.on('play', playClosureCb); 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 }}); if (lastTimestamp < timestamp) {
}, Main.serviceChannelEventTimestamps.set(event, timestamp);
(message: any) => { message.respond({ returnValue: true, subscriptionId: message.payload.subscriptionId, timestamp: timestamp, event: event, value: value });
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)}`);
}); });
}
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('setspeed', (message: SetSpeedMessage) => Main.emitter.emit('setspeed', message));
l.emitter.on("resume", () => Main.emitter.emit('resume'));
l.emitter.on("stop", () => Main.emitter.emit('stop'));
l.emitter.on("seek", (message) => Main.emitter.emit('seek', message));
l.emitter.on("setvolume", (message) => Main.emitter.emit('setvolume', message));
l.emitter.on("setspeed", (message) => Main.emitter.emit('setspeed', message));
l.emitter.on('connect', (message) => { l.emitter.on('connect', (message) => {
ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => { ConnectionMonitor.onConnect(l, message, l instanceof WebSocketListenerService, () => {
@ -151,48 +251,65 @@ export class Main {
l.emitter.on('pong', (message) => { l.emitter.on('pong', (message) => {
ConnectionMonitor.onPingPong(message, l instanceof WebSocketListenerService); 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(); 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) { catch (err) {
logger.error("Error initializing service:", err); logger.error("Error initializing service:", err);
Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR }); Main.emitter.emit('toast', { message: `Error initializing service: ${err}`, icon: ToastIcon.ERROR });
} }
} }
} }
export function getComputerName() { 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) { 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 }); Main.emitter.emit('toast', { message: error, icon: ToastIcon.ERROR });
} }
function registerService(service: Service, method: string, callback: (message: any) => any) { // Fallback for simulator or TV devices that don't work with the luna://com.palm.connectionmanager/getStatus method
let callbackRef = null; function getAllIPv4Addresses() {
service.register(method, (message: any) => { const interfaces = os.networkInterfaces();
if (message.isSubscription) { const ipv4Addresses: string[] = [];
callbackRef = callback(message);
Main.emitter.on(method, callbackRef);
}
message.respond({ returnValue: true, value: { subscribed: true }}); for (const interfaceName in interfaces) {
}, const addresses = interfaces[interfaceName];
(message: any) => { if (!addresses) continue;
logger.info(`Canceled ${method} service subscriber`);
Main.emitter.removeAllListeners(method); for (const addressInfo of addresses) {
message.respond({ returnValue: true, value: message.payload }); if (addressInfo.family === 'IPv4' && !addressInfo.internal) {
}); ipv4Addresses.push(addressInfo.address);
}
}
}
return ipv4Addresses;
} }

View file

@ -0,0 +1 @@
@futo:registry=https://gitlab.futo.org/api/v4/projects/305/packages/npm/

View file

@ -1,9 +1,9 @@
{ {
"id": "com.futo.fcast.receiver", "id": "com.futo.fcast.receiver",
"version": "1.1.0", "version": "2.0.0",
"vendor": "FUTO", "vendor": "FUTO",
"type": "web", "type": "web",
"main": "main_window/index.html", "main": "index.html",
"title": "FCast Receiver", "title": "FCast Receiver",
"appDescription": "FCast Receiver", "appDescription": "FCast Receiver",
"icon": "assets/icons/icon.png", "icon": "assets/icons/icon.png",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Before After
Before After

View file

@ -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', '<link rel="stylesheet" href="./1920x1080.css" />');
}
else {
document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="./1280x720.css" />');
}
}
else {
const resolution = sessionStorage.getItem('resolution');
if (resolution) {
window.onload = () => {
if (resolution == '1920x1080') {
document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="./1920x1080.css" />');
}
else {
document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="./1280x720.css" />');
}
}
}
else {
window.onresize = () => {
if (window.innerWidth >= 1920 && window.innerHeight >= 1080) {
sessionStorage.setItem('resolution', '1920x1080');
document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="./1920x1080.css" />');
}
else {
sessionStorage.setItem('resolution', '1280x720');
document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="./1280x720.css" />');
}
};
}
}
}
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 };
};

View file

@ -1,26 +1,26 @@
{ {
"name": "com.futo.fcast.receiver", "name": "com.futo.fcast.receiver",
"version": "1.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "com.futo.fcast.receiver", "name": "com.futo.fcast.receiver",
"version": "1.1.0", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"dashjs": "^4.7.4", "dashjs": "^4.7.4",
"hls.js": "^1.5.15", "hls.js": "^1.5.15",
"http": "^0.0.1-security", "log4js": "^6.9.1",
"https": "^1.0.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"url": "^0.11.4", "url": "^0.11.4",
"uuid": "^11.0.3", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@futo/mdns-js": "1.0.3",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38", "@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10", "@types/node-forge": "^1.3.10",
@ -32,13 +32,15 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.4.0", "typescript-eslint": "^8.4.0",
"webpack": "^5.99.6", "webpack": "^5.99.6",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
},
"optionalDependencies": {
"utf-8-validate": "^6.0.5"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@ -837,6 +839,47 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -3045,11 +3088,19 @@
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -3880,7 +3931,6 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/forwarded": { "node_modules/forwarded": {
@ -3903,6 +3953,20 @@
"node": ">= 0.8" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -4071,7 +4135,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@ -4134,11 +4197,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -4156,12 +4214,6 @@
"node": ">= 0.8" "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": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -5261,6 +5313,15 @@
"node": ">=6" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -5378,6 +5439,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -5430,47 +5507,6 @@
"node": ">= 0.4" "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": { "node_modules/media-typer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@ -5575,7 +5611,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@ -6425,6 +6460,12 @@
"node": ">=0.10.0" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@ -6840,6 +6881,20 @@
"node": ">= 0.8" "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": { "node_modules/string-length": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@ -7358,6 +7413,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -7428,17 +7492,30 @@
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT" "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": { "node_modules/uuid": {
"version": "11.1.0", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT",
"bin": { "bin": {
"uuid": "dist/esm/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {

View file

@ -1,6 +1,6 @@
{ {
"name": "com.futo.fcast.receiver", "name": "com.futo.fcast.receiver",
"version": "1.1.0", "version": "2.0.0",
"description": "An application implementing a FCast receiver.", "description": "An application implementing a FCast receiver.",
"author": "FUTO", "author": "FUTO",
"license": "MIT", "license": "MIT",
@ -10,6 +10,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@futo/mdns-js": "1.0.3",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38", "@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10", "@types/node-forge": "^1.3.10",
@ -21,7 +22,6 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"mdns-js": "github:mdns-js/node-mdns-js",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"typescript": "^5.5.4", "typescript": "^5.5.4",
@ -29,15 +29,18 @@
"webpack": "^5.99.6", "webpack": "^5.99.6",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
}, },
"@comment dependencies.uuid": "Versions > 9.0.1 are broken on webOS",
"dependencies": { "dependencies": {
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"dashjs": "^4.7.4", "dashjs": "^4.7.4",
"hls.js": "^1.5.15", "hls.js": "^1.5.15",
"http": "^0.0.1-security", "log4js": "^6.9.1",
"https": "^1.0.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"url": "^0.11.4", "url": "^0.11.4",
"uuid": "^11.0.3", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
},
"optionalDependencies": {
"utf-8-validate": "^6.0.5"
} }
} }

View file

@ -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');

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<style>
iframe, body, html {
width: 100%;
height: 100%;
margin: 0;
border: none;
}
</style>
</head>
<body>
<iframe id="page", src="./main_window/index.html"></iframe>
<script src="./main.js"></script>
</body>
</html>

View file

@ -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;
}
/* } */

View file

@ -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;
}
/* } */

View file

@ -1,153 +1,143 @@
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { preloadData } from 'common/main/Preload'; 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.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
const logger = window.targetAPI.logger;
enum RemoteKeyCode { declare global {
Stop = 413, interface Window {
Rewind = 412, targetAPI: any;
Play = 415, webOSApp: any;
Pause = 19, }
FastForward = 417,
Back = 461,
} }
try { const logger = window.targetAPI.logger;
let getSessions = null;
const toastService = requestService('toast', (message: any) => { toast(message.value.message, message.value.icon, message.value.duration); }); try {
const getDeviceInfoService = window.webOS.service.request('luna://com.palm.connectionmanager', { initializeWindowSizeStylesheet();
method: 'getStatus',
parameters: {}, 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) => { onSuccess: (message: any) => {
// logger.info('Network info status message', message); logger.info('Network info status message', message);
const deviceName = 'FCast-LGwebOSTV'; const deviceName = 'FCast-LGwebOSTV';
const connections = []; const connections: any[] = [];
let fallback = true;
if (message.wired.state !== 'disconnected') { 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...) // wifiDirect never seems to be connected, despite being connected (which is needed for signalLevel...)
// if (message.wifiDirect.state !== 'disconnected') { // if (message.wifiDirect.state !== 'disconnected') {
if (message.wifi.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 }; if (fallback) {
preloadData.onDeviceInfoCb(); 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) => { onFailure: (message: any) => {
logger.error(`Main: com.palm.connectionmanager/getStatus ${JSON.stringify(message)}`); 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, subscribe: true,
resubscribe: true resubscribe: true
}); });
window.targetAPI.getSessions(() => { window.targetAPI.getSessions(() => {
return new Promise((resolve, reject) => { 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); }); window.targetAPI.initializeSubscribedKeys(() => {
const onDisconnectService = requestService('disconnect', (message: any) => { preloadData.onDisconnectCb(null, message.value); }); return new Promise((resolve, reject) => {
const playService = requestService('play', (message: any) => { serviceManager.call('get_subscribed_keys', {}, (message: any) => resolve(message.value), (message: any) => reject(message));
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();
// WebOS 22 and earlier does not work well using the history API, preloadData.sendEventCb = (event: EventMessage) => {
// so manually handling page navigation... serviceManager.call('send_event', event, null, (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); });
// history.pushState({}, '', '../main_window/index.html'); };
window.open('../player/index.html', '_self');
}
});
const launchHandler = () => { const launchHandler = () => {
// Launch handler not supported in simulator due to JSON parsing errors of launch parameters
const params = window.webOSDev.launchParams(); const params = window.webOSDev.launchParams();
logger.info(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`); logger.info(`Main: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`);
const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); // WebOS 6.0 and earlier: Timestamp tracking seems to be necessary as launch event is raised regardless if app is in foreground or not
if (params.playData !== undefined && params.timestamp != lastTimestamp) { const lastTimestamp = Number(sessionStorage.getItem('lastTimestamp'));
localStorage.setItem('lastTimestamp', params.timestamp); if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) {
sessionStorage.setItem('playData', JSON.stringify(params.playData)); sessionStorage.setItem('lastTimestamp', params.timestamp);
toastService?.cancel(); play(params.messageInfo);
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');
} }
}; };
document.addEventListener('webOSLaunch', launchHandler); window.parent.webOSApp.setLaunchHandler(launchHandler);
document.addEventListener('webOSRelaunch', 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. const play = (messageInfo: any) => {
// window.onpopstate = () => { sessionStorage.setItem('playInfo', JSON.stringify(messageInfo));
// window.webOS.platformBack(); getDeviceInfoService?.cancel();
// };
document.addEventListener('keydown', (event: any) => { window.parent.webOSApp.loadPage(`${messageInfo.contentViewer}/index.html`);
// 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;
}
});
} }
catch (err) { catch (err) {
logger.error(`Main: preload ${JSON.stringify(err)}`); logger.error(`Main: preload`, err);
toast(`Error starting the application (preload): ${JSON.stringify(err)}`, ToastIcon.ERROR); preloadData.onToastCb(`Error starting the application: ${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
});
} }

View file

@ -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 backgroundVideo = document.getElementById('video-player');
const loadingScreen = document.getElementById('loading-screen'); const loadingScreen = document.getElementById('loading-screen');
@ -27,6 +29,51 @@ backgroundVideo.onplaying = () => {
backgroundVideo.onplaying = null; backgroundVideo.onplaying = null;
}; };
window.parent.webOSApp.setKeyDownHandler(keyDownEventHandler);
window.parent.webOSApp.setKeyUpHandler(keyUpEventHandler);
export function onQRCodeRendered() { export function onQRCodeRendered() {
qrCodeRendered = true; 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);
};

View file

@ -15,6 +15,7 @@
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div> <div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div> </div>
<div id="main-container"> <div id="main-container">
<img id="image-background"/>
<video id="video-player" class="video" autoplay loop> <video id="video-player" class="video" autoplay loop>
<source src="../assets/video/background.mp4" type="video/mp4"> <source src="../assets/video/background.mp4" type="video/mp4">
</video> </video>

View file

@ -1,17 +1,15 @@
/* WebOS custom player styles */
html {
overflow: hidden;
}
.card-title { .card-title {
font-family: InterBold; font-family: InterBold;
} }
#overlay { #overlay {
font-family: InterRegular; 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 { #title-text {
@ -27,6 +25,15 @@
font-family: InterBold; font-family: InterBold;
} }
/* gap not supported in WebOS 6.0 */
#ips {
gap: unset;
}
#ips-iface-icon {
margin-right: 15px;
}
#window-can-be-closed { #window-can-be-closed {
font-family: InterRegular; font-family: InterRegular;
} }

View file

@ -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;
}
/* } */

View file

@ -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;
}
/* } */

View file

@ -1,171 +1,153 @@
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { preloadData } from 'common/player/Preload'; 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'; import { toast, ToastIcon } from 'common/components/Toast';
require('lib/webOSTVjs-1.2.10/webOSTV.js'); require('lib/webOSTVjs-1.2.10/webOSTV.js');
require('lib/webOSTVjs-1.2.10/webOSTV-dev.js'); require('lib/webOSTVjs-1.2.10/webOSTV-dev.js');
declare global {
interface Window {
targetAPI: any;
webOSApp: any;
}
}
const logger = window.targetAPI.logger; const logger = window.targetAPI.logger;
try { try {
const serviceId = 'com.futo.fcast.receiver.service'; initializeWindowSizeStylesheet();
let getSessions = null;
window.webOSAPI = { window.parent.webOSApp.pendingPlay = JSON.parse(sessionStorage.getItem('playInfo'));
pendingPlay: JSON.parse(sessionStorage.getItem('playData')) const contentViewer = window.parent.webOSApp.pendingPlay?.contentViewer;
};
preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => { const serviceManager: ServiceManager = window.parent.webOSApp.serviceManager;
window.webOS.service.request(`luna://${serviceId}/`, { serviceManager.subscribeToServiceChannel((message: any) => {
method: 'send_playback_error', switch (message.event) {
parameters: { error }, case 'toast':
onSuccess: () => {}, preloadData.onToastCb(message.value.message, message.value.icon, message.value.duration);
onFailure: (message: any) => { break;
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 playService = window.webOS.service.request(`luna://${serviceId}/`, { case 'play': {
method:"play", if (contentViewer !== message.value.contentViewer) {
parameters: {}, sessionStorage.setItem('playInfo', JSON.stringify(message.value));
onSuccess: (message: any) => { window.parent.webOSApp.loadPage(`${message.value.contentViewer}/index.html`);
// 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;
} }
else { 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) => { case 'pause':
logger.error(`Player: play ${JSON.stringify(message)}`); preloadData.onPauseCb();
}, break;
subscribe: true,
resubscribe: true 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(); }); preloadData.sendPlaybackErrorCb = (error: PlaybackErrorMessage) => {
const resumeService = requestService('resume', () => { preloadData.onResumeCb(); }); serviceManager.call('send_playback_error', error, null, (message: any) => { logger.error(`Player: send_playback_error ${JSON.stringify(message)}`); });
const stopService = requestService('stop', () => { };
playService.cancel(); preloadData.sendPlaybackUpdateCb = (update: PlaybackUpdateMessage) => {
pauseService.cancel(); serviceManager.call('send_playback_update', update, null, (message: any) => { logger.error(`Player: send_playback_update ${JSON.stringify(message)}`); });
resumeService.cancel(); };
stopService.cancel(); preloadData.sendVolumeUpdateCb = (update: VolumeUpdateMessage) => {
seekService.cancel(); serviceManager.call('send_volume_update', update, null, (message: any) => { logger.error(`Player: send_volume_update ${JSON.stringify(message)}`); });
setVolumeService.cancel(); };
setSpeedService.cancel(); preloadData.sendEventCb = (event: EventMessage) => {
getSessions?.cancel(); serviceManager.call('send_event', event, null, (message: any) => { logger.error(`Player: send_event ${JSON.stringify(message)}`); });
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.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(() => { window.targetAPI.getSessions(() => {
return new Promise((resolve, reject) => { 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 = () => { const launchHandler = () => {
// args don't seem to be passed in via event despite what documentation says... // args don't seem to be passed in via event despite what documentation says...
const params = window.webOSDev.launchParams(); const params = window.webOSDev.launchParams();
logger.info(`Player: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`); logger.info(`Player: (Re)launching FCast Receiver with args: ${JSON.stringify(params)}`);
const lastTimestamp = Number(localStorage.getItem('lastTimestamp')); // WebOS 6.0 and earlier: Timestamp tracking seems to be necessary as launch event is raised regardless if app is in foreground or not
if (params.playData !== undefined && params.timestamp != lastTimestamp) { const lastTimestamp = Number(sessionStorage.getItem('lastTimestamp'));
localStorage.setItem('lastTimestamp', params.timestamp); if (params.messageInfo !== undefined && params.timestamp != lastTimestamp) {
sessionStorage.setItem('playData', JSON.stringify(params.playData)); sessionStorage.setItem('lastTimestamp', params.timestamp);
playService?.cancel(); sessionStorage.setItem('playInfo', JSON.stringify(params.messageInfo));
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, window.parent.webOSApp.loadPage(`${params.messageInfo.contentViewer}/index.html`);
// so manually handling page navigation...
// history.pushState({}, '', '../main_window/index.html');
window.open('../player/index.html', '_self');
} }
}; };
document.addEventListener('webOSLaunch', launchHandler); window.parent.webOSApp.setLaunchHandler(launchHandler);
document.addEventListener('webOSRelaunch', launchHandler); document.addEventListener('visibilitychange', () => serviceManager.call('visibility_changed', { hidden: document.hidden, window: contentViewer }));
} }
catch (err) { 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); 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
});
}

View file

@ -1,82 +1,125 @@
import { import {
isLive,
onPlay, onPlay,
onPlayPlaylist,
setPlaylistItem,
playerCtrlStateUpdate,
playlistIndex,
player, player,
uiHideTimer,
PlayerControlEvent, PlayerControlEvent,
playerCtrlCaptions, playerCtrlCaptions,
playerCtrlDuration,
playerCtrlLiveBadge,
playerCtrlPosition,
playerCtrlProgressBar,
playerCtrlProgressBarBuffer,
playerCtrlProgressBarHandle,
playerCtrlProgressBarProgress,
playerCtrlStateUpdate,
playerCtrlVolumeBar,
playerCtrlVolumeBarHandle,
playerCtrlVolumeBarProgress,
videoCaptions, videoCaptions,
formatDuration,
skipBack, skipBack,
skipForward, skipForward,
keyDownEventHandler,
keyUpEventHandler,
playerCtrlProgressBarHandle,
} from 'common/player/Renderer'; } 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 captionsBaseHeightCollapsed = 150;
const captionsBaseHeightExpanded = 320; const captionsBaseHeightExpanded = 320;
const captionsLineHeight = 68; const captionsLineHeight = 68;
enum RemoteKeyCode { const playPreviousContainer = document.getElementById('playPreviousContainer');
Stop = 413, const actionContainer = document.getElementById('actionContainer');
Rewind = 412, const playNextContainer = document.getElementById('playNextContainer');
Play = 415,
Pause = 19, const playPrevious = document.getElementById('playPrevious');
FastForward = 417, const playNext = document.getElementById('playNext');
Back = 461,
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 { export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean {
let handledCase = false; let handledCase = false;
switch (event) { 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: default:
break; break;
} }
@ -84,22 +127,168 @@ export function targetPlayerCtrlStateUpdate(event: PlayerControlEvent): boolean
return handledCase; return handledCase;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any export function targetPlayerCtrlPostStateUpdate(event: PlayerControlEvent) {
export function targetKeyDownEventListener(event: any): boolean { 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 handledCase = false;
let key = '';
switch (event.keyCode) { switch (event.keyCode) {
case RemoteKeyCode.Stop: case KeyCode.KeyK:
// history.back(); case KeyCode.Space:
window.open('../main_window/index.html', '_self'); // Play/pause toggle
if (player?.isPaused()) {
player?.play();
} else {
player?.pause();
}
event.preventDefault();
handledCase = true; handledCase = true;
break; break;
case RemoteKeyCode.Rewind: case KeyCode.Enter:
skipBack(); 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(); event.preventDefault();
handledCase = true; handledCase = true;
break; 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: case RemoteKeyCode.Play:
if (player.isPaused()) { if (player.isPaused()) {
@ -107,6 +296,7 @@ export function targetKeyDownEventListener(event: any): boolean {
} }
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
key = 'Play';
break; break;
case RemoteKeyCode.Pause: case RemoteKeyCode.Pause:
if (!player.isPaused()) { if (!player.isPaused()) {
@ -114,32 +304,42 @@ export function targetKeyDownEventListener(event: any): boolean {
} }
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
key = 'Pause';
break; break;
// Note that in simulator rewind and fast forward key codes are sent twice...
case RemoteKeyCode.FastForward: case RemoteKeyCode.FastForward:
skipForward(); skipForward(event.repeat);
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
key = 'FastForward';
break; break;
// WebOS 22 and earlier does not work well using the history API,
// so manually handling page navigation...
case RemoteKeyCode.Back: case RemoteKeyCode.Back:
// history.back(); window.parent.webOSApp.loadPage('main_window/index.html');
window.open('../main_window/index.html', '_self');
event.preventDefault(); event.preventDefault();
handledCase = true; handledCase = true;
key = 'Back';
break; break;
default: default:
break; break;
} }
return handledCase; return { handledCase: handledCase, key: key };
}; };
if (window.webOSAPI.pendingPlay !== null) { export function targetKeyUpEventListener(event: KeyboardEvent): { handledCase: boolean, key: string } {
onPlay(null, window.webOSAPI.pendingPlay); 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 { export {

View file

@ -10,7 +10,12 @@
<script src="./preload.js"></script> <script src="./preload.js"></script>
</head> </head>
<body> <body>
<div id="title-icon"></div>
<div id="loading-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<div id="idle-background"></div>
<img id="thumbnailImage" />
<video id="videoPlayer" autoplay preload="auto"></video> <video id="videoPlayer" autoplay preload="auto"></video>
<div id="mediaTitle" class="captionsContainer"></div>
<div id="videoCaptions" class="captionsContainer"></div> <div id="videoCaptions" class="captionsContainer"></div>
<div id="controls" class="container"> <div id="controls" class="container">
@ -20,7 +25,7 @@
<div id="progressBarProgress" class="progressBarProgress" ></div> <div id="progressBarProgress" class="progressBarProgress" ></div>
<div id="progressBarPosition" class="progressBarPosition" ></div> <div id="progressBarPosition" class="progressBarPosition" ></div>
<!-- <div class="progressBarChapterContainer"></div> --> <!-- <div class="progressBarChapterContainer"></div> -->
<div id="progressBarHandle" class="progressBarHandle" ></div> <div id="progressBarHandle" class="progressBarHandle progressBarHandleHide" ></div>
<div id="progressBarInteractiveArea" class="progressBarInteractiveArea" ></div> <div id="progressBarInteractiveArea" class="progressBarInteractiveArea" ></div>
</div> </div>
@ -32,7 +37,9 @@
</div> </div>
<div class="leftButtonContainer"> <div class="leftButtonContainer">
<div id="action" class="play iconSize"></div> <div id="playPreviousContainer" class="buttonFocusContainer"><div id="playPrevious" class="playPrevious iconSize"></div></div>
<div id="actionContainer" class="buttonFocusContainer"><div id="action" class="play iconSize"></div></div>
<div id="playNextContainer" class="buttonFocusContainer"><div id="playNext" class="playNext iconSize"></div></div>
<div id="volume" class="volume_high iconSize"></div> <div id="volume" class="volume_high iconSize"></div>
<div class="volumeContainer"> <div class="volumeContainer">
@ -52,7 +59,7 @@
<div class="buttonContainer"> <div class="buttonContainer">
<!-- <div id="fullscreen" class="fullscreen_on iconSize"></div> --> <!-- <div id="fullscreen" class="fullscreen_on iconSize"></div> -->
<div id="speed" class="speed iconSize"></div> <div id="speed" class="speed iconSize"></div>
<div id="captions" class="captions_off iconSize"></div> <div id="captions" class="captions_off iconSize" style="display: none"></div>
<div id="duration" class="duration">00:00</div> <div id="duration" class="duration">00:00</div>
</div> </div>

View file

@ -1,7 +1,12 @@
/* WebOS custom player styles */ /* WebOS custom player styles */
html {
overflow: hidden;
}
.container { .container {
height: 240px; height: 240px;
background: linear-gradient(to top, rgba(0, 0, 0, 1.0) 0%, rgba(0, 0, 0, 0.0) 80%);
} }
.iconSize { .iconSize {
@ -15,39 +20,9 @@
} }
.volumeContainer { .volumeContainer {
height: 48px;
width: 184px;
display: none; 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 { .progressBarContainer {
bottom: 120px; bottom: 120px;
left: 32px; left: 32px;
@ -134,21 +109,42 @@
} }
.leftButtonContainer { .leftButtonContainer {
bottom: 48px; bottom: 24px;
left: 48px; left: 48px;
height: 48px; height: 96px;
/* right: 320px; */ right: 48px;
right: 32px; /* gap: 48px; */
gap: 48px; gap: unset;
justify-content: center; 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 { .buttonContainer {
bottom: 48px; bottom: 48px;
right: 48px; right: 48px;
height: 48px; height: 48px;
gap: 48px; /* gap: 48px; */
gap: unset;
} }
.captionsContainer { .captionsContainer {
@ -180,3 +176,11 @@
#captions { #captions {
display: none; display: none;
} }
#toast-notification {
gap: unset;
}
#toast-text {
font-family: InterRegular;
}

View file

@ -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;
}
/* } */

View file

@ -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;
}
/* } */

View file

@ -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);
}
}

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<title>FCast Receiver</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="../assets/fonts/inter.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./style.css" />
<script src="../player/preload.js"></script>
</head>
<body>
<!-- Empty video element as a workaround to fix issue with white border outline without it... -->
<video id="idleBackground" class="video"></video>
<div id="viewer" class="viewer">
<div id="titleIcon"></div>
<div id="loadingSpinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
<img id="viewerImage" class="viewer" />
<iframe id="viewerGeneric" class="viewer"></iframe>
</div>>
<div id="controls" class="container">
<div id="leftButtonContainer" class="buttonContainer">
<div id="mediaTitle"></div>
</div>
<div id="centerButtonContainer" class="buttonContainer">
<div id="playPreviousContainer" class="buttonFocusContainer"><div id="playPrevious" class="playPrevious iconSize" style="display: none"></div></div>
<div id="actionContainer" class="buttonFocusContainer"><div id="action" class="play iconSize" style="display: none"></div></div>
<div id="playlistLength" style="display: none"></div>
<div id="playNextContainer" class="buttonFocusContainer"><div id="playNext" class="playNext iconSize" style="display: none"></div></div>
</div>
<!-- <div id="rightButtonContainer" class="buttonContainer">
<div id="fullscreen" class="fullscreen_on iconSize"></div>
</div> -->
</div>
<div id="toast-notification">
<div id="toast-icon"></div>
<div id="toast-text"></div>
</div>
<script src="./renderer.js"></script>
</body>
</html>

View file

@ -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;
}

View file

@ -15,16 +15,86 @@ module.exports = [
{ {
mode: buildMode, mode: buildMode,
entry: { entry: {
preload: './src/main/Preload.ts', main: './src/Main.ts',
renderer: './src/main/Renderer.ts',
}, },
target: 'web', target: ['web', 'es5'],
module: { module: {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')], include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')],
use: [{ loader: 'ts-loader' }] 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: [ plugins: [
new CopyWebpackPlugin({ new CopyWebpackPlugin({
patterns: [ patterns: [
// Common assets
{
from: '../common/assets/**',
to: '../[path][name][ext]',
context: path.resolve(__dirname, '..', '..', 'common'),
globOptions: { ignore: ['**/*.txt'] }
},
{ {
from: '../../common/web/main/common.css', from: '../../common/web/main/common.css',
to: '[name][ext]', 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/*', from: './src/main/*',
to: '[name][ext]', to: '[name][ext]',
@ -88,13 +137,18 @@ module.exports = [
preload: './src/player/Preload.ts', preload: './src/player/Preload.ts',
renderer: './src/player/Renderer.ts', renderer: './src/player/Renderer.ts',
}, },
target: 'web', target: ['web', 'es5'],
module: { module: {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')], include: [path.resolve(__dirname, '../../common/web'), path.resolve(__dirname, 'src')],
use: [{ loader: 'ts-loader' }] 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)
})
]
}
]; ];

View file

@ -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