diff --git a/frontend/app.js b/frontend/app.js
index b29e6d3..b3b8088 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -5971,215 +5971,14 @@ async function renderStats() {
// like a phone, but only listens: the host's state messages carry the song id,
// position, speed and lyric offset, and this view extrapolates the position
// between updates. Lyrics come from the shared /api/notes store.
-const Presenter = (() => {
- const noop = { boot() {}, active: () => false };
- const view = $('presenterView');
- if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
- const overlay = LowerThird.active();
- const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
- const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
- const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
- const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
- const list = $('pvList');
- const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
+const Presenter = {
+ async boot() {
+ if (!new URLSearchParams(location.search).has('present') && !LowerThird.active()) return;
+ return Lazy.proxy('feature:presenter', ['boot'], 'Presenter').boot();
+ },
+ active: () => !document.getElementById('presenterView')?.classList.contains('hidden'),
+};
- function setStatus(t) { $('pvStatus').textContent = t; }
-
- function show() {
- view.classList.remove('hidden');
- document.body.classList.add('presenter-open');
- // The app underneath may have focused its search box; keys belong here now.
- try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
- try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
- keepAwake();
- bumpIdle();
- }
-
- async function keepAwake() {
- try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
- }
-
- function showPairForm(msg = '') {
- $('pvPair').classList.remove('hidden');
- $('pvMsg').textContent = msg;
- setStatus('Not paired');
- }
-
- async function pair(code) {
- code = String(code || '').replace(/\D/g, '');
- if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
- $('pvMsg').textContent = 'Connecting…';
- try {
- const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: 'đź–Ą Presenter' }) });
- const j = await res.json().catch(() => null);
- if (!j || !j.ok) { showPairForm('âš ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
- save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
- $('pvPair').classList.add('hidden');
- connect();
- } catch {
- showPairForm('⚠Network error — try again');
- }
- }
-
- function connect() {
- const p = load();
- if (!p) { showPairForm(); return; }
- clearTimeout(st.retry);
- try { if (st.ws) st.ws.close(); } catch { /* gone */ }
- setStatus('Connecting…');
- const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: 'đź–Ą Presenter' }));
- st.ws = ws;
- ws.onmessage = (e) => {
- let m; try { m = JSON.parse(e.data); } catch { return; }
- if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
- if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
- if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
- else if (m.type === 'state') onState(m.state);
- else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
- };
- ws.onclose = (e) => {
- if (st.ws !== ws) return;
- st.ws = null;
- if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
- setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
- st.retry = setTimeout(connect, 3000);
- };
- }
-
- function onState(s) {
- st.state = s;
- st.stateAt = Date.now();
- const v = s && s.v;
- setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
- const id = v && v.id;
- if (id !== st.vid) loadSong(id, v);
- }
-
- async function loadSong(id, v) {
- const seq = ++st.seq;
- st.vid = id;
- st.all = [];
- st.docOff = 0;
- render(v);
- if (!id || !isMediaId(id)) return;
- let notes = st.notes.get(id);
- if (!notes) {
- try {
- const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
- notes = j && j.ok ? j : null;
- if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
- } catch { notes = null; }
- }
- if (seq !== st.seq) return;
- const doc = notes && notes.lyrics && notes.lyrics.data;
- st.all = (doc && doc.lines) || [];
- st.docOff = (doc && Number(doc.offset)) || 0;
- render(v);
- }
-
- function render(v) {
- if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
- const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
- const title = $('pvTitle');
- if (!sung.length) {
- list.innerHTML = '';
- title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `${escapeHtml(v.channel)}` : ''}` : '';
- title.classList.toggle('hidden', !v);
- return;
- }
- title.classList.add('hidden');
- const pad = Math.round(list.clientHeight / 2);
- list.style.paddingTop = pad + 'px';
- list.style.paddingBottom = pad + 'px';
- list.innerHTML = sung.map((l) => `
${LyricsDisplay.html(l)}
`).join('');
- refit();
- st.idx = -2;
- tick(true);
- setTimeout(refit, 900); // once more after fonts/layout settle
- }
-
- function refit() {
- fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
- }
-
- function liveTime() {
- const s = st.state;
- if (!s) return 0;
- return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
- }
-
- function tick(force = false) {
- if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
- if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
- const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
- if (idx === st.idx && !force) return;
- st.idx = idx;
- const rows = [...list.querySelectorAll('.sl-line')];
- const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
- rows.forEach((el, k) => {
- const d = pos < 0 ? 99 : Math.abs(k - pos);
- el.classList.toggle('active', d === 0);
- el.classList.toggle('near', d === 1);
- el.classList.toggle('past', pos >= 0 && k < pos);
- });
- const target = pos >= 0 ? rows[pos] : rows[0];
- if (target) {
- const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
- go();
- setTimeout(go, 380);
- }
- }
-
- function toggleFullscreen() {
- if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
- else view.requestFullscreen?.().catch(() => {});
- }
-
- // The status bar and cursor hide after 3 s without mouse movement.
- function bumpIdle() {
- view.classList.remove('idle');
- clearTimeout(st.idleTimer);
- st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
- }
-
- function boot() {
- let param = null;
- try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
- if (param === null) return;
- try {
- const u = new URL(location.href);
- u.searchParams.delete('present');
- history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
- } catch { /* keep */ }
- show();
- const code = String(param).replace(/\D/g, '');
- if (overlay && load()) connect();
- else if (code.length === 6) pair(code);
- else if (load()) connect();
- else showPairForm();
- setInterval(() => tick(), 200);
- window.addEventListener('resize', () => { refit(); tick(true); });
- if (document.fonts) {
- document.fonts.ready.then(refit);
- document.fonts.addEventListener('loadingdone', refit);
- }
- view.addEventListener('mousemove', bumpIdle);
- view.addEventListener('dblclick', toggleFullscreen);
- $('pvFull').addEventListener('click', toggleFullscreen);
- $('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
- $('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
- document.addEventListener('keydown', (e) => {
- if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
- if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
- else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
- }, true);
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
- });
- }
-
- return { boot, active: () => !view.classList.contains('hidden') };
-})();
// Open a video and jump to `t` seconds once it can play (deep links, notes).
async function playVideoAt(v, t = 0) {
@@ -10632,7 +10431,7 @@ async function boot() {
syncOnLaunch();
startPlaylistInboxWatch();
if(LowerThird.active()) await Lazy.load('feature:lower-third');
- Presenter.boot();
+ await Presenter.boot();
if (LowerThird.active()) return;
SectionRail.boot();
Share.bootFromUrl();
diff --git a/frontend/assets.json b/frontend/assets.json
index dd849b4..140186a 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -316,6 +316,14 @@
"eager": false,
"background": true,
"files": []
+ },
+ "feature:presenter": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/presenter.js"
+ ]
}
}
}
diff --git a/frontend/index.html b/frontend/index.html
index 7798602..8eae8af 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/frontend/lazy.js b/frontend/lazy.js
index a10e2bd..7bb4258 100644
--- a/frontend/lazy.js
+++ b/frontend/lazy.js
@@ -111,7 +111,8 @@
proxy(name, methods, globalName) {
return Object.fromEntries(methods.map(method => [method, async (...args) => {
await load(name);
- const target = root[globalName || name.split(':')[1]];
+ const key = globalName || name.split(':')[1];
+ const target = root.YT?.[key] || root[key];
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
return target[method](...args);
}]));
diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js
index 8265580..b084413 100644
--- a/frontend/lazy.test.js
+++ b/frontend/lazy.test.js
@@ -76,3 +76,11 @@ test('new group files with a changed dependency contract cannot run against the
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]};
await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true);
});
+
+test('classic chunk registry proxies use window.YT and keep the executed singleton pinned',async()=>{
+ const {root,inserted}=fixture();root.YT={TestApi:{open:n=>n+2}};
+ const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
+ assert.equal(await proxy.open(3),5);
+ const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
+ assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.length,2);
+});
diff --git a/frontend/presenter.js b/frontend/presenter.js
new file mode 100644
index 0000000..0f7961a
--- /dev/null
+++ b/frontend/presenter.js
@@ -0,0 +1,210 @@
+/* Lazy classic chunk; its executing singleton stays pinned until reload. */
+(window.YT ||= {}).Presenter = (() => {
+ const noop = { boot() {}, active: () => false };
+ const view = $('presenterView');
+ if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
+ const overlay = LowerThird.active();
+ const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
+ const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
+ const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
+ const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
+ const list = $('pvList');
+ const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
+
+ function setStatus(t) { $('pvStatus').textContent = t; }
+
+ function show() {
+ view.classList.remove('hidden');
+ document.body.classList.add('presenter-open');
+ // The app underneath may have focused its search box; keys belong here now.
+ try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
+ try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
+ keepAwake();
+ bumpIdle();
+ }
+
+ async function keepAwake() {
+ try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
+ }
+
+ function showPairForm(msg = '') {
+ $('pvPair').classList.remove('hidden');
+ $('pvMsg').textContent = msg;
+ setStatus('Not paired');
+ }
+
+ async function pair(code) {
+ code = String(code || '').replace(/\D/g, '');
+ if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
+ $('pvMsg').textContent = 'Connecting…';
+ try {
+ const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: 'đź–Ą Presenter' }) });
+ const j = await res.json().catch(() => null);
+ if (!j || !j.ok) { showPairForm('âš ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
+ save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
+ $('pvPair').classList.add('hidden');
+ connect();
+ } catch {
+ showPairForm('⚠Network error — try again');
+ }
+ }
+
+ function connect() {
+ const p = load();
+ if (!p) { showPairForm(); return; }
+ clearTimeout(st.retry);
+ try { if (st.ws) st.ws.close(); } catch { /* gone */ }
+ setStatus('Connecting…');
+ const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: 'đź–Ą Presenter' }));
+ st.ws = ws;
+ ws.onmessage = (e) => {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
+ if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
+ if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
+ else if (m.type === 'state') onState(m.state);
+ else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
+ };
+ ws.onclose = (e) => {
+ if (st.ws !== ws) return;
+ st.ws = null;
+ if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
+ setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
+ st.retry = setTimeout(connect, 3000);
+ };
+ }
+
+ function onState(s) {
+ st.state = s;
+ st.stateAt = Date.now();
+ const v = s && s.v;
+ setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
+ const id = v && v.id;
+ if (id !== st.vid) loadSong(id, v);
+ }
+
+ async function loadSong(id, v) {
+ const seq = ++st.seq;
+ st.vid = id;
+ st.all = [];
+ st.docOff = 0;
+ render(v);
+ if (!id || !isMediaId(id)) return;
+ let notes = st.notes.get(id);
+ if (!notes) {
+ try {
+ const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
+ notes = j && j.ok ? j : null;
+ if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
+ } catch { notes = null; }
+ }
+ if (seq !== st.seq) return;
+ const doc = notes && notes.lyrics && notes.lyrics.data;
+ st.all = (doc && doc.lines) || [];
+ st.docOff = (doc && Number(doc.offset)) || 0;
+ render(v);
+ }
+
+ function render(v) {
+ if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
+ const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
+ const title = $('pvTitle');
+ if (!sung.length) {
+ list.innerHTML = '';
+ title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `${escapeHtml(v.channel)}` : ''}` : '';
+ title.classList.toggle('hidden', !v);
+ return;
+ }
+ title.classList.add('hidden');
+ const pad = Math.round(list.clientHeight / 2);
+ list.style.paddingTop = pad + 'px';
+ list.style.paddingBottom = pad + 'px';
+ list.innerHTML = sung.map((l) => `${LyricsDisplay.html(l)}
`).join('');
+ refit();
+ st.idx = -2;
+ tick(true);
+ setTimeout(refit, 900); // once more after fonts/layout settle
+ }
+
+ function refit() {
+ fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
+ }
+
+ function liveTime() {
+ const s = st.state;
+ if (!s) return 0;
+ return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
+ }
+
+ function tick(force = false) {
+ if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
+ if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
+ const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
+ if (idx === st.idx && !force) return;
+ st.idx = idx;
+ const rows = [...list.querySelectorAll('.sl-line')];
+ const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
+ rows.forEach((el, k) => {
+ const d = pos < 0 ? 99 : Math.abs(k - pos);
+ el.classList.toggle('active', d === 0);
+ el.classList.toggle('near', d === 1);
+ el.classList.toggle('past', pos >= 0 && k < pos);
+ });
+ const target = pos >= 0 ? rows[pos] : rows[0];
+ if (target) {
+ const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
+ go();
+ setTimeout(go, 380);
+ }
+ }
+
+ function toggleFullscreen() {
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
+ else view.requestFullscreen?.().catch(() => {});
+ }
+
+ // The status bar and cursor hide after 3 s without mouse movement.
+ function bumpIdle() {
+ view.classList.remove('idle');
+ clearTimeout(st.idleTimer);
+ st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
+ }
+
+ function boot() {
+ let param = null;
+ try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
+ if (param === null) return;
+ try {
+ const u = new URL(location.href);
+ u.searchParams.delete('present');
+ history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
+ } catch { /* keep */ }
+ show();
+ const code = String(param).replace(/\D/g, '');
+ if (overlay && load()) connect();
+ else if (code.length === 6) pair(code);
+ else if (load()) connect();
+ else showPairForm();
+ setInterval(() => tick(), 200);
+ window.addEventListener('resize', () => { refit(); tick(true); });
+ if (document.fonts) {
+ document.fonts.ready.then(refit);
+ document.fonts.addEventListener('loadingdone', refit);
+ }
+ view.addEventListener('mousemove', bumpIdle);
+ view.addEventListener('dblclick', toggleFullscreen);
+ $('pvFull').addEventListener('click', toggleFullscreen);
+ $('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
+ $('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
+ document.addEventListener('keydown', (e) => {
+ if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
+ if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
+ else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
+ }, true);
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
+ });
+ }
+
+ return { boot, active: () => !view.classList.contains('hidden') };
+})();
diff --git a/frontend/sw.js b/frontend/sw.js
index ed90ab1..4bfd5b0 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -140,6 +140,7 @@ const SHELL = [
'/shell-core.js',
'/views-core.js',
'/section-rail.js',
+ '/presenter.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',