diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/cast-session.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/cast-session.js | 191 |
1 files changed, 191 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js new file mode 100644 index 0000000..007722c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js @@ -0,0 +1,191 @@ +import { html, useState, useEffect, useRef } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import * as platform from './platform.js'; + +// ── The television this session casts to ───────────────────────────────────── +// +// docs/MESHBAY_DESIGN.md §11.4. Chosen once, from any cast button — Videos' +// toolbar, a photo album's bar, the lightbox, the player — and from then on a +// film opened plays there with the player as its remote, and a photo opened +// in the lightbox is shown there. In memory only: a television chosen +// yesterday may be off today, and choosing it again is one tap. +// +// The receiver is the default media receiver, which shows a film or a +// picture and nothing else; the interface itself stays on this device. + +let current = null; +const listeners = new Set(); + +function notify() { + for (const fn of listeners) fn(current); +} + +export const castSession = { + get device() { return current; }, + get supported() { return Boolean(platform.capabilities.lanCast && platform.cast.available); }, + choose(device) { + current = device ? { id: device.id, name: device.name } : null; + notify(); + }, + /** Forget the television without touching it: whoever stopped it already has. */ + forget() { this.choose(null); }, + /** Stop casting: the television goes back to its idle screen. */ + async end() { + this.choose(null); + photoGen++; + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop().catch(() => {}); + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, +}; + +export function useCastDevice() { + const [device, setDevice] = useState(castSession.device); + useEffect(() => castSession.subscribe(setDevice), []); + return device; +} + +// ── A photo on the television ──────────────────────────────────────────────── +// +// Scaled to fit 1920×1080 and re-encoded as JPEG before it leaves: a camera's +// original is 20 MB and 6000 px wide, more than a receiver decodes in good +// time, and some are formats it does not decode at all. EXIF orientation is +// applied here, so a portrait photo arrives upright and the receiver centres +// it. Photos asked for while one is still on its way are skipped to the +// newest: paging quickly through an album shows where the reader stopped, +// not every photo passed on the way. + +const TV_WIDTH = 1920; +const TV_HEIGHT = 1080; +const JPEG_QUALITY = 0.9; + +async function scaledForTv(blob) { + const bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' }); + try { + const scale = Math.min(1, TV_WIDTH / bitmap.width, TV_HEIGHT / bitmap.height); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const jpeg = await new Promise((resolve, reject) => canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('JPEG encoding failed'))), + 'image/jpeg', JPEG_QUALITY)); + return new Uint8Array(await jpeg.arrayBuffer()); + } finally { + bitmap.close(); + } +} + +let photoGen = 0; +let photoChain = Promise.resolve(); + +/** Show `blob` on the session's television, if there is one. */ +export function showPhoto(blob) { + const gen = ++photoGen; + photoChain = photoChain.then(async () => { + const device = current; + if (gen !== photoGen || !device || !platform.cast.canShowImages) return; + const bytes = await scaledForTv(blob); + if (gen !== photoGen) return; + const shown = await platform.cast.image({ bytes, type: 'image/jpeg' }); + if (!shown || gen !== photoGen) return; + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: shown.url }); + } else { + await platform.cast.chromecastConnect({ deviceId: device.id, mediaUrl: shown.url }); + } + }).catch((err) => console.warn('[cast] photo not shown:', err)); + return photoChain; +} + +// ── The cast button ────────────────────────────────────────────────────────── +// +// One button, wherever it is put: with no television chosen it lists the +// receivers as they answer; with one, it says which and offers to stop. +// `overlay` is for the dark full-screen views (lightbox), `page` for a +// toolbar. `cls` and `iconCls` are the button's and its icon's own classes, +// so it matches its neighbours. +// `onChosen` runs after a television is picked, for the view that has +// something to show on it straight away. + +export function CastButton({ variant = 'page', cls = 'tb-btn tb-btn-icon', iconCls = '', onChosen }) { + const device = useCastDevice(); + const [open, setOpen] = useState(false); + const [devices, setDevices] = useState([]); + const [scanning, setScanning] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open || device) return undefined; + setDevices([]); + setScanning(true); + return platform.cast.discover((list, still) => { + setDevices(list || []); + setScanning(still); + }); + }, [open, device]); + + // Closed by a press anywhere else, as any menu is. + useEffect(() => { + if (!open) return undefined; + const outside = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('pointerdown', outside); + return () => document.removeEventListener('pointerdown', outside); + }, [open]); + + if (!castSession.supported) return null; + + const overlay = variant === 'overlay'; + const menuCls = overlay ? 'cast-picker' : 'ctx-menu cast-menu'; + const itemCls = overlay ? 'cast-picker-item' : 'ctx-menu-item'; + const title = device ? `${t('cast.casting_to')} ${device.name}` : t('cast.start'); + + return html` + <div class="cast-wrapper" ref=${wrapRef}> + <button class="${cls} cast-btn ${device ? 'casting' : ''}" + onClick=${() => setOpen(!open)} title=${title} aria-label=${title} + aria-expanded=${open ? 'true' : 'false'}> + <${Icon} name="cast" cls=${iconCls} /></button> + ${open && html` + <div class=${menuCls} role="menu"> + ${device && html` + <div class="${itemCls} cast-menu-current"> + <${Icon} name="cast" /> ${t('cast.casting_to')} ${device.name} + </div> + <button class=${itemCls} role="menuitem" onClick=${() => { + setOpen(false); + castSession.end(); + }}> + <${Icon} name="close" /> ${t('cast.stop')} + </button> + `} + ${!device && devices.map((d) => html` + <button class=${itemCls} role="menuitem" key=${d.id} onClick=${() => { + setOpen(false); + castSession.choose(d); + if (onChosen) onChosen(d); + }}> + <${Icon} name="cast" /> ${d.name} + </button> + `)} + ${!device && scanning && html` + <div class="${itemCls} cast-picker-scanning"> + <span class="spinner" style="width:14px;height:14px"></span> + ${t('cast.scanning')} + </div> + `} + ${!device && !scanning && devices.length === 0 && html` + <div class="${itemCls} cast-picker-empty">${t('cast.no_devices')}</div> + `} + </div> + `} + </div> + `; +} |