aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 01:16:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 01:16:12 +0200
commit243bcf017b4957237b3ddf556bd0a37296eb8798 (patch)
tree7d0820796737aad4dcfb89de7bec1e7271759af6 /packages/meshbay-client
parentac8ed437659d765120d1b0cc9f83e942549b479f (diff)
downloadmeshbay-243bcf017b4957237b3ddf556bd0a37296eb8798.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client')
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js18
-rw-r--r--packages/meshbay-client/src/cast-relay.js55
-rw-r--r--packages/meshbay-client/src/main.js21
-rw-r--r--packages/meshbay-client/src/preload.js1
4 files changed, 85 insertions, 10 deletions
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'),