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.
286 lines
7.3 KiB
HTML
286 lines
7.3 KiB
HTML
<!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>
|