diff --git a/receivers/common/web/ConnectionMonitor.ts b/receivers/common/web/ConnectionMonitor.ts new file mode 100644 index 0000000..39ea395 --- /dev/null +++ b/receivers/common/web/ConnectionMonitor.ts @@ -0,0 +1,52 @@ +import { Opcode } from 'common/Packets'; + +const connectionPingTimeout = 2500; +const connections = []; +const heartbeatRetries = {}; +let uiUpdateCallbacks = { + onConnect: null, + onDisconnect: null, +} + +export function setUiUpdateCallbacks(callbacks: any) { + uiUpdateCallbacks = callbacks; +} + +// Window might be re-created while devices are still connected +window.targetAPI.onPing((_event, value: any) => { + if (value) { + heartbeatRetries[value.id] = 0; + + if (!connections.includes(value.id)) { + connections.push(value.id); + uiUpdateCallbacks.onConnect(connections, value.id); + } + } +}); +window.targetAPI.onConnect((_event, value: any) => { + connections.push(value.id); + uiUpdateCallbacks.onConnect(connections, value); +}); +window.targetAPI.onDisconnect((_event, value: any) => { + console.log(`Device disconnected: ${JSON.stringify(value)}`); + const index = connections.indexOf(value.id); + if (index != -1) { + connections.splice(index, 1); + uiUpdateCallbacks.onDisconnect(connections, value.id); + } +}); + +setInterval(() => { + if (connections.length > 0) { + window.targetAPI.sendSessionMessage(Opcode.Ping, null); + + for (const session of connections) { + if (heartbeatRetries[session] > 3) { + console.warn(`Could not ping device with connection id ${session}. Disconnecting...`); + window.targetAPI.disconnectDevice(session); + } + + heartbeatRetries[session] = heartbeatRetries[session] === undefined ? 1 : heartbeatRetries[session] + 1; + } + } +}, connectionPingTimeout); diff --git a/receivers/common/web/TcpListenerService.ts b/receivers/common/web/TcpListenerService.ts index ff9a20b..beb9c14 100644 --- a/receivers/common/web/TcpListenerService.ts +++ b/receivers/common/web/TcpListenerService.ts @@ -7,12 +7,11 @@ import { v4 as uuidv4 } from 'modules/uuid'; export class TcpListenerService { public static PORT = 46899; - private static TIMEOUT = 2500; - emitter = new EventEmitter(); private server: net.Server; private sessions: FCastSession[] = []; + private sessionMap = {}; start() { if (this.server != null) { @@ -48,6 +47,10 @@ export class TcpListenerService { }); } + disconnect(connectionId: string) { + this.sessionMap[connectionId].socket.destroy(); + } + private async handleServerError(err: NodeJS.ErrnoException) { errorHandler(err); } @@ -60,22 +63,7 @@ export class TcpListenerService { this.sessions.push(session); const connectionId = uuidv4(); - let heartbeatRetries = 0; - socket.setTimeout(TcpListenerService.TIMEOUT); - socket.on('timeout', () => { - try { - if (heartbeatRetries > 3) { - Main.logger.warn(`Could not ping device ${socket.remoteAddress}:${socket.remotePort}. Disconnecting...`); - socket.destroy(); - } - - heartbeatRetries += 1; - session.send(Opcode.Ping); - } catch (e) { - Main.logger.warn(`Error while pinging sender device ${socket.remoteAddress}:${socket.remotePort}.`, e); - socket.destroy(); - } - }); + this.sessionMap[connectionId] = session; socket.on("error", (err) => { Main.logger.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err); @@ -84,7 +72,6 @@ export class TcpListenerService { socket.on("data", buffer => { try { - heartbeatRetries = 0; session.processBytes(buffer); } catch (e) { Main.logger.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e); diff --git a/receivers/common/web/WebSocketListenerService.ts b/receivers/common/web/WebSocketListenerService.ts index a8fb002..b37b122 100644 --- a/receivers/common/web/WebSocketListenerService.ts +++ b/receivers/common/web/WebSocketListenerService.ts @@ -12,6 +12,7 @@ export class WebSocketListenerService { private server: WebSocketServer; private sessions: FCastSession[] = []; + private sessionMap = {}; start() { if (this.server != null) { @@ -45,6 +46,10 @@ export class WebSocketListenerService { }); } + disconnect(connectionId: string) { + this.sessionMap[connectionId].close(); + } + private async handleServerError(err: NodeJS.ErrnoException) { errorHandler(err); } @@ -54,6 +59,8 @@ export class WebSocketListenerService { const session = new FCastSession(socket, (data) => socket.send(data)); const connectionId = uuidv4(); + this.sessionMap[connectionId] = session; + session.bindEvents(this.emitter); this.sessions.push(session); diff --git a/receivers/common/web/main/Preload.ts b/receivers/common/web/main/Preload.ts index d41fae6..ba0b9d2 100644 --- a/receivers/common/web/main/Preload.ts +++ b/receivers/common/web/main/Preload.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { Opcode } from 'common/Packets'; declare global { interface Window { @@ -22,11 +23,13 @@ if (TARGET === 'electron') { }) electronAPI.contextBridge.exposeInMainWorld('targetAPI', { - onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on("device-info", callback), - onConnect: (callback: any) => electronAPI.ipcRenderer.on("connect", callback), - onDisconnect: (callback: any) => electronAPI.ipcRenderer.on("disconnect", callback), - onPing: (callback: any) => electronAPI.ipcRenderer.on("ping", callback), + onDeviceInfo: (callback: any) => electronAPI.ipcRenderer.on('device-info', callback), getDeviceInfo: () => preloadData.deviceInfo, + sendSessionMessage: (opcode: Opcode, message: any) => electronAPI.ipcRenderer.send('send-session-message', { opcode: opcode, message: message }), + disconnectDevice: (session: string) => electronAPI.ipcRenderer.send('disconnect-device', session), + onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback), + onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback), + onPing: (callback: any) => electronAPI.ipcRenderer.on('ping', callback), }); // @ts-ignore diff --git a/receivers/common/web/main/Renderer.ts b/receivers/common/web/main/Renderer.ts index 1ea1d2e..c214614 100644 --- a/receivers/common/web/main/Renderer.ts +++ b/receivers/common/web/main/Renderer.ts @@ -1,12 +1,12 @@ import QRCode from 'modules/qrcode'; +import * as connectionMonitor from '../ConnectionMonitor'; import { onQRCodeRendered } from 'src/main/Renderer'; import { toast, ToastIcon } from '../components/Toast'; const connectionStatusText = document.getElementById('connection-status-text'); const connectionStatusSpinner = document.getElementById('connection-spinner'); const connectionStatusCheck = document.getElementById('connection-check'); -let connections = []; let renderedConnectionInfo = false; let renderedAddresses = null; let qrCodeUrl = null; @@ -14,25 +14,15 @@ let qrWidth = null; window.addEventListener('resize', (event) => calculateQRCodeWidth()); -// Window might be re-created while devices are still connected -window.targetAPI.onPing((_event, value: any) => { - if (value && !connections.includes(value.id)) { - connections.push(value.id); - onConnect(value.id); - } -}); - -window.targetAPI.onDeviceInfo(renderIPsAndQRCode); -window.targetAPI.onConnect((_event, value: any) => { - connections.push(value.id); - onConnect(value); -}); -window.targetAPI.onDisconnect((_event, value: any) => { - console.log(`Device disconnected: ${JSON.stringify(value)}`); - const index = connections.indexOf(value.id); - if (index != -1) { - connections.splice(index, 1); - +connectionMonitor.setUiUpdateCallbacks({ + onConnect: (connections: string[], connectionInfo: any) => { + console.log(`Device connected: ${JSON.stringify(connectionInfo)}`); + connectionStatusText.textContent = connections.length > 1 ? 'Multiple devices connected:\r\n Ready to cast' : 'Connected: Ready to cast'; + connectionStatusSpinner.style.display = 'none'; + connectionStatusCheck.style.display = 'inline-block'; + }, + onDisconnect: (connections: string[], connectionInfo: any) => { + console.log(`Device disconnected: ${JSON.stringify(connectionInfo)}`); if (connections.length === 0) { connectionStatusText.textContent = 'Waiting for a connection'; connectionStatusSpinner.style.display = 'inline-block'; @@ -43,21 +33,16 @@ window.targetAPI.onDisconnect((_event, value: any) => { connectionStatusText.textContent = connections.length > 1 ? 'Multiple devices connected:\r\n Ready to cast' : 'Connected: Ready to cast'; toast('A device has disconnected', ToastIcon.INFO); } - } + }, }); +window.targetAPI.onDeviceInfo(renderIPsAndQRCode); + if(window.targetAPI.getDeviceInfo()) { console.log('device info already present'); renderIPsAndQRCode(); } -function onConnect(value: any) { - console.log(`Device connected: ${JSON.stringify(value)}`); - connectionStatusText.textContent = connections.length > 1 ? 'Multiple devices connected:\r\n Ready to cast' : 'Connected: Ready to cast'; - connectionStatusSpinner.style.display = 'none'; - connectionStatusCheck.style.display = 'inline-block'; -} - function renderIPsAndQRCode() { const value = window.targetAPI.getDeviceInfo(); console.log(`Network Interface Info: ${value}`); diff --git a/receivers/common/web/main/common.css b/receivers/common/web/main/common.css index d2796bb..50c916b 100644 --- a/receivers/common/web/main/common.css +++ b/receivers/common/web/main/common.css @@ -293,6 +293,7 @@ body, html { position: relative; top: -200px; max-width: 70%; + width: fit-content; background: #F0F0F0; border: 3px solid rgba(0, 0, 0, 0.08); diff --git a/receivers/common/web/player/Preload.ts b/receivers/common/web/player/Preload.ts index 49d308c..c0989c5 100644 --- a/receivers/common/web/player/Preload.ts +++ b/receivers/common/web/player/Preload.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage } from 'common/Packets'; +import { PlaybackErrorMessage, PlaybackUpdateMessage, VolumeUpdateMessage, Opcode } from 'common/Packets'; export {}; declare global { @@ -28,8 +28,11 @@ if (TARGET === 'electron') { onPause: (callback: any) => electronAPI.ipcRenderer.on("pause", callback), onResume: (callback: any) => electronAPI.ipcRenderer.on("resume", callback), onSeek: (callback: any) => electronAPI.ipcRenderer.on("seek", callback), - onSetVolume: (callback: any) => electronAPI.ipcRenderer.on("setvolume", callback), - onSetSpeed: (callback: any) => electronAPI.ipcRenderer.on("setspeed", callback) + sendSessionMessage: (opcode: Opcode, message: any) => electronAPI.ipcRenderer.send('send-session-message', { opcode: opcode, message: message }), + disconnectDevice: (session: string) => electronAPI.ipcRenderer.send('disconnect-device', session), + onConnect: (callback: any) => electronAPI.ipcRenderer.on('connect', callback), + onDisconnect: (callback: any) => electronAPI.ipcRenderer.on('disconnect', callback), + onPing: (callback: any) => electronAPI.ipcRenderer.on('ping', callback), }); // @ts-ignore diff --git a/receivers/common/web/player/Renderer.ts b/receivers/common/web/player/Renderer.ts index 14d04ed..a8c8d6b 100644 --- a/receivers/common/web/player/Renderer.ts +++ b/receivers/common/web/player/Renderer.ts @@ -2,6 +2,8 @@ import dashjs from 'modules/dashjs'; import Hls, { LevelLoadedData } from 'modules/hls.js'; import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage } from 'common/Packets'; import { Player, PlayerType } from './Player'; +import * as connectionMonitor from '../ConnectionMonitor'; +import { toast, ToastIcon } from '../components/Toast'; import { targetPlayerCtrlStateUpdate, targetKeyDownEventListener, @@ -330,6 +332,17 @@ function onPlay(_event, value: PlayMessage) { window.targetAPI.onSetSpeed((_event, value: SetSpeedMessage) => { player.setPlaybackRate(value.speed); playerCtrlStateUpdate(PlayerControlEvent.SetPlaybackRate); }); }; +connectionMonitor.setUiUpdateCallbacks({ + onConnect: (connections: string[], connectionInfo: any) => { + console.log(`Device connected: ${JSON.stringify(connectionInfo)}`); + toast('Device connected', ToastIcon.INFO); + }, + onDisconnect: (connections: string[], connectionInfo: any) => { + console.log(`Device disconnected: ${JSON.stringify(connectionInfo)}`); + toast('Device disconnected. If you experience playback issues, please reconnect.', ToastIcon.INFO); + }, +}); + window.targetAPI.onPlay(onPlay); let scrubbing = false; diff --git a/receivers/common/web/player/common.css b/receivers/common/web/player/common.css index 79fec0c..9d84cb4 100644 --- a/receivers/common/web/player/common.css +++ b/receivers/common/web/player/common.css @@ -456,3 +456,140 @@ body { background-size: cover; opacity: 0; } + +#toast-notification { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px 20px; + + position: relative; + top: calc(-100% + 20px); + margin: auto; + max-width: 25%; + width: fit-content; + + background: rgba(0, 0, 0, 0.7); + border: 3px solid rgba(255, 255, 255, 0.08); + box-shadow: 0px 100px 80px rgba(0, 0, 0, 0.33), 0px 64.8148px 46.8519px rgba(0, 0, 0, 0.250556), 0px 38.5185px 25.4815px rgba(0, 0, 0, 0.200444), 0px 20px 13px rgba(0, 0, 0, 0.165), 0px 8.14815px 6.51852px rgba(0, 0, 0, 0.129556), 0px 1.85185px 3.14815px rgba(0, 0, 0, 0.0794444); + border-radius: 12px; + opacity: 0; +} + +#toast-icon { + width: 88px; + height: 88px; + background-image: url(../assets/icons/app/info.svg); + background-size: cover; + filter: grayscale(0.5); + flex-shrink: 0; +} + +#toast-text { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + margin-right: 5px; + + font-family: InterVariable; + font-size: 28px; + font-style: normal; + font-weight: 400; +} + +.toast-fade-in { + animation: toast-fade-in 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1; +} + +.toast-fade-out { + animation: toast-fade-out 1.0s cubic-bezier(0.5, 0, 0.5, 1) 1; +} + +@keyframes toast-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes toast-fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +/* Display scaling (Minimum supported resolution is 960x540) */ +@media only screen and ((min-width: 2560px) or (min-height: 1440px)) { + #toast-notification { + padding: 12px; + } + + #toast-icon { + width: 70px; + height: 70px; + margin: 5px 10px; + margin-right: 15px; + } + + #toast-text { + font-size: 28px; + } +} + +@media only screen and ((max-width: 2559px) or (max-height: 1439px)) { + #toast-notification { + padding: 12px; + } + + #toast-icon { + width: 60px; + height: 60px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 22px; + } +} + +@media only screen and ((max-width: 1919px) or (max-height: 1079px)) { + #toast-notification { + padding: 8px; + } + + #toast-icon { + width: 40px; + height: 40px; + margin: 5px 5px; + margin-right: 10px; + } + + #toast-text { + font-size: 16px; + } +} + +@media only screen and ((max-width: 1279px) or (max-height: 719px)) { + #toast-notification { + padding: 4px; + } + + #toast-icon { + width: 32px; + height: 32px; + margin: 5px 5px; + } + + #toast-text { + font-size: 14px; + } +} diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts index f798c71..fa56f0b 100644 --- a/receivers/electron/src/Main.ts +++ b/receivers/electron/src/Main.ts @@ -188,9 +188,18 @@ export class Main { l.emitter.on("setvolume", (message) => Main.playerWindow?.webContents?.send("setvolume", message)); l.emitter.on("setspeed", (message) => Main.playerWindow?.webContents?.send("setspeed", message)); - l.emitter.on('connect', (message) => Main.mainWindow?.webContents?.send('connect', message)); - l.emitter.on('disconnect', (message) => Main.mainWindow?.webContents?.send('disconnect', message)); - l.emitter.on('ping', (message) => Main.mainWindow?.webContents?.send('ping', message)); + l.emitter.on('connect', (message) => { + Main.mainWindow?.webContents?.send('connect', message); + Main.playerWindow?.webContents?.send('connect', message); + }); + l.emitter.on('disconnect', (message) => { + Main.mainWindow?.webContents?.send('disconnect', message); + Main.playerWindow?.webContents?.send('disconnect', message); + }); + l.emitter.on('ping', (message) => { + Main.mainWindow?.webContents?.send('ping', message); + Main.playerWindow?.webContents?.send('ping', message); + }); l.start(); ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => { @@ -204,6 +213,15 @@ export class Main { ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => { l.send(Opcode.VolumeUpdate, value); }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcMain.on('send-session-message', (event: IpcMainEvent, value: any) => { + l.send(value.opcode, value.message); + }); + + ipcMain.on('disconnect-device', (event: IpcMainEvent, value: string) => { + l.disconnect(value); + }); }); ipcMain.on('send-download-request', async () => { diff --git a/receivers/electron/src/player/index.html b/receivers/electron/src/player/index.html index d576866..caad4a8 100644 --- a/receivers/electron/src/player/index.html +++ b/receivers/electron/src/player/index.html @@ -86,6 +86,11 @@ +