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 one film, one // picture or one track at a time and nothing else; the interface itself stays // on this device. `owner` says which of the three the television is showing // now ('video', 'photo', 'music'), so that the music bar stops treating the // receiver as its own once a film or a photo has taken it. let current = null; let owner = null; const listeners = new Set(); function notify() { for (const fn of listeners) fn(current); } export const castSession = { get device() { return current; }, get owner() { return owner; }, get supported() { return Boolean(platform.capabilities.lanCast && platform.cast.available); }, /** Whether a whole file (a photo, a track) can be sent: an older shell cannot. */ get canSendFiles() { return this.supported && platform.cast.canSendFiles; }, /** The television now shows what `who` sends it. */ claim(who) { owner = who; notify(); }, 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() { owner = null; 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 file on the television ───────────────────────────────────────────────── // // A photo or a music track, whole, handed to the relay and loaded on the // session's television — connected if it is not yet, reloaded if it is. The // relay, not the page, tells the receiver what the file is; the page only // names it (title, artist, album, cover) and says where in it to begin. export async function loadFile({ owner: who, startAt = 0, ...file }) { const device = current; if (!device || !castSession.canSendFiles) throw new Error('No television to cast to'); castSession.claim(who); const sent = await platform.cast.file(file); if (!sent) throw new Error('The cast relay did not take the file'); const status = await platform.cast.status(); if (status && status.chromecast && status.chromecast.connected) { await platform.cast.chromecastReload({ mediaUrl: sent.url, startAt }); } else { await platform.cast.chromecastConnect({ deviceId: device.id, mediaUrl: sent.url, startAt }); } } // ── 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 () => { if (gen !== photoGen || !current || !castSession.canSendFiles) return; const bytes = await scaledForTv(blob); if (gen !== photoGen) return; await loadFile({ owner: 'photo', bytes, type: 'image/jpeg' }); }).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. `up` opens the menu above the // button, for one at the bottom of the window. export function CastButton({ variant = 'page', cls = 'tb-btn tb-btn-icon', iconCls = '', onChosen, up = false, }) { 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'}${up ? ' cast-menu-up' : ''}`; const itemCls = overlay ? 'cast-picker-item' : 'ctx-menu-item'; const title = device ? `${t('cast.casting_to')} ${device.name}` : t('cast.start'); return html`
${open && html` `}
`; }