jevstrudel.git / website / src / jev / client.mjs
1import { siteName } from './site.mjs';
2import { codeHash, endReplay, fetchPerformance, PERFORMANCE_PARAM } from './performance.mjs';
3import { LISTENER_PARAM, listenerParam } from './listeners.mjs';
4import { armSandboxReplay, playInSandbox, setOwnCode, showForeign } from './sandbox.mjs';

The browser side of the song index: index.astro embeds loadSongs() as JSON in #jev-songs, and the REPL reads it back from the page.

The song the home page opens on: selected on the jev panel, loaded into the editor, and played once audio is allowed (see useReplContext).

11export const featuredSongId = 'jev/lightning-in-a-bottle';
13let songsCache;
14export function readSongs() {
15  if (songsCache === undefined) {
16    const el = typeof document !== 'undefined' && document.getElementById('jev-songs');
17    songsCache = el ? JSON.parse(el.textContent) : [];
18  }
19  return songsCache;
20}

The song this page opens on: a song's own page (/songs/<theme>/<song>/) names it in a jev-song meta tag (ReplPage.astro); the home page opens on the featured song.

25export function songPageId() {
26  const meta = typeof document !== 'undefined' && document.querySelector('meta[name="jev-song"]');
27  return meta?.content || null;
28}
29export function startSongId() {
30  return songPageId() ?? featuredSongId;
31}

A listener's song link (/?listener=<id>) opens on that song instead, fetched from the Worker when the editor starts (useReplContext).

35export function startSong() {
36  if (!songPageId() && listenerParam()) return undefined;
37  const id = startSongId();
38  return readSongs().find((s) => s.id === id && s.code);
39}

The song in the editor, as the song browser, the radio and "is it art?" see it: set when a song is loaded, and read against the editor's code, so an edited song still counts as that song.

44let playingId = null;
45const playingListeners = new Set();
46export const nowPlaying = {
47  subscribe(listener) {
48    playingListeners.add(listener);
49    return () => playingListeners.delete(listener);
50  },
51  get: () => playingId,
52  set(id) {
53    playingId = id;
54    for (const listener of playingListeners) listener();
55  },
56};

Songs are arranged with masks that count cycles from 0, so they must start from stopped: the scheduler's stop() resets the cycle count, pause() does not. A song picked here plays live: a performance being replayed ends (performance.mjs). Only the site's own songs: a listener's has no code (listeners.mjs) and plays in the sandbox (sandbox.mjs, playListenerSong).

63export async function playSong(editor, song) {
64  if (!editor || !song.code) return;
65  endReplay();
66  showSong(song);
67  nowPlaying.set(song.id);
68  await editor.stop();
69  setOwnCode(editor, song.code);
70  await editor.evaluate();
71}

A listener's song (listeners.mjs): its code into the editor as foreign, so it is evaluated only in the sandbox (sandbox.mjs), and, with play, played there. With replay, a recorded performance's id: the sandbox's jev()s play that take and ask Jev nothing, still in the sandbox (the frame arms it; sandbox.mjs's armSandboxReplay). Resolves to { replaying: { changed } } when it replays, or { error } when that take could not be loaded (Jev then plays the song live), else {}.

80export async function loadListenerSong(editor, song, { play = false, replay = null } = {}) {
81  if (!editor || !song.foreignCode) return {};
82  endReplay();
83  let replaying = null;
84  let error;
85  if (replay) {
86    try {
87      const performance = await fetchPerformance(replay);
88      if (performance.song !== song.id) throw new Error(`that take is of ${performance.song}`);
89      replaying = { jevs: performance.jevs, changed: performance.codeHash !== (await codeHash(song.foreignCode)) };
90    } catch (e) {
91      error = e.message;
92    }
93  }
94  showSong(song);
95  nowPlaying.set(song.id);
96  await editor.stop();
97  // `code`: the song as written, so the recorder knows an edited one (JevPerformance.jsx)
98  showForeign(editor, song.foreignCode, { song: { title: song.title, author: song.author, id: song.id, code: song.foreignCode } });
99  armSandboxReplay(song.id, replaying);
100  if (play) await playInSandbox(song.foreignCode);
101  return replaying ? { replaying: { changed: replaying.changed } } : error ? { error } : {};
102}

A song's shareable address. Picking a song puts it in the address bar (keeping the query, e.g. ?session=, but not a replayed ?performance=, which that song does not play) and the tab title, so the link to copy is always the song that is playing. A listener's song has no page of its own (the site is built before it exists): its address is the home page with ?listener=<id>.

110export const songPath = (song) => `/songs/${song.id}/`;
111export function showSong(song) {
112  document.title = `${song.title} - ${siteName}`;
113  try {
114    const query = new URLSearchParams(location.search);
115    query.delete(PERFORMANCE_PARAM);
116    query.delete(LISTENER_PARAM);
117    if (song.listenerId) query.set(LISTENER_PARAM, song.listenerId);
118    const search = query.size ? `?${query}` : '';
119    history.replaceState(history.state, '', (song.listenerId ? '/' : songPath(song)) + search);
120  } catch {
121    // an embedded or sandboxed page may refuse; the song still plays
122  }
123}

Whether the song the page opens on plays by itself. Never on a reload: the PWA's "update" reloads the page, and a song the visitor had stopped must not start again on their next click. A song's own page is a link to that song, so it plays whenever it is opened; the home page's featured song plays once per tab (and the tools mark it played to keep it quiet).

130const AUTOPLAYED = 'jevstrudel.autoplayed';
131const reloaded = () => performance.getEntriesByType?.('navigation')[0]?.type === 'reload';
132export function takeAutoplay() {
133  if (reloaded()) return false;
134  if (songPageId()) return true;
135  try {
136    if (sessionStorage.getItem(AUTOPLAYED)) return false;
137    sessionStorage.setItem(AUTOPLAYED, '1');
138  } catch {
139    // no storage: play, as a first visit would
140  }
141  return true;
142}