aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js
blob: 007722ca327659aec7159cf0321691099642427a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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>
  `;
}