aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/cast-session.js
blob: 9f9391995babfe9345ece669816fd47f74fcb83d (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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
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`
    <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>
  `;
}