diff options
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 48 | ||||
| -rw-r--r-- | packages/meshbay-client/src/cast-relay.js | 151 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 44 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 18 |
4 files changed, 245 insertions, 16 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 55deb5b..8c6c9c6 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -41,12 +41,31 @@ const TEXT_TRACK_STYLE = Object.freeze({ }); /** - * What to hand `player.load()` for a stream, with or without subtitles. + * What to hand `player.load()` for a stream, with or without subtitles, or + * for a whole file the relay serves. * * `streamType: 'LIVE'` because the relay has no beginning to seek back to — * the film's own timeline lives on this side, and a seek restarts the relay. + * A file is the relay's to describe (`file`: its type and what to show with + * it), never the page's: a photo is not a stream at all, and a music track is + * an ordinary buffered file the receiver seeks in itself. */ -function mediaFor(mediaUrl, subtitle) { +function mediaFor(mediaUrl, subtitle, file) { + if (file && file.contentType.startsWith('image/')) { + return { contentId: mediaUrl, contentType: file.contentType, streamType: 'NONE', + metadata: { metadataType: 4 } }; // PhotoMediaMetadata + } + if (file) { + const meta = file.meta || {}; + return { + contentId: mediaUrl, contentType: file.contentType, streamType: 'BUFFERED', + metadata: { + metadataType: 3, // MusicTrackMediaMetadata + title: meta.title || '', artist: meta.artist || '', albumName: meta.album || '', + images: meta.cover ? [{ url: meta.cover }] : [], + }, + }; + } const media = { contentId: mediaUrl, contentType: 'video/mp4', @@ -67,11 +86,14 @@ function mediaFor(mediaUrl, subtitle) { return media; } -function loadOptionsFor(subtitle) { - return { +function loadOptionsFor(subtitle, startAt) { + const options = { autoplay: true, activeTrackIds: subtitle && subtitle.url ? [TEXT_TRACK_ID] : [], }; + // Where in a track to begin: a cast started mid-song carries on from there. + if (startAt > 0) options.currentTime = startAt; + return options; } class CastChromecast { @@ -139,7 +161,7 @@ class CastChromecast { } } - async connect(deviceId, mediaUrl, subtitle) { + async connect(deviceId, mediaUrl, subtitle, file, startAt) { const device = this._devices.get(deviceId); if (!device) throw new Error(`Unknown device: ${deviceId}`); @@ -179,7 +201,7 @@ class CastChromecast { debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { - player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), + player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt), (err, s) => { if (err) return reject(err); resolve(s); @@ -191,12 +213,12 @@ class CastChromecast { return { deviceName: device.name, playerState: status.playerState }; } - async reload(mediaUrl, subtitle) { + async reload(mediaUrl, subtitle, file, startAt) { if (!this._player) throw new Error('Not connected'); debug(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"` + ` with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { - this._player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), + this._player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt), (err, s) => { if (err) return reject(err); resolve(s); @@ -250,6 +272,16 @@ class CastChromecast { return { playerState: status && status.playerState }; } + /** Where in a track the receiver should be; a film seeks by restarting the relay instead. */ + async seek(seconds) { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.seek(Math.max(0, Number(seconds) || 0), (err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + async play() { if (!this._player) throw new Error('Not connected'); const status = await new Promise((resolve, reject) => { diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js index 58eb9ac..ed20b33 100644 --- a/packages/meshbay-client/src/cast-relay.js +++ b/packages/meshbay-client/src/cast-relay.js @@ -15,6 +15,12 @@ * a media element, so unlike the stream it needs CORS headers to be readable * at all. * + * A whole file — a photo, or a music track with its cover at `/cover` — is + * served at `/file`, one at a time: the page decrypts it and hands it over in + * pieces (begin, writes, end), and the receiver loads it as a picture or as + * music. Ranges are honoured there, which is what a receiver seeks with. The + * relay starts for a file when it is not already running. + * * Mitigations: * · Bind to the LAN interface, never 0.0.0.0 * · Fixed port range (19550-19553), opened only during active cast @@ -50,6 +56,16 @@ const util = require('node:util'); const debug = util.debuglog('cast-relay'); const RING_CAP = 64; +// What the receiver is ever told a photo is. The page re-encodes every photo +// to JPEG, so this list is a guard, not a menu. +const FILE_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', + 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/flac', 'audio/ogg', 'audio/opus', + 'audio/wav', 'audio/webm', +]); +// A track is a few megabytes, a long lossless one a few hundred; this only +// stops a page from filling the machine's memory. +const FILE_MAX_BYTES = 1024 * 1024 * 1024; const BACKPRESSURE_HIGH = 8 * 1024 * 1024; const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; @@ -163,6 +179,9 @@ class CastRelay { this._bytesSent = 0; this._subtitle = null; this._subtitleVersion = 0; + this._file = null; + this._fileVersion = 0; + this._incoming = null; } get active() { return this._server !== null; } @@ -185,6 +204,70 @@ class CastRelay { + `?t=${this._token}&v=${this._subtitleVersion}`; } + /** + * Where the current file can be fetched, or null when there is none. + * Versioned for the same reason as the subtitle: a receiver handed the same + * address twice shows what it already has. + */ + get fileUrl() { + if (!this._server || !this._file) return null; + return `http://${this._lanIP}:${this._port}/file` + + `?t=${this._token}&v=${this._fileVersion}`; + } + + get fileType() { return this._file ? this._file.type : null; } + + /** What the receiver is told the file is: title, artist, album, cover. */ + get fileMeta() { + if (!this._file) return null; + const { title, artist, album } = this._file.meta; + const cover = this._file.cover + ? `http://${this._lanIP}:${this._port}/cover?t=${this._token}&v=${this._fileVersion}` + : null; + return { title, artist, album, cover }; + } + + /** + * A file arrives in three steps, so that a large one never has to cross in + * a single message: `beginFile` names it, `writeFile` appends, `endFile` + * puts it up and answers its address. The relay starts if nothing has. + */ + async beginFile({ type, size, cover, title, artist, album }) { + if (!FILE_TYPES.has(type)) throw new Error(`Not something a receiver shows: ${type}`); + if (!(size > 0 && size <= FILE_MAX_BYTES)) throw new Error(`Bad file size: ${size}`); + if (cover && !FILE_TYPES.has(cover.type)) throw new Error(`Bad cover type: ${cover.type}`); + if (!this._server) await this.start({}); + const text = (v) => (typeof v === 'string' ? v.slice(0, 500) : ''); + this._incoming = { + type, size, parts: [], received: 0, + cover: cover && cover.type.startsWith('image/') + ? { bytes: Buffer.from(cover.bytes), type: cover.type } : null, + meta: { title: text(title), artist: text(artist), album: text(album) }, + }; + return true; + } + + writeFile(bytes) { + const f = this._incoming; + if (!f) throw new Error('No file is being sent'); + const buf = Buffer.from(bytes); + if (f.received + buf.length > f.size) throw new Error('More bytes than announced'); + f.parts.push(buf); + f.received += buf.length; + return true; + } + + endFile() { + const f = this._incoming; + this._incoming = null; + if (!f) throw new Error('No file is being sent'); + if (f.received !== f.size) throw new Error(`File cut short: ${f.received} of ${f.size} bytes`); + this._file = { bytes: Buffer.concat(f.parts), type: f.type, cover: f.cover, meta: f.meta }; + this._fileVersion++; + debug(`[cast-relay] file set: ${f.size} bytes, ${f.type}, v${this._fileVersion}`); + return { url: this.fileUrl }; + } + get subtitle() { if (!this._subtitle) return null; return { @@ -232,6 +315,8 @@ class CastRelay { this._chunkCount = 0; this._bytesSent = 0; this._subtitle = null; + this._file = null; + this._incoming = null; this.setSubtitle(subtitle); const server = http.createServer((req, res) => this._handle(req, res)); @@ -323,6 +408,8 @@ class CastRelay { this._token = null; this._initSegment = null; this._subtitle = null; + this._file = null; + this._incoming = null; this._ring = []; this._accum.reset(); this._fragCount = 0; @@ -366,6 +453,16 @@ class CastRelay { return; } + if (url.pathname === '/file') { + this._handleFile(req, res); + return; + } + + if (url.pathname === '/cover') { + this._handleCover(res); + return; + } + if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); @@ -419,6 +516,60 @@ class CastRelay { res.end(this._subtitle.vtt); debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`); } + + /** + * The whole file, or the one range asked for: a receiver seeks in a track + * by asking for the bytes from there on. + */ + _handleFile(req, res) { + const f = this._file; + if (!f) { + res.writeHead(404, CORS_HEADERS); + res.end(); + return; + } + const total = f.bytes.length; + const base = { + ...CORS_HEADERS, + 'Content-Type': f.type, + 'Cache-Control': 'no-store', + 'Accept-Ranges': 'bytes', + }; + const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); + if (m && (m[1] !== '' || m[2] !== '')) { + let from = m[1] === '' ? total - Number(m[2]) : Number(m[1]); + let to = m[1] === '' || m[2] === '' ? total - 1 : Math.min(Number(m[2]), total - 1); + from = Math.max(0, from); + if (from > to || from >= total) { + res.writeHead(416, { ...base, 'Content-Range': `bytes */${total}` }); + res.end(); + return; + } + res.writeHead(206, { ...base, 'Content-Range': `bytes ${from}-${to}/${total}`, + 'Content-Length': to - from + 1 }); + res.end(f.bytes.subarray(from, to + 1)); + return; + } + res.writeHead(200, { ...base, 'Content-Length': total }); + res.end(f.bytes); + debug(`[cast-relay] file served: ${total} bytes`); + } + + _handleCover(res) { + const c = this._file && this._file.cover; + if (!c) { + res.writeHead(404, CORS_HEADERS); + res.end(); + return; + } + res.writeHead(200, { + ...CORS_HEADERS, + 'Content-Type': c.type, + 'Content-Length': c.bytes.length, + 'Cache-Control': 'no-store', + }); + res.end(c.bytes); + } } module.exports = CastRelay; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 4c8707c..a2a7a86 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -20,8 +20,8 @@ 'use strict'; -const { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage, - shell, Tray } = +const { app, BrowserWindow, dialog, ipcMain, Menu, powerSaveBlocker, protocol, + safeStorage, shell, Tray } = require('electron'); const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); @@ -2304,6 +2304,19 @@ function registerBridge() { return true; }); + // The screen may sleep while music plays; the machine may not, or the + // connection to the node goes with it. + let playbackBlocker = null; + handle('playback:keep-alive', async (_event, on) => { + if (on === true && playbackBlocker === null) { + playbackBlocker = powerSaveBlocker.start('prevent-app-suspension'); + } else if (on !== true && playbackBlocker !== null) { + powerSaveBlocker.stop(playbackBlocker); + playbackBlocker = null; + } + return true; + }); + handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, @@ -2320,16 +2333,31 @@ function registerBridge() { handle('cast:devices', async () => castChromecast.devices()); - handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { - return castChromecast.connect(deviceId, mediaUrl, - subtitle === undefined ? castRelay.subtitle : subtitle); + // A whole file for the receiver — a photo, a music track — in three steps, + // so that a large one never crosses in a single message. + handle('cast:file:begin', async (_e, f) => castRelay.beginFile(f || {})); + handle('cast:file:write', async (_e, data) => castRelay.writeFile(Buffer.from(data))); + handle('cast:file:end', async () => castRelay.endFile()); + + // The relay's file is loaded as what the relay says it is, anything else as + // the stream; the page does not get to say which. + const loadOf = (mediaUrl, subtitle) => ( + mediaUrl && mediaUrl === castRelay.fileUrl + ? { subtitle: null, file: { contentType: castRelay.fileType, meta: castRelay.fileMeta } } + : { subtitle: subtitle === undefined ? castRelay.subtitle : subtitle, file: null }); + + handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle, startAt }) => { + const l = loadOf(mediaUrl, subtitle); + return castChromecast.connect(deviceId, mediaUrl, l.subtitle, l.file, l.file ? startAt : 0); }); - handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { - return castChromecast.reload(mediaUrl, - subtitle === undefined ? castRelay.subtitle : subtitle); + handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle, startAt }) => { + const l = loadOf(mediaUrl, subtitle); + return castChromecast.reload(mediaUrl, l.subtitle, l.file, l.file ? startAt : 0); }); + handle('cast:chromecast:seek', async (_e, seconds) => castChromecast.seek(seconds)); + // The player becomes a remote while casting: these drive the receiver. handle('cast:chromecast:pause', async () => castChromecast.pause()); handle('cast:chromecast:play', async () => castChromecast.play()); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 43cf317..0de76db 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -165,6 +165,11 @@ contextBridge.exposeInMainWorld('meshbay', { serviceMode: (action) => ipcRenderer.invoke('node:service-mode', action), }, + // Music is playing: the machine must not idle-sleep under it. + playback: { + keepAlive: (on) => ipcRenderer.invoke('playback:keep-alive', on === true), + }, + // LAN cast relay. The main process runs a local HTTP server and the // renderer feeds it decrypted segments. A Chromecast or Smart TV on the // same Wi-Fi plays from the URL. @@ -173,6 +178,18 @@ contextBridge.exposeInMainWorld('meshbay', { push: (data) => ipcRenderer.invoke('cast:push', data), stop: () => ipcRenderer.invoke('cast:stop'), subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), + // A file in pieces of a few megabytes, as the Android bridge sends it. + file: async (f) => { + const bytes = f.bytes; + await ipcRenderer.invoke('cast:file:begin', { + type: f.type, size: bytes.length, cover: f.cover || null, + title: f.title, artist: f.artist, album: f.album, + }); + for (let at = 0; at < bytes.length; at += 4 * 1024 * 1024) { + await ipcRenderer.invoke('cast:file:write', bytes.subarray(at, at + 4 * 1024 * 1024)); + } + return ipcRenderer.invoke('cast:file:end'); + }, finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), scan: () => ipcRenderer.invoke('cast:scan'), @@ -182,6 +199,7 @@ contextBridge.exposeInMainWorld('meshbay', { chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), chromecastPause: () => ipcRenderer.invoke('cast:chromecast:pause'), chromecastPlay: () => ipcRenderer.invoke('cast:chromecast:play'), + chromecastSeek: (seconds) => ipcRenderer.invoke('cast:chromecast:seek', seconds), }, // A sink that writes to disk as chunks arrive, never a buffer handed over at |