Initial commit: ASCILINE YouTube Streamer
ASCII-art YouTube streaming for the Tesla in-car browser. - FastAPI server on a Mac mini, no Docker. - yt-dlp resolver: ID/URL/search. - ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain consumer. Separate ffmpeg for AAC/ADTS audio. - Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure fillText color path, on-demand selection flush. - HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on plain http during local dev. - LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
This commit is contained in:
527
static/app.js
Normal file
527
static/app.js
Normal file
@@ -0,0 +1,527 @@
|
||||
// ASCILINE YouTube streamer — Tesla browser frontend.
|
||||
//
|
||||
// Wire protocol (unchanged from vendor/asciline): text INIT message
|
||||
// "INIT:fps:mode:cols:rows:pixel" then binary frames whose first 4 bytes
|
||||
// are a big-endian frame index and whose body is mode-dependent (mode 1
|
||||
// = utf-8 text; modes 2/3 = [charCode,r,g,b] per cell).
|
||||
|
||||
'use strict';
|
||||
|
||||
const queryInput = document.getElementById('query-input');
|
||||
const btnPlay = document.getElementById('btn-play');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const presetBtns = document.querySelectorAll('.preset-btn');
|
||||
const presetHint = document.getElementById('preset-hint');
|
||||
const btnMute = document.getElementById('btn-mute');
|
||||
const volumeSlider = document.getElementById('volume-slider');
|
||||
const playerContainer = document.getElementById('player-container');
|
||||
const asciiPlayer = document.getElementById('ascii-player');
|
||||
const canvas = document.getElementById('ascii-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const idleOverlay = document.getElementById('idle-overlay');
|
||||
const audioEl = document.getElementById('ascii-audio');
|
||||
|
||||
const statusTitle = document.getElementById('status-title');
|
||||
const statusLive = document.getElementById('status-live');
|
||||
const statusState = document.getElementById('status-state');
|
||||
const statusFps = document.getElementById('status-fps');
|
||||
const statusPerf = document.getElementById('status-perf-hint');
|
||||
|
||||
let appState = 'idle'; // idle | resolving | playing | error
|
||||
let ws = null;
|
||||
let sessionId = null;
|
||||
let pollTimer = null;
|
||||
let currentPreset = 'low';
|
||||
let selectedPreset = 'low';
|
||||
|
||||
const WS_MAX_RETRIES = 5;
|
||||
let wsRetryCount = 0;
|
||||
let wsRetryTimer = null;
|
||||
|
||||
// Renderer state. Master clock is wall-clock, anchored on the first
|
||||
// server frame (audio's currentTime updates in coarse ~250 ms steps which
|
||||
// caused stall→batch-drop bursts on the M4). A small lead lets the very
|
||||
// first frame paint immediately instead of waiting half a second.
|
||||
const frameBuffer = [];
|
||||
const FRAME_BUFFER_MAX = 3; // server queue is 2; 3 keeps ~125 ms of slack
|
||||
const PRESENT_LEAD_MS = 60;
|
||||
let targetFps = 24;
|
||||
let renderMode = 1;
|
||||
let pixelMode = false;
|
||||
let readyToRender = false;
|
||||
let gridCols = 0, gridRows = 0;
|
||||
let charWidth = 0, charHeight = 0;
|
||||
let xPos = null, yPos = null;
|
||||
let dotImageData = null;
|
||||
let selectionBuffer = null;
|
||||
let clockAnchored = false;
|
||||
let streamStartTime = 0;
|
||||
const textDecoder = new TextDecoder();
|
||||
const CHAR_LUT = Array.from({ length: 128 }, (_, i) => String.fromCharCode(i));
|
||||
|
||||
// HUD: painted FPS plus worst paint-to-paint gap (JIT) and current client
|
||||
// buffer depth. JIT exposes bursts that a 1-second average would smear.
|
||||
let frameCount = 0;
|
||||
let measuredFps = 0;
|
||||
let lastFpsUpdate = 0;
|
||||
let fpsWindowFrames = 0;
|
||||
let fpsWindowStart = 0;
|
||||
let fpsBelowThreshold = false;
|
||||
let lastPaintTime = 0;
|
||||
let maxPaintInterval = 0;
|
||||
|
||||
// ───────── Preset selection UI ─────────
|
||||
presetBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.dataset.preset;
|
||||
if (p === selectedPreset) return;
|
||||
selectedPreset = p;
|
||||
presetBtns.forEach(b => b.classList.toggle('active', b.dataset.preset === p));
|
||||
if (appState === 'playing') presetHint.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ───────── Volume / mute ─────────
|
||||
let isMuted = false;
|
||||
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
if (!audioEl) return;
|
||||
audioEl.volume = parseFloat(volumeSlider.value);
|
||||
isMuted = audioEl.volume === 0;
|
||||
updateMuteBtn();
|
||||
});
|
||||
|
||||
btnMute.addEventListener('click', () => {
|
||||
isMuted = !isMuted;
|
||||
if (audioEl) audioEl.muted = isMuted;
|
||||
updateMuteBtn();
|
||||
});
|
||||
|
||||
function updateMuteBtn() {
|
||||
btnMute.textContent = isMuted ? '🔇' : '🔊';
|
||||
btnMute.classList.toggle('muted', isMuted);
|
||||
}
|
||||
|
||||
// ───────── Canvas setup ─────────
|
||||
function buildCanvas(cols, rows) {
|
||||
gridCols = cols;
|
||||
gridRows = rows;
|
||||
|
||||
const syncSize = (el) => {
|
||||
el.style.width = playerContainer.clientWidth + 'px';
|
||||
el.style.height = playerContainer.clientHeight + 'px';
|
||||
el.style.objectFit = 'contain';
|
||||
el.style.position = 'absolute';
|
||||
el.style.top = '0';
|
||||
el.style.left = '0';
|
||||
};
|
||||
|
||||
if (pixelMode) {
|
||||
canvas.width = cols;
|
||||
canvas.height = rows;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
dotImageData = ctx.createImageData(cols, rows);
|
||||
const d = dotImageData.data;
|
||||
for (let i = 3; i < d.length; i += 4) d[i] = 255;
|
||||
syncSize(canvas);
|
||||
asciiPlayer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.imageRendering = '';
|
||||
dotImageData = null;
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
charWidth = ctx.measureText('M').width;
|
||||
charHeight = 8;
|
||||
canvas.width = cols * charWidth;
|
||||
canvas.height = rows * charHeight;
|
||||
canvas.style.display = 'block';
|
||||
|
||||
selectionBuffer = new Uint8Array((cols + 1) * rows);
|
||||
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10;
|
||||
|
||||
syncSize(canvas);
|
||||
|
||||
const cw = playerContainer.clientWidth;
|
||||
const ch = playerContainer.clientHeight;
|
||||
const fs = Math.min(cw / canvas.width, ch / canvas.height);
|
||||
const ox = (cw - canvas.width * fs) / 2;
|
||||
const oy = (ch - canvas.height * fs) / 2;
|
||||
|
||||
asciiPlayer.style.width = canvas.width + 'px';
|
||||
asciiPlayer.style.height = canvas.height + 'px';
|
||||
asciiPlayer.style.position = 'absolute';
|
||||
asciiPlayer.style.top = '0';
|
||||
asciiPlayer.style.left = '0';
|
||||
asciiPlayer.style.transformOrigin = 'top left';
|
||||
asciiPlayer.style.transform = `translate(${ox}px,${oy}px) scale(${fs})`;
|
||||
asciiPlayer.style.fontSize = '8px';
|
||||
asciiPlayer.style.lineHeight = '8px';
|
||||
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
ctx.textBaseline = 'top';
|
||||
xPos = new Float32Array(cols);
|
||||
yPos = new Float32Array(rows);
|
||||
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
|
||||
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
|
||||
}
|
||||
|
||||
// ───────── Render loop ─────────
|
||||
function renderFrame(now) {
|
||||
if (appState !== 'playing' || !readyToRender) return;
|
||||
requestAnimationFrame(renderFrame);
|
||||
|
||||
if (frameBuffer.length === 0) return;
|
||||
|
||||
if (!clockAnchored) {
|
||||
streamStartTime = now - frameBuffer[0].time * 1000 - PRESENT_LEAD_MS;
|
||||
clockAnchored = true;
|
||||
}
|
||||
const masterClock = (now - streamStartTime) / 1000.0;
|
||||
|
||||
// Drop frames that fell visibly behind (>100 ms) so a tab that was
|
||||
// backgrounded for a moment doesn't replay a backlog on resume.
|
||||
while (frameBuffer.length > 1 && frameBuffer[0].time < masterClock - 0.1) {
|
||||
frameBuffer.shift();
|
||||
}
|
||||
if (frameBuffer[0].time > masterClock + 0.05) return;
|
||||
|
||||
const frameObj = frameBuffer.shift();
|
||||
const frame = frameObj.data;
|
||||
|
||||
frameCount++;
|
||||
if (lastPaintTime > 0) {
|
||||
const gap = now - lastPaintTime;
|
||||
if (gap > maxPaintInterval) maxPaintInterval = gap;
|
||||
}
|
||||
lastPaintTime = now;
|
||||
|
||||
if (now - lastFpsUpdate >= 1000) {
|
||||
measuredFps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsUpdate = now;
|
||||
const jitter = Math.round(maxPaintInterval);
|
||||
maxPaintInterval = 0;
|
||||
statusFps.textContent =
|
||||
`FPS ${measuredFps}/${Math.round(targetFps)} ` +
|
||||
`JIT ${jitter}ms BUF ${frameBuffer.length}`;
|
||||
}
|
||||
|
||||
fpsWindowFrames++;
|
||||
if (now - fpsWindowStart >= 5000) {
|
||||
const windowFps = fpsWindowFrames / 5;
|
||||
fpsBelowThreshold = windowFps < targetFps * 0.70;
|
||||
statusPerf.classList.toggle('hidden', !fpsBelowThreshold);
|
||||
fpsWindowFrames = 0;
|
||||
fpsWindowStart = now;
|
||||
}
|
||||
|
||||
if (renderMode === 1) {
|
||||
asciiPlayer.style.display = 'block';
|
||||
asciiPlayer.style.color = '#fff';
|
||||
asciiPlayer.textContent = frame;
|
||||
return;
|
||||
}
|
||||
if (pixelMode) {
|
||||
const view = frame;
|
||||
const data = dotImageData.data;
|
||||
for (let src = 0, dst = 0; src < view.length; src += 3, dst += 4) {
|
||||
data[dst] = view[src + 2];
|
||||
data[dst + 1] = view[src + 1];
|
||||
data[dst + 2] = view[src];
|
||||
}
|
||||
ctx.putImageData(dotImageData, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Color ASCII path: per-cell fillText with a same-color run optim.
|
||||
// Selection buffer is kept in memory every frame; the transparent <pre>'s
|
||||
// textContent is built on demand in the selectstart/copy handlers so the
|
||||
// paint loop never triggers a layout reflow.
|
||||
const view = frame;
|
||||
const buf = selectionBuffer;
|
||||
const stride = gridCols + 1;
|
||||
ctx.fillStyle = '#050505';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = 'bold 8px Courier New';
|
||||
ctx.textBaseline = 'top';
|
||||
let col = 0, row = 0, prevPacked = -1;
|
||||
for (let idx = 0; idx < view.length; idx += 4) {
|
||||
const ch = view[idx];
|
||||
const r = view[idx + 1];
|
||||
const g = view[idx + 2];
|
||||
const b = view[idx + 3];
|
||||
const packed = (r << 16) | (g << 8) | b;
|
||||
if (packed !== prevPacked) {
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
prevPacked = packed;
|
||||
}
|
||||
ctx.fillText(CHAR_LUT[ch], xPos[col], yPos[row]);
|
||||
buf[row * stride + col] = ch;
|
||||
col++;
|
||||
if (col >= gridCols) { col = 0; row++; }
|
||||
}
|
||||
asciiPlayer.style.display = 'block';
|
||||
asciiPlayer.style.color = 'transparent';
|
||||
}
|
||||
|
||||
// ───────── WebSocket ─────────
|
||||
function connectWs(sid) {
|
||||
frameBuffer.length = 0;
|
||||
frameCount = 0;
|
||||
measuredFps = 0;
|
||||
fpsWindowFrames = 0;
|
||||
fpsWindowStart = 0;
|
||||
fpsBelowThreshold = false;
|
||||
readyToRender = false;
|
||||
clockAnchored = false;
|
||||
lastPaintTime = 0;
|
||||
maxPaintInterval = 0;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws/video?session=${encodeURIComponent(sid)}`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => setStatus('BUFFERING', 'resolving');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
if (event.data.startsWith('Error:')) {
|
||||
setError(event.data.replace(/^Error:\s*/, ''));
|
||||
closeWs();
|
||||
return;
|
||||
}
|
||||
if (event.data.startsWith('INIT:')) {
|
||||
wsRetryCount = 0;
|
||||
const p = event.data.split(':');
|
||||
targetFps = parseFloat(p[1]);
|
||||
renderMode = parseInt(p[2]);
|
||||
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
|
||||
setStatus('PLAYING', 'playing');
|
||||
idleOverlay.classList.add('hidden');
|
||||
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.src = `/audio?session=${encodeURIComponent(sid)}&_=${Date.now()}`;
|
||||
audioEl.muted = isMuted;
|
||||
audioEl.volume = parseFloat(volumeSlider.value);
|
||||
audioEl.load();
|
||||
audioEl.play().catch((err) => {
|
||||
// Surface failures rather than swallow them — a
|
||||
// Secure-cookie-on-http misconfig used to 403 /audio
|
||||
// silently.
|
||||
statusPerf.textContent = `audio: ${err && err.name || 'error'}`;
|
||||
statusPerf.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
readyToRender = true;
|
||||
lastFpsUpdate = performance.now();
|
||||
fpsWindowStart = lastFpsUpdate;
|
||||
requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
// Mode-1 text frame: "<idx>\n<frame text>".
|
||||
const nl = event.data.indexOf('\n');
|
||||
const idx = parseInt(event.data.substring(0, nl));
|
||||
frameBuffer.push({ data: event.data.substring(nl + 1), time: idx / targetFps });
|
||||
} else {
|
||||
const view = new DataView(event.data);
|
||||
const idx = view.getUint32(0, false);
|
||||
frameBuffer.push({ data: new Uint8Array(event.data, 4), time: idx / targetFps });
|
||||
}
|
||||
while (frameBuffer.length > FRAME_BUFFER_MAX) frameBuffer.shift();
|
||||
};
|
||||
|
||||
ws.onclose = (evt) => {
|
||||
// 1000 = normal close from our own stop/replace; 4003 = auth rejected.
|
||||
if (appState !== 'playing' || evt.code === 1000 || evt.code === 4003) {
|
||||
if (appState === 'playing') {
|
||||
setStatus('ENDED', 'idle');
|
||||
finishPlayback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
wsRetryCount++;
|
||||
if (wsRetryCount > WS_MAX_RETRIES) {
|
||||
setError(`Stream disconnected — reconnect failed after ${WS_MAX_RETRIES} attempts`);
|
||||
finishPlayback();
|
||||
return;
|
||||
}
|
||||
const delay = wsRetryCount * 1000;
|
||||
setStatus(`RECONNECTING (${wsRetryCount}/${WS_MAX_RETRIES})…`, 'resolving');
|
||||
wsRetryTimer = setTimeout(() => {
|
||||
wsRetryTimer = null;
|
||||
if (appState === 'playing' && sessionId === sid) connectWs(sid);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => { /* onclose drives reconnect */ };
|
||||
}
|
||||
|
||||
// ───────── Control API ─────────
|
||||
async function doPlay() {
|
||||
const query = queryInput.value.trim();
|
||||
if (!query) { queryInput.focus(); return; }
|
||||
|
||||
presetHint.classList.add('hidden');
|
||||
stopPoll();
|
||||
closeWs();
|
||||
clearCanvas();
|
||||
|
||||
wsRetryCount = 0;
|
||||
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
|
||||
|
||||
appState = 'resolving';
|
||||
currentPreset = selectedPreset;
|
||||
setUiState('resolving');
|
||||
setStatus('RESOLVING…', 'resolving');
|
||||
statusTitle.textContent = query;
|
||||
statusLive.classList.add('hidden');
|
||||
statusFps.textContent = '';
|
||||
statusPerf.classList.add('hidden');
|
||||
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, preset: currentPreset }),
|
||||
});
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
setError('Network error — ' + err.message);
|
||||
setUiState('idle');
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
setError(data.error || `Server error ${resp.status}`);
|
||||
setUiState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
sessionId = data.session_id;
|
||||
appState = 'playing';
|
||||
setUiState('playing');
|
||||
updateTitle(data.title, data.is_live);
|
||||
connectWs(sessionId);
|
||||
startPoll();
|
||||
}
|
||||
|
||||
async function doStop() {
|
||||
stopPoll();
|
||||
wsRetryCount = 0;
|
||||
if (wsRetryTimer) { clearTimeout(wsRetryTimer); wsRetryTimer = null; }
|
||||
try { await fetch('/api/stop', { method: 'POST' }); } catch (_) {}
|
||||
closeWs();
|
||||
finishPlayback();
|
||||
setStatus('IDLE', 'idle');
|
||||
statusTitle.textContent = '';
|
||||
statusLive.classList.add('hidden');
|
||||
presetHint.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ───────── Status polling ─────────
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (appState !== 'playing') { stopPoll(); return; }
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch('/api/status');
|
||||
data = await r.json();
|
||||
} catch (_) { return; }
|
||||
if (data.title) updateTitle(data.title, data.is_live);
|
||||
if (data.state === 'error') {
|
||||
setError(data.error || 'Stream error');
|
||||
closeWs();
|
||||
finishPlayback();
|
||||
stopPoll();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
|
||||
// ───────── UI helpers ─────────
|
||||
function setUiState(state) {
|
||||
btnPlay.disabled = false;
|
||||
btnStop.disabled = !(state === 'resolving' || state === 'playing');
|
||||
}
|
||||
|
||||
function setStatus(label, stateClass) {
|
||||
statusState.textContent = label;
|
||||
statusState.className = 'status-segment status-state';
|
||||
if (stateClass) statusState.classList.add('state-' + stateClass);
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
appState = 'error';
|
||||
statusTitle.textContent = '⚠ ' + msg;
|
||||
setStatus('ERROR', 'error');
|
||||
statusLive.classList.add('hidden');
|
||||
statusFps.textContent = '';
|
||||
statusPerf.classList.add('hidden');
|
||||
setUiState('idle');
|
||||
idleOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateTitle(title, isLive) {
|
||||
if (title) statusTitle.textContent = title;
|
||||
statusLive.classList.toggle('hidden', !isLive);
|
||||
}
|
||||
|
||||
function finishPlayback() {
|
||||
appState = 'idle';
|
||||
readyToRender = false;
|
||||
frameBuffer.length = 0;
|
||||
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
|
||||
clearCanvas();
|
||||
idleOverlay.classList.remove('hidden');
|
||||
setUiState('idle');
|
||||
statusFps.textContent = '';
|
||||
statusPerf.classList.add('hidden');
|
||||
fpsBelowThreshold = false;
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = 'none';
|
||||
asciiPlayer.textContent = '';
|
||||
asciiPlayer.style.display = 'none';
|
||||
}
|
||||
|
||||
function closeWs() {
|
||||
if (!ws) return;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
try { ws.close(); } catch (_) {}
|
||||
ws = null;
|
||||
}
|
||||
|
||||
// ───────── Selection / copy ─────────
|
||||
// The transparent <pre> exists so users can drag-select and copy the ASCII
|
||||
// frame. Materializing its textContent on every paint forces a layout
|
||||
// reflow per frame; instead, we materialize only when the user actually
|
||||
// starts a selection or fires a copy.
|
||||
const flushSelectionLayer = () => {
|
||||
if (!selectionBuffer || appState !== 'playing' || renderMode === 1) return;
|
||||
asciiPlayer.textContent = textDecoder.decode(selectionBuffer);
|
||||
};
|
||||
asciiPlayer.addEventListener('selectstart', flushSelectionLayer);
|
||||
document.addEventListener('copy', flushSelectionLayer);
|
||||
|
||||
// ───────── Wiring ─────────
|
||||
btnPlay.addEventListener('click', doPlay);
|
||||
btnStop.addEventListener('click', doStop);
|
||||
queryInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doPlay(); });
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (gridCols > 0 && appState === 'playing') buildCanvas(gridCols, gridRows);
|
||||
});
|
||||
84
static/index.html
Normal file
84
static/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ASCILINE</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── TOP BAR ─────────────────────────────────────────────────── -->
|
||||
<header id="topbar">
|
||||
<div id="brand">ASCILINE</div>
|
||||
<input
|
||||
id="query-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="Video ID, URL or search…"
|
||||
aria-label="Video query"
|
||||
>
|
||||
<button id="btn-play" class="btn btn-primary" aria-label="Play">
|
||||
<span class="btn-icon">▶</span>
|
||||
<span class="btn-label">PLAY</span>
|
||||
</button>
|
||||
<button id="btn-stop" class="btn btn-secondary" aria-label="Stop" disabled>
|
||||
<span class="btn-icon">■</span>
|
||||
<span class="btn-label">STOP</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ── SUB BAR ─────────────────────────────────────────────────── -->
|
||||
<div id="subbar">
|
||||
<div id="preset-group" role="group" aria-label="Quality preset">
|
||||
<span class="subbar-label">PRESET</span>
|
||||
<button class="preset-btn active" data-preset="low">LOW</button>
|
||||
<button class="preset-btn" data-preset="med">MED</button>
|
||||
<button class="preset-btn" data-preset="high">HIGH</button>
|
||||
</div>
|
||||
<div id="preset-hint" class="subbar-hint hidden">
|
||||
Preset change takes effect on next Play
|
||||
</div>
|
||||
<div id="volume-group">
|
||||
<span class="subbar-label">VOL</span>
|
||||
<button id="btn-mute" class="icon-btn" aria-label="Toggle mute">🔊</button>
|
||||
<input type="range" id="volume-slider" min="0" max="1" step="0.05" value="1"
|
||||
aria-label="Volume">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CANVAS PLAYER ───────────────────────────────────────────── -->
|
||||
<main id="player-container">
|
||||
<pre id="ascii-player"></pre>
|
||||
<canvas id="ascii-canvas"></canvas>
|
||||
|
||||
<div id="idle-overlay">
|
||||
<div id="idle-content">
|
||||
<div id="idle-logo">ASCILINE</div>
|
||||
<div id="idle-sub">ASCII video streaming</div>
|
||||
<div id="idle-arrow">↑ type a video above and hit PLAY</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ── STATUS BAR ──────────────────────────────────────────────── -->
|
||||
<footer id="statusbar">
|
||||
<span id="status-title" class="status-segment"></span>
|
||||
<span id="status-live" class="live-badge hidden">● LIVE</span>
|
||||
<span id="status-state" class="status-segment status-state">IDLE</span>
|
||||
<span id="status-fps" class="status-segment status-fps"></span>
|
||||
<span id="status-perf-hint" class="status-segment status-perf hidden">↓ try a lower preset</span>
|
||||
</footer>
|
||||
|
||||
<!-- hidden audio element; no src until Play -->
|
||||
<audio id="ascii-audio" preload="auto"></audio>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
285
static/pin.html
Normal file
285
static/pin.html
Normal file
@@ -0,0 +1,285 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ASCILINE — Enter PIN</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<style>
|
||||
/* Inline minimal theme — matches style.css variables without importing it
|
||||
so this page loads before /static/ is auth-gated. */
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--bg2: #0d0d0d;
|
||||
--bg3: #1a1a1a;
|
||||
--border: #2a2a2a;
|
||||
--border-hi: #3d3d3d;
|
||||
--accent: #F5A623;
|
||||
--accent-dim:#b87a18;
|
||||
--fg: #e8e8e8;
|
||||
--fg-dim: #888;
|
||||
--fg-faint: #444;
|
||||
--red: #e84040;
|
||||
--green: #3ecf6e;
|
||||
--font-ui: 'Arial Narrow', Arial, sans-serif;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-ui);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#card {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#brand {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 6px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
#subtitle {
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--fg-faint);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 32px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* PIN dots */
|
||||
#pin-display {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pin-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-hi);
|
||||
background: transparent;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.pin-dot.filled {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
#msg {
|
||||
height: 22px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
#msg.error { color: var(--red); }
|
||||
#msg.locked { color: var(--red); }
|
||||
|
||||
/* Keypad */
|
||||
#keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.key {
|
||||
height: 72px;
|
||||
background: var(--bg3);
|
||||
border: 1.5px solid var(--border-hi);
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 80ms, border-color 80ms, transform 60ms;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.key:active:not(:disabled) {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.key:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.key-clear {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.key-zero {
|
||||
/* 0 sits in the middle column of the last row */
|
||||
}
|
||||
.key-submit {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
font-size: 20px;
|
||||
}
|
||||
.key-submit:hover:not(:disabled) {
|
||||
background: #ffb838;
|
||||
border-color: #ffb838;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="card">
|
||||
<div id="brand">ASCILINE</div>
|
||||
<div id="subtitle">Enter PIN to continue</div>
|
||||
|
||||
<div id="pin-display"></div>
|
||||
<div id="msg"></div>
|
||||
|
||||
<div id="keypad">
|
||||
<button class="key" data-digit="1">1</button>
|
||||
<button class="key" data-digit="2">2</button>
|
||||
<button class="key" data-digit="3">3</button>
|
||||
<button class="key" data-digit="4">4</button>
|
||||
<button class="key" data-digit="5">5</button>
|
||||
<button class="key" data-digit="6">6</button>
|
||||
<button class="key" data-digit="7">7</button>
|
||||
<button class="key" data-digit="8">8</button>
|
||||
<button class="key" data-digit="9">9</button>
|
||||
<button class="key key-clear" data-action="clear">CLR</button>
|
||||
<button class="key key-zero" data-digit="0">0</button>
|
||||
<button class="key key-submit" data-action="submit">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const MAX_LEN = 8;
|
||||
let pin = "";
|
||||
let locked = false;
|
||||
|
||||
const display = document.getElementById("pin-display");
|
||||
const msg = document.getElementById("msg");
|
||||
const keys = document.querySelectorAll(".key");
|
||||
|
||||
function renderDots(len) {
|
||||
display.innerHTML = "";
|
||||
for (let i = 0; i < Math.max(len, 4); i++) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pin-dot" + (i < len ? " filled" : "");
|
||||
display.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
function setMsg(text, cls) {
|
||||
msg.textContent = text;
|
||||
msg.className = cls || "";
|
||||
}
|
||||
|
||||
function setDisabled(state) {
|
||||
keys.forEach(k => k.disabled = state);
|
||||
}
|
||||
|
||||
renderDots(0);
|
||||
|
||||
document.getElementById("keypad").addEventListener("click", function (e) {
|
||||
if (locked) return;
|
||||
const btn = e.target.closest(".key");
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
const digit = btn.dataset.digit;
|
||||
const action = btn.dataset.action;
|
||||
|
||||
if (digit !== undefined) {
|
||||
if (pin.length < MAX_LEN) {
|
||||
pin += digit;
|
||||
renderDots(pin.length);
|
||||
setMsg("");
|
||||
}
|
||||
} else if (action === "clear") {
|
||||
pin = "";
|
||||
renderDots(0);
|
||||
setMsg("");
|
||||
} else if (action === "submit") {
|
||||
if (pin.length === 0) { setMsg("Enter your PIN", ""); return; }
|
||||
submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Also support physical keyboard for desktop testing.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (locked) return;
|
||||
if (e.key >= "0" && e.key <= "9") {
|
||||
if (pin.length < MAX_LEN) {
|
||||
pin += e.key;
|
||||
renderDots(pin.length);
|
||||
setMsg("");
|
||||
}
|
||||
} else if (e.key === "Backspace") {
|
||||
pin = pin.slice(0, -1);
|
||||
renderDots(pin.length);
|
||||
setMsg("");
|
||||
} else if (e.key === "Enter") {
|
||||
submit();
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
setDisabled(true);
|
||||
setMsg("Checking…", "");
|
||||
|
||||
fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pin: pin }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.status === 200) {
|
||||
setMsg("OK", "");
|
||||
// Small delay so the user sees "OK" before redirect.
|
||||
setTimeout(function () { window.location.replace("/"); }, 300);
|
||||
} else if (res.status === 429) {
|
||||
locked = true;
|
||||
setDisabled(true);
|
||||
const secs = res.data.retry_after || 600;
|
||||
setMsg("Too many attempts. Wait " + Math.ceil(secs / 60) + " min.", "locked");
|
||||
} else {
|
||||
pin = "";
|
||||
renderDots(0);
|
||||
setDisabled(false);
|
||||
setMsg("Wrong PIN", "error");
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
setDisabled(false);
|
||||
setMsg("Network error", "error");
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
412
static/style.css
Normal file
412
static/style.css
Normal file
@@ -0,0 +1,412 @@
|
||||
/* ── ASCILINE Phase 4 — Tesla Dashboard UI ──────────────────────────────
|
||||
Aesthetic: industrial utility. Barlow Condensed (labels/buttons) +
|
||||
JetBrains Mono (status readout). Amber accent on pure black.
|
||||
Touch targets ≥ 48px throughout. ──────────────────────────────────── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
/* ── CSS variables ────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--bg2: #0d0d0d;
|
||||
--bg3: #1a1a1a;
|
||||
--border: #2a2a2a;
|
||||
--border-hi: #3d3d3d;
|
||||
--accent: #F5A623;
|
||||
--accent-dim: #b87a18;
|
||||
--accent-glow: rgba(245,166,35,.18);
|
||||
--fg: #e8e8e8;
|
||||
--fg-dim: #888;
|
||||
--fg-faint: #444;
|
||||
--red: #e84040;
|
||||
--green: #3ecf6e;
|
||||
--font-ui: 'Barlow Condensed', 'Arial Narrow', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
|
||||
--topbar-h: 60px;
|
||||
--subbar-h: 48px;
|
||||
--statusbar-h: 40px;
|
||||
}
|
||||
|
||||
/* ── Reset ────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-ui);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── TOP BAR ──────────────────────────────────────────────────────────── */
|
||||
#topbar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: var(--topbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#brand {
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#query-input {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
background: var(--bg3);
|
||||
border: 1.5px solid var(--border-hi);
|
||||
border-radius: 6px;
|
||||
color: var(--fg);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 16px;
|
||||
outline: none;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
min-width: 0;
|
||||
caret-color: var(--accent);
|
||||
}
|
||||
|
||||
#query-input::placeholder { color: var(--fg-faint); }
|
||||
|
||||
#query-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background 120ms, border-color 120ms, opacity 120ms, transform 80ms;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(0.96); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #ffb838;
|
||||
border-color: #ffb838;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: var(--fg-faint);
|
||||
border-color: var(--fg-faint);
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border-color: var(--border-hi);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--fg-dim);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary:disabled {
|
||||
color: var(--fg-faint);
|
||||
border-color: var(--fg-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon { font-size: 14px; }
|
||||
.btn-label { line-height: 1; }
|
||||
|
||||
/* ── SUB BAR ──────────────────────────────────────────────────────────── */
|
||||
#subbar {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
left: 0; right: 0;
|
||||
height: var(--subbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 14px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.subbar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.5px;
|
||||
color: var(--fg-faint);
|
||||
margin-right: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#preset-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
border: 1.5px solid var(--border-hi);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, color 120ms, border-color 120ms;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
margin-left: -1px; /* collapse borders */
|
||||
}
|
||||
.preset-btn:first-of-type { border-radius: 5px 0 0 5px; margin-left: 0; }
|
||||
.preset-btn:last-of-type { border-radius: 0 5px 5px 0; }
|
||||
.preset-btn:active { background: var(--accent-dim); }
|
||||
.preset-btn.active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-color: var(--accent);
|
||||
z-index: 1;
|
||||
}
|
||||
.preset-btn:hover:not(.active) {
|
||||
background: var(--bg3);
|
||||
color: var(--fg);
|
||||
border-color: var(--fg-dim);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#preset-hint {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 12px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 300ms;
|
||||
}
|
||||
|
||||
#volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--border-hi);
|
||||
border-radius: 5px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.icon-btn:hover { border-color: var(--fg-dim); color: var(--fg); }
|
||||
.icon-btn.muted { color: var(--fg-faint); }
|
||||
|
||||
#volume-slider {
|
||||
width: 110px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* ── PLAYER CONTAINER ─────────────────────────────────────────────────── */
|
||||
#player-container {
|
||||
position: fixed;
|
||||
top: calc(var(--topbar-h) + var(--subbar-h));
|
||||
left: 0; right: 0;
|
||||
bottom: var(--statusbar-h);
|
||||
background: #050505;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* vendored renderer sets these directly via style; we provide base values */
|
||||
#ascii-player {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
font-family: var(--font-mono);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ascii-canvas {
|
||||
display: none;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
/* ── IDLE OVERLAY ─────────────────────────────────────────────────────── */
|
||||
#idle-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #050505;
|
||||
z-index: 10;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
#idle-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#idle-content {
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#idle-logo {
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(48px, 10vw, 96px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 16px;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#idle-sub {
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--fg-faint);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
#idle-arrow {
|
||||
font-size: 15px;
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: 1px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 600;
|
||||
animation: pulse-arrow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-arrow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── STATUS BAR ────────────────────────────────────────────────────────── */
|
||||
#statusbar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: var(--statusbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 14px;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-segment {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#status-title {
|
||||
flex: 1;
|
||||
color: var(--fg-dim);
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
color: var(--red);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
border: 1.5px solid var(--red);
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
animation: live-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.status-state {
|
||||
color: var(--fg-faint);
|
||||
letter-spacing: 1.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-state.state-playing { color: var(--green); }
|
||||
.status-state.state-resolving { color: var(--accent); }
|
||||
.status-state.state-error { color: var(--red); }
|
||||
|
||||
.status-fps {
|
||||
color: var(--fg-faint);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.status-perf {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* shared utility */
|
||||
.hidden { display: none !important; }
|
||||
Reference in New Issue
Block a user