aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js48
-rw-r--r--packages/meshbay-client/src/cast-relay.js154
-rw-r--r--packages/meshbay-client/src/main.js30
-rw-r--r--packages/meshbay-client/src/preload.js14
4 files changed, 191 insertions, 55 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js
index 4c8ddf0..8c6c9c6 100644
--- a/packages/meshbay-client/src/cast-chromecast.js
+++ b/packages/meshbay-client/src/cast-chromecast.js
@@ -42,17 +42,30 @@ const TEXT_TRACK_STYLE = Object.freeze({
/**
* What to hand `player.load()` for a stream, with or without subtitles, or
- * for a photo.
+ * 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 photo is not a stream at all.
+ * 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, contentType) {
- if (contentType && contentType.startsWith('image/')) {
- return { contentId: mediaUrl, contentType, streamType: 'NONE',
+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',
@@ -73,11 +86,14 @@ function mediaFor(mediaUrl, subtitle, contentType) {
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 {
@@ -145,7 +161,7 @@ class CastChromecast {
}
}
- async connect(deviceId, mediaUrl, subtitle, contentType) {
+ async connect(deviceId, mediaUrl, subtitle, file, startAt) {
const device = this._devices.get(deviceId);
if (!device) throw new Error(`Unknown device: ${deviceId}`);
@@ -185,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, contentType), loadOptionsFor(subtitle),
+ player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt),
(err, s) => {
if (err) return reject(err);
resolve(s);
@@ -197,12 +213,12 @@ class CastChromecast {
return { deviceName: device.name, playerState: status.playerState };
}
- async reload(mediaUrl, subtitle, contentType) {
+ 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, contentType), loadOptionsFor(subtitle),
+ this._player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt),
(err, s) => {
if (err) return reject(err);
resolve(s);
@@ -256,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 c5eadc7..ed20b33 100644
--- a/packages/meshbay-client/src/cast-relay.js
+++ b/packages/meshbay-client/src/cast-relay.js
@@ -15,9 +15,11 @@
* 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.
+ * 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
@@ -56,7 +58,14 @@ 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 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;
@@ -170,8 +179,9 @@ class CastRelay {
this._bytesSent = 0;
this._subtitle = null;
this._subtitleVersion = 0;
- this._image = null;
- this._imageVersion = 0;
+ this._file = null;
+ this._fileVersion = 0;
+ this._incoming = null;
}
get active() { return this._server !== null; }
@@ -195,26 +205,67 @@ class CastRelay {
}
/**
- * Where the current photo can be fetched, or null when there is none.
+ * 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 imageUrl() {
- if (!this._server || !this._image) return null;
- return `http://${this._lanIP}:${this._port}/image`
- + `?t=${this._token}&v=${this._imageVersion}`;
+ get fileUrl() {
+ if (!this._server || !this._file) return null;
+ return `http://${this._lanIP}:${this._port}/file`
+ + `?t=${this._token}&v=${this._fileVersion}`;
}
- get imageType() { return this._image ? this._image.type : null; }
+ get fileType() { return this._file ? this._file.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}`);
+ /** 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({});
- 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 };
+ 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() {
@@ -264,7 +315,8 @@ class CastRelay {
this._chunkCount = 0;
this._bytesSent = 0;
this._subtitle = null;
- this._image = null;
+ this._file = null;
+ this._incoming = null;
this.setSubtitle(subtitle);
const server = http.createServer((req, res) => this._handle(req, res));
@@ -356,7 +408,8 @@ class CastRelay {
this._token = null;
this._initSegment = null;
this._subtitle = null;
- this._image = null;
+ this._file = null;
+ this._incoming = null;
this._ring = [];
this._accum.reset();
this._fragCount = 0;
@@ -400,8 +453,13 @@ class CastRelay {
return;
}
- if (url.pathname === '/image') {
- this._handleImage(res);
+ if (url.pathname === '/file') {
+ this._handleFile(req, res);
+ return;
+ }
+
+ if (url.pathname === '/cover') {
+ this._handleCover(res);
return;
}
@@ -459,20 +517,58 @@ class CastRelay {
debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`);
}
- _handleImage(res) {
- if (!this._image) {
+ /**
+ * 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': this._image.type,
- 'Content-Length': this._image.bytes.length,
+ 'Content-Type': c.type,
+ 'Content-Length': c.bytes.length,
'Cache-Control': 'no-store',
});
- res.end(this._image.bytes);
- debug(`[cast-relay] image served: ${this._image.bytes.length} bytes`);
+ res.end(c.bytes);
}
}
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 1c371f5..a2a7a86 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -2333,29 +2333,31 @@ 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 });
- });
+ // 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 photo is loaded as a picture, anything else as the stream;
- // the page does not get to say which.
+ // 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.imageUrl
- ? { subtitle: null, contentType: castRelay.imageType }
- : { subtitle: subtitle === undefined ? castRelay.subtitle : subtitle,
- contentType: 'video/mp4' });
+ 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 }) => {
+ handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle, startAt }) => {
const l = loadOf(mediaUrl, subtitle);
- return castChromecast.connect(deviceId, mediaUrl, l.subtitle, l.contentType);
+ return castChromecast.connect(deviceId, mediaUrl, l.subtitle, l.file, l.file ? startAt : 0);
});
- handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => {
+ handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle, startAt }) => {
const l = loadOf(mediaUrl, subtitle);
- return castChromecast.reload(mediaUrl, l.subtitle, l.contentType);
+ 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 f12d68e..0de76db 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -178,7 +178,18 @@ 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),
+ // 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'),
@@ -188,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