diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt index e43cf89..3e4d5f6 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/NetworkService.kt @@ -64,12 +64,14 @@ class NetworkService : Service() { val player = PlayerActivity.instance val updateMessage = if (player != null) { PlaybackUpdateMessage( + System.currentTimeMillis(), player.currentPosition / 1000.0, player.duration / 1000.0, if (player.isPlaying) 1 else 2 ) } else { PlaybackUpdateMessage( + System.currentTimeMillis(), 0.0, 0.0, 0 diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt index a6f878c..c7a81e5 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/Packets.kt @@ -17,6 +17,7 @@ data class SeekMessage( @Serializable data class PlaybackUpdateMessage( + val generationTime: Long, val time: Double, val duration: Double, val state: Int @@ -24,6 +25,7 @@ data class PlaybackUpdateMessage( @Serializable data class VolumeUpdateMessage( + val generationTime: Long, val volume: Double ) diff --git a/receivers/electron/package-lock.json b/receivers/electron/package-lock.json index 4c6455d..1f08fa3 100644 --- a/receivers/electron/package-lock.json +++ b/receivers/electron/package-lock.json @@ -1,15 +1,21 @@ { "name": "fcast-receiver", - "version": "1.0.1", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fcast-receiver", - "version": "1.0.1", + "version": "1.0.7", "license": "MIT", + "dependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.3", + "ws": "^8.14.2" + }, "devDependencies": { "@types/workerpool": "^6.1.1", + "@types/ws": "^8.5.10", "electron": "^22.2.0", "mdns-js": "github:mdns-js/node-mdns-js", "ts-loader": "^9.4.2", @@ -213,6 +219,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", @@ -548,6 +563,18 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -1540,6 +1567,16 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/node-gyp-build": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.7.1.tgz", + "integrity": "sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -2177,6 +2214,18 @@ "punycode": "^2.1.0" } }, + "node_modules/utf-8-validate": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.3.tgz", + "integrity": "sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -2340,6 +2389,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/receivers/electron/package.json b/receivers/electron/package.json index 57f3273..b30d606 100644 --- a/receivers/electron/package.json +++ b/receivers/electron/package.json @@ -11,11 +11,17 @@ }, "devDependencies": { "@types/workerpool": "^6.1.1", + "@types/ws": "^8.5.10", "electron": "^22.2.0", "mdns-js": "github:mdns-js/node-mdns-js", "ts-loader": "^9.4.2", "typescript": "^4.9.5", "webpack": "^5.75.0", "webpack-cli": "^5.0.1" + }, + "dependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.3", + "ws": "^8.14.2" } } diff --git a/receivers/electron/src/FCastSession.ts b/receivers/electron/src/FCastSession.ts index 4ed8e16..60899d3 100644 --- a/receivers/electron/src/FCastSession.ts +++ b/receivers/electron/src/FCastSession.ts @@ -1,6 +1,7 @@ import net = require('net'); import { EventEmitter } from 'node:events'; import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets'; +import { WebSocket } from 'ws'; enum SessionState { Idle = 0, @@ -28,12 +29,14 @@ export class FCastSession { buffer: Buffer = Buffer.alloc(MAXIMUM_PACKET_LENGTH); bytesRead = 0; packetLength = 0; - socket: net.Socket; + socket: net.Socket | WebSocket; + writer: (data: Buffer) => void; state: SessionState; emitter = new EventEmitter(); - constructor(socket: net.Socket) { + constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) { this.socket = socket; + this.writer = writer; this.state = SessionState.WaitingForLength; } @@ -67,7 +70,15 @@ export class FCastSession { packet = header; } - this.socket.write(packet); + this.writer(packet); + } + + close() { + if (this.socket instanceof WebSocket) { + this.socket.close(); + } else if (this.socket instanceof net.Socket) { + this.socket.end(); + } } processBytes(receivedBytes: Buffer) { @@ -77,7 +88,7 @@ export class FCastSession { return; } - console.log(`${receivedBytes.length} bytes received from ${this.socket.remoteAddress}:${this.socket.remotePort}`); + console.log(`${receivedBytes.length} bytes received`); switch (this.state) { case SessionState.WaitingForLength: @@ -104,17 +115,14 @@ export class FCastSession { this.state = SessionState.WaitingForData; this.packetLength = this.buffer.readUInt32LE(0); this.bytesRead = 0; - console.log(`Packet length header received from ${this.socket.remoteAddress}:${this.socket.remotePort}: ${this.packetLength}`); + console.log(`Packet length header received from: ${this.packetLength}`); if (this.packetLength > MAXIMUM_PACKET_LENGTH) { - console.log(`Maximum packet length is 32kB, killing socket ${this.socket.remoteAddress}:${this.socket.remotePort}: ${this.packetLength}`); - this.socket.end(); - this.state = SessionState.Disconnected; - return; + throw new Error(`Maximum packet length is 32kB: ${this.packetLength}`); } if (bytesRemaining > 0) { - console.log(`${bytesRemaining} remaining bytes ${this.socket.remoteAddress}:${this.socket.remotePort} pushed to handlePacketBytes`); + console.log(`${bytesRemaining} remaining bytes pushed to handlePacketBytes`); this.handlePacketBytes(receivedBytes.slice(bytesToRead)); } } @@ -129,7 +137,7 @@ export class FCastSession { console.log(`handlePacketBytes: Read ${bytesToRead} bytes from packet`); if (this.bytesRead >= this.packetLength) { - console.log(`Packet finished receiving from ${this.socket.remoteAddress}:${this.socket.remotePort} of ${this.packetLength} bytes.`); + console.log(`Packet finished receiving from of ${this.packetLength} bytes.`); this.handlePacket(); this.state = SessionState.WaitingForLength; @@ -137,14 +145,14 @@ export class FCastSession { this.bytesRead = 0; if (bytesRemaining > 0) { - console.log(`${bytesRemaining} remaining bytes ${this.socket.remoteAddress}:${this.socket.remotePort} pushed to handleLengthBytes`); + console.log(`${bytesRemaining} remaining bytes pushed to handleLengthBytes`); this.handleLengthBytes(receivedBytes.slice(bytesToRead)); } } } private handlePacket() { - console.log(`Processing packet of ${this.bytesRead} bytes from ${this.socket.remoteAddress}:${this.socket.remotePort}`); + console.log(`Processing packet of ${this.bytesRead} bytes from`); const opcode = this.buffer[0]; const body = this.packetLength > 1 ? this.buffer.toString('utf8', 1, this.packetLength) : null; @@ -172,7 +180,7 @@ export class FCastSession { break; } } catch (e) { - console.warn(`Error handling packet from ${this.socket.remoteAddress}:${this.socket.remotePort}.`, e); + console.warn(`Error handling packet from.`, e); } } } \ No newline at end of file diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts index 0af6279..31ec5c8 100644 --- a/receivers/electron/src/Main.ts +++ b/receivers/electron/src/Main.ts @@ -1,14 +1,16 @@ import { BrowserWindow, ipcMain, IpcMainEvent, nativeImage, Tray, Menu, dialog } from 'electron'; import path = require('path'); -import { FCastService } from './FCastService'; +import { TcpListenerService } from './TcpListenerService'; import { PlaybackUpdateMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets'; import { DiscoveryService } from './DiscoveryService'; import { Updater } from './Updater'; +import { WebSocketListenerService } from './WebSocketListenerService'; export default class Main { static mainWindow: Electron.BrowserWindow; static application: Electron.App; - static service: FCastService; + static tcpListenerService: TcpListenerService; + static webSocketListenerService: WebSocketListenerService; static discoveryService: DiscoveryService; static tray: Tray; @@ -92,42 +94,55 @@ export default class Main { Main.discoveryService = new DiscoveryService(); Main.discoveryService.start(); - Main.service = new FCastService(); - Main.service.emitter.on("play", (message) => { - if (Main.mainWindow == null) { - Main.mainWindow = new BrowserWindow({ - fullscreen: true, - autoHideMenuBar: true, - webPreferences: { - preload: path.join(__dirname, 'preload.js') - } - }); + Main.tcpListenerService = new TcpListenerService(); + Main.webSocketListenerService = new WebSocketListenerService(); + const listeners = [Main.tcpListenerService, Main.webSocketListenerService]; - Main.mainWindow.setAlwaysOnTop(false, 'pop-up-menu'); - Main.mainWindow.show(); - - Main.mainWindow.loadFile(path.join(__dirname, 'index.html')); - Main.mainWindow.on('ready-to-show', () => { + listeners.forEach(l => { + l.emitter.on("play", (message) => { + if (Main.mainWindow == null) { + Main.mainWindow = new BrowserWindow({ + fullscreen: true, + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload.js') + } + }); + + Main.mainWindow.setAlwaysOnTop(false, 'pop-up-menu'); + Main.mainWindow.show(); + + Main.mainWindow.loadFile(path.join(__dirname, 'index.html')); + Main.mainWindow.on('ready-to-show', () => { + Main.mainWindow?.webContents?.send("play", message); + }); + Main.mainWindow.on('closed', Main.onClose); + } else { Main.mainWindow?.webContents?.send("play", message); - }); - Main.mainWindow.on('closed', Main.onClose); - } else { - Main.mainWindow?.webContents?.send("play", message); - } - }); - - Main.service.emitter.on("pause", () => Main.mainWindow?.webContents?.send("pause")); - Main.service.emitter.on("resume", () => Main.mainWindow?.webContents?.send("resume")); + } + }); + + l.emitter.on("pause", () => Main.mainWindow?.webContents?.send("pause")); + l.emitter.on("resume", () => Main.mainWindow?.webContents?.send("resume")); + + l.emitter.on("stop", () => { + Main.mainWindow.close(); + Main.mainWindow = null; + }); + + l.emitter.on("seek", (message) => Main.mainWindow?.webContents?.send("seek", message)); + l.emitter.on("setvolume", (message) => Main.mainWindow?.webContents?.send("setvolume", message)); + l.start(); - Main.service.emitter.on("stop", () => { - Main.mainWindow.close(); - Main.mainWindow = null; + ipcMain.on('send-playback-update', (event: IpcMainEvent, value: PlaybackUpdateMessage) => { + l.sendPlaybackUpdate(value); + }); + + ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => { + l.sendVolumeUpdate(value); + }); }); - Main.service.emitter.on("seek", (message) => Main.mainWindow?.webContents?.send("seek", message)); - Main.service.emitter.on("setvolume", (message) => Main.mainWindow?.webContents?.send("setvolume", message)); - Main.service.start(); - ipcMain.on('toggle-full-screen', () => { const window = Main.mainWindow; if (!window) { @@ -145,14 +160,6 @@ export default class Main { window.setFullScreen(false); }); - - ipcMain.on('send-playback-update', (event: IpcMainEvent, value: PlaybackUpdateMessage) => { - Main.service.sendPlaybackUpdate(value); - }); - - ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => { - Main.service.sendVolumeUpdate(value); - }); } static main(app: Electron.App) { diff --git a/receivers/electron/src/Packets.ts b/receivers/electron/src/Packets.ts index 3267591..f3d0ed7 100644 --- a/receivers/electron/src/Packets.ts +++ b/receivers/electron/src/Packets.ts @@ -15,13 +15,16 @@ export class SeekMessage { export class PlaybackUpdateMessage { constructor( + public generationTime: number, public time: number, + public duration: number, public state: number ) {} } export class VolumeUpdateMessage { constructor( + public generationTime: number, public volume: number ) {} } diff --git a/receivers/electron/src/FCastService.ts b/receivers/electron/src/TcpListenerService.ts similarity index 95% rename from receivers/electron/src/FCastService.ts rename to receivers/electron/src/TcpListenerService.ts index 4d346fa..037a83b 100644 --- a/receivers/electron/src/FCastService.ts +++ b/receivers/electron/src/TcpListenerService.ts @@ -5,7 +5,7 @@ import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetVolumeMessage, Volu import { dialog } from 'electron'; import Main from './Main'; -export class FCastService { +export class TcpListenerService { emitter = new EventEmitter(); private server: net.Server; @@ -41,7 +41,7 @@ export class FCastService { session.sendPlaybackUpdate(value); } catch (e) { console.warn("Failed to send update.", e); - session.socket.end(); + session.close(); } }); } @@ -54,7 +54,7 @@ export class FCastService { session.sendVolumeUpdate(value); } catch (e) { console.warn("Failed to send update.", e); - session.socket.end(); + session.close(); } }); } @@ -82,7 +82,7 @@ export class FCastService { private handleConnection(socket: net.Socket) { console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`); - const session = new FCastSession(socket); + const session = new FCastSession(socket, (data) => socket.write(data)); session.emitter.on("play", (body: PlayMessage) => { this.emitter.emit("play", body) }); session.emitter.on("pause", () => { this.emitter.emit("pause") }); session.emitter.on("resume", () => { this.emitter.emit("resume") }); diff --git a/receivers/electron/src/WebSocketListenerService.ts b/receivers/electron/src/WebSocketListenerService.ts new file mode 100644 index 0000000..f1a92ae --- /dev/null +++ b/receivers/electron/src/WebSocketListenerService.ts @@ -0,0 +1,121 @@ +import net = require('net'); +import { FCastSession } from './FCastSession'; +import { EventEmitter } from 'node:events'; +import { PlaybackUpdateMessage, PlayMessage, SeekMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets'; +import { dialog } from 'electron'; +import Main from './Main'; +import { WebSocket, WebSocketServer } from 'ws'; + +export class WebSocketListenerService { + emitter = new EventEmitter(); + + private server: WebSocketServer; + private sessions: FCastSession[] = []; + + start() { + if (this.server != null) { + return; + } + + this.server = new WebSocketServer({ port: 46898 }) + .on("connection", this.handleConnection.bind(this)) + .on("error", this.handleServerError.bind(this)); + } + + stop() { + if (this.server == null) { + return; + } + + const server = this.server; + this.server = null; + + server.close(); + } + + sendPlaybackUpdate(value: PlaybackUpdateMessage) { + console.info("Sending playback update.", value); + + this.sessions.forEach(session => { + try { + session.sendPlaybackUpdate(value); + } catch (e) { + console.warn("Failed to send update.", e); + session.close(); + } + }); + } + + sendVolumeUpdate(value: VolumeUpdateMessage) { + console.info("Sending volume update.", value); + + this.sessions.forEach(session => { + try { + session.sendVolumeUpdate(value); + } catch (e) { + console.warn("Failed to send update.", e); + session.close(); + } + }); + } + + private async handleServerError(err: NodeJS.ErrnoException) { + console.error("Server error:", err); + + const restartPrompt = await dialog.showMessageBox({ + type: 'error', + title: 'Failed to start', + message: 'The application failed to start properly.', + buttons: ['Restart', 'Close'], + defaultId: 0, + cancelId: 1 + }); + + if (restartPrompt.response === 0) { + Main.application.relaunch(); + Main.application.exit(0); + } else { + Main.application.exit(0); + } + } + + private handleConnection(socket: WebSocket) { + console.log('New WebSocket connection'); + + const session = new FCastSession(socket, (data) => socket.send(data)); + session.emitter.on("play", (body: PlayMessage) => { this.emitter.emit("play", body) }); + session.emitter.on("pause", () => { this.emitter.emit("pause") }); + session.emitter.on("resume", () => { this.emitter.emit("resume") }); + session.emitter.on("stop", () => { this.emitter.emit("stop") }); + session.emitter.on("seek", (body: SeekMessage) => { this.emitter.emit("seek", body) }); + session.emitter.on("setvolume", (body: SetVolumeMessage) => { this.emitter.emit("setvolume", body) }); + this.sessions.push(session); + + socket.on("error", (err) => { + console.warn(`Error.`, err); + session.close(); + }); + + socket.on('message', data => { + try { + if (data instanceof Buffer) { + session.processBytes(data); + } else { + console.warn("Received unhandled string message", data); + } + } catch (e) { + console.warn(`Error while handling packet.`, e); + session.close(); + } + }); + + socket.on("close", () => { + console.log('WebSocket connection closed'); + + const index = this.sessions.indexOf(session); + if (index != -1) { + this.sessions.splice(index, 1); + } + }); + } +} \ No newline at end of file diff --git a/receivers/electron/src/renderer.js b/receivers/electron/src/renderer.js index e7d0b46..84e1072 100644 --- a/receivers/electron/src/renderer.js +++ b/receivers/electron/src/renderer.js @@ -15,10 +15,31 @@ const player = videojs("video-player", options, function onPlayerReady() { } }); -player.on("pause", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: 2 }) }); -player.on("play", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: 1 }) }); -player.on("seeked", () => { window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: player.paused() ? 2 : 1 }) }); -player.on("volumechange", () => { window.electronAPI.sendVolumeUpdate({ volume: player.volume() }); }); +player.on("pause", () => { window.electronAPI.sendPlaybackUpdate({ + generationTime: Date.now(), + time: Math.round(player.currentTime()), + duration: Math.round(player.duration()), + state: 2 +})}); + +player.on("play", () => { window.electronAPI.sendPlaybackUpdate({ + generationTime: Date.now(), + time: Math.round(player.currentTime()), + duration: Math.round(player.duration()), + state: 1 +})}); + +player.on("seeked", () => { window.electronAPI.sendPlaybackUpdate({ + generationTime: Date.now(), + time: Math.round(player.currentTime()), + duration: Math.round(player.duration()), + state: player.paused() ? 2 : 1 }) +}); + +player.on("volumechange", () => { window.electronAPI.sendVolumeUpdate({ + generationTime: Date.now(), + volume: player.volume() +})}); window.electronAPI.onPlay((_event, value) => { console.log("Handle play message renderer", value); @@ -57,7 +78,12 @@ window.electronAPI.onSetVolume((_event, value) => { }); setInterval(() => { - window.electronAPI.sendPlaybackUpdate({ time: Math.round(player.currentTime()), state: player.paused() ? 2 : 1 }); + window.electronAPI.sendPlaybackUpdate({ + generationTime: Date.now(), + time: Math.round(player.currentTime()), + duration: Math.round(player.duration()), + state: player.paused() ? 2 : 1 + }); }, 1000); let mouseTimer = null;