Files
chimera-Public/renderer/system-view.html
T

425 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws:;"/>
<title>System View — Chimera</title>
<style>@font-face{font-family:Inter;src:url('assets/fonts/inter-latin.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:'Space Grotesk';src:url('assets/fonts/space-grotesk-latin.woff2') format('woff2');font-style:normal;font-weight:300 700;font-display:swap}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0a11;
--surface: #1d1822;
--border: #342b3d;
--text: #f4eefb;
--text-dim: #c6bbd2;
--accent: #CBBAFF;
--radius: 12px;
}
html, body { height: 100%; background: #000; color: var(--text);
font-family: Inter,'Segoe UI',system-ui,sans-serif;
font-size: 13px; overflow: hidden; user-select: none; }
/* ── Controls bar ─────────────────────────────────────────────────────────── */
#controls {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
position: relative; z-index: 10; flex-wrap: wrap;
}
.ctrl-label { font-size: 11px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
select {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 3px 6px; font-size: 12px; outline: none; cursor: pointer;
}
select:focus { border-color: var(--accent); }
select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sel-video { flex: 2; min-width: 120px; }
.sel-audio { flex: 2; min-width: 100px; }
.sel-res { width: 100px; }
.sel-fps { width: 68px; }
button {
background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 3px 10px; font-size: 12px; cursor: pointer;
white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
}
button:hover:not(:disabled) { background: #2c2c2c; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Recording indicator ──────────────────────────────────────────────────── */
#rec-status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: #ff5252; font-weight: 500; flex-shrink: 0;
}
#rec-status.hidden { display: none; }
.rec-dot {
width: 8px; height: 8px; border-radius: 50%; background: #ff5252; flex-shrink: 0;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:0.3; } }
/* ── Video area ───────────────────────────────────────────────────────────── */
#shell { display: flex; flex-direction: column; height: 100%; }
#viewer {
flex: 1; position: relative; display: flex;
align-items: center; justify-content: center; background: #000; overflow: hidden;
}
#sv-video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* ── No-signal overlay ────────────────────────────────────────────────────── */
#no-signal {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px;
color: var(--text-dim); font-size: 13px; pointer-events: none;
}
#no-signal.hidden { display: none; }
#no-signal img { max-width: 280px; opacity: 0.55; }
/* ── Fullscreen button ────────────────────────────────────────────────────── */
#btn-fs {
position: absolute; bottom: 14px; right: 14px; z-index: 20;
background: rgba(18,18,18,.85); border: 1px solid #555; border-radius: 6px;
padding: 6px 10px; font-size: 16px; cursor: pointer; color: var(--text);
opacity: 0; transition: opacity 0.2s, background 0.15s; pointer-events: none;
}
#viewer:hover #btn-fs, #btn-fs:focus-visible { opacity: 1; pointer-events: auto; }
#btn-fs:hover { background: rgba(50,50,50,.95); }
/* ── In-app fullscreen — CSS-based, reliable in Electron ─────────────────── */
body.sv-fullscreen #controls { display: none; }
body.sv-fullscreen #viewer { position: fixed; inset: 0; z-index: 9000; }
/* Always show the exit button when fullscreen */
body.sv-fullscreen #btn-fs { opacity: 1; pointer-events: auto; }
@media (hover: none) { #btn-fs { opacity: 1; pointer-events: auto; } }
@media (prefers-reduced-motion: reduce) { .rec-dot { animation: none; } button, #btn-fs { transition: none; } }
</style>
</head>
<body>
<div id="shell">
<div id="controls">
<label class="ctrl-label" for="video-select">Video</label>
<select id="video-select" class="sel-video"><option value="">— Select source —</option></select>
<label class="ctrl-label" for="audio-select">Audio</label>
<select id="audio-select" class="sel-audio"><option value="">— No audio —</option></select>
<label class="ctrl-label" for="res-select">Resolution</label>
<select id="res-select" class="sel-res">
<option value="">Auto</option>
<option value="1280x720">1280×720</option>
<option value="1920x1080">1920×1080</option>
<option value="2560x1440">2560×1440</option>
<option value="3840x2160">3840×2160</option>
</select>
<button id="btn-mute" disabled>&#128266; Mute</button>
<select id="fps-select" class="sel-fps" aria-label="GIF frame rate">
<option value="5">5 fps</option>
<option value="10" selected>10 fps</option>
<option value="15">15 fps</option>
<option value="25">25 fps</option>
<option value="30">30 fps</option>
<option value="60">60 fps</option>
</select>
<button id="btn-record" disabled>&#9210; Record GIF</button>
<span id="rec-status" class="hidden">
<span class="rec-dot"></span>REC <span id="rec-time">0:00</span>
</span>
</div>
<div id="viewer">
<video id="sv-video" autoplay playsinline muted></video>
<div id="no-signal">
<img src="assets/no-signal/no-signal.png" alt="No signal"/>
<p id="no-signal-msg">Select a video source above to begin</p>
</div>
<button id="btn-fs" type="button" title="Fullscreen (or double-click)" aria-label="Enter fullscreen">&#x26F6;</button>
</div>
</div>
<script src="gif-encoder.js"></script>
<script>
'use strict';
// ── URL params ─────────────────────────────────────────────────────────────────
const params = new URLSearchParams(window.location.search);
const initVideoId = params.get('videoId') || '';
const initAudioId = params.get('audioId') || '';
const initResolution = params.get('resolution') || '';
// ── State ──────────────────────────────────────────────────────────────────────
let stream = null;
let muted = false;
let recording = false;
let recFrames = [];
let recInterval = null;
let recTimer = null;
let recStart = 0;
let recCanvas = null;
let recCtx = null;
// AudioContext for low-latency audio — avoids Chromium's video-clock sync buffering
let audioCtx = null;
let audioGain = null;
const GIF_W = 640, GIF_H = 360;
// ── Element refs ───────────────────────────────────────────────────────────────
const videoEl = document.getElementById('sv-video');
const videoSel = document.getElementById('video-select');
const audioSel = document.getElementById('audio-select');
const resSel = document.getElementById('res-select');
const fpsSel = document.getElementById('fps-select');
const btnMute = document.getElementById('btn-mute');
const btnRecord = document.getElementById('btn-record');
const btnFs = document.getElementById('btn-fs');
const noSignal = document.getElementById('no-signal');
const noSignalMsg= document.getElementById('no-signal-msg');
const controls = document.getElementById('controls');
const recStatus = document.getElementById('rec-status');
const recTimeEl = document.getElementById('rec-time');
// ── Audio helpers ──────────────────────────────────────────────────────────────
function audioStop() {
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null; }
audioGain = null;
}
// ── Device enumeration ─────────────────────────────────────────────────────────
async function enumerateDevices() {
try {
if (!stream) {
try {
const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
tmp.getTracks().forEach(t => t.stop());
} catch (_) {}
}
const devices = await navigator.mediaDevices.enumerateDevices();
const prevVideo = videoSel.value, prevAudio = audioSel.value;
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)));
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)));
const tV = prevVideo || initVideoId, tA = prevAudio || initAudioId;
if (tV && [...videoSel.options].some(o => o.value === tV)) videoSel.value = tV;
if (tA && [...audioSel.options].some(o => o.value === tA)) audioSel.value = tA;
} catch (e) {
console.error('enumerateDevices failed:', e);
}
}
// ── Stream management ──────────────────────────────────────────────────────────
async function startStream() {
if (recording) stopRecording();
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
audioStop();
videoEl.srcObject = null;
const videoId = videoSel.value;
if (!videoId) {
noSignalMsg.textContent = 'Select a video source above to begin';
noSignal.classList.remove('hidden');
btnMute.disabled = true;
btnRecord.disabled = true;
return;
}
noSignalMsg.textContent = 'Starting stream…';
const audioId = audioSel.value;
const res = resSel.value;
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 {
stream = 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`);
stream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false));
} else {
throw exactErr;
}
}
// Feed only video tracks to the video element. Chromium syncs audio to
// the video clock which adds ~150-300 ms of buffering / choppiness.
// Routing audio separately via AudioContext drops this to ~20-40 ms.
videoEl.srcObject = new MediaStream(stream.getVideoTracks());
videoEl.muted = true; // always; audio is handled by AudioContext below
const audioTracks = stream.getAudioTracks();
if (audioId && audioTracks.length) {
audioCtx = new AudioContext({ latencyHint: 'interactive' });
audioGain = audioCtx.createGain();
audioGain.gain.value = muted ? 0 : 1;
audioCtx.createMediaStreamSource(new MediaStream(audioTracks))
.connect(audioGain)
.connect(audioCtx.destination);
}
noSignal.classList.add('hidden');
btnMute.disabled = false;
btnRecord.disabled = false;
updateMuteBtn();
} catch (e) {
noSignalMsg.textContent = `Error: ${e.message}`;
noSignal.classList.remove('hidden');
btnMute.disabled = true;
btnRecord.disabled = true;
}
}
function updateMuteBtn() {
btnMute.textContent = muted ? '🔇 Unmute' : '🔊 Mute';
}
// ── GIF Recording ──────────────────────────────────────────────────────────────
function getRecCtx() {
const w = videoEl.videoWidth || GIF_W;
const h = videoEl.videoHeight || GIF_H;
if (!recCanvas || recCanvas.width !== w || recCanvas.height !== h) {
recCanvas = document.createElement('canvas');
recCanvas.width = w; recCanvas.height = h;
recCtx = recCanvas.getContext('2d');
}
return recCtx;
}
function startRecording() {
if (recording || !stream) return;
const fps = parseInt(fpsSel.value) || 10;
recording = true; recFrames = []; recStart = Date.now();
btnRecord.textContent = '⏹ Stop & Save';
recStatus.classList.remove('hidden');
const ctx = getRecCtx();
const gifW = recCanvas.width, gifH = recCanvas.height;
recInterval = setInterval(() => {
ctx.drawImage(videoEl, 0, 0, gifW, gifH);
recFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data.buffer));
}, Math.round(1000 / fps));
recTimer = setInterval(() => {
const s = Math.floor((Date.now() - recStart) / 1000);
recTimeEl.textContent = `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;
}, 500);
}
function stopRecording() {
if (!recording) return;
clearInterval(recInterval); clearInterval(recTimer);
recording = false;
btnRecord.textContent = '⏺ Record GIF';
recStatus.classList.add('hidden');
recTimeEl.textContent = '0:00';
if (!recFrames.length) return;
const fps = parseInt(fpsSel.value) || 10;
const gifW = recCanvas?.width || GIF_W;
const gifH = recCanvas?.height || GIF_H;
const enc = new GifEncoder(gifW, gifH, { fps });
for (const f of recFrames) enc.addFrame(f);
recFrames = [];
const bytes = enc.encode();
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
const a = Object.assign(document.createElement('a'), {
href: url, download: `chimera-${Date.now()}.gif`,
});
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
// ── In-app fullscreen (CSS-based — requestFullscreen unreliable in Electron) ───
function toggleFullscreen() {
const entering = !document.body.classList.contains('sv-fullscreen');
document.body.classList.toggle('sv-fullscreen', entering);
btnFs.innerHTML = entering ? '&#x2715;' : '&#x26F6;';
btnFs.title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)';
btnFs.setAttribute('aria-label', entering ? 'Exit fullscreen' : 'Enter fullscreen');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.body.classList.contains('sv-fullscreen')) {
toggleFullscreen();
}
});
// ── Event handlers ─────────────────────────────────────────────────────────────
videoSel.addEventListener('change', startStream);
audioSel.addEventListener('change', startStream);
resSel.addEventListener('change', () => { if (videoSel.value) startStream(); });
btnMute.addEventListener('click', () => {
muted = !muted;
if (audioGain) audioGain.gain.value = muted ? 0 : 1;
updateMuteBtn();
});
btnRecord.addEventListener('click', () => { recording ? stopRecording() : startRecording(); });
btnFs.addEventListener('click', toggleFullscreen);
document.getElementById('viewer').addEventListener('dblclick', toggleFullscreen);
navigator.mediaDevices.addEventListener('devicechange', () => enumerateDevices().catch(() => {}));
// ── Boot ───────────────────────────────────────────────────────────────────────
(async () => {
// If device IDs were passed via URL query (auto-launch path), skip the
// getUserMedia label-unlock inside enumerateDevices and start the stream
// immediately. Enumeration runs in the background after the stream opens —
// by then the stream itself has already unlocked device labels.
if (initVideoId) {
// Pre-populate the selects with the saved IDs so startStream can read them
// even before enumerateDevices has filled the <option> list.
if (!videoSel.value) videoSel.add(new Option(initVideoId, initVideoId));
videoSel.value = initVideoId;
if (initAudioId) {
if (![...audioSel.options].some(o => o.value === initAudioId))
audioSel.add(new Option(initAudioId, initAudioId));
audioSel.value = initAudioId;
}
if (initResolution && [...resSel.options].some(o => o.value === initResolution))
resSel.value = initResolution;
// Start stream right away — don't wait for enumeration.
await startStream();
// Enumerate in the background now that the stream has unlocked labels;
// this refreshes the dropdown with proper device names.
enumerateDevices().catch(() => {});
} else {
// No device pre-selected — enumerate first so the user can pick one.
await enumerateDevices();
if (initResolution && [...resSel.options].some(o => o.value === initResolution))
resSel.value = initResolution;
if (videoSel.value) await startStream();
}
})();
</script>
</body>
</html>