c58fa9591b
A retro CRT-themed web TV that channel-surfs live HLS streams through a security-hardened, same-origin Rust relay. Front end (vanilla JS + HLS.js, no framework/build step): - SourceProvider abstraction + canonical Channel/Program model so public streams and future self-hosted media share one UI (MockProvider built first to keep the UI provably source-agnostic; IptvOrgProvider for live data) - Red/black phosphor CRT theme, virtualized 1994-style guide (The Almanac), cable-box zapping with 7-segment OSD, scrying-static transitions - Theater/Dim/Ambient immersion modes, dead-stream "orb clouds over" handling - The Lexicon in-world naming with mundane-mode; full a11y + reduced-motion - HLS.js pinned + Subresource-Integrity verified Rust relay (hyper 1.x + tokio-rustls/ring + hickory-resolver): - Serves the SPA and proxies HLS from one origin (no CORS, untainted canvas) - SSRF defense: resolve -> reject private/loopback/link-local/metadata IPs -> connect to the validated IP (no re-resolve, defeats DNS-rebind), fail-closed - HTTPS-only egress, re-validated redirects, manifest URL rewrite, size/ concurrency caps, strict CSP + security headers, path-traversal guard - /metrics observability + structured logs (host only, never raw URLs) Verified: all SSRF probes rejected, CSP clean, real Apple HLS manifest rewritten, ~39k iptv-org channels fetched end-to-end, tests green, zero warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
56 lines
2.0 KiB
JavaScript
56 lines
2.0 KiB
JavaScript
// lexicon.js — in-world UI vocabulary (upgrade #3).
|
|
//
|
|
// Lore names are flavor layered on top of clear function. Visual labels get the
|
|
// lore word; ARIA labels ALWAYS stay plain-English (set directly in markup), so
|
|
// screen readers and the "mundane mode" toggle never lose meaning.
|
|
|
|
export const LEXICON = {
|
|
guide: 'The Almanac',
|
|
guideShort: 'Almanac',
|
|
favorites: 'The Grimoire',
|
|
search: 'Scry',
|
|
channelEntry: 'Incantation',
|
|
sleepTimer: 'The Orb Dims',
|
|
volume: 'Resonance',
|
|
noSignal: 'THE ORB CLOUDS OVER',
|
|
noSignalSub: 'no signal — seeking another vision',
|
|
loading: 'Summoning…',
|
|
nextUp: 'What Fate Foretells',
|
|
live: 'LIVE',
|
|
help: 'Incantations', // keyboard-help title
|
|
theater: 'Theater',
|
|
dim: 'Dim the Hall',
|
|
ambient: 'Orb Glow',
|
|
brand: 'THE PONDERING ORB',
|
|
};
|
|
|
|
const PLAIN = {
|
|
guide: 'TV Guide', guideShort: 'Guide', favorites: 'Favorites', search: 'Search',
|
|
channelEntry: 'Channel', sleepTimer: 'Sleep timer', volume: 'Volume',
|
|
noSignal: 'NO SIGNAL', noSignalSub: 'stream unavailable — trying another',
|
|
loading: 'Loading…', nextUp: 'Next up', live: 'LIVE', help: 'Keyboard shortcuts',
|
|
theater: 'Theater', dim: 'Dim', ambient: 'Ambient glow', brand: 'THE PONDERING ORB',
|
|
};
|
|
|
|
const KEY = 'orb.mundaneMode';
|
|
let mundane = (() => { try { return localStorage.getItem(KEY) === '1'; } catch { return false; } })();
|
|
|
|
/** @param {string} k @returns {string} */
|
|
export function lex(k) { return (mundane ? PLAIN : LEXICON)[k] ?? PLAIN[k] ?? k; }
|
|
|
|
export function isMundane() { return mundane; }
|
|
|
|
export function setMundane(on) {
|
|
mundane = !!on;
|
|
try { localStorage.setItem(KEY, mundane ? '1' : '0'); } catch { /* ignore */ }
|
|
applyLexicon();
|
|
}
|
|
|
|
/** Fill every [data-lex] element's textContent. Never touches aria-label. */
|
|
export function applyLexicon(root = document) {
|
|
for (const el of root.querySelectorAll('[data-lex]')) {
|
|
const k = el.getAttribute('data-lex');
|
|
el.textContent = lex(k);
|
|
}
|
|
}
|