// ── System View ─────────────────────────────────────────────────────────────── let _svGifWorker = null; let _svGifJobId = 0; let _svGifSavePath = ''; let _svVidWriteQueue = Promise.resolve(); const SV_GIF_MAX_WIDTH = 480; const SV_GIF_MAX_COLORS = 128; const SV_GIF_MAX_FPS = 8; function updateSvSourceUi() { $('page-system-view')?.classList.toggle('sv-has-source', Boolean($('sv-video-device')?.value)); } async function loadSystemView(options = {}) { const passive = !!options.passive; if (window.pork.isWebMode) { if (!passive) await _loadSystemViewWeb(); return; } $('sv-status-codec').textContent = svDetectCodec().label; if (!passive) await enumerateSvDevices(); let autoLoad = false; // Restore all persisted system-view settings try { const s = await window.pork.getSettings(); if (s.svResolution) $('sv-resolution').value = s.svResolution; if (s.svGifFps) $('sv-gif-fps').value = s.svGifFps; if (s.svVidQuality) $('sv-vid-quality').value = s.svVidQuality; if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys }; autoLoad = !!s.svAutoLoad; $('sv-auto-load').checked = autoLoad; // Restore saved device selections only if nothing is currently selected if (!passive) { const videoSel = $('sv-video-device'); const audioSel = $('sv-audio-device'); if (!videoSel.value && s.svVideoDevice && [...videoSel.options].some(o => o.value === s.svVideoDevice)) { videoSel.value = s.svVideoDevice; } if (!audioSel.value && s.svAudioDevice && [...audioSel.options].some(o => o.value === s.svAudioDevice)) { audioSel.value = s.svAudioDevice; } } } catch (_) {} if (!$('sv-gif-fps').value) $('sv-gif-fps').value = '8'; if (!$('sv-vid-quality').value) $('sv-vid-quality').value = '8000000'; // Re-attach existing stream if still active (user navigated away and back) if (_svStream && !_svStream.active) _svStream = null; if (_svStream) { // Re-attach video-only (AudioContext keeps running independently) $('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks()); $('sv-video').muted = true; $('sv-no-signal').classList.add('hidden'); $('btn-sv-mute').disabled = false; $('btn-sv-popout').disabled = false; $('btn-sv-record').disabled = false; $('btn-sv-vid-rec').disabled = false; $('sv-ctx-popout').disabled = false; updateSvMuteBtn(); svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); } else if (!passive && autoLoad && $('sv-video-device').value) { // Auto-start stream only when the user explicitly enabled it await startSvStream(); } updateSvSourceUi(); } async function enumerateSvDevices() { let devices; try { if (!navigator.mediaDevices?.enumerateDevices) throw new Error('MediaDevices is unavailable in this browser.'); // Device discovery must never trigger a camera permission prompt merely by // opening the page. Permission is requested only when the user explicitly // starts a selected source. devices = await Promise.race([ navigator.mediaDevices.enumerateDevices(), new Promise(resolve => setTimeout(() => resolve([]), 1800)), ]); } catch (e) { console.error('enumerateDevices failed:', e); $('sv-no-signal-msg').textContent = 'Capture devices could not be listed. Check the secure browser context and permissions, then retry.'; $('sv-no-signal').classList.remove('hidden'); return; } const prevVideo = $('sv-video-device').value; const prevAudio = $('sv-audio-device').value; // Video const videoSel = $('sv-video-device'); while (videoSel.options.length > 1) videoSel.remove(1); devices.filter(d => d.kind === 'videoinput').forEach(d => { videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId)); }); // Audio const audioSel = $('sv-audio-device'); while (audioSel.options.length > 1) audioSel.remove(1); devices.filter(d => d.kind === 'audioinput').forEach(d => { audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId)); }); if (!devices.some(device => device.kind === 'videoinput')) { $('sv-no-signal-msg').textContent = 'No capture source detected. Connect a capture device, then refresh the device list.'; $('sv-no-signal').classList.remove('hidden'); } // Restore previous selection if device still present if (prevVideo && [...videoSel.options].some(o => o.value === prevVideo)) videoSel.value = prevVideo; if (prevAudio && [...audioSel.options].some(o => o.value === prevAudio)) audioSel.value = prevAudio; updateSvSourceUi(); } function _svAudioStop() { if (_svAudioCtx) { _svAudioCtx.close().catch(() => {}); _svAudioCtx = null; } _svAudioGain = null; } // ── System View — stream relay (web UI forwarding) ──────────────────────────── let _relayWs = null; let _relayRecorder = null; let _relayRequested = false; let _webViewerWs = null; // browser-side viewer WS const _RELAY_CODECS = [ 'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8', 'video/webm', ]; async function startSvRelay() { if (_relayWs || !_svStream || !_svStream.active) return; try { const status = await window.pork.webuiStatus(); if (!status.running) return; const mimeType = _RELAY_CODECS.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm'; _relayWs = new WebSocket(`ws://localhost:${status.port}/sv-stream?role=encoder`); _relayWs.onopen = () => { _relayWs.send(JSON.stringify({ type: 'meta', mimeType })); _relayRecorder = new MediaRecorder(_svStream, { mimeType, videoBitsPerSecond: 4_000_000, }); _relayRecorder.ondataavailable = async (e) => { if (e.data.size === 0 || !_relayWs || _relayWs.readyState !== WebSocket.OPEN) return; try { const buf = await e.data.arrayBuffer(); if (_relayWs && _relayWs.readyState === WebSocket.OPEN) _relayWs.send(buf); } catch (_) {} }; _relayRecorder.start(500); }; _relayWs.onclose = () => { if (_relayRecorder && _relayRecorder.state !== 'inactive') { try { _relayRecorder.stop(); } catch (_) {} } _relayRecorder = null; _relayWs = null; }; _relayWs.onerror = () => { try { _relayWs.close(); } catch (_) {} }; } catch (e) { console.error('[SvRelay] Failed to start:', e); } } function stopSvRelay() { if (_relayRecorder && _relayRecorder.state !== 'inactive') { try { _relayRecorder.stop(); } catch (_) {} } _relayRecorder = null; if (_relayWs) { try { _relayWs.close(); } catch (_) {} _relayWs = null; } } window.pork.on('sv:relay:request', ({ active }) => { _relayRequested = active; if (active && _svStream && _svStream.active) { startSvRelay().catch(() => {}); } else if (!active) { stopSvRelay(); } }); // ── System View — web-mode viewer ───────────────────────────────────────────── async function _loadSystemViewWeb() { // In server mode the capture card belongs to the browser machine, so use // MediaDevices there directly instead of waiting for an Electron relay. const deviceBar = document.querySelector('.sv-device-bar'); if (deviceBar) deviceBar.hidden = false; ['btn-sv-refresh-devices', 'btn-sv-mute', 'btn-sv-record', 'sv-gif-fps', 'btn-sv-vid-rec', 'sv-vid-quality', ].forEach(id => { const el = $(id); if (el) el.hidden = false; }); $('btn-sv-popout').hidden = true; $('sv-status-codec').textContent = typeof MediaRecorder !== 'undefined' ? svDetectCodec().label : 'Unavailable'; if (!window.isSecureContext || !navigator.mediaDevices?.enumerateDevices) { $('sv-no-signal-msg').textContent = window.t('systemView.httpsRequired.detail', 'Capture needs HTTPS or localhost, plus camera and microphone access.'); $('sv-no-signal').classList.remove('hidden'); svSetIdle(window.t('systemView.httpsRequired', 'HTTPS required for capture')); return; } await enumerateSvDevices(); try { const settings = await window.pork.getSettings(); if (settings.svResolution) $('sv-resolution').value = settings.svResolution; if (settings.svGifFps) $('sv-gif-fps').value = settings.svGifFps; if (settings.svVidQuality) $('sv-vid-quality').value = settings.svVidQuality; if (settings.svVideoDevice && [...$('sv-video-device').options].some(option => option.value === settings.svVideoDevice)) $('sv-video-device').value = settings.svVideoDevice; if (settings.svAudioDevice && [...$('sv-audio-device').options].some(option => option.value === settings.svAudioDevice)) $('sv-audio-device').value = settings.svAudioDevice; $('sv-auto-load').checked = settings.svAutoLoad === true; if (settings.svAutoLoad && $('sv-video-device').value) await startSvStream(); updateSvSourceUi(); } catch (_) {} } async function startSvStream() { if (_svRecording) stopSvRecording(); stopSvRelay(); if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; } _svAudioStop(); $('sv-video').srcObject = null; const videoId = $('sv-video-device').value; updateSvSourceUi(); if (!videoId) { $('sv-no-signal-msg').textContent = 'Select a video source above to begin'; $('sv-no-signal').classList.remove('hidden'); $('btn-sv-mute').disabled = true; $('btn-sv-popout').disabled = true; $('btn-sv-record').disabled = true; $('btn-sv-vid-rec').disabled = true; $('sv-ctx-popout').disabled = true; svSetIdle('No device selected'); return; } $('sv-no-signal-msg').textContent = 'Starting stream…'; const audioId = $('sv-audio-device').value; const res = $('sv-resolution').value; // "1920x1080" or "" const [rW, rH] = res ? res.split('x').map(Number) : []; const _buildConstraints = (exactRes) => { const vc = { deviceId: { exact: videoId } }; if (rW) { vc.width = exactRes ? { exact: rW } : { ideal: rW }; vc.height = exactRes ? { exact: rH } : { ideal: rH }; } return { video: vc, audio: audioId ? { deviceId: { exact: audioId }, echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 2, } : false, }; }; try { // First try exact resolution; fall back to ideal if the device can't match try { _svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true)); } catch (exactErr) { if (rW && exactErr.name === 'OverconstrainedError') { console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`); _svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false)); } else { throw exactErr; } } // Feed only video tracks to the video element. Chromium syncs audio // to the video element's clock, which introduces ~150-300 ms of buffering. // Routing audio separately via AudioContext (latencyHint:'interactive') // drops that to ~20-40 ms and eliminates the choppiness. $('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks()); $('sv-video').muted = true; // always; audio is handled by AudioContext below const audioTracks = _svStream.getAudioTracks(); if (audioId && audioTracks.length) { _svAudioCtx = new AudioContext({ latencyHint: 'interactive' }); _svAudioGain = _svAudioCtx.createGain(); _svAudioGain.gain.value = _svMuted ? 0 : 1; _svAudioCtx.createMediaStreamSource(new MediaStream(audioTracks)) .connect(_svAudioGain) .connect(_svAudioCtx.destination); } $('sv-no-signal').classList.add('hidden'); $('btn-sv-mute').disabled = false; $('btn-sv-popout').disabled = false; $('btn-sv-record').disabled = false; $('btn-sv-vid-rec').disabled = false; $('sv-ctx-popout').disabled = false; updateSvMuteBtn(); svSetLive($('sv-video-device').selectedOptions[0]?.text || videoId); if (_relayRequested) startSvRelay().catch(() => {}); } catch (e) { $('sv-no-signal-msg').textContent = `Error: ${e.message}`; $('sv-no-signal').classList.remove('hidden'); $('btn-sv-mute').disabled = true; $('btn-sv-popout').disabled = true; $('btn-sv-record').disabled = true; $('btn-sv-vid-rec').disabled = true; $('sv-ctx-popout').disabled = true; svSetIdle(`Error: ${e.message}`); } } function updateSvMuteBtn() { $('btn-sv-mute').textContent = _svMuted ? '🔇 Unmute' : '🔊 Mute'; } // ── System View — status bar ─────────────────────────────────────────────────── function svSetLive(label) { $('sv-status-dot').className = 'sv-status-dot live'; $('sv-status-text').textContent = `Live — ${label}`; } function svSetIdle(msg = 'No device selected') { $('sv-status-dot').className = 'sv-status-dot'; $('sv-status-text').textContent = msg; $('sv-status-res').textContent = ''; } function svSetSaving() { $('sv-status-dot').className = 'sv-status-dot saving'; $('sv-status-text').textContent = 'Saving video…'; } function svSetGifSaving() { $('sv-status-dot').className = 'sv-status-dot saving'; $('sv-status-text').textContent = 'Saving GIF…'; } $('sv-video').addEventListener('loadedmetadata', () => { const { videoWidth: w, videoHeight: h } = $('sv-video'); if (w && h) $('sv-status-res').textContent = `${w}×${h}`; }); // ── System View — codec detection ───────────────────────────────────────────── function svDetectCodec() { const candidates = [ { mime: 'video/mp4;codecs="avc1.640032,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' }, { mime: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' }, { mime: 'video/mp4;codecs=avc1,mp4a.40.2', ext: 'mp4', label: 'H264/AAC · MP4' }, { mime: 'video/mp4', ext: 'mp4', label: 'MP4' }, { mime: 'video/webm;codecs="vp9,opus"', ext: 'webm', label: 'VP9/Opus · WebM' }, { mime: 'video/webm;codecs="vp8,opus"', ext: 'webm', label: 'VP8/Opus · WebM' }, { mime: 'video/webm', ext: 'webm', label: 'WebM' }, ]; return candidates.find(c => MediaRecorder.isTypeSupported(c.mime)) || { mime: '', ext: 'webm', label: 'WebM (default)' }; } // ── System View — video recording ───────────────────────────────────────────── function svFmtBytes(n) { if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function svFmtTime(ms) { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function svUpdateVidStatus() { const elapsed = _svVidPaused ? _svVidStart : Date.now() - _svVidStart; $('sv-vid-rec-time').textContent = svFmtTime(elapsed); $('sv-vid-rec-size').textContent = _svVidBytes > 0 ? ` · ${svFmtBytes(_svVidBytes)}` : ''; } async function startSvVidRecording() { if (!_svStream) return; const codec = svDetectCodec(); _svVidMimeType = codec.mime; _svVidExt = codec.ext; $('sv-status-codec').textContent = codec.label; const bitrate = Math.max(2_500_000, Math.min(12_000_000, parseInt($('sv-vid-quality').value) || 8_000_000)); const name = `chimera-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}.${_svVidExt}`; _svVidChunks = []; if (!window.pork.isWebMode) { try { const picked = await window.pork.systemViewPickSave({ defaultPath: name, filters: [{ name: `${_svVidExt.toUpperCase()} Video`, extensions: [_svVidExt] }], }); if (picked?.canceled || !picked?.filePath) return; _svVidWritable = picked.filePath; _svVidWriteQueue = Promise.resolve(); await window.pork.systemViewWriteFile({ filePath: _svVidWritable, buffer: new Uint8Array(0).buffer }); } catch (e) { setStatus(`Video save dialog failed: ${e.message}`, 'error'); return; } } else { _svVidWritable = name; } _svVidBytes = 0; _svVidPaused = false; _svVidStart = Date.now(); const recOpts = { videoBitsPerSecond: bitrate, audioBitsPerSecond: 256_000 }; if (_svVidMimeType) recOpts.mimeType = _svVidMimeType; _svVidRecorder = new MediaRecorder(_svStream, recOpts); _svVidRecorder.ondataavailable = async (e) => { if (!e.data || e.data.size === 0) return; _svVidBytes += e.data.size; if (window.pork.isWebMode) { _svVidChunks.push(e.data); } else if (_svVidWritable) { const filePath = _svVidWritable; _svVidWriteQueue = _svVidWriteQueue.then(async () => { const buffer = await e.data.arrayBuffer(); await window.pork.systemViewAppendFile({ filePath, buffer }); }).catch(err => { console.error('[system-view] video append failed', err); }); } }; _svVidRecorder.onstop = async () => { clearInterval(_svVidTimer); svSetSaving(); await _svVidWriteQueue.catch(() => {}); if (window.pork.isWebMode && _svVidChunks?.length) { const url = URL.createObjectURL(new Blob(_svVidChunks, { type: _svVidMimeType || 'video/webm' })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = _svVidWritable || name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); } _svVidChunks = null; _svVidWritable = null; _svVidRecorder = null; svSetVidIdle(); if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); }; _svVidRecorder.start(500); $('btn-sv-vid-rec').hidden = true; $('btn-sv-vid-pause').hidden = false; $('btn-sv-vid-stop').hidden = false; $('sv-vid-rec-status').hidden = false; $('sv-vid-rec-label').textContent = 'REC'; $('sv-vid-rec-status').className = 'sv-vid-rec-status'; $('btn-sv-record').disabled = true; // no GIF while video recording _svVidTimer = setInterval(svUpdateVidStatus, 500); } function pauseSvVidRecording() { if (!_svVidRecorder) return; if (_svVidPaused) { _svVidRecorder.resume(); _svVidStart = Date.now() - _svVidStart; _svVidPaused = false; $('btn-sv-vid-pause').textContent = '⏸ Pause'; $('sv-vid-rec-label').textContent = 'REC'; $('sv-vid-rec-status').className = 'sv-vid-rec-status'; } else { _svVidRecorder.pause(); _svVidStart = Date.now() - _svVidStart; _svVidPaused = true; $('btn-sv-vid-pause').textContent = '▶ Resume'; $('sv-vid-rec-label').textContent = 'PAUSED'; $('sv-vid-rec-status').className = 'sv-vid-rec-status paused'; } } function stopSvVidRecording() { if (!_svVidRecorder) return; if (_svVidPaused) { _svVidRecorder.resume(); _svVidPaused = false; } _svVidRecorder.stop(); } function svSetVidIdle() { $('btn-sv-vid-rec').hidden = false; $('btn-sv-vid-pause').hidden = true; $('btn-sv-vid-stop').hidden = true; $('sv-vid-rec-status').hidden = true; $('btn-sv-vid-pause').textContent = '⏸ Pause'; $('sv-vid-rec-label').textContent = 'REC'; $('sv-vid-rec-status').className = 'sv-vid-rec-status'; $('btn-sv-record').disabled = !_svStream; _svVidPaused = false; } // ── System View — recording helpers ─────────────────────────────────────────── function getSvRecCtx() { const vid = $('sv-video'); const srcW = vid.videoWidth || GIF_W; const srcH = vid.videoHeight || GIF_H; const scale = srcW > SV_GIF_MAX_WIDTH ? (SV_GIF_MAX_WIDTH / srcW) : 1; const w = Math.max(2, Math.round(srcW * scale)); const h = Math.max(2, Math.round(srcH * scale)); if (!_svRecCanvas || _svRecCanvas.width !== w || _svRecCanvas.height !== h) { _svRecCanvas = document.createElement('canvas'); _svRecCanvas.width = w; _svRecCanvas.height = h; _svRecCtx = _svRecCanvas.getContext('2d'); } return _svRecCtx; } function getSvGifWorker() { if (_svGifWorker) return _svGifWorker; const workerSource = ` importScripts(${JSON.stringify(new URL('gif-encoder.js', location.href).href)}); self.onmessage = e => { const { id, width, height, fps, maxColors, frames } = e.data || {}; try { const enc = new self.GifEncoder(width, height, { fps, maxColors }); for (const frame of frames || []) enc.addFrame(new Uint8ClampedArray(frame)); const bytes = enc.encode(); self.postMessage({ id, ok: true, bytes }, [bytes.buffer]); } catch (error) { self.postMessage({ id, ok: false, error: error?.message || String(error) }); } }; `; const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' })); _svGifWorker = new Worker(workerUrl); URL.revokeObjectURL(workerUrl); return _svGifWorker; } function encodeGifOffThread({ width, height, fps, frames, maxColors }) { return new Promise((resolve, reject) => { const worker = getSvGifWorker(); const id = ++_svGifJobId; const onMessage = (event) => { const msg = event.data || {}; if (msg.id !== id) return; worker.removeEventListener('message', onMessage); if (!msg.ok) reject(new Error(msg.error || 'GIF encode failed')); else resolve(new Uint8Array(msg.bytes)); }; worker.addEventListener('message', onMessage); worker.postMessage({ id, width, height, fps, maxColors, frames: frames.map(f => f.buffer), }, frames.map(f => f.buffer)); }); } async function startSvRecording() { if (_svRecording || !_svStream) return; const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10); const defaultName = `chimera-${Date.now()}.gif`; if (window.pork.isWebMode) { _svGifSavePath = defaultName; } else { const picked = await window.pork.systemViewPickSave({ defaultPath: defaultName, filters: [{ name: 'GIF Image', extensions: ['gif'] }], }); if (picked?.canceled || !picked?.filePath) return; _svGifSavePath = picked.filePath; } _svRecording = true; _svRecFrames = []; _svRecStart = Date.now(); $('btn-sv-record').textContent = '⏹ Stop & Save'; $('sv-rec-status').hidden = false; const ctx = getSvRecCtx(); const vid = $('sv-video'); const gifW = _svRecCanvas.width, gifH = _svRecCanvas.height; _svRecInterval = setInterval(() => { ctx.drawImage(vid, 0, 0, gifW, gifH); _svRecFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data)); }, Math.round(1000 / fps)); _svRecTimer = setInterval(() => { const s = Math.floor((Date.now() - _svRecStart) / 1000); $('sv-rec-time').textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }, 500); } async function stopSvRecording() { if (!_svRecording) return; clearInterval(_svRecInterval); clearInterval(_svRecTimer); _svRecording = false; $('btn-sv-record').textContent = '⏺ Record GIF'; $('sv-rec-status').hidden = true; $('sv-rec-time').textContent = '0:00'; if (!_svRecFrames.length) return; const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10); const gifW = _svRecCanvas?.width || GIF_W; const gifH = _svRecCanvas?.height || GIF_H; const frames = _svRecFrames; const filePath = _svGifSavePath; _svRecFrames = []; _svGifSavePath = ''; svSetGifSaving(); try { const bytes = await encodeGifOffThread({ width: gifW, height: gifH, fps, frames, maxColors: SV_GIF_MAX_COLORS, }); if (window.pork.isWebMode) { const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filePath || `chimera-${Date.now()}.gif`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); } else { await window.pork.systemViewWriteFile({ filePath, buffer: bytes.buffer }); } } catch (e) { console.error('[system-view] gif save failed', e); setStatus(`GIF save failed: ${e.message}`, 'error'); } if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); } // System View — event handlers (attached once at startup) $('sv-video-device').addEventListener('change', () => { window.pork.setSettings({ svVideoDevice: $('sv-video-device').value }); updateSvSourceUi(); startSvStream(); }); $('sv-audio-device').addEventListener('change', () => { window.pork.setSettings({ svAudioDevice: $('sv-audio-device').value }); startSvStream(); }); $('btn-sv-refresh-devices').addEventListener('click', () => enumerateSvDevices()); $('sv-resolution').addEventListener('change', () => { window.pork.setSettings({ svResolution: $('sv-resolution').value }); if ($('sv-video-device').value) startSvStream(); }); $('sv-gif-fps').addEventListener('change', () => window.pork.setSettings({ svGifFps: $('sv-gif-fps').value })); $('sv-vid-quality').addEventListener('change', () => window.pork.setSettings({ svVidQuality: $('sv-vid-quality').value })); $('sv-auto-load').addEventListener('change', () => { window.pork.setSettings({ svAutoLoad: $('sv-auto-load').checked }); }); $('btn-sv-mute').addEventListener('click', () => { _svMuted = !_svMuted; if (_svAudioGain) _svAudioGain.gain.value = _svMuted ? 0 : 1; updateSvMuteBtn(); }); // In-app fullscreen: CSS-based fixed overlay — requestFullscreen() is // unreliable in Electron's frameless window and silently fails. function svToggleFullscreen() { const wrap = $('sv-viewer-wrap'); const entering = !wrap.classList.contains('sv-viewer-wrap--fullscreen'); wrap.classList.toggle('sv-viewer-wrap--fullscreen', entering); $('btn-sv-fullscreen').title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)'; $('btn-sv-fullscreen').innerHTML = entering ? '✕' : '⛶'; } $('btn-sv-fullscreen').addEventListener('click', svToggleFullscreen); $('sv-viewer-wrap').addEventListener('dblclick', svToggleFullscreen); $('btn-sv-record').addEventListener('click', () => { (_svRecording ? stopSvRecording() : startSvRecording()).catch(err => { setStatus(err.message, 'error'); }); }); $('btn-sv-vid-rec').addEventListener('click', startSvVidRecording); $('btn-sv-vid-pause').addEventListener('click', pauseSvVidRecording); $('btn-sv-vid-stop').addEventListener('click', stopSvVidRecording); // Right-click context menu on video area const _svCtxMenu = $('sv-ctx-menu'); $('sv-viewer-wrap').addEventListener('contextmenu', e => { e.preventDefault(); const x = Math.min(e.clientX, window.innerWidth - 164); const y = Math.min(e.clientY, window.innerHeight - 80); _svCtxMenu.style.left = `${x}px`; _svCtxMenu.style.top = `${y}px`; _svCtxMenu.hidden = false; }); document.addEventListener('click', () => { _svCtxMenu.hidden = true; }); $('sv-ctx-fs').addEventListener('click', svToggleFullscreen); $('sv-ctx-popout').addEventListener('click', () => $('btn-sv-popout').click()); if (navigator.mediaDevices) { navigator.mediaDevices.addEventListener('devicechange', () => enumerateSvDevices().catch(() => {})); } $('btn-sv-popout').addEventListener('click', async () => { const videoId = $('sv-video-device').value; const audioId = $('sv-audio-device').value; const resolution = $('sv-resolution').value; if (_svRecording) stopSvRecording(); stopSvRelay(); // Stop the main stream — the pop-out will start its own if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; } _svAudioStop(); $('sv-video').srcObject = null; $('sv-no-signal').classList.remove('hidden'); $('sv-no-signal-msg').textContent = 'Stream moved to pop-out window'; $('btn-sv-mute').disabled = true; $('btn-sv-popout').disabled = true; $('btn-sv-record').disabled = true; await window.pork.openSystemViewPopout(videoId, audioId, resolution); }); // When pop-out closes, auto-restart stream in main window if device still selected window.pork.on('system-view:popout-closed', () => { if ($('page-system-view')?.classList.contains('active') && $('sv-video-device').value) { startSvStream(); } }); // ── System View mini-cards (Transfers / Jailbreak) ──────────────────────────── function svMiniGetStatus() { const sel = $('sv-video-device'); const opt = sel?.selectedOptions?.[0]; return (opt && opt.value) ? `Capture device: ${opt.text}` : 'No capture device selected.'; } ['transfers', 'jailbreak'].forEach(pageId => { $(`sv-mini-${pageId}-hdr`).addEventListener('click', () => { const card = $(`sv-mini-${pageId}`); card.classList.toggle('open'); if (card.classList.contains('open')) { $(`sv-mini-${pageId}-status`).textContent = svMiniGetStatus(); } }); $(`btn-sv-mini-${pageId}-nav`).addEventListener('click', () => navigate('system-view')); $(`btn-sv-mini-${pageId}-popout`).addEventListener('click', async () => { const videoId = $('sv-video-device').value; const audioId = $('sv-audio-device').value; const resolution = $('sv-resolution').value; try { await window.pork.openSystemViewPopout(videoId, audioId, resolution); } catch (e) { setStatus(e.message, 'error'); } }); }); document.querySelectorAll('.nav-link').forEach(a => a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.page); }) );