768 lines
30 KiB
JavaScript
768 lines
30 KiB
JavaScript
// ── 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); })
|
||
);
|
||
|