From 243bcf017b4957237b3ddf556bd0a37296eb8798 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 01:16:12 +0200 Subject: feat(cast): photos on the TV, and a television chosen once for the session A cast button in Videos' toolbar, at the top of Photos, in an album's bar and in the lightbox, in a group and in Search alike. A television chosen there is kept for the session: a film opened plays on it with the player as its remote from the start, and a photo opened in the lightbox is shown on it, scaled to 1920x1080, upright, as JPEG. The lightbox gains a slideshow. The relay serves one photo at /image behind the stream's token, on the desktop and on Android; the shell, not the page, decides that the receiver loads it as a picture. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-client/src/cast-chromecast.js | 18 ++++++--- packages/meshbay-client/src/cast-relay.js | 55 ++++++++++++++++++++++++++ packages/meshbay-client/src/main.js | 21 ++++++++-- packages/meshbay-client/src/preload.js | 1 + 4 files changed, 85 insertions(+), 10 deletions(-) (limited to 'packages/meshbay-client/src') diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 55deb5b..4c8ddf0 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -41,12 +41,18 @@ 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 photo. * * `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 photo is not a stream at all. */ -function mediaFor(mediaUrl, subtitle) { +function mediaFor(mediaUrl, subtitle, contentType) { + if (contentType && contentType.startsWith('image/')) { + return { contentId: mediaUrl, contentType, streamType: 'NONE', + metadata: { metadataType: 4 } }; // PhotoMediaMetadata + } const media = { contentId: mediaUrl, contentType: 'video/mp4', @@ -139,7 +145,7 @@ class CastChromecast { } } - async connect(deviceId, mediaUrl, subtitle) { + async connect(deviceId, mediaUrl, subtitle, contentType) { const device = this._devices.get(deviceId); if (!device) throw new Error(`Unknown device: ${deviceId}`); @@ -179,7 +185,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, contentType), loadOptionsFor(subtitle), (err, s) => { if (err) return reject(err); resolve(s); @@ -191,12 +197,12 @@ class CastChromecast { return { deviceName: device.name, playerState: status.playerState }; } - async reload(mediaUrl, subtitle) { + async reload(mediaUrl, subtitle, contentType) { 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, contentType), loadOptionsFor(subtitle), (err, s) => { if (err) return reject(err); resolve(s); diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js index 58eb9ac..c5eadc7 100644 --- a/packages/meshbay-client/src/cast-relay.js +++ b/packages/meshbay-client/src/cast-relay.js @@ -15,6 +15,10 @@ * a media element, so unlike the stream it needs CORS headers to be readable * at all. * + * A photo is served at `/image`, one at a time: the page decrypts it, scales + * it to the television and hands it here, and the receiver loads it as a + * picture. The relay starts for it 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 +54,9 @@ 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 IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const BACKPRESSURE_HIGH = 8 * 1024 * 1024; const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; @@ -163,6 +170,8 @@ class CastRelay { this._bytesSent = 0; this._subtitle = null; this._subtitleVersion = 0; + this._image = null; + this._imageVersion = 0; } get active() { return this._server !== null; } @@ -185,6 +194,29 @@ class CastRelay { + `?t=${this._token}&v=${this._subtitleVersion}`; } + /** + * Where the current photo 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 imageUrl() { + if (!this._server || !this._image) return null; + return `http://${this._lanIP}:${this._port}/image` + + `?t=${this._token}&v=${this._imageVersion}`; + } + + get imageType() { return this._image ? this._image.type : null; } + + /** Serve one photo, starting the relay if nothing else has. */ + async showImage({ bytes, type }) { + if (!IMAGE_TYPES.has(type)) throw new Error(`Not a picture a receiver shows: ${type}`); + if (!this._server) await this.start({}); + this._image = { bytes: Buffer.from(bytes), type }; + this._imageVersion++; + debug(`[cast-relay] image set: ${this._image.bytes.length} bytes, ${type}, v${this._imageVersion}`); + return { url: this.imageUrl }; + } + get subtitle() { if (!this._subtitle) return null; return { @@ -232,6 +264,7 @@ class CastRelay { this._chunkCount = 0; this._bytesSent = 0; this._subtitle = null; + this._image = null; this.setSubtitle(subtitle); const server = http.createServer((req, res) => this._handle(req, res)); @@ -323,6 +356,7 @@ class CastRelay { this._token = null; this._initSegment = null; this._subtitle = null; + this._image = null; this._ring = []; this._accum.reset(); this._fragCount = 0; @@ -366,6 +400,11 @@ class CastRelay { return; } + if (url.pathname === '/image') { + this._handleImage(res); + return; + } + if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); @@ -419,6 +458,22 @@ class CastRelay { res.end(this._subtitle.vtt); debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`); } + + _handleImage(res) { + if (!this._image) { + res.writeHead(404, CORS_HEADERS); + res.end(); + return; + } + res.writeHead(200, { + ...CORS_HEADERS, + 'Content-Type': this._image.type, + 'Content-Length': this._image.bytes.length, + 'Cache-Control': 'no-store', + }); + res.end(this._image.bytes); + debug(`[cast-relay] image served: ${this._image.bytes.length} bytes`); + } } module.exports = CastRelay; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index fa4f3ec..1c371f5 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2333,14 +2333,27 @@ function registerBridge() { handle('cast:devices', async () => castChromecast.devices()); + // One photo at a time, scaled and re-encoded by the page. + handle('cast:image', async (_e, img) => { + return castRelay.showImage({ bytes: Buffer.from(img.bytes), type: img.type }); + }); + + // The relay's photo is loaded as a picture, anything else as the stream; + // the page does not get to say which. + const loadOf = (mediaUrl, subtitle) => ( + mediaUrl && mediaUrl === castRelay.imageUrl + ? { subtitle: null, contentType: castRelay.imageType } + : { subtitle: subtitle === undefined ? castRelay.subtitle : subtitle, + contentType: 'video/mp4' }); + handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { - return castChromecast.connect(deviceId, mediaUrl, - subtitle === undefined ? castRelay.subtitle : subtitle); + const l = loadOf(mediaUrl, subtitle); + return castChromecast.connect(deviceId, mediaUrl, l.subtitle, l.contentType); }); handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { - return castChromecast.reload(mediaUrl, - subtitle === undefined ? castRelay.subtitle : subtitle); + const l = loadOf(mediaUrl, subtitle); + return castChromecast.reload(mediaUrl, l.subtitle, l.contentType); }); // The player becomes a remote while casting: these drive the receiver. diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 3af6c10..f12d68e 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -178,6 +178,7 @@ contextBridge.exposeInMainWorld('meshbay', { push: (data) => ipcRenderer.invoke('cast:push', data), stop: () => ipcRenderer.invoke('cast:stop'), subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), + image: (img) => ipcRenderer.invoke('cast:image', img), finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), scan: () => ipcRenderer.invoke('cast:scan'), -- cgit v1.2.3