Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:38:08 +02:00
commit c737a37f71
531 files changed
+119270

No files matched your search

+767
View File
@@ -0,0 +1,767 @@
// ── 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 ? '&#x2715;' : '&#x26F6;';
}
$('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); })
);