aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
commit8730281d739d9ed1d6f2d366772582eea8ba0294 (patch)
tree4df2f3c8d42dc17147bcdec23109aa9c0f1b5b35 /packages/meshbay-hub/src/meshbay_hub
parentae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (diff)
downloadmeshbay-8730281d739d9ed1d6f2d366772582eea8ba0294.tar.gz
feat: LAN Wi-Fi casting to Chromecast via local HTTP relay
Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can play the stream. The relay runs in the Electron main process — same trust boundary as downloads and MSE playback. - cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC chunks into moof+mdat pairs), ring buffer, backpressure, finish() for clean end-of-stream, fixed port range 19550-19553 - cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol (castv2-client), connect/reload/disconnect lifecycle - Seek-aware: relay restarts on every seek, Chromecast reloads new URL; generation counter prevents stale async errors from killing active restarts; landingPlayheadRef suppresses programmatic seeking events - Device picker in video top bar with scan, device selection, copy-URL fallback, and cast status indicator - IPC bridge (main/preload/platform) for start/push/stop/finish/status/ discover/chromecastConnect/chromecastReload/chromecastDisconnect - Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js199
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js55
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js4
14 files changed, 360 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 50ee9f6..7780d08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -406,6 +406,9 @@ const ICON_PATHS = {
server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z',
'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z',
'M8 7.5h.01', 'M8 16.5h.01'],
+ cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21',
+ 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6',
+ 'M2 21h.01'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
@@ -3766,6 +3769,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// live; the render needs to reach it for "start from the beginning".
const requestSeekRef = useRef(null);
const [resumedFrom, setResumedFrom] = useState(0);
+ const [castActive, setCastActive] = useState(false);
+ const [castUrl, setCastUrl] = useState(null);
+ const [castPickerOpen, setCastPickerOpen] = useState(false);
+ const [castDevices, setCastDevices] = useState([]);
+ const [castScanning, setCastScanning] = useState(false);
+ const [castDeviceName, setCastDeviceName] = useState(null);
+ const castActiveRef = useRef(false);
+ const castCodecRef = useRef(null);
+ const initSegmentRef = useRef(null);
+ const castRestartPendingRef = useRef(false);
+ const castDeviceRef = useRef(null);
+ const castRestartGenRef = useRef(0);
+ const landingPlayheadRef = useRef(false);
/**
* The buffered range the playhead is actually in, or null.
@@ -3872,6 +3888,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
* So the clock drives this, not the data.
*/
const pump = useCallback(() => {
+ if (awaitingInitRef.current) return;
const transport = transportRef.current;
evictBehind();
flushQueue();
@@ -3984,6 +4001,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
seekTargetRef.current = target;
outstandingRef.current = STREAM_WINDOW;
setPhase('loading');
+ console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW);
t.requestStream(entry.id, STREAM_WINDOW, target);
}, SEEK_DEBOUNCE_MS);
};
@@ -4010,6 +4028,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// the range can begin slightly later than the target; never seek
// behind what is actually there.
if (Math.abs(v.currentTime - target) > 0.5) {
+ landingPlayheadRef.current = true;
v.currentTime = Math.max(target, a);
}
v.play().catch(() => {});
@@ -4064,25 +4083,33 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
quotaRef.current = 0;
awaitingInitRef.current = false;
seekTargetRef.current = start;
+ console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
setPhase('streaming');
pump();
};
const onSeeking = () => {
+ if (landingPlayheadRef.current) {
+ landingPlayheadRef.current = false;
+ return;
+ }
const v = videoRef.current;
if (!v || cancelled) return;
const target = v.currentTime;
// Inside what is buffered, the browser handles it and the node need not
- // hear about it at all.
- const sb = sbRef.current;
- if (sb) {
- try {
- for (let i = 0; i < sb.buffered.length; i++) {
- if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
- return;
+ // hear about it at all — unless a cast is active, because the relay
+ // cannot seek within its HTTP stream and must be restarted.
+ if (!castActiveRef.current) {
+ const sb = sbRef.current;
+ if (sb) {
+ try {
+ for (let i = 0; i < sb.buffered.length; i++) {
+ if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
+ return;
+ }
}
- }
- } catch { /* fall through and ask the node */ }
+ } catch { /* fall through and ask the node */ }
+ }
}
requestSeek(target);
};
@@ -4100,6 +4127,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
if (cancelled) return;
if (msg.file_id && msg.file_id !== entry.id) return;
const mime = `video/mp4; codecs="${msg.codec}"`;
+ castCodecRef.current = msg.codec;
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
setError(t('video.err_mse', { codec: msg.codec }));
@@ -4115,6 +4143,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// scrubber is drawn from.
if (sbRef.current && msRef.current
&& msRef.current.readyState === 'open') {
+ console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
+ initSegmentRef.current = null;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.stop().catch(() => {});
+ castRestartPendingRef.current = true;
+ }
reinitAt(msg.start || 0).catch(() => {
setError(t('video.err_transport'));
setPhase('error');
@@ -4122,8 +4156,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
return;
}
+ // If we reach here during a seek (readyState was 'ended' after the
+ // previous stream finished), the seek-landing path above could not run.
+ // A fresh MediaSource is needed, but the seek state must still be reset
+ // or awaitingInit stays true and every segment is dropped forever.
+ awaitingInitRef.current = false;
+ endedRef.current = false;
+ appendingRef.current = false;
+ queueRef.current = [];
+ sbRef.current = null;
+ initSegmentRef.current = null;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.stop().catch(() => {});
+ castRestartPendingRef.current = true;
+ }
+
const ms = new MediaSource();
msRef.current = ms;
+ if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
const url = URL.createObjectURL(ms);
blobUrlRef.current = url;
@@ -4187,6 +4237,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// for credit that cannot come. A race, which is why the same seek
// worked twice and hung on the third.
outstandingRef.current = Math.max(0, outstandingRef.current - 1);
+ if (awaitingInitRef.current) {
+ console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
+ }
// Between asking for a seek and its `stream_init`, everything on the
// channel is the film we just left. Same file, so `file_id` cannot
// tell them apart — ordering can.
@@ -4199,6 +4252,44 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
+ if (initSegmentRef.current === null) {
+ initSegmentRef.current = plaintext;
+ if (castRestartPendingRef.current && castActiveRef.current
+ && platform.cast.available) {
+ castRestartPendingRef.current = false;
+ const gen = ++castRestartGenRef.current;
+ const device = castDeviceRef.current;
+ platform.cast.start({
+ codec: castCodecRef.current,
+ initSegment: plaintext,
+ }).then(async (result) => {
+ if (castRestartGenRef.current !== gen) return;
+ if (!result) return;
+ setCastUrl(result.url);
+ const status = await platform.cast.status();
+ if (status && status.chromecast && status.chromecast.connected) {
+ await platform.cast.chromecastReload({ mediaUrl: result.url });
+ } else if (device) {
+ await platform.cast.chromecastConnect({
+ deviceId: device.id, mediaUrl: result.url,
+ });
+ setCastDeviceName(device.name);
+ } else {
+ navigator.clipboard.writeText(result.url).catch(() => {});
+ }
+ }).catch((err) => {
+ if (castRestartGenRef.current !== gen) return;
+ console.error('[cast] restart failed:', err);
+ platform.cast.stop().catch(() => {});
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null); setCastDeviceName(null);
+ });
+ }
+ }
+ if (castActiveRef.current && !castRestartPendingRef.current
+ && platform.cast.available) {
+ platform.cast.push(plaintext).catch(() => {});
+ }
queueRef.current.push(plaintext);
// pump(), not flushQueue(): arriving data is the moment to top the
// window back up, and that is what keeps the stream continuous.
@@ -4216,6 +4307,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// would call endOfStream() and truncate the film at the seek point.
if (awaitingInitRef.current) return;
endedRef.current = true;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.finish().catch(() => {});
+ }
flushQueue();
};
@@ -4305,6 +4399,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
writeResumePosition(entry.id, videoRef.current.currentTime,
durationRef.current);
}
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.chromecastDisconnect().catch(() => {});
+ platform.cast.stop().catch(() => {});
+ castActiveRef.current = false;
+ }
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
if (videoRef.current) {
@@ -4366,6 +4465,88 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}}>
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${platform.capabilities.lanCast && html`
+ <div class="cast-wrapper" style="position:relative">
+ <button class="video-close ${castActive ? 'cast-active' : ''}"
+ onClick=${async () => {
+ if (castActive) {
+ await platform.cast.chromecastDisconnect().catch(() => {});
+ await platform.cast.stop();
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null);
+ setCastDeviceName(null);
+ castDeviceRef.current = null;
+ } else if (castCodecRef.current && initSegmentRef.current) {
+ if (castPickerOpen) {
+ setCastPickerOpen(false);
+ } else {
+ setCastPickerOpen(true);
+ setCastScanning(true);
+ setCastDevices([]);
+ platform.cast.discover().then((devices) => {
+ setCastDevices(devices || []);
+ setCastScanning(false);
+ }).catch(() => setCastScanning(false));
+ }
+ }
+ }}
+ title="${castActive ? t('cast.stop') : t('cast.start')}">
+ <${Icon} name="cast" /></button>
+ ${castPickerOpen && html`
+ <div class="cast-picker">
+ ${castScanning && html`
+ <div class="cast-picker-item cast-picker-scanning">
+ <span class="spinner" style="width:14px;height:14px"></span>
+ ${t('cast.scanning')}
+ </div>
+ `}
+ ${castDevices.map(d => html`
+ <button class="cast-picker-item" onClick=${() => {
+ setCastPickerOpen(false);
+ setCastDeviceName(d.name);
+ setCastActive(true); castActiveRef.current = true;
+ castRestartPendingRef.current = true;
+ castDeviceRef.current = d;
+ if (videoRef.current && requestSeekRef.current) {
+ requestSeekRef.current(videoRef.current.currentTime);
+ }
+ }}>
+ <${Icon} name="cast" /> ${d.name}
+ </button>
+ `)}
+ ${!castScanning && castDevices.length === 0 && html`
+ <div class="cast-picker-item cast-picker-empty">
+ ${t('cast.no_devices')}
+ </div>
+ `}
+ <div class="cast-picker-sep"></div>
+ <button class="cast-picker-item" onClick=${async () => {
+ setCastPickerOpen(false);
+ setCastActive(true); castActiveRef.current = true;
+ castRestartPendingRef.current = true;
+ if (videoRef.current && requestSeekRef.current) {
+ requestSeekRef.current(videoRef.current.currentTime);
+ }
+ }}>
+ <${Icon} name="clip" /> ${t('cast.copy_url')}
+ </button>
+ </div>
+ `}
+ </div>
+ `}
+ ${castUrl && html`
+ <span class="cast-status-label">
+ ${castDeviceName
+ ? castDeviceName
+ : html`<input class="cast-url-input" readOnly value=${castUrl}
+ onClick=${(e) => {
+ e.target.select();
+ navigator.clipboard.writeText(castUrl).catch(() => {});
+ }}
+ title="${t('cast.copy_url')}" />`
+ }
+ </span>
+ `}
${onDownload && html`
<button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
onClick=${() => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index ec56f03..e309834 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -154,6 +154,14 @@ export default {
'video.err_transport': 'Transport nicht verbunden',
'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}',
+ // LAN-Cast
+ 'cast.start': 'Auf Gerät übertragen',
+ 'cast.stop': 'Übertragung beenden',
+ 'cast.copy_url': 'Stream-URL kopieren',
+ 'cast.copied': 'URL kopiert',
+ 'cast.scanning': 'Suche nach Geräten…',
+ 'cast.no_devices': 'Keine Geräte gefunden',
+
// Settings
'settings.title': 'Einstellungen',
'settings.coming_soon': 'Demnächst verfügbar.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a8392c7..9a5058e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -152,6 +152,14 @@ export default {
'video.err_transport': 'Transport not connected',
'video.err_mse': 'Codec not supported for streaming: {codec}',
+ // LAN cast
+ 'cast.start': 'Cast to device',
+ 'cast.stop': 'Stop casting',
+ 'cast.copy_url': 'Copy stream URL',
+ 'cast.copied': 'URL copied',
+ 'cast.scanning': 'Scanning for devices…',
+ 'cast.no_devices': 'No devices found',
+
// Settings
'settings.title': 'Settings',
'settings.coming_soon': 'Coming soon.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 5afb3c3..5a95347 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -152,6 +152,14 @@ export default {
'video.err_transport': 'Transporte no conectado',
'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}',
+ // LAN cast
+ 'cast.start': 'Enviar a dispositivo',
+ 'cast.stop': 'Detener envío',
+ 'cast.copy_url': 'Copiar URL del stream',
+ 'cast.copied': 'URL copiada',
+ 'cast.scanning': 'Buscando dispositivos…',
+ 'cast.no_devices': 'No se encontraron dispositivos',
+
// Settings
'settings.title': 'Ajustes',
'settings.coming_soon': 'Próximamente.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 9eb2f30..4139fc9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -153,6 +153,14 @@ export default {
'video.err_transport': 'Transport non connecté',
'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}',
+ // LAN cast
+ 'cast.start': 'Diffuser sur un appareil',
+ 'cast.stop': 'Arrêter la diffusion',
+ 'cast.copy_url': "Copier l'URL du flux",
+ 'cast.copied': 'URL copiée',
+ 'cast.scanning': "Recherche d'appareils…",
+ 'cast.no_devices': 'Aucun appareil trouvé',
+
// Settings
'settings.title': 'Paramètres',
'settings.coming_soon': 'Bientôt disponible.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 5653ec0..531c8f7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -153,6 +153,14 @@ export default {
'video.err_transport': 'Trasporto non connesso',
'video.err_mse': 'Codec non supportato per lo streaming: {codec}',
+ // LAN cast
+ 'cast.start': 'Trasmetti al dispositivo',
+ 'cast.stop': 'Interrompi trasmissione',
+ 'cast.copy_url': 'Copia URL dello stream',
+ 'cast.copied': 'URL copiato',
+ 'cast.scanning': 'Ricerca dispositivi…',
+ 'cast.no_devices': 'Nessun dispositivo trovato',
+
// Settings
'settings.title': 'Impostazioni',
'settings.coming_soon': 'Prossimamente.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index aa3899d..1a68827 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -150,6 +150,14 @@ export default {
'video.err_transport': 'トランスポートが接続されていません',
'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}',
+ // LAN cast
+ 'cast.start': 'デバイスにキャスト',
+ 'cast.stop': 'キャストを停止',
+ 'cast.copy_url': 'ストリームURLをコピー',
+ 'cast.copied': 'URLをコピーしました',
+ 'cast.scanning': 'デバイスを検索中…',
+ 'cast.no_devices': 'デバイスが見つかりません',
+
// Settings
'settings.title': '設定',
'settings.coming_soon': '近日公開。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index c5841dd..d0c9649 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -154,6 +154,14 @@ export default {
'video.err_transport': 'Transport niet verbonden',
'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}',
+ // LAN cast
+ 'cast.start': 'Naar apparaat casten',
+ 'cast.stop': 'Casten stoppen',
+ 'cast.copy_url': 'Stream-URL kopiëren',
+ 'cast.copied': 'URL gekopieerd',
+ 'cast.scanning': 'Apparaten zoeken…',
+ 'cast.no_devices': 'Geen apparaten gevonden',
+
// Settings
'settings.title': 'Instellingen',
'settings.coming_soon': 'Binnenkort beschikbaar.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index eec144b..630548b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -159,6 +159,14 @@ export default {
'video.err_transport': 'Transport nie jest połączony',
'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}',
+ // LAN cast
+ 'cast.start': 'Przesyłaj na urządzenie',
+ 'cast.stop': 'Zatrzymaj przesyłanie',
+ 'cast.copy_url': 'Kopiuj URL strumienia',
+ 'cast.copied': 'URL skopiowany',
+ 'cast.scanning': 'Wyszukiwanie urządzeń…',
+ 'cast.no_devices': 'Nie znaleziono urządzeń',
+
// Settings
'settings.title': 'Ustawienia',
'settings.coming_soon': 'Wkrótce.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index ce2adf2..9a6809a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -154,6 +154,14 @@ export default {
'video.err_transport': 'Transporte não conectado',
'video.err_mse': 'Codec sem suporte para transmissão: {codec}',
+ // LAN cast
+ 'cast.start': 'Transmitir para dispositivo',
+ 'cast.stop': 'Parar transmissão',
+ 'cast.copy_url': 'Copiar URL do stream',
+ 'cast.copied': 'URL copiada',
+ 'cast.scanning': 'Procurando dispositivos…',
+ 'cast.no_devices': 'Nenhum dispositivo encontrado',
+
// Settings
'settings.title': 'Configurações',
'settings.coming_soon': 'Em breve.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index f249835..549b77e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -147,6 +147,14 @@ export default {
'video.err_transport': '传输未连接',
'video.err_mse': '该编解码器不支持流式播放:{codec}',
+ // LAN cast
+ 'cast.start': '投射到设备',
+ 'cast.stop': '停止投射',
+ 'cast.copy_url': '复制流媒体链接',
+ 'cast.copied': '链接已复制',
+ 'cast.scanning': '正在搜索设备…',
+ 'cast.no_devices': '未找到设备',
+
// Settings
'settings.title': '设置',
'settings.coming_soon': '即将推出。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 02107f5..d400b4e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -46,6 +46,8 @@ export const capabilities = {
localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders),
// A real save dialog and a write that does not pass through the page.
nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave),
+ // Cast decrypted video to a device on the same LAN via a local HTTP relay.
+ lanCast: Boolean(bridge && bridge.capabilities && bridge.capabilities.lanCast),
};
/**
@@ -234,8 +236,56 @@ export const node = {
},
};
+/**
+ * LAN cast relay — re-serve decrypted video segments over HTTP so a
+ * Chromecast or Smart TV on the same Wi-Fi can play the stream.
+ *
+ * Absent in a browser, where the relay cannot run: there is no main process
+ * to bind a server socket in, and a page cannot open one.
+ */
+export const cast = {
+ available: Boolean(bridge && bridge.cast),
+ async start(opts) {
+ if (!bridge || !bridge.cast) return null;
+ return bridge.cast.start(opts);
+ },
+ async push(data) {
+ if (!bridge || !bridge.cast) return false;
+ return bridge.cast.push(data);
+ },
+ async stop() {
+ if (!bridge || !bridge.cast) return false;
+ return bridge.cast.stop();
+ },
+ async finish() {
+ if (!bridge || !bridge.cast) return false;
+ return bridge.cast.finish();
+ },
+ async status() {
+ if (!bridge || !bridge.cast) return null;
+ return bridge.cast.status();
+ },
+ async discover() {
+ if (!bridge || !bridge.cast) return [];
+ return bridge.cast.discover();
+ },
+ async chromecastConnect(opts) {
+ if (!bridge || !bridge.cast) return null;
+ return bridge.cast.chromecastConnect(opts);
+ },
+ async chromecastReload(opts) {
+ if (!bridge || !bridge.cast) return null;
+ return bridge.cast.chromecastReload(opts);
+ },
+ async chromecastDisconnect() {
+ if (!bridge || !bridge.cast) return false;
+ return bridge.cast.chromecastDisconnect();
+ },
+};
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
- apiFetch, device, bridgeMessage, folder, rootPicker, node };
+ apiFetch, device, bridgeMessage, folder, rootPicker, node,
+ cast };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -244,5 +294,6 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave,
if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
- bridgeMessage, folder, rootPicker, node };
+ bridgeMessage, folder, rootPicker, node,
+ cast };
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 7ce073a..ee51a8a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1060,6 +1060,39 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.video-close:hover { background: rgba(255, 255, 255, 0.25); }
.video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; }
+.video-close.cast-active { background: rgba(59, 130, 246, 0.35); }
+.video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); }
+.cast-url-input {
+ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 4px; color: #e2e8f0; font-size: 0.75em; font-family: inherit;
+ padding: 4px 8px; min-width: 0; flex: 1; max-width: 420px;
+ cursor: text; outline: none;
+}
+.cast-url-input:focus { border-color: rgba(59, 130, 246, 0.6); }
+.cast-wrapper { display: inline-block; }
+.cast-picker {
+ position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
+ background: rgba(28, 28, 32, 0.97); border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 8px; padding: 4px 0; min-width: 230px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
+}
+.cast-picker-item {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ padding: 9px 14px; background: none; border: none;
+ color: #e2e8f0; font-size: 0.82em; cursor: pointer; text-align: left;
+ font-family: inherit;
+}
+.cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); }
+.cast-picker-item svg { width: 16px; height: 16px; flex-shrink: 0; }
+.cast-picker-scanning { color: #94a3b8; cursor: default; }
+.cast-picker-scanning:hover { background: none; }
+.cast-picker-empty { color: #64748b; cursor: default; font-style: italic; }
+.cast-picker-empty:hover { background: none; }
+.cast-picker-sep { height: 1px; margin: 4px 10px; background: rgba(255,255,255,0.1); }
+.cast-status-label {
+ color: #94a3b8; font-size: 0.78em; display: flex; align-items: center;
+ max-width: 420px; overflow: hidden; white-space: nowrap;
+}
.video-container {
width: 100%;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 17886f4..2cdf15d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -742,12 +742,14 @@ class MeshBayTransport {
// `start` is a seek: the node retires whatever this session was streaming
// and spawns ffmpeg again from there. Omitted or zero is the film's
// beginning, which is what an 0.1 node understands.
+ console.log('[stream] sending stream_req start:', start, 'credits:', credits);
this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits, start });
}
/** Room for `n` more segments. */
grantStreamCredit(n = 1) {
if (!this._connected) return;
+ console.log('[stream] grant credit:', n);
this._send({ type: 'stream_more', v: '0.1', n });
}
@@ -1200,6 +1202,7 @@ class MeshBayTransport {
return;
}
if (msg.type === 'stream_init') {
+ console.log('[stream] recv stream_init, start:', msg.start, 'codec:', msg.codec, 'handler:', !!this._onStreamInit);
if (this._onStreamInit) this._onStreamInit(msg);
return;
}
@@ -1208,6 +1211,7 @@ class MeshBayTransport {
return;
}
if (msg.type === 'stream_end') {
+ console.log('[stream] recv stream_end');
if (this._onStreamEnd) this._onStreamEnd(msg);
return;
}