aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/cast-relay.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src/cast-relay.js')
-rw-r--r--packages/meshbay-client/src/cast-relay.js55
1 files changed, 55 insertions, 0 deletions
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;