diff --git a/CLAUDE.md b/CLAUDE.md index 39cda84..877c7eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,9 @@ screen.js | `splitscreenAlwaysSplit` | `'true'`/`'false'` | | `splitscreenPanelPrefs` | JSON array of per-panel pref objects (see below) | | `splitscreenControlsHidden` | `'true'`/`'false'` — whether bottom controls bar was hidden | +| `splitscreenRoomKey` | Persistent 6-char LAN room key (splitscreen#21) — survives sessions so viewer bookmarks stay valid; rotated only via settings Regenerate | +| `splitscreenLanShareActive` | `'true'` while a LAN share is live — read on load to auto-resume the share after a crash/reload | +| `splitscreenLanShareCfg` | JSON of the shared panel's follower config (detect fields stripped) — what `config` replies carry | ### Panel pref object shape (in `splitscreenPanelPrefs`) @@ -262,6 +265,18 @@ A panel can be detached into its own browser window (`⇱ Pop`) for multi-monito **Caveats.** localStorage is shared between the windows; per-panel viz keys (e.g. `h3d_bg_panel_*`) written by both are last-write-wins (acceptable). The `ss-follower` chrome-hiding CSS keys on hardcoded element ids — brittle if core renames them (deliberate tradeoff vs. chasing every id). Re-popping the same panel isn't possible (it's removed from the layout on the first pop); multiple *different* panels can be popped at once (tracked in the `popups` Map by `popupId`). +## LAN share / remote followers (splitscreen#21) + +A second follower transport: the server's `/ws/sync/{session_id}` relay (feedBack#1030, core ≥ the #1032 merge) carries the SAME message protocol as the BroadcastChannel to browsers on **other machines**. `_followerBusHandler(msg)` is the shared dispatch for both transports; popups wire it to `ch.onmessage` in `buildFollowerLayout`, remote viewers wire it to the relay socket. + +**Main side.** `📡 LAN` (per-panel, next to `⇱ Pop`; `panel.lanBtn`, main-window only) starts a share: `startLanShare(panel)` captures the panel's follower config **with detect fields stripped** into `_lanShare.cfg`, connects `_lanConnect()` to `/ws/sync/`, and opens the share dialog (`_showLanShareModal` — key big, join URLs from the desktop preload's `network.getLanAccess().urls` when available else `location.origin`, Copy, Stop). `_lanSend(msg)` mirrors every broadcast-channel post over the relay — `time` frames throttled to ~20 Hz (`LAN_TIME_MIN_INTERVAL_MS`); the local BC leg stays ≤60 Hz. The broadcaster now runs when `popups.size > 0 || _lanShare`. Relay-specific messages: viewers send `{type:'hello', popupId}`; the host answers each with `{type:'config', popupId, filename, cfg, t, playing}` (only once `currentFilename` exists — viewers hello-poll until then); `{type:'share-ended'}` is sent by `stopLanShare()` and is the ONLY terminal signal for viewers. Sharing is persistent: `_maybeResumeLanShare()` re-arms an active share at plugin load (crash/reload recovery), and the playSong wrapper re-arms the broadcaster. + +**Viewer side.** `?ss=` (and no `ssFollower`) parses into `REMOTE_JOIN` (via `normalizeRoomKey` — 6 chars, `ROOM_KEY_ALPHABET`, case-insensitive). `bootRemoteJoin()` shows the waiting overlay and connects; on the first `config` it builds `FOLLOWER = makeRemoteFollowerCfg(msg, popupId)` (**`FOLLOWER` is `let` now**; `remote: true`; detect stripped) and runs the normal `bootFollowerMode()` — chart data still streams per-panel over `/ws/highway` directly, only clock/session messages ride the relay. Reconnect: infinite backoff (1s→10s cap), re-`hello` on every open; `main-closed` puts a REMOTE viewer into the recoverable waiting overlay (host reload/relaunch auto-resumes), NOT the orphan overlay — only `share-ended` orphans it. Remote viewers hide the Dock button, never post `docked`/`closed`, and never bind input devices. + +**Room key.** `ensureRoomKey()` — persistent per-install (`splitscreenRoomKey`), settings section displays it with Regenerate (rotating stops any live share). Alphabet `ABCDEFGHJKMNPQRSTVWXYZ23456789` (no 0/O/1/I/L/U) makes keys read-aloud-safe; `buildShareUrl(origin, key)` → `/?ss=`. + +**Test-env note.** The end-of-IIFE boot gates remote-join/share-resume behind `_nodeTestEnv` (modern node has a global `WebSocket`, so a capability check alone would open real sockets under `node tests/screen.test.js`). + ## `playSong` wrapper and the `_onReady` race The plugin wraps `window.playSong` to: diff --git a/README.md b/README.md index 7a00fb8..5a0a8b8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,22 @@ Every popup has a small toolbar pinned to its bottom edge with a **Layout** pick > Pop-out uses standard `window.open` and `BroadcastChannel`. The popup must be triggered by your click (a user gesture) so popup blockers should leave it alone — but if your browser does block it (or doesn't support `BroadcastChannel`), the panel stays put and you get a brief notice instead. If you close the main window while popups are open, each popup detects it and shows a "main window closed" notice so you know to close it (best-effort — if the browser doesn't deliver that last message the popup just freezes, same as before). +### Sharing a panel to other devices on your network (📡 LAN) + +Where **⇱ Pop** opens a follower window on *this* machine, **📡 LAN** (next to it, top-right of every panel) lets a browser on **another machine** — a laptop, a tablet, a TV browser — watch that panel live, synced to this machine's playback. The room hears one audio source (yours); each viewer gets their own screen-sized highway. + +Click **📡 LAN** and you get a dialog with a six-character **room key** (e.g. `K7TR4M`) and a join URL like `http://192.168.1.20:8000/?ss=K7TR4M`. Read the key across the room or have viewers type the URL once and **bookmark it — the key is permanent** for your install (see below), so next session they just tap the bookmark. Viewers are *mirrors*, not moves: the panel stays in your layout, and the viewer window can re-split itself (Single/Top-Bottom/Left-Right/Quad) without affecting you. Note-detection is never forwarded to viewers, and viewers have no Dock button. + +The **room key** is generated once and saved (Settings → Split Screen shows it, with a **Regenerate** button that rotates it — invalidating old bookmarks). Sharing survives crashes and restarts: if the app (or just the tab) goes down mid-song, viewers show "Reconnecting…" and recover automatically when you're back — the share re-arms itself on launch. Ending it is explicit: **Stop sharing** in the LAN dialog, which tells every viewer the session is over. + +Requirements & caveats: + +- The server must be reachable on your LAN. On the desktop app, enable **LAN access** (Plugin Manager → Network); Docker/standalone servers just need the port reachable. Your OS firewall may prompt once. +- Needs a server with the `/ws/sync` relay endpoint (feedBack ≥ the #1030 relay). On an older server, LAN sharing simply won't connect; everything else is unaffected. +- Viewer devices need WebGL2 for the 3D highway (any recent tablet/laptop is fine; the renderer auto-scales on weak GPUs). +- Browsers only keep screens awake on secure pages, so a tablet viewing over plain `http://` may sleep mid-song — tap it awake, or raise the device's screen-timeout for the session. +- If your machine's LAN IP changes (DHCP), bookmarked URLs go stale — give it a DHCP reservation in your router if that bites. + ## Settings Open **Settings → Split Screen** to pick the default layout (Top/Bottom, Left/Right, Tri 1+2, Tri 2+1, or Quad). The choice is stored in `localStorage` as `splitscreenLayout` and applies the next time you toggle split screen on. diff --git a/plugin.json b/plugin.json index e08fe55..9c2bcfa 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "splitscreen", "name": "Split Screen", - "version": "1.10.8", + "version": "1.11.0", "private": false, "settings": { "html": "settings.html", @@ -10,6 +10,6 @@ "script": "screen.js", "tour": "tour.json", "category": "creation", - "description": "Practice multiple arrangements at once — split into 2–4 synced panels, each with its own view, or pop one out to another monitor.", + "description": "Practice multiple arrangements at once — split into 2–4 synced panels, each with its own view, pop one out to another monitor, or share one to other devices on your network.", "icon": "assets/thumb.svg" } diff --git a/screen.js b/screen.js index b8fc08f..a71067f 100644 --- a/screen.js +++ b/screen.js @@ -184,11 +184,13 @@ try { // and main agree on the song). window.slopsmith.on('song:ready', () => { if (FOLLOWER) return; - if (typeof ssChannel === 'undefined' || !ssChannel) return; - if (!popups || popups.size === 0) return; if (!currentFilename) return; - try { ssChannel.postMessage({ type: 'song-changed', filename: currentFilename }); } - catch (e) { console.warn('[splitscreen] song-changed broadcast failed:', e); } + const msg = { type: 'song-changed', filename: currentFilename }; + if (typeof ssChannel !== 'undefined' && ssChannel && popups && popups.size) { + try { ssChannel.postMessage(msg); } + catch (e) { console.warn('[splitscreen] song-changed broadcast failed:', e); } + } + _lanSend(msg); // no-op unless a LAN share is active }); } @@ -341,6 +343,90 @@ try { // interaction. const _vizPluginsReady = fetchVizPlugins(); + // ── LAN share helpers (splitscreen#21) ── + // Declared ABOVE the FOLLOWER/REMOTE_JOIN parse and the settings-sync + // block, both of which call into these during IIFE evaluation (the + // ROOM_KEY_* consts would otherwise be in their temporal dead zone). + // WS URL for the core session-sync relay endpoint (feedBack#1030). + function getSyncUrl(key) { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}/ws/sync/${key}`; + } + + // Room-key alphabet: unambiguity-filtered (no 0/O, 1/I/L, U) so a key can + // be read aloud across the room and typed on a TV remote without lookalike + // errors. 6 chars ≈ 30 bits — plenty for a trusted LAN: a wrong key just + // lands in an empty relay room, and the relay rate-caps scanning. + const ROOM_KEY_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; + const ROOM_KEY_LENGTH = 6; + function generateRoomKey() { + let out = ''; + try { + const buf = new Uint32Array(ROOM_KEY_LENGTH); + crypto.getRandomValues(buf); + for (let i = 0; i < ROOM_KEY_LENGTH; i++) out += ROOM_KEY_ALPHABET[buf[i] % ROOM_KEY_ALPHABET.length]; + } catch (_) { + for (let i = 0; i < ROOM_KEY_LENGTH; i++) out += ROOM_KEY_ALPHABET[Math.floor(Math.random() * ROOM_KEY_ALPHABET.length)]; + } + return out; + } + + // Case-insensitive entry: trim + uppercase, then validate against the + // alphabet. The filtered alphabet is what makes lookalike tolerance work — + // a generated key can never contain 0/O/1/I/L/U, so there is nothing to + // mis-map. Returns the canonical uppercase key, or null. + function normalizeRoomKey(raw) { + if (typeof raw !== 'string') return null; + const key = raw.trim().toUpperCase(); + if (key.length !== ROOM_KEY_LENGTH) return null; + for (const c of key) { + if (ROOM_KEY_ALPHABET.indexOf(c) === -1) return null; + } + return key; + } + + // The persistent per-install room key (splitscreen#21: saved and reused so + // viewer bookmarks keep working across sessions; rotate via the settings + // page's Regenerate button). + function ensureRoomKey() { + let key = null; + try { key = normalizeRoomKey(localStorage.getItem('splitscreenRoomKey')); } catch (_) {} + if (!key) { + key = generateRoomKey(); + try { localStorage.setItem('splitscreenRoomKey', key); } catch (_) {} + } + return key; + } + + function buildShareUrl(origin, key) { + return String(origin).replace(/\/+$/, '') + '/?ss=' + key; + } + + // Build the FOLLOWER config for a remote viewer from a relay `config` + // message. Mirrors the URL-param parse shape, with two deliberate + // differences: `remote: true` (gates dock/close semantics) and the + // note-detect fields stripped — viewers are passive mirrors and must + // never inherit the host's mic/device bindings. + function makeRemoteFollowerCfg(msg, popupId) { + const cfg = (msg && msg.cfg) || {}; + return { + remote: true, + popupId: popupId || '', + filename: msg.filename, + arrangement: parseInt(cfg.arrangement, 10) || 0, + name: cfg.name || '', + mode: cfg.mode || '2d', + inverted: cfg.inverted === 1 || cfg.inverted === true, + lefty: cfg.lefty === 1 || cfg.lefty === true, + mastery: Number.isFinite(cfg.mastery) ? cfg.mastery : NaN, + lyrics: !!cfg.lyrics, + barHidden: !!cfg.barHidden, + detectChannel: 'mono', + detectDeviceName: '', + detectVerifierOffsetMs: 0, + }; + } + // ══════════════════════════════════════════════════════════════════════ // Pop-out / follower-mode (multi-monitor support). // @@ -362,7 +448,10 @@ try { // only. Carries the panel config received from the opener. // ══════════════════════════════════════════════════════════════════════ const popups = new Map(); - const FOLLOWER = (function () { + // `let`, not `const`: a REMOTE (LAN) viewer boots with only `?ss=` + // in the URL and receives its panel config over the sync relay — FOLLOWER + // is assigned there (bootRemoteJoin), always before bootFollowerMode runs. + let FOLLOWER = (function () { try { const params = new URLSearchParams(window.location.search); if (params.get('ssFollower') !== '1') return null; @@ -389,6 +478,19 @@ try { return null; } })(); + // Remote (LAN) viewer join key — `?ss=` (splitscreen#21). The + // URL carries ONLY the key so it stays hand-typeable; the panel config + // arrives over the server's /ws/sync relay (feedBack#1030) via the + // hello/config handshake in bootRemoteJoin. Null when absent/invalid. + const REMOTE_JOIN = (function () { + try { + if (FOLLOWER) return null; // explicit follower params win + const params = new URLSearchParams(window.location.search); + return normalizeRoomKey(params.get('ss')); + } catch (_) { + return null; + } + })(); const SS_CHANNEL_NAME = 'slopsmith-ss'; let ssChannel = null; // shared BroadcastChannel (lazily opened) function _ssChannel() { @@ -543,6 +645,27 @@ try { }); } + // LAN room key (splitscreen#21) — display + Regenerate. The key is + // persistent by design (viewer bookmarks stay valid across sessions); + // regenerating rotates it and stops any live share, since its viewers + // would otherwise keep waiting on a room the host will never rejoin. + const roomKeyEl = document.getElementById('splitscreen-room-key'); + if (roomKeyEl) { + roomKeyEl.textContent = ensureRoomKey(); + const regenBtn = document.getElementById('splitscreen-room-key-regen'); + if (regenBtn) { + regenBtn.addEventListener('click', () => { + const next = generateRoomKey(); + try { localStorage.setItem('splitscreenRoomKey', next); } catch (_) {} + roomKeyEl.textContent = next; + if (_lanShare) { + stopLanShare(); + _showMainToast('Room key regenerated — LAN sharing stopped. Share again to use the new key.'); + } + }); + } + } + // ── Panel preference persistence ── // Snapshot a live panel into the splitscreenPanelPrefs entry shape. Mode is // encoded into arrName (LYRICS_VALUE / JUMPING_TAB_VALUE: / @@ -1098,7 +1221,11 @@ try { 'white-space:nowrap;outline:none;'; popOutBtn.addEventListener('mouseenter', () => { popOutBtn.style.background = 'rgba(8,8,16,0.95)'; popOutBtn.style.borderColor = '#4080e0'; }); popOutBtn.addEventListener('mouseleave', () => { popOutBtn.style.background = 'rgba(8,8,16,0.5)'; popOutBtn.style.borderColor = 'transparent'; }); - if (FOLLOWER) { + if (FOLLOWER && FOLLOWER.remote) { + // A LAN viewer is a passive mirror — there is no panel slot in the + // host window to dock back into. + popOutBtn.style.display = 'none'; + } else if (FOLLOWER) { popOutBtn.textContent = '⇲ Dock'; popOutBtn.title = 'Return this panel to the main window'; } else { @@ -1107,6 +1234,20 @@ try { } nameWrap.insertBefore(popOutBtn, nameInput); + // Share to LAN — main window only. Starts (or re-targets) the LAN + // share with THIS panel's config and opens the share dialog (room + // key + URL). Wired in initPanel() like popOutBtn. + let lanBtn = null; + if (!FOLLOWER) { + lanBtn = document.createElement('button'); + lanBtn.textContent = '📡 LAN'; + lanBtn.title = 'Share this panel to other devices on your network'; + lanBtn.style.cssText = popOutBtn.style.cssText; + lanBtn.addEventListener('mouseenter', () => { lanBtn.style.background = 'rgba(8,8,16,0.95)'; lanBtn.style.borderColor = '#4080e0'; }); + lanBtn.addEventListener('mouseleave', () => { lanBtn.style.background = 'rgba(8,8,16,0.5)'; lanBtn.style.borderColor = 'transparent'; }); + nameWrap.insertBefore(lanBtn, nameInput); + } + panelDiv.appendChild(bar); // Per-panel viz settings popover (filled lazily by buildVizPopover). @@ -1171,7 +1312,7 @@ try { channelBtn, deviceSelect, latWrap, latVal, latDown, latUp, vizSettingsBtn, vizPopover, masteryHeading, masterySlider, masteryLabel, - popOutBtn, + popOutBtn, lanBtn, }; } @@ -1755,6 +1896,21 @@ try { else popOutPanel(panel); }; + // Share-to-LAN button handler (main window only). If a share is + // already running, re-target it to this panel's current config so + // fresh viewers boot with what the user just pointed at. + if (panel.lanBtn) { + panel.lanBtn.onclick = () => { + if (!_lanShare) { + if (!startLanShare(panel)) return; + } else { + _lanShare.cfg = _lanCaptureCfg(panel); + try { localStorage.setItem('splitscreenLanShareCfg', JSON.stringify(_lanShare.cfg)); } catch (_) {} + } + _showLanShareModal(); + }; + } + // Populate arrangement dropdown (includes Lyrics, JT, and viz plugin options). // Use panel.arrIndex (already resolved from prefs above) so the dropdown // reflects the saved arrangement even when a special-mode restore is @@ -2545,6 +2701,7 @@ try { // could race ahead of a deferred _redockPanel and drop the popups entry. function dockFollowerPanel(panel) { if (!FOLLOWER) return; + if (FOLLOWER.remote) return; // LAN viewers have nothing to dock into _followerDocking = true; try { const ch = _ssChannel(); @@ -2880,7 +3037,9 @@ try { if (_popupBroadcastInterval) return; const audio = document.getElementById('audio'); const ch = _ssChannel(); - if (!audio || !ch) return; + // LAN share runs the broadcaster too — even in a browser without + // BroadcastChannel (the relay leg doesn't need it). + if (!audio || (!ch && !_lanShare)) return; _popupBroadcastInterval = setInterval(() => { // Reap popups that vanished without firing beforeunload (crash / // forced close / OS kill): otherwise their slot lingers and we'd @@ -2889,7 +3048,7 @@ try { for (const [id, e] of popups) { if (e.popup && e.popup.closed) popups.delete(id); } - if (popups.size === 0) { _stopPopupBroadcaster(); return; } + if (popups.size === 0 && !_lanShare) { _stopPopupBroadcaster(); return; } // Only broadcast when the playhead actually moved — skips ~60 // redundant structured-clone messages/sec (and the follower's // per-panel setTime + toast checks) while the main audio is paused. @@ -2902,7 +3061,9 @@ try { // Carry the play/pause state on every tick — cheap, and it // means a freshly-opened popup learns it from the first // message instead of waiting for a play/pause transition. - ch.postMessage({ type: 'time', t, playing: !audio.paused }); + const msg = { type: 'time', t, playing: !audio.paused }; + if (ch && popups.size) ch.postMessage(msg); + _lanSend(msg); // throttled to ~20 Hz inside _lanSend } }, 1000 / 60); } @@ -2914,10 +3075,307 @@ try { _lastBroadcastTime = null; } + // ══════════════════════════════════════════════════════════════════════ + // LAN share (main-window side) — splitscreen#21. + // + // Mirrors the popup sync messages (time / playstate / song-changed / + // main-closed) to the server's /ws/sync/ relay (feedBack#1030) + // so browsers on OTHER machines can run the same follower mode. The + // message protocol is identical to the BroadcastChannel leg, plus: + // hello (viewer → host): announce/poll; host answers with `config`. + // config (host → viewer): filename + the shared panel cfg + playhead. + // Re-answered on every hello, which is what + // makes late join / refresh / reconnect work. + // share-ended (host → viewers): terminal — user clicked Stop sharing. + // + // Persistence: the room key lives in localStorage (splitscreenRoomKey, + // persistent so viewer bookmarks survive sessions) and an active share + // is re-armed on the next load (_maybeResumeLanShare) — a host that + // crashes or reloads resumes publishing on the same key and viewers + // reconnect without interaction. + // ══════════════════════════════════════════════════════════════════════ + let _lanShare = null; // { key, cfg, ws, retryTimer, backoffMs } | null + let _lanLastTimeSentPerf = 0; + // ~20 Hz on the network leg (the local BroadcastChannel stays ≤60 Hz). + // The follower clock interpolates between messages, so this is visually + // indistinguishable while cutting relay traffic to a third. + const LAN_TIME_MIN_INTERVAL_MS = 50; + + function _lanSend(msg) { + const ws = _lanShare && _lanShare.ws; + if (!ws || ws.readyState !== 1) return; + if (msg.type === 'time') { + const now = performance.now(); + if (now - _lanLastTimeSentPerf < LAN_TIME_MIN_INTERVAL_MS) return; + _lanLastTimeSentPerf = now; + } + try { ws.send(JSON.stringify(msg)); } catch (_) {} + } + + // Answer a viewer's `hello`. No song loaded yet → answer nothing; the + // viewer hello-polls until a config with a filename can be produced. + function _lanHelloResponse(popupId) { + if (!_lanShare || !currentFilename) return null; + const audio = document.getElementById('audio'); + return { + type: 'config', + popupId: popupId || '', + filename: currentFilename, + cfg: _lanShare.cfg || null, + t: (audio && Number.isFinite(audio.currentTime)) ? audio.currentTime : 0, + playing: !!(audio && !audio.paused), + }; + } + + function _lanConnect() { + if (!_lanShare || _lanShare.ws) return; + let ws; + try { ws = new WebSocket(getSyncUrl(_lanShare.key)); } catch (_) { _lanScheduleReconnect(); return; } + _lanShare.ws = ws; + ws.onopen = () => { if (_lanShare) _lanShare.backoffMs = 1000; }; + ws.onmessage = (ev) => { + if (!_lanShare) return; + let msg = null; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg && msg.type === 'hello') { + const resp = _lanHelloResponse(msg.popupId); + if (resp) _lanSend(resp); + } + }; + ws.onclose = () => { + if (!_lanShare || _lanShare.ws !== ws) return; + _lanShare.ws = null; + _lanScheduleReconnect(); + }; + ws.onerror = () => { try { ws.close(); } catch (_) {} }; + } + + function _lanScheduleReconnect() { + if (!_lanShare || _lanShare.retryTimer) return; + const delay = _lanShare.backoffMs || 1000; + _lanShare.backoffMs = Math.min(delay * 2, 10000); + _lanShare.retryTimer = setTimeout(() => { + if (!_lanShare) return; + _lanShare.retryTimer = null; + _lanConnect(); + }, delay); + } + + // Capture `panel` as the shared config — with the note-detect fields + // stripped (viewers are passive mirrors; never forward mic bindings). + function _lanCaptureCfg(panel) { + return Object.assign(_captureFollowerConfig(panel), { + detectChannel: 'mono', detectDeviceName: '', detectVerifierOffsetMs: 0, + }); + } + + function startLanShare(panel) { + if (typeof WebSocket !== 'function') { + _showMainToast('LAN sharing requires WebSocket support.'); + return false; + } + const key = ensureRoomKey(); + const cfg = panel ? _lanCaptureCfg(panel) : null; + _lanShare = { key, cfg, ws: null, retryTimer: null, backoffMs: 1000 }; + try { + localStorage.setItem('splitscreenLanShareActive', 'true'); + localStorage.setItem('splitscreenLanShareCfg', JSON.stringify(cfg)); + } catch (_) {} + _lanConnect(); + _ensureMainBroadcasterAndListener(); + _startPopupBroadcaster(); + return true; + } + + function stopLanShare() { + if (!_lanShare) return; + _lanSend({ type: 'share-ended' }); // terminal for viewers — before teardown + const s = _lanShare; + _lanShare = null; // null first: onclose must not reconnect + if (s.retryTimer) clearTimeout(s.retryTimer); + if (s.ws) { try { s.ws.close(); } catch (_) {} } + try { + localStorage.removeItem('splitscreenLanShareActive'); + localStorage.removeItem('splitscreenLanShareCfg'); + } catch (_) {} + } + + // Re-arm a share that was active when this window last unloaded (crash, + // reload, app relaunch) so viewers left open on other devices recover + // without any interaction — the crash-recovery contract (splitscreen#21). + function _maybeResumeLanShare() { + if (FOLLOWER || REMOTE_JOIN || _lanShare) return; + if (typeof WebSocket !== 'function') return; + try { + if (localStorage.getItem('splitscreenLanShareActive') !== 'true') return; + let cfg = null; + try { cfg = JSON.parse(localStorage.getItem('splitscreenLanShareCfg') || 'null'); } catch (_) {} + _lanShare = { key: ensureRoomKey(), cfg, ws: null, retryTimer: null, backoffMs: 1000 }; + _lanConnect(); + _ensureMainBroadcasterAndListener(); + _startPopupBroadcaster(); + } catch (_) {} + } + + // Copy with a non-secure-context fallback. LAN hosts are frequently plain + // http (e.g. a Docker session at http://192.168.x.x), where + // navigator.clipboard does not exist — fall back to the classic + // hidden-textarea + execCommand('copy') path. Resolves true on success. + function _copyTextToClipboard(text) { + return new Promise((resolve) => { + try { + if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) { + navigator.clipboard.writeText(text).then( + () => resolve(true), + () => resolve(_copyViaExecCommand(text))); + return; + } + } catch (_) {} + resolve(_copyViaExecCommand(text)); + }); + } + function _copyViaExecCommand(text) { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:fixed;left:-9999px;top:0;'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + return ok; + } catch (_) { + return false; + } + } + + // Share dialog: the room key big (the part someone reads aloud), the join + // URL(s), Copy, and Stop sharing. On desktop, the preload's network API + // supplies the machine's LAN addresses (the renderer itself loads from + // 127.0.0.1, which is useless to other devices); in a plain browser / + // Docker session, location.origin is already the address the viewer needs. + let _lanShareModalEl = null; + let _lanShareModalSeq = 0; // invocation nonce — see the await note below + function _closeLanShareModal() { + if (_lanShareModalEl) { try { _lanShareModalEl.remove(); } catch (_) {} _lanShareModalEl = null; } + } + async function _showLanShareModal() { + // The getLanAccess() await below yields: a second invocation entering + // meanwhile would otherwise stack a second overlay on top of this + // one's (the sync close-at-entry can't see a not-yet-appended modal). + // Only the latest invocation is allowed to render. + const seq = ++_lanShareModalSeq; + _closeLanShareModal(); + const key = _lanShare ? _lanShare.key : ensureRoomKey(); + + let origins = [location.origin]; + let lanNote = ''; + try { + const desktop = window.feedBackDesktop || window.slopsmithDesktop; + const net = desktop && desktop.network; + if (net && typeof net.getLanAccess === 'function') { + const res = await net.getLanAccess(); + if (res && res.enabled && Array.isArray(res.urls) && res.urls.length) { + origins = res.urls; + } else if (res && !res.enabled) { + lanNote = 'LAN access is OFF — enable it in the Plugin Manager’s Network section, or other devices cannot reach this machine.'; + } + } + } catch (_) {} + if (seq !== _lanShareModalSeq) return; // superseded while awaiting + + const overlay = document.createElement('div'); + overlay.id = 'ss-lan-share-modal'; + overlay.style.cssText = + 'position:fixed;inset:0;z-index:10003;background:rgba(0,0,0,0.55);' + + 'display:flex;align-items:center;justify-content:center;font-family:sans-serif;'; + overlay.addEventListener('pointerdown', (e) => { if (e.target === overlay) _closeLanShareModal(); }); + + const box = document.createElement('div'); + box.style.cssText = + 'background:rgba(10,10,20,0.98);border:1px solid #4080e0;border-radius:10px;' + + 'padding:20px 24px;max-width:520px;width:calc(100vw - 48px);color:#e5e7eb;' + + 'box-shadow:0 10px 40px rgba(0,0,0,0.6);'; + + const title = document.createElement('div'); + title.style.cssText = 'font-size:15px;font-weight:600;margin-bottom:10px;'; + title.textContent = _lanShare ? 'Sharing to LAN' : 'LAN room key'; + box.appendChild(title); + + const keyEl = document.createElement('div'); + keyEl.style.cssText = + 'font-family:monospace;font-size:34px;font-weight:700;letter-spacing:6px;' + + 'text-align:center;color:#8ab4ff;margin:6px 0 14px;user-select:text;'; + keyEl.textContent = key; + box.appendChild(keyEl); + + if (lanNote) { + const warn = document.createElement('div'); + warn.style.cssText = 'font-size:12px;color:#fbbf24;margin-bottom:10px;'; + warn.textContent = lanNote; + box.appendChild(warn); + } + + const urlLabel = document.createElement('div'); + urlLabel.style.cssText = 'font-size:12px;color:#9ca3af;margin-bottom:4px;'; + urlLabel.textContent = 'Open on the other device (or bookmark it — the key is permanent):'; + box.appendChild(urlLabel); + const urls = origins.map(o => buildShareUrl(o, key)); + for (const u of urls) { + const line = document.createElement('div'); + line.style.cssText = 'font-family:monospace;font-size:13px;color:#d1d5db;user-select:text;margin:2px 0;'; + line.textContent = u; + box.appendChild(line); + } + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;justify-content:flex-end;flex-wrap:wrap;'; + const mkBtn = (label) => { + const b = document.createElement('button'); + b.textContent = label; + b.style.cssText = + 'padding:6px 12px;border-radius:6px;font-size:12px;cursor:pointer;outline:none;' + + 'background:#1a1a2e;border:1px solid #333;color:#ccc;'; + return b; + }; + const copyBtn = mkBtn('Copy URL'); + copyBtn.onclick = () => { + const flash = (label) => { + copyBtn.textContent = label; + setTimeout(() => { copyBtn.textContent = 'Copy URL'; }, 2000); + }; + _copyTextToClipboard(urls[0]).then((ok) => { + flash(ok ? 'Copied!' : 'Copy failed — select it above'); + }); + }; + btnRow.appendChild(copyBtn); + if (_lanShare) { + const stopBtn = mkBtn('Stop sharing'); + stopBtn.style.borderColor = '#7f1d1d'; + stopBtn.style.color = '#fca5a5'; + stopBtn.onclick = () => { + stopLanShare(); + _closeLanShareModal(); + _showMainToast('LAN sharing stopped.'); + }; + btnRow.appendChild(stopBtn); + } + const closeBtn = mkBtn('Close'); + closeBtn.onclick = _closeLanShareModal; + btnRow.appendChild(closeBtn); + box.appendChild(btnRow); + + overlay.appendChild(box); + document.body.appendChild(overlay); + _lanShareModalEl = overlay; + } + // ══════════════════════════════════════════════════════════════════════ // Main-window broadcaster / listener for popped-out panels // ══════════════════════════════════════════════════════════════════════ let _mainChannelListenerAttached = false; + let _mainAudioListenersEl = null; // the