feat: initial release of The Pondering Orb

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>
This commit is contained in:
2026-06-15 05:34:09 -04:00
commit c58fa9591b
22 changed files with 3851 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Rust
/server/target
**/*.rs.bk
# build artifacts
/dist
*.log
# editor
.vscode
.idea
*.swp
# OS
.DS_Store
# local config / secrets (never commit)
*.env
.env
config.local.*
*.key
*.pem
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ty Limoges
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+158
View File
@@ -0,0 +1,158 @@
<div align="center">
# 🔮 The Pondering Orb
**A retro CRT-themed web TV. Channel-surf live streams through a security-hardened, same-origin relay.**
*A reformed mad-wizard gazes into a scrying orb and flips channels like it's 1994 — but every upstream is treated as hostile.*
[![Stack](https://img.shields.io/badge/frontend-vanilla_JS_+_HLS.js-e10000?style=flat-square)](#tech-stack)
[![Relay](https://img.shields.io/badge/relay-Rust_(hyper_+_rustls)-e10000?style=flat-square)](#the-relay)
[![Security](https://img.shields.io/badge/posture-SSRF--hardened_·_strict_CSP-e10000?style=flat-square)](#security-posture)
[![License](https://img.shields.io/badge/license-MIT-e10000?style=flat-square)](#license)
</div>
---
## What it is
The Pondering Orb turns any HLS source into a 1994 cable-TV experience in the browser: a red/black phosphor CRT frame, a scrolling TV guide, channel zapping with a 7-segment OSD, and YouTube-style theater / dim / ambient-glow modes.
It is built around one idea: **a channel is "a named thing on a guide that resolves to an HLS stream at a point in time."** Public live streams (via [iptv-org](https://github.com/iptv-org/iptv)) and — later — your own self-hosted media library are just different *providers* feeding the same UI. No rewrite required to add the second.
> **"Purple team"** here is a security *identity* (red offense + blue defense), not a color. The palette stays red/black; the wizard theme is naming and lore on top of clear, accessible function.
This is a side project built side-by-side with a mentor's reference design (a NAS + ErsatzTV + Raspberry Pi feeding a physical CRT). Same premise — *your media as 24/7 TV channels* — different architecture: portable, browser-based, and security-first.
## Features
- 📺 **Authentic CRT presentation** — phosphor glow, scanlines, screen curvature, vignette breathing (all CSS, no WebGL).
- 🔢 **Cable-box channel zapping** — number entry with debounce + 7-segment OSD, channel up/down, last-channel toggle, auto-fading now-playing banner.
- 📡 **"Scrying Static" transitions** — procedural channel-change noise that clears the moment the new stream buffers (reduced-motion aware).
- 🗓️ **The Almanac** — a virtualized, 1994-style scrolling TV guide with now/next, a live current-time playhead, and keyboard navigation. Scales to tens of thousands of channels.
- 🎬 **Immersion stack** — Theater, Dim, and Ambient-backlight modes (à la YouTube), composable and keyboard-driven.
- 🌫️ **Graceful dead-stream handling** — "the orb clouds over" no-signal card, automatic fallback URLs, and auto-skip.
- 🧙 **The Lexicon** — optional in-world vocabulary (guide → *The Almanac*, search → *Scry*) with a one-click "mundane mode"; screen-reader labels always stay plain-English.
-**Accessible by default** — full keyboard map + help overlay, ARIA roles/live regions, visible focus rings, and a complete `prefers-reduced-motion` kill-switch.
## Security posture
Every stream URL comes from a public, third-party, mutable playlist — so the Orb treats all of it as hostile input. The browser only ever talks to its own origin; the Rust relay is the single egress point, and all SSRF risk is concentrated in one auditable module.
| Control | Implementation |
| --- | --- |
| **SSRF defense** | Resolves DNS itself, rejects the *whole host* if **any** resolved IP is private / loopback / link-local / CGNAT / cloud-metadata (`169.254.169.254`) / IPv6 ULA+link-local, then connects to that **exact validated IP** — no re-resolve, defeating DNS-rebinding. Fail-closed. |
| **HTTPS-only egress** | Non-`https` upstreams are refused outright. |
| **Bounded redirects** | Each redirect hop is re-validated through the same SSRF gate. |
| **Strict CSP** | `default-src 'none'`; no `unsafe-inline` / `unsafe-eval`; scripting, styling, and XHR all locked to `'self'`. |
| **Untrusted-input rendering** | Playlist/EPG fields are inserted as text only (never `innerHTML`); URL schemes allow-listed; field/length caps guard against client-side DoS. |
| **Hardened serving** | Path-traversal-safe static handler, `nosniff`, `no-referrer`, `X-Frame-Options: DENY`, locked `Permissions-Policy`. |
| **Supply chain** | HLS.js pinned to an exact version and Subresource-Integrity verified. |
| **Observability** | `/metrics` (Prometheus) counts relay outcomes including SSRF blocks; structured JSON logs record host + outcome, **never** the raw URL (query strings can carry stream tokens). |
### Verified
The current build passes its full probe battery: all SSRF vectors rejected (`403`/`400`), CSP headers present with no `unsafe-*`, path traversal blocked, a real Apple HLS manifest fetched and its nested URLs rewritten through the relay, and the live iptv-org catalog (~39k channels) retrieved end-to-end through the hardened path. Rust unit tests and JS syntax checks are green with zero build warnings.
## Quick start
**Full experience** (serves the SPA *and* the relay from one origin):
```sh
cd server
cargo run --release
# → http://127.0.0.1:8088
```
**Front-end only** (no relay; uses the built-in mock realm and CORS-clean test streams):
```sh
cd web
python3 -m http.server 8099
# → http://127.0.0.1:8099
```
### Keyboard
| Key | Action | | Key | Action |
| --- | --- | --- | --- | --- |
| `09` | Tune channel | | `T` | Theater mode |
| `↑ / ↓` | Volume · guide nav | | `D` | Dim mode |
| `PgUp/PgDn` | Channel up / down | | `A` | Ambient glow |
| `Backspace` | Last channel | | `F` | Fullscreen |
| `G` | Toggle the Almanac | | `?` | Help |
Append `?provider=mock` or `?provider=iptv` to force a data source.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Browser (same origin — no CORS, untainted ambient canvas) │
│ │
│ index.html ── app.js ──┬── player.js (HLS.js, controls) │
│ ├── zapper.js (tuning, OSD) │
│ ├── guide.js (virtualized EPG) │
│ ├── ambient.js (backlight) │
│ └── providers/ │
│ ├── source-provider.js ◄── the │
│ ├── mock.js interface
│ └── iptv-org.js │
└───────────────────────────────┬─────────────────────────────┘
│ / · /relay · /metrics
┌───────────────────────────────▼─────────────────────────────┐
│ Rust server (binds 127.0.0.1) │
│ main.rs static serving + security headers + observability│
│ relay.rs HLS proxy + manifest URL rewrite + redirects │
│ ssrf.rs resolve → validate IP → connect (sole egress) │
└───────────────────────────────┬─────────────────────────────┘
Public HLS origins · iptv-org JSON API
```
Everything in the UI talks only to the `SourceProvider` contract — never directly to iptv-org or any one source. `MockProvider` was built first to keep the UI provably source-agnostic; a future `LocalMediaProvider` (your own library, packaged to HLS) drops in alongside it.
### Tech stack
- **Front end:** vanilla JavaScript (ES modules) + [HLS.js](https://github.com/video-dev/hls.js) — no framework, no build step.
- **Relay:** Rust — [hyper](https://hyper.rs) 1.x, [tokio](https://tokio.rs), [rustls](https://github.com/rustls/rustls) (ring), [hickory-resolver](https://github.com/hickory-dns/hickory-dns).
## Roadmap
**Now (v1):** hardened same-origin relay · iptv-org provider · zapping · virtualized guide · theater/dim/ambient · dead-stream handling · observability.
**Next:** stream health prober + scoring · session resurrection · off-thread XMLTV parsing · service-worker PWA.
**Later:** *The Scryer's Lens* (live stream trust/provenance overlay) · *The Chronicle* (channel-lineup-as-code with diff/audit) · *Cursed Channels* (adversarial test harness that proves the defenses) · *The Wizard's Workbench* (compose your own channels; export as a signed "Spellbook") · `LocalMediaProvider` for self-hosted media.
## Project structure
```
web/ Static SPA
index.html CRT shell + markup
styles.css Red/black CRT theme (all effects, responsive, a11y)
app.js Bootstrap, global state, keyboard map, modes
player.js HLS.js wiring, ghost controls, dead-stream handling
zapper.js Channel tuning, OSD, scrying-static transition
guide.js Virtualized EPG overlay
ambient.js Ambient backlight canvas
lexicon.js In-world vocabulary + mundane-mode
providers/ SourceProvider contract + Mock + iptv-org
lib/ Pinned, SRI-verified HLS.js
server/ Rust same-origin server + hardened relay
src/{main,relay,ssrf}.rs
```
## Disclaimer
This project does **not** host, store, or transmit any media. It indexes **publicly available** stream URLs aggregated by the [iptv-org](https://github.com/iptv-org/iptv) project. You are solely responsible for ensuring any stream you access is licensed and lawful in your jurisdiction.
## Acknowledgments
- Inspired by a mentor's NAS → CRT "your movies as 24/7 channels" build.
- Channel data and the public stream index come from the excellent [iptv-org](https://github.com/iptv-org/iptv) community project.
## License
[MIT](LICENSE) — code only. Channel data and streams are the property of their respective owners.
+1184
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "pondering-orb"
version = "0.1.0"
edition = "2021"
description = "The Pondering Orb — hardened same-origin static server + HLS relay for a retro CRT web TV."
license = "MIT"
[[bin]]
name = "pondering-orb"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "sync", "fs"] }
hyper = { version = "1", features = ["server", "client", "http1"] }
hyper-util = { version = "0.1", features = ["server", "client", "client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
bytes = "1"
# TLS to upstream stream origins (https-only egress).
# Use the `ring` provider (pure-ish, no cmake/C toolchain) rather than the
# aws-lc-rs default, so the build stays self-contained.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
webpki-roots = "0.26"
# DNS resolution so we can validate the *resolved* IP before connecting (SSRF defense)
hickory-resolver = "0.24"
# robust URL parse + relative-join for manifest rewriting
url = "2"
[profile.release]
# reproducible-ish, lean binary (CI-gate task hardens this further)
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+330
View File
@@ -0,0 +1,330 @@
//! The Pondering Orb — same-origin static server + hardened HLS relay.
//!
//! Binds 127.0.0.1 by default. Serves web/ AND proxies streams from the same
//! origin so the SPA needs no CORS and the ambient canvas never taints. The
//! relay is the sole egress point; all SSRF defense lives in ssrf.rs.
mod relay;
mod ssrf;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use bytes::Bytes;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tokio_rustls::TlsConnector;
use url::Url;
use relay::{
build_tls_connector, fetch_validated, looks_like_manifest, max_for, rewrite_manifest,
RelayError,
};
const MAX_INFLIGHT_RELAY: usize = 32;
#[derive(Default)]
struct Metrics {
relay_ok: AtomicU64,
relay_ssrf_block: AtomicU64,
relay_upstream_fail: AtomicU64,
relay_ratelimit: AtomicU64,
relay_bad_request: AtomicU64,
}
struct AppState {
web_root: PathBuf,
tls: TlsConnector,
metrics: Metrics,
relay_gate: Semaphore,
}
#[tokio::main]
async fn main() {
let bind: SocketAddr = std::env::var("ORB_BIND")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| "127.0.0.1:8088".parse().unwrap());
let web_root = locate_web_root();
log_line("startup", &format!("web_root={}", web_root.display()), None, None);
let state = Arc::new(AppState {
web_root,
tls: build_tls_connector(),
metrics: Metrics::default(),
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
});
let listener = TcpListener::bind(bind).await.expect("bind");
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
loop {
let (stream, _peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
log_line("accept_error", &e.to_string(), None, None);
continue;
}
};
let io = TokioIo::new(stream);
let st = state.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| handle(st.clone(), req));
if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
// client disconnects are noise; log at debug granularity only
let _ = e;
}
});
}
}
fn locate_web_root() -> PathBuf {
if let Ok(p) = std::env::var("ORB_WEB_ROOT") {
return PathBuf::from(p);
}
for cand in ["web", "../web", "./web"] {
let p = PathBuf::from(cand);
if p.join("index.html").exists() {
return p.canonicalize().unwrap_or(p);
}
}
PathBuf::from("web")
}
async fn handle(
state: Arc<AppState>,
req: Request<Incoming>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let path = req.uri().path().to_string();
let resp = match path.as_str() {
"/relay" => serve_relay(&state, &req).await,
"/metrics" => serve_metrics(&state),
"/healthz" => text(StatusCode::OK, "ok"),
_ => serve_static(&state, &path).await,
};
Ok(with_security_headers(resp, &path))
}
// ----------------------------- /relay ------------------------------------
async fn serve_relay(state: &AppState, req: &Request<Incoming>) -> Response<Full<Bytes>> {
let query = req.uri().query().unwrap_or("");
let raw = url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == "url")
.map(|(_, v)| v.into_owned());
let Some(raw) = raw else {
state.metrics.relay_bad_request.fetch_add(1, Ordering::Relaxed);
return text(StatusCode::BAD_REQUEST, "missing url param");
};
let parsed = match Url::parse(&raw) {
Ok(u) => u,
Err(_) => {
state.metrics.relay_bad_request.fetch_add(1, Ordering::Relaxed);
return text(StatusCode::BAD_REQUEST, "malformed url");
}
};
if parsed.scheme() != "https" {
state.metrics.relay_bad_request.fetch_add(1, Ordering::Relaxed);
log_line("relay_block", "non-https", parsed.host_str(), None);
return text(StatusCode::BAD_REQUEST, "only https upstreams allowed");
}
// concurrency cap
let _permit = match state.relay_gate.try_acquire() {
Ok(p) => p,
Err(_) => {
state.metrics.relay_ratelimit.fetch_add(1, Ordering::Relaxed);
return text(StatusCode::SERVICE_UNAVAILABLE, "relay busy");
}
};
let is_manifest_hint = parsed.path().to_ascii_lowercase().ends_with(".m3u8");
let cap = max_for(is_manifest_hint);
let started = Instant::now();
match fetch_validated(&state.tls, &parsed, cap).await {
Ok(up) => {
let ms = started.elapsed().as_millis() as u64;
state.metrics.relay_ok.fetch_add(1, Ordering::Relaxed);
if looks_like_manifest(&up.final_url, &up.content_type) {
let text_body = String::from_utf8_lossy(&up.body);
let rewritten = rewrite_manifest(&text_body, &up.final_url);
log_line("relay_ok", &format!("manifest redirects={}", up.redirects), parsed.host_str(), Some(ms));
return bytes_resp(
StatusCode::OK,
"application/vnd.apple.mpegurl",
Bytes::from(rewritten),
);
}
log_line("relay_ok", &format!("segment redirects={}", up.redirects), parsed.host_str(), Some(ms));
bytes_resp(StatusCode::OK, &up.content_type, up.body)
}
Err(e) => {
let ms = started.elapsed().as_millis() as u64;
match &e {
RelayError::Ssrf(_) => {
state.metrics.relay_ssrf_block.fetch_add(1, Ordering::Relaxed);
log_line("relay_ssrf_block", &e.to_string(), parsed.host_str(), Some(ms));
text(StatusCode::FORBIDDEN, "blocked by relay policy")
}
_ => {
state.metrics.relay_upstream_fail.fetch_add(1, Ordering::Relaxed);
log_line("relay_upstream_fail", &e.to_string(), parsed.host_str(), Some(ms));
text(StatusCode::BAD_GATEWAY, "upstream unavailable")
}
}
}
}
}
// ----------------------------- /metrics ----------------------------------
fn serve_metrics(state: &AppState) -> Response<Full<Bytes>> {
let m = &state.metrics;
let body = format!(
"# HELP orb_relay_total Relay requests by outcome.\n\
# TYPE orb_relay_total counter\n\
orb_relay_total{{event=\"ok\"}} {}\n\
orb_relay_total{{event=\"ssrf_block\"}} {}\n\
orb_relay_total{{event=\"upstream_fail\"}} {}\n\
orb_relay_total{{event=\"ratelimit\"}} {}\n\
orb_relay_total{{event=\"bad_request\"}} {}\n",
m.relay_ok.load(Ordering::Relaxed),
m.relay_ssrf_block.load(Ordering::Relaxed),
m.relay_upstream_fail.load(Ordering::Relaxed),
m.relay_ratelimit.load(Ordering::Relaxed),
m.relay_bad_request.load(Ordering::Relaxed),
);
bytes_resp(StatusCode::OK, "text/plain; version=0.0.4", Bytes::from(body))
}
// ----------------------------- static ------------------------------------
async fn serve_static(state: &AppState, req_path: &str) -> Response<Full<Bytes>> {
let rel = if req_path == "/" { "index.html" } else { req_path.trim_start_matches('/') };
// Path-traversal guard: join, canonicalize, ensure still under web_root.
let joined = state.web_root.join(rel);
let canon = match joined.canonicalize() {
Ok(c) => c,
Err(_) => return text(StatusCode::NOT_FOUND, "not found"),
};
if !canon.starts_with(&state.web_root) {
return text(StatusCode::FORBIDDEN, "forbidden");
}
match tokio::fs::read(&canon).await {
Ok(bytes) => {
let ct = content_type_for(&canon);
// Inject the relay marker so the SPA auto-selects the live provider.
if canon.file_name().and_then(|n| n.to_str()) == Some("index.html") {
let html = String::from_utf8_lossy(&bytes).replacen(
"<head>",
"<head>\n<meta name=\"orb-relay\" content=\"1\">",
1,
);
return bytes_resp(StatusCode::OK, ct, Bytes::from(html));
}
bytes_resp(StatusCode::OK, ct, Bytes::from(bytes))
}
Err(_) => text(StatusCode::NOT_FOUND, "not found"),
}
}
fn content_type_for(p: &Path) -> &'static str {
match p.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("map") => "application/json",
_ => "application/octet-stream",
}
}
// ----------------------------- helpers -----------------------------------
fn with_security_headers(mut resp: Response<Full<Bytes>>, path: &str) -> Response<Full<Bytes>> {
let h = resp.headers_mut();
// Strict, same-origin CSP. img/media relaxed only where the design requires:
// - img-src https: → remote channel logos (images can't execute)
// - media-src blob: → HLS.js feeds segments to <video> via MSE blobs
// All scripting/styling/XHR stay 'self'; the relay is the only egress.
h.insert(
"Content-Security-Policy",
hyper::header::HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; \
media-src 'self' blob:; connect-src 'self'; font-src 'self'; object-src 'none'; \
base-uri 'none'; frame-src 'none'; form-action 'none'; frame-ancestors 'none'",
),
);
h.insert("X-Content-Type-Options", hyper::header::HeaderValue::from_static("nosniff"));
h.insert("Referrer-Policy", hyper::header::HeaderValue::from_static("no-referrer"));
h.insert("X-Frame-Options", hyper::header::HeaderValue::from_static("DENY"));
h.insert(
"Permissions-Policy",
hyper::header::HeaderValue::from_static("camera=(), microphone=(), geolocation=(), interest-cohort=()"),
);
// HSTS is only meaningful over TLS; harmless on loopback but include for parity.
h.insert(
"Strict-Transport-Security",
hyper::header::HeaderValue::from_static("max-age=31536000"),
);
let _ = path;
resp
}
fn bytes_resp(status: StatusCode, content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header(hyper::header::CONTENT_TYPE, content_type)
.body(Full::new(body))
.unwrap()
}
fn text(status: StatusCode, msg: &str) -> Response<Full<Bytes>> {
bytes_resp(status, "text/plain; charset=utf-8", Bytes::from(msg.to_string()))
}
/// Structured one-line JSON log. We log host (not full URL) + outcome — query
/// strings can carry stream tokens, so the raw URL is never recorded.
fn log_line(event: &str, detail: &str, host: Option<&str>, latency_ms: Option<u64>) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let host = host.unwrap_or("-");
match latency_ms {
Some(ms) => println!(
"{{\"ts\":{ts},\"event\":\"{event}\",\"host\":\"{host}\",\"latency_ms\":{ms},\"detail\":\"{}\"}}",
esc(detail)
),
None => println!(
"{{\"ts\":{ts},\"event\":\"{event}\",\"host\":\"{host}\",\"detail\":\"{}\"}}",
esc(detail)
),
}
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', " ")
}
+303
View File
@@ -0,0 +1,303 @@
//! Hardened HLS relay.
//!
//! - https-only upstreams
//! - every hop SSRF-validated (resolve → reject internal IPs → connect to the
//! exact validated IP, no re-resolve)
//! - bounded redirect following, each redirect re-validated
//! - response size caps
//! - HLS manifests rewritten so every nested URL (variants, segments, keys)
//! flows back through this same relay
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Limited};
use hyper::client::conn::http1;
use hyper::{Request, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use url::Url;
use crate::ssrf::{resolve_and_validate, SsrfError};
const MAX_MANIFEST: usize = 10 * 1024 * 1024; // 10 MB
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
const MAX_REDIRECTS: u32 = 4;
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
#[derive(Debug)]
pub enum RelayError {
Ssrf(SsrfError),
NotHttps,
BadUrl,
Connect(String),
Tls(String),
Http(String),
TooLarge,
TooManyRedirects,
Upstream(StatusCode),
}
impl std::fmt::Display for RelayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RelayError::Ssrf(e) => write!(f, "ssrf: {e}"),
RelayError::NotHttps => write!(f, "only https upstreams allowed"),
RelayError::BadUrl => write!(f, "malformed url"),
RelayError::Connect(e) => write!(f, "connect: {e}"),
RelayError::Tls(e) => write!(f, "tls: {e}"),
RelayError::Http(e) => write!(f, "http: {e}"),
RelayError::TooLarge => write!(f, "response exceeds size cap"),
RelayError::TooManyRedirects => write!(f, "too many redirects"),
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
}
}
}
pub struct Upstream {
pub status: StatusCode,
pub content_type: String,
pub body: Bytes,
pub final_url: Url,
pub redirects: u32,
}
/// Build the shared rustls/TLS connector (webpki roots, explicit ring provider).
pub fn build_tls_connector() -> TlsConnector {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("tls protocol versions")
.with_root_certificates(roots)
.with_no_client_auth();
TlsConnector::from(Arc::new(config))
}
fn is_https(u: &Url) -> bool {
u.scheme() == "https"
}
/// One validated HTTPS GET (no redirect following — caller loops).
async fn fetch_once(
connector: &TlsConnector,
url: &Url,
max_bytes: usize,
) -> Result<Upstream, RelayError> {
if !is_https(url) {
return Err(RelayError::NotHttps);
}
let host = url.host_str().ok_or(RelayError::BadUrl)?.to_string();
let port = url.port_or_known_default().unwrap_or(443);
// SSRF: resolve ourselves, validate, connect to that exact IP.
let addr = resolve_and_validate(&host, port)
.await
.map_err(RelayError::Ssrf)?;
let tcp = TcpStream::connect(addr)
.await
.map_err(|e| RelayError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
let tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| RelayError::Tls(e.to_string()))?;
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
tokio::spawn(async move {
let _ = conn.await;
});
let mut pq = url.path().to_string();
if let Some(q) = url.query() {
pq.push('?');
pq.push_str(q);
}
let req = Request::builder()
.uri(pq)
.header(hyper::header::HOST, host.as_str())
.header(hyper::header::USER_AGENT, UA)
.header(hyper::header::ACCEPT, "*/*")
.body(Empty::<Bytes>::new())
.map_err(|e| RelayError::Http(e.to_string()))?;
let resp = sender
.send_request(req)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
let status = resp.status();
let content_type = resp
.headers()
.get(hyper::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let location = resp
.headers()
.get(hyper::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Redirect: return an empty body, let the caller re-validate + re-fetch.
if status.is_redirection() {
if let Some(loc) = location {
let next = url.join(&loc).map_err(|_| RelayError::BadUrl)?;
return Ok(Upstream {
status,
content_type,
body: Bytes::new(),
final_url: next,
redirects: 0,
});
}
}
let limited = Limited::new(resp.into_body(), max_bytes);
let collected = limited.collect().await.map_err(|_| RelayError::TooLarge)?;
let body = collected.to_bytes();
Ok(Upstream {
status,
content_type,
body,
final_url: url.clone(),
redirects: 0,
})
}
/// Public entry: validated GET with bounded, re-validated redirect following.
pub async fn fetch_validated(
connector: &TlsConnector,
start: &Url,
max_bytes: usize,
) -> Result<Upstream, RelayError> {
let mut current = start.clone();
let mut redirects = 0u32;
loop {
let up = fetch_once(connector, &current, max_bytes).await?;
if up.status.is_redirection() && !up.final_url.as_str().is_empty() {
redirects += 1;
if redirects > MAX_REDIRECTS {
return Err(RelayError::TooManyRedirects);
}
current = up.final_url;
continue;
}
if up.status.is_server_error() || up.status.is_client_error() {
return Err(RelayError::Upstream(up.status));
}
return Ok(Upstream { redirects, ..up });
}
}
/// Heuristic: is this an HLS manifest we should rewrite?
pub fn looks_like_manifest(url: &Url, content_type: &str) -> bool {
let ct = content_type.to_ascii_lowercase();
ct.contains("mpegurl")
|| ct.contains("application/x-mpegurl")
|| ct.contains("vnd.apple.mpegurl")
|| url.path().to_ascii_lowercase().ends_with(".m3u8")
}
/// Rewrite every nested URI in an HLS manifest so it routes back through /relay.
/// Relative URIs are resolved against `base` first. Never executes content.
pub fn rewrite_manifest(text: &str, base: &Url) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 4);
for raw in text.split_inclusive('\n') {
let line = raw.trim_end_matches(['\n', '\r']);
let nl = &raw[line.len()..];
if line.is_empty() {
out.push_str(raw);
continue;
}
if line.starts_with('#') {
out.push_str(&rewrite_tag_uris(line, base));
out.push_str(nl);
continue;
}
// bare URI line (variant playlist or media segment)
match base.join(line) {
Ok(abs) => {
out.push_str(&wrap(&abs));
}
Err(_) => out.push_str(line), // leave untouched if unparseable
}
out.push_str(nl);
}
out
}
/// Rewrite URI="..." attributes inside EXT-X-* tags (KEY, MEDIA, MAP, I-FRAME…).
fn rewrite_tag_uris(line: &str, base: &Url) -> String {
const NEEDLE: &str = "URI=\"";
let Some(start) = line.find(NEEDLE) else {
return line.to_string();
};
let uri_start = start + NEEDLE.len();
let Some(rel_end) = line[uri_start..].find('"') else {
return line.to_string();
};
let end = uri_start + rel_end;
let uri = &line[uri_start..end];
match base.join(uri) {
Ok(abs) => {
let mut s = String::with_capacity(line.len() + 40);
s.push_str(&line[..uri_start]);
s.push_str(&wrap_raw(&abs));
s.push_str(&line[end..]);
s
}
Err(_) => line.to_string(),
}
}
/// "/relay?url=<pct-encoded absolute>"
fn wrap(abs: &Url) -> String {
format!("/relay?url={}", pct(abs.as_str()))
}
fn wrap_raw(abs: &Url) -> String {
// for inside an already-quoted attribute
wrap(abs)
}
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
fn pct(s: &str) -> String {
const UNRESERVED: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
let mut out = String::with_capacity(s.len() * 3);
for &b in s.as_bytes() {
if UNRESERVED.contains(&b) {
out.push(b as char);
} else {
out.push('%');
out.push(hex((b >> 4) & 0xf));
out.push(hex(b & 0xf));
}
}
out
}
fn hex(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
_ => (b'A' + (n - 10)) as char,
}
}
pub const fn max_for(is_manifest: bool) -> usize {
if is_manifest {
MAX_MANIFEST
} else {
MAX_SEGMENT
}
}
+128
View File
@@ -0,0 +1,128 @@
//! SSRF defense — the whole reason the relay exists.
//!
//! Every upstream URL is attacker-influenced (it comes from public iptv-org
//! playlists). We resolve the host ourselves, reject any resolved IP that is
//! private / loopback / link-local / cloud-metadata / IPv6-local, and then
//! connect to *that exact validated IP* — never re-resolving — so a DNS-rebind
//! between check and connect cannot smuggle us onto an internal address.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::TokioAsyncResolver;
#[derive(Debug)]
pub enum SsrfError {
Resolve(String),
NoSafeAddress,
Blocked(IpAddr),
}
impl std::fmt::Display for SsrfError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SsrfError::Resolve(e) => write!(f, "dns resolve failed: {e}"),
SsrfError::NoSafeAddress => write!(f, "no non-forbidden address for host"),
SsrfError::Blocked(ip) => write!(f, "address blocked by SSRF policy: {ip}"),
}
}
}
impl std::error::Error for SsrfError {}
/// True if this IP must never be reached by the relay.
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_forbidden_v4(v4),
IpAddr::V6(v6) => is_forbidden_v6(v6),
}
}
fn is_forbidden_v4(ip: Ipv4Addr) -> bool {
ip.is_private() // 10/8, 172.16/12, 192.168/16
|| ip.is_loopback() // 127/8
|| ip.is_link_local() // 169.254/16 (incl. 169.254.169.254 metadata)
|| ip.is_broadcast() // 255.255.255.255
|| ip.is_documentation() // 192.0.2/24, 198.51.100/24, 203.0.113/24
|| ip.is_unspecified() // 0.0.0.0
|| ip.octets()[0] == 0 // 0.0.0.0/8
|| (ip.octets()[0] == 100 && (ip.octets()[1] & 0xC0) == 64) // 100.64/10 CGNAT/Tailscale
|| (ip.octets()[0] & 0xF0) == 0xF0 // 240/4 reserved (+ multicast handled below)
|| ip.is_multicast() // 224/4
}
fn is_forbidden_v6(ip: Ipv6Addr) -> bool {
// Reject IPv4-mapped/compat addresses by re-checking the embedded v4.
if let Some(v4) = ip.to_ipv4_mapped() {
return is_forbidden_v4(v4);
}
if let Some(v4) = ip.to_ipv4() {
return is_forbidden_v4(v4);
}
ip.is_loopback() // ::1
|| ip.is_unspecified() // ::
|| ip.is_multicast() // ff00::/8
|| (ip.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|| (ip.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
}
/// Resolve `host` and return one validated, connectable address.
/// Rejects the whole host if ANY resolved address is forbidden (fail-closed:
/// prevents a host that returns one public + one internal record from slipping
/// through on a later lookup).
pub async fn resolve_and_validate(host: &str, port: u16) -> Result<SocketAddr, SsrfError> {
// A bare IP literal in the URL must also be validated.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_forbidden_ip(ip) {
return Err(SsrfError::Blocked(ip));
}
return Ok(SocketAddr::new(ip, port));
}
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
Ok(r) => r,
Err(_) => TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
};
let lookup = resolver
.lookup_ip(host)
.await
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
let mut chosen: Option<IpAddr> = None;
for ip in lookup.iter() {
if is_forbidden_ip(ip) {
return Err(SsrfError::Blocked(ip)); // fail-closed
}
if chosen.is_none() {
chosen = Some(ip);
}
}
match chosen {
Some(ip) => Ok(SocketAddr::new(ip, port)),
None => Err(SsrfError::NoSafeAddress),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_metadata_and_private() {
for s in [
"169.254.169.254", "127.0.0.1", "10.0.0.1", "192.168.1.1",
"172.16.5.4", "0.0.0.0", "100.100.100.100", "::1",
"fe80::1", "fc00::1", "::ffff:127.0.0.1",
] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_forbidden_ip(ip), "should block {s}");
}
}
#[test]
fn allows_public() {
for s in ["1.1.1.1", "104.21.7.81", "8.8.8.8", "2606:4700::1111"] {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_forbidden_ip(ip), "should allow {s}");
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// ambient.js — YouTube-style ambient backlight (upgrade in v1 UI).
//
// A tiny 32x18 offscreen sample of the video, drawn to a canvas BEHIND the
// bezel and blurred huge by CSS, so the screen's edge colors bleed outward as a
// phosphor halo. Cheap: ~20fps, sampled every 3rd rAF. Works only because the
// relay makes video same-origin (canvas never tainted). Respects reduced-motion.
const W = 32, H = 18;
export class Ambient {
/** @param {HTMLVideoElement} video @param {HTMLCanvasElement} canvas */
constructor(video, canvas) {
this.video = video;
this.canvas = canvas;
this.canvas.width = W;
this.canvas.height = H;
this.ctx = canvas.getContext('2d', { willReadFrequently: false });
this.raf = 0;
this.frame = 0;
this.active = false;
this._reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
}
start() {
if (this.active || this._reduced) return;
this.active = true;
const loop = () => {
if (!this.active) return;
this.raf = requestAnimationFrame(loop);
if ((this.frame++ % 3) !== 0) return; // ~20fps
if (document.hidden) return;
const v = this.video;
if (v.readyState < 2 || v.videoWidth === 0) return;
try { this.ctx.drawImage(v, 0, 0, W, H); }
catch { /* tainted (cross-origin) — bail quietly */ this.stop(); }
};
this.raf = requestAnimationFrame(loop);
}
stop() {
this.active = false;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
toggle() { this.active ? this.stop() : this.start(); return this.active; }
}
+184
View File
@@ -0,0 +1,184 @@
// app.js — bootstrap + global state + keyboard map + immersion modes.
import { ChannelAggregator } from './providers/source-provider.js';
import { MockProvider } from './providers/mock.js';
import { IptvOrgProvider } from './providers/iptv-org.js';
import { Player } from './player.js';
import { Zapper } from './zapper.js';
import { Guide } from './guide.js';
import { Ambient } from './ambient.js';
import { applyLexicon, setMundane, isMundane } from './lexicon.js';
const $ = (id) => document.getElementById(id);
const els = {
root: $('orb-root'),
screen: $('screen'),
video: $('v'),
ambientCanvas: $('ambient'),
staticCanvas: $('static'),
nosignal: $('nosignal'),
// channel OSD
osdChannel: $('osd-channel'), osdLogo: $('osd-logo'), osdNum: $('osd-num'),
osdName: $('osd-name'), osdNow: $('osd-now'), osdProgFill: $('osd-prog-fill'), osdLive: $('osd-live'),
// number OSD
osdNumber: $('osd-number'), osdNumberVal: $('osd-number-val'),
// controls
btnPlay: $('btn-play'), btnChup: $('btn-chup'), btnChdn: $('btn-chdn'),
btnMute: $('btn-mute'), vol: $('vol'), btnCc: $('btn-cc'), btnQual: $('btn-qual'),
btnPip: $('btn-pip'), btnGuide: $('btn-guide'), btnFull: $('btn-full'),
ctlLiveWrap: $('ctl-live-wrap'), ctlClock: $('ctl-clock'), qualMenu: $('qual-menu'),
// guide
guide: $('guide'), guideScroll: $('guide-scroll'), guideRows: $('guide-rows'),
guideTimehdr: $('guide-timehdr'), guidePlayhead: $('guide-playhead'),
guideClock: $('guide-clock'), guideClose: $('guide-close'),
// help
help: $('help'), helpList: $('help-list'), helpClose: $('help-close'), mundane: $('mundane'),
};
// ---- immersion modes ----
const modes = { theater: false, dim: false, ambient: false };
let ambient;
function setMode(name, on) {
modes[name] = on;
els.root.setAttribute(`data-mode-${name}`, String(on));
if (name === 'ambient') on ? ambient.start() : ambient.stop();
}
function toggleMode(name) { setMode(name, !modes[name]); }
function collapseModes() {
if (modes.dim) return setMode('dim', false);
if (modes.theater) return setMode('theater', false);
if (modes.ambient) return setMode('ambient', false);
}
// dim mode: brighten scrim briefly on activity
let dimActT;
function dimActivity() {
if (!modes.dim) return;
els.root.classList.add('activity');
clearTimeout(dimActT);
dimActT = setTimeout(() => els.root.classList.remove('activity'), 2500);
}
// ---- keyboard help ----
const KEYS = [
['09', 'Tune channel (incantation)'],
['↑ / ↓', 'Volume (player) · navigate (guide)'],
['PgUp/PgDn', 'Channel up / down'],
['Backspace', 'Last channel'],
['G', 'Toggle the Almanac (guide)'],
['T', 'Theater mode'],
['D', 'Dim the hall'],
['A', 'Orb glow (ambient)'],
['F', 'Fullscreen'],
['M', 'Mute'],
['P', 'Picture-in-picture'],
['C', 'Captions'],
['Esc', 'Close / collapse'],
['?', 'This help'],
];
function buildHelp() {
els.helpList.innerHTML = '';
for (const [k, d] of KEYS) {
const dt = document.createElement('dt');
const kbd = document.createElement('kbd'); kbd.textContent = k; dt.appendChild(kbd);
const dd = document.createElement('dd'); dd.textContent = d;
els.helpList.append(dt, dd);
}
}
function toggleHelp(force) {
const show = force ?? els.help.hidden;
els.help.hidden = !show;
}
// ---- global keymap ----
let player, zapper, guide;
function onKeydown(e) {
dimActivity();
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (guide.open && ['ArrowUp','ArrowDown','Enter'].includes(e.key)) return; // guide handles
if (e.key >= '0' && e.key <= '9') { zapper.pushDigit(e.key); return; }
switch (e.key) {
case 'g': case 'G': e.preventDefault(); guide.toggle(); break;
case 't': case 'T': toggleMode('theater'); break;
case 'd': case 'D': toggleMode('dim'); break;
case 'a': case 'A': toggleMode('ambient'); break;
case 'f': case 'F': player.toggleFullscreen(); break;
case 'm': case 'M': player.toggleMute(); break;
case 'p': case 'P': player.togglePip(); break;
case 'c': case 'C': player.toggleCaptions(); break;
case 'ArrowUp': if (!guide.open){ e.preventDefault(); player.setVolume(+0.1);} break;
case 'ArrowDown': if (!guide.open){ e.preventDefault(); player.setVolume(-0.1);} break;
case 'PageUp': e.preventDefault(); zapper.tuneToIndexOffset(-1); break;
case 'PageDown': e.preventDefault(); zapper.tuneToIndexOffset(+1); break;
case 'Backspace': e.preventDefault(); zapper.tuneLast(); break;
case '?': toggleHelp(); break;
case 'Escape':
if (!els.help.hidden) { toggleHelp(false); break; }
if (guide.open) { guide.hide(); break; }
collapseModes();
break;
}
}
// ---- provider selection ----
// ?provider=mock | iptv (default: mock unless the relay injected the meta tag)
function pickProviders() {
const params = new URLSearchParams(location.search);
const which = params.get('provider') || 'auto';
const agg = new ChannelAggregator();
if (which === 'mock') { agg.register(new MockProvider()); return { agg, label: 'mock' }; }
if (which === 'iptv') { agg.register(new IptvOrgProvider()); return { agg, label: 'iptv' }; }
const servedByRelay = !!document.querySelector('meta[name="orb-relay"]');
if (servedByRelay) { agg.register(new IptvOrgProvider()); return { agg, label: 'iptv' }; }
agg.register(new MockProvider());
return { agg, label: 'mock' };
}
// ---- boot ----
async function boot() {
applyLexicon();
buildHelp();
els.mundane.checked = isMundane();
els.mundane.addEventListener('change', () => setMundane(els.mundane.checked));
els.helpClose.addEventListener('click', () => toggleHelp(false));
ambient = new Ambient(els.video, els.ambientCanvas);
player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg));
const { agg, label } = pickProviders();
zapper = new Zapper(els, player, agg);
guide = new Guide(els, agg, (ch) => zapper.tune(ch));
els.btnChup.addEventListener('click', () => zapper.tuneToIndexOffset(-1));
els.btnChdn.addEventListener('click', () => zapper.tuneToIndexOffset(+1));
els.btnGuide.addEventListener('click', () => guide.toggle());
document.addEventListener('keydown', onKeydown);
['mousemove','click','touchstart'].forEach((ev) =>
document.addEventListener(ev, dimActivity, { passive: true }));
try {
const channels = await agg.listAllChannels();
console.info(`[orb] ${label} provider: ${channels.length} channels`);
if (channels.length) await zapper.tune(channels[0]);
runHealthPass(agg, guide);
} catch (e) {
console.error('[orb] boot failed:', e);
player._showNoSignal?.();
}
}
// background health pass → guide skull glyphs (Oracle's Memory seed)
async function runHealthPass(agg, guide) {
for (const ch of agg.channels) {
const owner = agg.providers.get(ch.source);
if (!owner?.healthCheck) continue;
try { const r = await owner.healthCheck(ch.id); guide.setHealth(ch.id, r.ok); }
catch { guide.setHealth(ch.id, false); }
}
}
boot();
+189
View File
@@ -0,0 +1,189 @@
// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
// Virtualized rows (fixed height) so thousands of channels stay smooth.
const ROW_H = 46;
const CH_COL = 150;
const HOUR_W = 180; // px per hour
const WINDOW_HRS = 6; // how far right the grid extends
const OVERSCAN = 4;
export class Guide {
/**
* @param {object} els
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
* @param {(ch:any)=>void} onTune
*/
constructor(els, agg, onTune) {
this.els = els;
this.agg = agg;
this.onTune = onTune;
this.open = false;
this.focusRow = 0;
this._programCache = new Map(); // channelId -> Program[]
this._health = new Map(); // channelId -> bool
this.scroll = els.guideScroll;
this.rowsEl = els.guideRows;
this.scroll.addEventListener('scroll', () => this._renderViewport(), { passive: true });
this.scroll.addEventListener('keydown', (e) => this._onKey(e));
els.guideClose.addEventListener('click', () => this.hide());
this._baseT = 0;
}
async toggle() { this.open ? this.hide() : this.show(); }
async show() {
const list = this.agg.channels;
if (!list.length) return;
this.open = true;
const g = this.els.guide;
g.hidden = false;
g.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => g.classList.add('show'));
// time base = top of current half-hour
const now = Date.now();
this._baseT = now - (now % (30 * 60 * 1000));
this.rowsEl.style.setProperty('--row-h', ROW_H + 'px');
this.scroll.style.setProperty('--ch-col', CH_COL + 'px');
this.scroll.style.setProperty('--hour-w', HOUR_W + 'px');
this.rowsEl.style.height = (list.length * ROW_H) + 'px';
this._buildTimeHeader();
this._renderViewport();
this._positionPlayhead();
this._clockTimer = setInterval(() => { this._tickClock(); this._positionPlayhead(); }, 30000);
this._tickClock();
this.scroll.focus();
}
hide() {
this.open = false;
const g = this.els.guide;
g.classList.remove('show');
g.setAttribute('aria-hidden', 'true');
clearInterval(this._clockTimer);
setTimeout(() => { if (!this.open) g.hidden = true; }, 300);
}
setHealth(channelId, ok) { this._health.set(channelId, ok); }
_tickClock() {
const t = new Date();
this.els.guideClock.textContent =
`${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
}
_buildTimeHeader() {
const hdr = this.els.guideTimehdr;
hdr.innerHTML = '';
hdr.style.marginLeft = CH_COL + 'px';
for (let h = 0; h < WINDOW_HRS; h++) {
const t = new Date(this._baseT + h * 3600 * 1000);
const tick = document.createElement('div');
tick.className = 'tick';
tick.style.flex = `0 0 ${HOUR_W}px`;
tick.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
hdr.appendChild(tick);
}
}
_positionPlayhead() {
const ph = this.els.guidePlayhead;
const offset = ((Date.now() - this._baseT) / 3600000) * HOUR_W;
if (offset < 0 || offset > WINDOW_HRS * HOUR_W) { ph.style.display = 'none'; return; }
ph.style.display = '';
ph.style.left = (CH_COL + offset - this.scroll.scrollLeft) + 'px';
}
async _renderViewport() {
const list = this.agg.channels;
const top = this.scroll.scrollTop;
const h = this.scroll.clientHeight;
const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + h) / ROW_H) + OVERSCAN);
this.rowsEl.innerHTML = '';
for (let i = first; i < last; i++) {
this.rowsEl.appendChild(this._renderRow(list[i], i));
}
this._positionPlayhead();
// lazily fetch programs for visible rows
for (let i = first; i < last; i++) this._ensurePrograms(list[i]);
}
_renderRow(ch, idx) {
const row = document.createElement('div');
row.className = 'guide-row';
row.style.position = 'absolute';
row.style.top = (idx * ROW_H) + 'px';
row.style.left = '0'; row.style.right = '0';
row.setAttribute('role', 'row');
const cell = document.createElement('div');
cell.className = 'guide-chcell';
cell.setAttribute('role', 'rowheader');
const num = document.createElement('span'); num.className = 'num'; num.textContent = ch.number;
const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = ch.name; // safe
cell.append(num, nm);
if (this._health.get(ch.id) === false) {
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
sk.title = 'no signal'; cell.appendChild(sk);
}
cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
const progs = document.createElement('div');
progs.className = 'guide-progs';
const cached = this._programCache.get(ch.id);
if (cached) this._layoutPrograms(progs, cached, ch);
row.append(cell, progs);
return row;
}
_layoutPrograms(container, programs, ch) {
const now = Date.now();
const winEnd = this._baseT + WINDOW_HRS * 3600 * 1000;
for (const p of programs) {
if (p.stop <= this._baseT || p.start >= winEnd) continue;
const left = ((Math.max(p.start, this._baseT) - this._baseT) / 3600000) * HOUR_W;
const right = ((Math.min(p.stop, winEnd) - this._baseT) / 3600000) * HOUR_W;
const el = document.createElement('div');
el.className = 'guide-prog' + (p.start <= now && now < p.stop ? ' airing' : '');
el.style.left = left + 'px';
el.style.width = Math.max(8, right - left - 2) + 'px';
el.setAttribute('role', 'gridcell');
el.tabIndex = -1;
const tEl = document.createElement('span'); tEl.className = 't';
const s = new Date(p.start);
tEl.textContent = `${String(s.getHours()).padStart(2,'0')}:${String(s.getMinutes()).padStart(2,'0')} `;
el.appendChild(tEl);
el.appendChild(document.createTextNode(p.title)); // safe
el.addEventListener('click', () => { this.onTune(ch); this.hide(); });
container.appendChild(el);
}
}
async _ensurePrograms(ch) {
if (this._programCache.has(ch.id)) return;
this._programCache.set(ch.id, []); // mark in-flight
const progs = await this.agg.getPrograms(ch.id, {
from: this._baseT - 3600 * 1000,
to: this._baseT + WINDOW_HRS * 3600 * 1000,
});
this._programCache.set(ch.id, progs);
if (this.open) this._renderViewport();
}
_onKey(e) {
const list = this.agg.channels;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
case 'Escape': e.preventDefault(); this.hide(); break;
}
}
_scrollToFocus() {
const y = this.focusRow * ROW_H;
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
else if (y + ROW_H > this.scroll.scrollTop + this.scroll.clientHeight)
this.scroll.scrollTop = y + ROW_H - this.scroll.clientHeight;
}
}
+130
View File
@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Pondering Orb</title>
<!-- DEV baseline CSP (meta). The Rust server sends a STRICTER same-origin CSP header
in production; when both are present the browser enforces the intersection, so
this loose-for-standalone policy never weakens prod. https: here only lets the
mock realm play CORS-clean test streams when served by a plain static server. -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data: https:;
media-src 'self' blob: https:;
connect-src 'self' https:;
font-src 'self';
object-src 'none';
base-uri 'none';
frame-src 'none';
form-action 'none'">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="orb-root" class="orb-root" data-mode-theater="false" data-mode-dim="false" data-mode-ambient="false">
<!-- ============ THE SCREEN (the orb) ============ -->
<div id="screen" class="screen" role="region" aria-label="TV player">
<canvas id="ambient" class="ambient" aria-hidden="true"></canvas>
<div class="bezel">
<video id="v" class="video" playsinline aria-label="Live TV stream"></video>
<!-- scrying static (channel-change transition) -->
<canvas id="static" class="static" aria-hidden="true"></canvas>
<!-- the orb clouds over (dead-stream / no-signal) -->
<div id="nosignal" class="nosignal" hidden role="status" aria-live="polite">
<div class="nosignal-bars" aria-hidden="true"></div>
<div class="nosignal-text" data-lex="noSignal">THE ORB CLOUDS OVER</div>
<div class="nosignal-sub" data-lex="noSignalSub">no signal — seeking another vision</div>
</div>
<!-- channel banner OSD (now-playing) -->
<div id="osd-channel" class="osd osd-channel" hidden role="status" aria-live="polite" aria-atomic="true">
<img id="osd-logo" class="osd-logo" alt="" width="96" height="54">
<div class="osd-meta">
<div class="osd-line1"><span id="osd-num" class="osd-num">--</span><span id="osd-name" class="osd-name"></span></div>
<div id="osd-now" class="osd-now"></div>
<div class="osd-prog"><span id="osd-prog-fill" class="osd-prog-fill"></span></div>
</div>
<div id="osd-live" class="osd-live" hidden><span class="live-dot" aria-hidden="true"></span><span data-lex="live">LIVE</span></div>
</div>
<!-- incantation (number entry) OSD -->
<div id="osd-number" class="osd osd-number" hidden role="status" aria-live="assertive" aria-atomic="true">
<span id="osd-number-val" class="seg">---</span>
</div>
<!-- ghost controls -->
<div id="controls" class="controls" role="toolbar" aria-label="Player controls">
<button id="btn-play" class="ctl" aria-label="Play/Pause" aria-pressed="false"></button>
<button id="btn-chdn" class="ctl" aria-label="Channel down">CH ▼</button>
<button id="btn-chup" class="ctl" aria-label="Channel up">CH ▲</button>
<div class="ctl-live" id="ctl-live-wrap" hidden><span class="live-dot" aria-hidden="true"></span><span data-lex="live">LIVE</span></div>
<span id="ctl-clock" class="ctl-clock" aria-hidden="true">--:--</span>
<span class="ctl-spacer"></span>
<button id="btn-mute" class="ctl" aria-label="Mute" aria-pressed="false">🔊</button>
<input id="vol" class="vol" type="range" min="0" max="100" value="100" aria-label="Volume">
<button id="btn-cc" class="ctl" aria-label="Captions" aria-pressed="false">CC</button>
<button id="btn-qual" class="ctl" aria-label="Quality">AUTO</button>
<button id="btn-pip" class="ctl" aria-label="Picture in picture" hidden>PiP</button>
<button id="btn-guide" class="ctl" aria-label="TV guide" data-lex="guideShort">Almanac</button>
<button id="btn-full" class="ctl" aria-label="Fullscreen"></button>
</div>
<!-- quality menu -->
<div id="qual-menu" class="qual-menu" hidden role="menu" aria-label="Quality"></div>
<!-- CRT overlays (scanlines / vignette) -->
<div class="crt-scan" aria-hidden="true"></div>
<div class="crt-vignette" aria-hidden="true"></div>
</div>
<div class="dim-scrim" id="dim-scrim" aria-hidden="true"></div>
</div>
<!-- ============ THE ALMANAC (guide) ============ -->
<section id="guide" class="guide" hidden aria-label="TV Guide" aria-hidden="true">
<header class="guide-head">
<h2 class="guide-title" data-lex="guide">The Almanac</h2>
<div class="guide-clock" id="guide-clock">--:--</div>
<button id="guide-close" class="ctl" aria-label="Close guide"></button>
</header>
<div class="guide-scroll" id="guide-scroll" role="grid" aria-label="Channel schedule" tabindex="0">
<div class="guide-timehdr" id="guide-timehdr" role="row" aria-hidden="true"></div>
<div class="guide-rows" id="guide-rows"></div>
<div class="guide-playhead" id="guide-playhead" aria-hidden="true"></div>
</div>
</section>
<!-- ============ INCANTATIONS (keyboard help) ============ -->
<div id="help" class="help" hidden role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
<div class="help-card">
<h2 data-lex="help">Incantations</h2>
<dl id="help-list"></dl>
<button id="help-close" class="ctl" aria-label="Close">close (Esc)</button>
<label class="mundane-toggle">
<input type="checkbox" id="mundane"> plain-English labels (mundane mode)
</label>
</div>
</div>
<!-- brand bug -->
<div class="brand-bug" aria-hidden="true" data-lex="brand">THE PONDERING ORB</div>
</div>
<!-- HLS engine: local, pinned (v1.5.17), SRI-verified. -->
<script src="lib/hls.min.js"
integrity="sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni"
crossorigin="anonymous"></script>
<script type="module" src="app.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
// 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);
}
}
+2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
VERSION=1.5.17
SHA256=484054e8cd03d3f6d1781fb7f402bdc318d8a4c527f933a95c624e27cc9a9470
SRI=sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
+1
View File
@@ -0,0 +1 @@
sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
+212
View File
@@ -0,0 +1,212 @@
// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
const HLS_CONFIG = {
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
manifestLoadingMaxRetry: 4,
manifestLoadingRetryDelay: 800,
fragLoadingMaxRetry: 4,
};
export class Player {
/**
* @param {object} els DOM refs
* @param {(msg:string)=>void} [onFatal] called when a stream is unplayable
*/
constructor(els, onFatal) {
this.els = els;
this.video = els.video;
this.hls = null;
this.onFatal = onFatal || (() => {});
this.onFirstFrag = null; // set by zapper to clear scrying static
this._fallbacks = [];
this._fallbackIdx = 0;
this._wireControls();
this._wireActivity();
}
/**
* Play a StreamHandle (with optional fallback URLs for resilience).
* @param {{url:string}} handle
* @param {string[]} [fallbackUrls]
*/
async play(handle, fallbackUrls = []) {
this._fallbacks = [handle.url, ...fallbackUrls.filter((u) => u !== handle.url)];
this._fallbackIdx = 0;
this._hideNoSignal();
this._loadCurrent();
}
_loadCurrent() {
const url = this._fallbacks[this._fallbackIdx];
if (!url) { this._showNoSignal(); this.onFatal('no playable source'); return; }
this._teardown();
const canNative = this.video.canPlayType('application/vnd.apple.mpegurl');
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls(HLS_CONFIG);
this.hls = hls;
hls.loadSource(url);
hls.attachMedia(this.video);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(() => {});
this._refreshLiveChrome();
this._buildQualityMenu();
});
hls.on(window.Hls.Events.FRAG_BUFFERED, () => {
if (this.onFirstFrag) { this.onFirstFrag(); }
});
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (!data || !data.fatal) return;
this._tryFallbackOrFail(`hls fatal: ${data.type}/${data.details}`);
});
} else if (canNative) {
this.video.src = url;
this.video.addEventListener('loadedmetadata', () => {
this.video.play().catch(() => {});
this._refreshLiveChrome();
}, { once: true });
this.video.addEventListener('error', () => this._tryFallbackOrFail('native hls error'), { once: true });
} else {
this._showNoSignal();
this.onFatal('HLS unsupported in this browser');
}
}
_tryFallbackOrFail(reason) {
this._fallbackIdx++;
if (this._fallbackIdx < this._fallbacks.length) {
console.warn(`[player] ${reason} — trying fallback ${this._fallbackIdx}`);
this._loadCurrent();
} else {
console.warn(`[player] ${reason} — no fallbacks left`);
this._showNoSignal();
this.onFatal(reason);
}
}
_teardown() {
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
this.video.removeAttribute('src');
try { this.video.load(); } catch {}
}
destroy() { this._teardown(); }
// ---------- dead-stream "the orb clouds over" ----------
_showNoSignal() { this.els.nosignal.hidden = false; }
_hideNoSignal() { this.els.nosignal.hidden = true; }
// ---------- live-aware chrome ----------
_refreshLiveChrome() {
const live = this._isLive();
this.els.osdLive.hidden = !live;
this.els.ctlLiveWrap.hidden = !live;
}
_isLive() {
if (this.hls && this.hls.levels && this.hls.levels.length) {
const d = this.hls.levels[this.hls.currentLevel]?.details;
if (d) return d.live === true;
}
return !Number.isFinite(this.video.duration);
}
_buildQualityMenu() {
const menu = this.els.qualMenu;
menu.innerHTML = '';
if (!this.hls || !this.hls.levels || this.hls.levels.length < 2) {
this.els.btnQual.hidden = true;
return;
}
this.els.btnQual.hidden = false;
const mk = (label, levelIdx) => {
const b = document.createElement('button');
b.setAttribute('role', 'menuitemradio');
b.textContent = label;
b.setAttribute('aria-checked', String(this.hls.autoLevelEnabled ? levelIdx === -1 : this.hls.currentLevel === levelIdx));
b.addEventListener('click', () => {
this.hls.currentLevel = levelIdx; // -1 = auto
this.els.btnQual.textContent = levelIdx === -1 ? 'AUTO' : label;
menu.hidden = true;
});
return b;
};
menu.appendChild(mk('Auto', -1));
// de-dup by height, descending
const seen = new Set();
this.hls.levels
.map((l, i) => ({ h: l.height, i }))
.filter((x) => x.h && !seen.has(x.h) && seen.add(x.h))
.sort((a, b) => b.h - a.h)
.forEach((x) => menu.appendChild(mk(`${x.h}p`, x.i)));
}
// ---------- controls ----------
_wireControls() {
const e = this.els;
e.btnPlay.addEventListener('click', () => this.togglePlay());
e.btnMute.addEventListener('click', () => this.toggleMute());
e.vol.addEventListener('input', () => { this.video.volume = e.vol.value / 100; this.video.muted = this.video.volume === 0; this._syncMute(); });
e.btnFull.addEventListener('click', () => this.toggleFullscreen());
e.btnCc.addEventListener('click', () => this.toggleCaptions());
e.btnQual.addEventListener('click', () => { e.qualMenu.hidden = !e.qualMenu.hidden; });
if (document.pictureInPictureEnabled) {
e.btnPip.hidden = false;
e.btnPip.addEventListener('click', () => this.togglePip());
}
this.video.addEventListener('play', () => { e.btnPlay.textContent = '❚❚'; e.btnPlay.setAttribute('aria-pressed','true'); });
this.video.addEventListener('pause', () => { e.btnPlay.textContent = '▶'; e.btnPlay.setAttribute('aria-pressed','false'); });
// wall-clock for live
setInterval(() => {
const t = new Date();
e.ctlClock.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
}, 1000);
}
togglePlay() { this.video.paused ? this.video.play().catch(()=>{}) : this.video.pause(); }
toggleMute() { this.video.muted = !this.video.muted; this._syncMute(); }
_syncMute() {
const m = this.video.muted || this.video.volume === 0;
this.els.btnMute.textContent = m ? '🔇' : '🔊';
this.els.btnMute.setAttribute('aria-pressed', String(m));
}
setVolume(delta) {
this.video.muted = false;
this.video.volume = Math.min(1, Math.max(0, this.video.volume + delta));
this.els.vol.value = Math.round(this.video.volume * 100);
this._syncMute();
}
async toggleFullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await this.els.root.requestFullscreen();
} catch {}
}
async togglePip() {
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else await this.video.requestPictureInPicture();
} catch {}
}
toggleCaptions() {
const tracks = this.video.textTracks;
if (!tracks || !tracks.length) return;
const on = tracks[0].mode === 'showing';
for (const tr of tracks) tr.mode = on ? 'disabled' : 'showing';
this.els.btnCc.setAttribute('aria-pressed', String(!on));
}
// ---------- ghost-control auto-hide ----------
_wireActivity() {
const show = () => {
this.els.screen.classList.add('controls-visible');
clearTimeout(this._hideT);
this._hideT = setTimeout(() => this.els.screen.classList.remove('controls-visible'), 3000);
};
['mousemove','touchstart','keydown'].forEach((ev) =>
this.els.screen.addEventListener(ev, show, { passive: true }));
show();
}
}
+176
View File
@@ -0,0 +1,176 @@
// iptv-org.js — IptvOrgProvider. Real public channels via the iptv-org JSON API.
//
// Data source of truth = the JSON API (CORS-clean GitHub Pages), NOT raw M3U:
// join channels.json + streams.json + logos.json, EPG via guides.json (XMLTV).
// Stream URLs are routed through the same-origin /relay so HLS.js can play them
// without CORS and the ambient canvas stays untainted.
//
// SECURITY: every field from the API is treated as hostile input. We never use
// innerHTML anywhere; here we additionally scheme-allowlist URLs and cap sizes.
import { SourceProvider } from './source-provider.js';
const API = 'https://iptv-org.github.io/api';
const MAX_CHANNELS = 20000; // anti client-side DoS
const MAX_FIELD = 200; // cap any displayed string
const clip = (s) => (typeof s === 'string' ? s.slice(0, MAX_FIELD) : '');
/** Only https logos (or data:). Anything else → dropped. */
function safeLogo(url) {
if (typeof url !== 'string') return undefined;
if (/^https:\/\//i.test(url) || /^data:image\//i.test(url)) return url;
return undefined;
}
/** Only https stream URLs survive. */
function safeStream(url) {
return (typeof url === 'string' && /^https:\/\//i.test(url)) ? url : null;
}
/** Wrap an upstream URL so it flows through the hardened same-origin relay. */
function viaRelay(url) { return '/relay?url=' + encodeURIComponent(url); }
async function getJSON(url, useRelay) {
// Under the production CSP (connect-src 'self') the JSON API must also flow
// through the relay; on a plain static server we fetch it directly.
const target = useRelay ? viaRelay(url) : url;
const r = await fetch(target, { credentials: 'omit', referrerPolicy: 'no-referrer' });
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
export class IptvOrgProvider extends SourceProvider {
constructor(opts = {}) {
super();
this.useRelay = opts.useRelay !== false; // default on (same-origin server)
this._channels = null;
this._streamsByCh = new Map(); // channelId -> https url[]
this._guideByCh = new Map(); // channelId -> xmltv url
this._epgCache = new Map(); // channelId -> Program[]
}
get id() { return 'iptv-org'; }
get name() { return 'iptv-org'; }
get supportsGuides() { return true; }
async listChannels() {
if (this._channels) return this._channels;
const [channels, streams, logos, guides] = await Promise.all([
getJSON(`${API}/channels.json`, this.useRelay),
getJSON(`${API}/streams.json`, this.useRelay),
getJSON(`${API}/logos.json`, this.useRelay).catch(() => []),
getJSON(`${API}/guides.json`, this.useRelay).catch(() => []),
]);
// streams: channel -> [https urls]
for (const s of streams) {
const cid = s.channel;
const url = safeStream(s.url);
if (!cid || !url) continue;
(this._streamsByCh.get(cid) || this._streamsByCh.set(cid, []).get(cid)).push(url);
}
// first guide source per channel
for (const g of guides) {
if (g.channel && g.site && !this._guideByCh.has(g.channel)) {
// guides.json entries point at grabber sites, not always direct XMLTV;
// we store the channel's xmltv id for matching when an EPG feed is wired.
this._guideByCh.set(g.channel, g);
}
}
const logoByCh = new Map();
for (const l of logos) {
if (l.channel && !logoByCh.has(l.channel)) {
const u = safeLogo(l.url); if (u) logoByCh.set(l.channel, u);
}
}
/** @type {import('./source-provider.js').Channel[]} */
const out = [];
for (const c of channels) {
if (out.length >= MAX_CHANNELS) break;
const urls = this._streamsByCh.get(c.id);
if (!urls || !urls.length) continue; // only channels we can actually play
const playable = this.useRelay ? urls.map(viaRelay) : urls;
out.push({
id: `iptv-org:${c.id}`,
source: 'iptv-org',
name: clip(c.name) || c.id,
logo: logoByCh.get(c.id),
group: clip((c.categories && c.categories[0]) || ''),
country: clip(c.country || ''),
language: clip((c.languages && c.languages[0]) || ''),
kind: 'live',
streamUrls: playable,
guideId: c.id,
});
}
this._channels = out;
return out;
}
async getStreamHandle(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return null;
return { url: ch.streamUrls[0], protocol: 'hls' };
}
async healthCheck(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return { ok: false, error: 'no stream' };
try {
// probe through the relay (same SSRF-validated path); HEAD then bail
const r = await fetch(ch.streamUrls[0], { method: 'GET', credentials: 'omit' });
return { ok: r.ok, error: r.ok ? undefined : `status ${r.status}` };
} catch (e) { return { ok: false, error: String(e) }; }
}
/**
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
* simply blank until an EPG source is wired in Phase 1.5).
*/
async getPrograms(channelId, window) {
const cached = this._epgCache.get(channelId);
if (!cached) return [];
return cached.filter((p) => p.stop > window.from && p.start < window.to);
}
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
setEpgXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
if (doc.querySelector('parsererror')) return;
// map xmltv channel id -> our namespaced id
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
for (const prog of doc.getElementsByTagName('programme')) {
const xmlCh = prog.getAttribute('channel');
const cid = idMap.get(xmlCh);
if (!cid) continue;
const start = parseXmltvTime(prog.getAttribute('start'));
const stop = parseXmltvTime(prog.getAttribute('stop'));
if (!start || !stop) continue;
const titleEl = prog.getElementsByTagName('title')[0];
const descEl = prog.getElementsByTagName('desc')[0];
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
arr.push({
channelId: cid,
title: clip(titleEl ? titleEl.textContent : 'Program'),
desc: clip(descEl ? descEl.textContent : ''),
start, stop,
});
}
}
}
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
function parseXmltvTime(s) {
if (!s) return 0;
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim());
if (!m) return 0;
const [, Y, Mo, D, H, Mi, S, tz] = m;
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0));
if (tz) {
const sign = tz[0] === '-' ? -1 : 1;
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000;
}
return ms;
}
+117
View File
@@ -0,0 +1,117 @@
// mock.js — MockProvider. Built FIRST, on purpose.
//
// Proves the UI is fully source-agnostic with ZERO network: fake channels, a
// rolling "scheduled-live" EPG timeline (so now/next + the guide populate), and
// a couple of real CORS-clean public TEST streams so playback can be exercised
// standalone (no relay needed). One channel points at a dead URL to exercise the
// "the orb clouds over" no-signal path.
import { SourceProvider } from './source-provider.js';
/** Tiny inline-SVG logo so channels render with no network. */
function logo(initials, accent = '#ff2b2b') {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="54">` +
`<rect width="96" height="54" fill="#0a0000"/>` +
`<rect x="1" y="1" width="94" height="52" fill="none" stroke="${accent}" stroke-width="1.5"/>` +
`<text x="48" y="34" font-family="monospace" font-size="22" font-weight="bold" ` +
`text-anchor="middle" fill="${accent}">${initials}</text></svg>`;
return 'data:image/svg+xml,' + encodeURIComponent(svg);
}
// CORS-clean public test streams (HLS). Stable, broadcaster-hosted demos.
const TEST_STREAMS = {
bipbop: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
mux: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
bbb: 'https://test-streams.mux.dev/test_001/stream.m3u8',
dead: 'https://stream.invalid.example/offline.m3u8',
};
const TITLE_POOL = {
'Arcane Cinema': ['The Reanimator', 'Hex of the Hollow', 'Salt & Sigil', 'Midnight Grimoire',
'The Pondering Hour', 'Witchlight', 'The Veil Lifts', 'Candle & Bone'],
'Hack the Planet': ['Packets at Dawn', 'Root of All Evil', 'The Blue Box', 'Zero Cool',
'Buffer Overflow', 'The Last Daemon', 'Kernel Panic', 'Shell Games'],
'Old Signals': ['Test Pattern Theatre', 'Static & Snow', 'The Late Late Broadcast',
'Color Bars', 'Sign-Off Sermon', 'Dead Air'],
'The Almanac': ['What Fate Foretells', 'Stars & Portents', 'The Long Now', 'Tomorrow, Maybe'],
};
function buildSchedule(channelId, group, now) {
/** @type {import('./source-provider.js').Program[]} */
const programs = [];
const titles = TITLE_POOL[group] || TITLE_POOL['Old Signals'];
// Deterministic-ish per channel so reloads look stable.
let seed = [...channelId].reduce((a, c) => a + c.charCodeAt(0), 0);
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
// Start two hours back, fill ~10h.
let t = now - 2 * 3600 * 1000;
t = t - (t % (30 * 60 * 1000)); // align to :00/:30
const end = now + 8 * 3600 * 1000;
let i = Math.floor(rand() * titles.length);
while (t < end) {
const slots = 1 + Math.floor(rand() * 3); // 30/60/90 min
const dur = slots * 30 * 60 * 1000;
programs.push({
channelId,
title: titles[i % titles.length],
desc: 'A scheduled broadcast. Tuning in drops you mid-program, live — just like real TV.',
start: t,
stop: t + dur,
});
t += dur;
i++;
}
return programs;
}
const CHANNELS = [
{ n: 2, name: 'Arcane Cinema', group: 'Arcane Cinema', stream: 'mux', init: 'AC' },
{ n: 3, name: 'Hack the Planet',group: 'Hack the Planet', stream: 'bbb', init: 'HP' },
{ n: 4, name: 'Bip-Bop Test', group: 'Old Signals', stream: 'bipbop', init: 'BB' },
{ n: 5, name: 'The Late Show', group: 'Old Signals', stream: 'mux', init: 'LS' },
{ n: 6, name: 'Dead Air', group: 'Old Signals', stream: 'dead', init: 'XX' },
{ n: 7, name: 'The Almanac', group: 'The Almanac', stream: 'bbb', init: 'AL' },
];
export class MockProvider extends SourceProvider {
get id() { return 'mock'; }
get name() { return 'The Mock Realm'; }
get supportsGuides() { return true; }
async listChannels() {
return CHANNELS.map((c) => ({
id: `mock:${c.n}`,
source: 'mock',
name: c.name,
number: c.n,
logo: logo(c.init),
group: c.group,
country: 'XX',
language: 'eng',
kind: 'live',
streamUrls: [TEST_STREAMS[c.stream]],
guideId: `mock:${c.n}`,
}));
}
async getStreamHandle(channelId) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (!c) return null;
// Mock returns direct URLs (test streams are CORS-clean) so the UI runs standalone.
return { url: TEST_STREAMS[c.stream], protocol: 'hls' };
}
async getPrograms(channelId, window) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (!c) return [];
const all = buildSchedule(channelId, c.group, Date.now());
return all.filter((p) => p.stop > window.from && p.start < window.to);
}
async healthCheck(channelId) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (c && c.stream === 'dead') return { ok: false, error: 'mock offline channel' };
return { ok: true };
}
}
+185
View File
@@ -0,0 +1,185 @@
// source-provider.js — the load-bearing abstraction.
//
// A channel is "a named thing on a guide that resolves to an HLS stream at a
// point in time." Public iptv-org streams and your future self-hosted media are
// just different SourceProviders feeding the SAME UI. Build providers against
// this contract; the UI (guide/zapper/player) only ever talks to the aggregator.
/**
* @typedef {Object} Channel
* @property {string} id Namespaced id, "source:rawId" (e.g. "iptv-org:CNN.us").
* @property {string} source Provider id that owns this channel.
* @property {string} name Display name (UNTRUSTED — render as textContent only).
* @property {number} [number] Assigned tuning number (filled by the aggregator).
* @property {string} [logo] Logo URL (UNTRUSTED — scheme-allowlisted before use).
* @property {string} [group] Category / group-title.
* @property {string} [country]
* @property {string} [language]
* @property {'live'|'vod'} kind Always 'live' for Phase 1; self-hosted "channels" are also 'live'.
* @property {string[]} streamUrls One or more upstream HLS URLs (primary + fallbacks).
* @property {string} [guideId] Key used to match EPG/program data.
*/
/**
* @typedef {Object} Program
* @property {string} channelId
* @property {string} title UNTRUSTED — textContent only.
* @property {string} [desc]
* @property {number} start Epoch ms (inclusive).
* @property {number} stop Epoch ms (exclusive).
*/
/**
* @typedef {Object} StreamHandle
* @property {string} url Playable URL handed to HLS.js (may be relay-wrapped).
* @property {'hls'} protocol
* @property {Object<string,string>} [headers]
*/
/**
* The contract every source implements. Extend this or just match the shape.
* @abstract
*/
export class SourceProvider {
/** @type {string} stable namespace, e.g. "iptv-org" */
get id() { throw new Error('provider must define id'); }
/** @type {string} human label */
get name() { return this.id; }
/** @type {boolean} */
get supportsGuides() { return false; }
/** @returns {Promise<Channel[]>} */
async listChannels() { throw new Error('not implemented'); }
/**
* Resolve a channel to something HLS.js can play (relay-wrapped if needed).
* @param {string} _channelId
* @returns {Promise<StreamHandle|null>}
*/
async getStreamHandle(_channelId) { throw new Error('not implemented'); }
/**
* EPG slice for a time window. Default: none.
* @param {string} _channelId
* @param {{from:number,to:number}} _window
* @returns {Promise<Program[]>}
*/
async getPrograms(_channelId, _window) { return []; }
/**
* Optional liveness probe.
* @param {string} _channelId
* @returns {Promise<{ok:boolean, error?:string}>}
*/
async healthCheck(_channelId) { return { ok: true }; }
}
/**
* Merges channels from many providers behind one interface and routes
* per-channel calls back to the owning provider by source-prefix.
*/
export class ChannelAggregator {
constructor() {
/** @type {Map<string, SourceProvider>} */
this.providers = new Map();
/** @type {Channel[]} */
this._channels = [];
/** @type {Map<string, Channel>} */
this._byId = new Map();
}
/** @param {SourceProvider} provider */
register(provider) {
this.providers.set(provider.id, provider);
return this;
}
/**
* Load + merge all providers. De-dups by id, assigns sequential tuning numbers.
* @returns {Promise<Channel[]>}
*/
async listAllChannels() {
const lists = await Promise.all(
[...this.providers.values()].map(async (p) => {
try { return await p.listChannels(); }
catch (e) { console.warn(`[aggregator] provider ${p.id} failed:`, e); return []; }
}),
);
const seen = new Set();
/** @type {Channel[]} */
const merged = [];
for (const list of lists) {
for (const ch of list) {
if (!ch || !ch.id || seen.has(ch.id)) continue;
seen.add(ch.id);
merged.push(ch);
}
}
// Respect any explicit number, otherwise assign by order starting at 2 (channel 1 reserved).
let next = 2;
const used = new Set(merged.filter((c) => Number.isFinite(c.number)).map((c) => c.number));
for (const ch of merged) {
if (!Number.isFinite(ch.number)) {
while (used.has(next)) next++;
ch.number = next;
used.add(next);
}
}
merged.sort((a, b) => a.number - b.number);
this._channels = merged;
this._byId = new Map(merged.map((c) => [c.id, c]));
return merged;
}
/** @returns {Channel[]} */
get channels() { return this._channels; }
/** @param {string} id @returns {Channel|undefined} */
getChannel(id) { return this._byId.get(id); }
/** @param {number} number @returns {Channel|undefined} */
getChannelByNumber(number) { return this._channels.find((c) => c.number === number); }
/** @param {string} channelId @returns {SourceProvider|undefined} */
_ownerOf(channelId) {
const source = channelId.split(':', 1)[0];
return this.providers.get(source);
}
/** @param {string} channelId @returns {Promise<StreamHandle|null>} */
async getStreamHandle(channelId) {
const owner = this._ownerOf(channelId);
if (!owner) return null;
return owner.getStreamHandle(channelId);
}
/**
* @param {string} channelId
* @param {{from:number,to:number}} window
* @returns {Promise<Program[]>}
*/
async getPrograms(channelId, window) {
const owner = this._ownerOf(channelId);
if (!owner) return [];
try { return await owner.getPrograms(channelId, window); }
catch { return []; }
}
/**
* now/next helper used by the OSD banner and guide.
* @param {string} channelId
* @param {number} [at] epoch ms (default: now)
* @returns {Promise<{now: Program|null, next: Program|null}>}
*/
async nowNext(channelId, at = Date.now()) {
const horizon = at + 6 * 3600 * 1000;
const programs = await this.getPrograms(channelId, { from: at - 3600 * 1000, to: horizon });
programs.sort((a, b) => a.start - b.start);
let now = null, next = null;
for (const p of programs) {
if (p.start <= at && at < p.stop) now = p;
else if (p.start > at && !next) next = p;
}
return { now, next };
}
}
+206
View File
@@ -0,0 +1,206 @@
/* The Pondering Orb — red/black phosphor CRT theme.
All CRT effects are CSS (no WebGL). prefers-reduced-motion kills every animation. */
:root{
--red: #ff2b2b;
--red-dim: #b21d1d;
--red-deep: #6e0f0f;
--red-glow: rgba(255,43,43,.55);
--amber: #ff6a3d;
--bg: #050102;
--panel: #120406;
--panel-2: #1a0608;
--line: #3a0d0f;
--ink: #ffd9d4; /* warm off-white text on red/black */
--ink-dim: #c98b86;
--seg-on: #ff2b2b;
--seg-off: #2a0809;
--focus: #ff2b2b;
--osd-bg: rgba(10,1,2,.82);
--ease: cubic-bezier(.2,.7,.2,1);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font-family:"Consolas","DejaVu Sans Mono","Courier New",monospace;overflow:hidden}
body{line-height:1.4}
button{font-family:inherit;color:inherit}
img{display:block}
/* ===================== layout root ===================== */
.orb-root{position:fixed;inset:0;display:flex;flex-direction:column;background:radial-gradient(120% 90% at 50% 0%, #160406 0%, var(--bg) 70%)}
/* ===================== the screen ===================== */
.screen{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;
padding:clamp(8px,2vw,28px);transition:padding .25s var(--ease)}
.bezel{position:relative;width:100%;height:100%;max-width:100%;max-height:100%;aspect-ratio:16/9;
margin:auto;background:#000;border-radius:18px/26px;overflow:hidden;
box-shadow:0 0 0 2px #240608, 0 0 0 10px #0c0203, 0 0 60px rgba(0,0,0,.9),
inset 0 0 120px rgba(0,0,0,.9);}
.video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
/* ambient backlight canvas sits BEHIND the bezel */
.ambient{position:absolute;inset:0;width:100%;height:100%;
filter:blur(60px) saturate(1.7);transform:scale(1.18);opacity:0;
transition:opacity .5s var(--ease);pointer-events:none;z-index:0}
.orb-root[data-mode-ambient="true"] .ambient{opacity:.85}
.screen{z-index:1}
/* ---- CRT overlays ---- */
.crt-scan{position:absolute;inset:0;pointer-events:none;z-index:6;
background:repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,.16) 3px)}
.crt-vignette{position:absolute;inset:0;pointer-events:none;z-index:6;
background:radial-gradient(120% 120% at 50% 50%, rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%);
animation:breathe 4s ease-in-out infinite}
@keyframes breathe{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.012);opacity:1}}
/* ===================== OSD (shared) ===================== */
.osd{position:absolute;z-index:7;background:var(--osd-bg);border:1px solid var(--line);
color:var(--ink);backdrop-filter:blur(2px);box-shadow:0 0 18px rgba(0,0,0,.7)}
.osd[hidden]{display:none}
/* channel banner */
.osd-channel{left:18px;bottom:18px;display:flex;gap:14px;align-items:center;padding:12px 16px;border-radius:8px;
min-width:300px;max-width:70%;opacity:0;transform:translateY(8px) scale(.98);
transition:opacity .3s var(--ease),transform .3s var(--ease)}
.osd-channel.show{opacity:1;transform:none}
.osd-logo{width:96px;height:54px;object-fit:contain;background:#000;border:1px solid var(--line);border-radius:4px}
.osd-meta{min-width:0;flex:1}
.osd-line1{display:flex;align-items:baseline;gap:10px}
.osd-num{font-weight:bold;color:var(--red);font-size:26px;text-shadow:0 0 10px var(--red-glow)}
.osd-name{font-size:18px;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.osd-now{color:var(--ink-dim);font-size:13px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.osd-prog{height:4px;background:var(--seg-off);border-radius:2px;margin-top:7px;overflow:hidden}
.osd-prog-fill{display:block;height:100%;width:0;background:var(--red);box-shadow:0 0 8px var(--red-glow)}
.osd-live{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:bold;font-size:13px;align-self:flex-start}
/* number entry (7-seg look) */
.osd-number{right:22px;top:22px;padding:8px 14px;border-radius:6px}
.seg{font-weight:bold;font-size:40px;letter-spacing:6px;color:var(--seg-on);
text-shadow:0 0 14px var(--red-glow);font-variant-numeric:tabular-nums}
.live-dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 8px var(--red);
display:inline-block;animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
/* ===================== scrying static ===================== */
.static{position:absolute;inset:0;width:100%;height:100%;z-index:5;opacity:0;pointer-events:none;
transition:opacity .12s linear}
.static.show{opacity:.9}
/* ===================== the orb clouds over ===================== */
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:10px;background:#050102;text-align:center;padding:20px}
.nosignal[hidden]{display:none}
.nosignal-bars{width:min(70%,520px);height:46%;border:1px solid var(--line);
background:linear-gradient(90deg,#6e0f0f 0 14%,#b21d1d 14% 28%,#ff6a3d 28% 42%,#ffd9d4 42% 56%,
#b21d1d 56% 70%,#6e0f0f 70% 84%,#1a0608 84% 100%);opacity:.5;filter:saturate(.8)}
.nosignal-text{color:var(--red);font-weight:bold;font-size:clamp(18px,3vw,30px);letter-spacing:4px;
text-shadow:0 0 14px var(--red-glow)}
.nosignal-sub{color:var(--ink-dim);font-size:13px;letter-spacing:1px}
/* ===================== ghost controls ===================== */
.controls{position:absolute;left:0;right:0;bottom:0;z-index:8;display:flex;align-items:center;gap:8px;
padding:12px 14px;background:linear-gradient(to top, rgba(5,1,2,.92), rgba(5,1,2,0));
opacity:0;transform:translateY(6px);transition:opacity .25s var(--ease),transform .25s var(--ease);
pointer-events:none;flex-wrap:wrap}
.screen.controls-visible .controls,.controls:focus-within{opacity:1;transform:none;pointer-events:auto}
.ctl{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:6px;
padding:6px 10px;font-size:13px;cursor:pointer;min-height:34px;transition:border-color .15s,box-shadow .15s,color .15s}
.ctl:hover{border-color:var(--red-dim);color:#fff}
.ctl-spacer{flex:1 1 auto}
.ctl-clock{color:var(--ink-dim);font-size:13px;font-variant-numeric:tabular-nums;padding:0 4px}
.ctl-live{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:bold;font-size:12px}
.vol{width:90px;accent-color:var(--red)}
.qual-menu{position:absolute;right:14px;bottom:58px;z-index:9;background:var(--panel-2);border:1px solid var(--line);
border-radius:6px;padding:4px;min-width:120px;box-shadow:0 0 18px rgba(0,0,0,.8)}
.qual-menu[hidden]{display:none}
.qual-menu button{display:block;width:100%;text-align:left;background:none;border:0;color:var(--ink);
padding:7px 10px;border-radius:4px;cursor:pointer;font-size:13px}
.qual-menu button:hover,.qual-menu button[aria-checked="true"]{background:var(--red-deep);color:#fff}
/* ===================== dim mode ===================== */
.dim-scrim{position:absolute;inset:0;z-index:4;background:rgba(0,0,0,.72);opacity:0;pointer-events:none;
transition:opacity .3s var(--ease)}
.orb-root[data-mode-dim="true"] .dim-scrim{opacity:1}
.orb-root[data-mode-dim="true"] .bezel{z-index:5;position:relative}
.orb-root[data-mode-dim="true"].activity .dim-scrim{opacity:.4}
/* ===================== theater mode ===================== */
.orb-root[data-mode-theater="true"] .screen{padding:0}
.orb-root[data-mode-theater="true"] .bezel{border-radius:0;box-shadow:none}
.orb-root[data-mode-theater="true"] .brand-bug{opacity:0}
/* ===================== the almanac (guide) ===================== */
.guide{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:20;background:var(--panel);
border-top:2px solid var(--red-deep);box-shadow:0 -12px 40px rgba(0,0,0,.8);
transform:translateY(100%);transition:transform .28s var(--ease);display:flex;flex-direction:column}
.guide:not([hidden]){display:flex}
.guide.show{transform:translateY(0)}
.guide-head{display:flex;align-items:center;gap:14px;padding:8px 14px;border-bottom:1px solid var(--line);
background:var(--panel-2)}
.guide-title{margin:0;font-size:15px;letter-spacing:3px;color:var(--red);text-shadow:0 0 10px var(--red-glow)}
.guide-clock{color:var(--ink-dim);font-variant-numeric:tabular-nums;margin-left:auto;font-size:13px}
.guide-scroll{position:relative;flex:1;overflow:auto;outline:none}
.guide-timehdr{position:sticky;top:0;z-index:3;display:flex;height:26px;background:var(--panel-2);
border-bottom:1px solid var(--line);margin-left:var(--ch-col,150px)}
.guide-timehdr .tick{flex:0 0 var(--hour-w,180px);font-size:11px;color:var(--ink-dim);
padding:5px 8px;border-left:1px solid var(--line)}
.guide-rows{position:relative}
.guide-row{display:flex;height:var(--row-h,46px);border-bottom:1px solid #220708}
.guide-chcell{position:sticky;left:0;z-index:2;flex:0 0 var(--ch-col,150px);display:flex;align-items:center;gap:8px;
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
.guide-chcell:hover{background:var(--red-deep)}
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
.guide-progs{position:relative;flex:1}
.guide-prog{position:absolute;top:3px;bottom:3px;background:var(--panel-2);border:1px solid var(--line);
border-radius:4px;padding:5px 8px;font-size:12px;overflow:hidden;cursor:pointer;white-space:nowrap;text-overflow:ellipsis}
.guide-prog:hover,.guide-prog:focus{border-color:var(--red-dim);outline:none}
.guide-prog.airing{background:rgba(255,43,43,.14);border-color:var(--red-dim)}
.guide-prog .t{color:var(--ink-dim);font-size:10px}
.guide-playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--red);box-shadow:0 0 8px var(--red);
z-index:4;pointer-events:none;left:var(--ch-col,150px)}
/* ===================== help / dialog ===================== */
.help{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;
background:rgba(5,1,2,.7);backdrop-filter:blur(2px)}
.help[hidden]{display:none}
.help-card{background:var(--panel-2);border:1px solid var(--red-deep);border-radius:10px;padding:22px 26px;
max-width:560px;width:90%;box-shadow:0 0 50px rgba(0,0,0,.85)}
.help-card h2{margin:0 0 14px;color:var(--red);letter-spacing:3px;text-shadow:0 0 10px var(--red-glow)}
.help-card dl{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;margin:0 0 16px}
.help-card dt{color:var(--red);font-weight:bold;white-space:nowrap}
.help-card dd{margin:0;color:var(--ink-dim)}
.mundane-toggle{display:flex;align-items:center;gap:8px;margin-top:12px;color:var(--ink-dim);font-size:13px}
kbd{background:#000;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:1px 6px;
font-family:inherit;color:var(--ink)}
/* ===================== brand bug ===================== */
.brand-bug{position:absolute;right:14px;top:10px;z-index:7;font-size:11px;letter-spacing:3px;
color:var(--red);opacity:.6;text-shadow:0 0 8px var(--red-glow);pointer-events:none;transition:opacity .3s}
/* ===================== focus rings (a11y) ===================== */
:focus-visible{outline:2px solid var(--focus);outline-offset:2px;box-shadow:0 0 8px var(--red-glow)}
/* ===================== responsive ===================== */
@media (max-width:768px){
.guide{height:70%}
.osd-channel{min-width:0;max-width:88%}
.vol{display:none}
.brand-bug{display:none}
}
/* ===================== reduced motion kill-switch ===================== */
@media (prefers-reduced-motion: reduce){
.crt-vignette{animation:none}
.live-dot{animation:none}
.ambient{display:none}
.static{display:none}
*{transition-duration:.001ms !important;animation-duration:.001ms !important;animation-iteration-count:1 !important}
}
+163
View File
@@ -0,0 +1,163 @@
// zapper.js — channel tuning: number entry, OSD banner, up/down, last-channel,
// and the "Scrying Static" channel-change transition.
const ENTRY_DEBOUNCE = 1200; // ms cable-box number accumulation
const BANNER_MS = 4000;
const STATIC_MIN = 300; // never flash shorter than this
const STATIC_MAX = 3000; // clear regardless after this
export class Zapper {
/**
* @param {object} els
* @param {import('./player.js').Player} player
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
*/
constructor(els, player, agg) {
this.els = els;
this.player = player;
this.agg = agg;
this.current = null; // current Channel
this.previous = null; // for last-channel toggle
this._entry = '';
this._entryT = 0;
this._bannerT = 0;
this._staticFrames = [];
this._reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
this._prepStatic();
// player calls this when first fragment buffers → clear the static
this.player.onFirstFrag = () => this._clearStatic();
}
// ---------- public tuning API ----------
async tuneToNumber(n) {
const ch = this.agg.getChannelByNumber(n);
if (!ch) { this._flashEntry(String(n), true); return; }
return this.tune(ch);
}
async tuneToIndexOffset(delta) {
const list = this.agg.channels;
if (!list.length) return;
let idx = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
idx = (idx + delta + list.length) % list.length;
return this.tune(list[idx]);
}
async tuneLast() { if (this.previous) return this.tune(this.previous); }
/** @param {import('./providers/source-provider.js').Channel} ch */
async tune(ch) {
if (!ch) return;
if (this.current && this.current.id !== ch.id) this.previous = this.current;
this.current = ch;
this._showStatic();
this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX);
const handle = await this.agg.getStreamHandle(ch.id);
if (!handle) { this.player._showNoSignal?.(); this._clearStatic(); }
else {
const fallbacks = (ch.streamUrls || []).slice(1);
await this.player.play(handle, fallbacks);
}
this._showBanner(ch);
this._announce(ch);
return ch;
}
// ---------- number entry (incantation) ----------
pushDigit(d) {
this._entry = (this._entry + d).slice(-4);
this._flashEntry(this._entry, false);
clearTimeout(this._entryT);
this._entryT = setTimeout(() => {
const n = parseInt(this._entry, 10);
this._entry = '';
this.els.osdNumber.hidden = true;
if (Number.isFinite(n)) this.tuneToNumber(n);
}, ENTRY_DEBOUNCE);
}
_flashEntry(text, invalid) {
this.els.osdNumber.hidden = false;
this.els.osdNumberVal.textContent = (text + '___').slice(0, 3);
this.els.osdNumberVal.style.color = invalid ? 'var(--ink-dim)' : '';
if (invalid) setTimeout(() => { this.els.osdNumber.hidden = true; }, 900);
}
// ---------- channel banner OSD ----------
async _showBanner(ch) {
const e = this.els;
e.osdNum.textContent = String(ch.number ?? '--');
e.osdName.textContent = ch.name || ''; // textContent: untrusted-safe
e.osdLogo.src = ch.logo || '';
e.osdLogo.alt = '';
e.osdNow.textContent = '';
e.osdProgFill.style.width = '0%';
e.osdChannel.hidden = false;
requestAnimationFrame(() => e.osdChannel.classList.add('show'));
clearTimeout(this._bannerT);
this._bannerT = setTimeout(() => {
e.osdChannel.classList.remove('show');
setTimeout(() => { e.osdChannel.hidden = true; }, 350);
}, BANNER_MS);
// now-playing (best effort)
try {
const { now } = await this.agg.nowNext(ch.id);
if (now && this.current && this.current.id === ch.id) {
e.osdNow.textContent = now.title;
const pct = Math.min(100, Math.max(0, ((Date.now() - now.start) / (now.stop - now.start)) * 100));
e.osdProgFill.style.width = pct.toFixed(1) + '%';
}
} catch {}
}
_announce(ch) {
// aria-live region already on osd-channel; ensure SR gets a concise message
this.els.osdChannel.setAttribute('aria-label', `Channel ${ch.number}, ${ch.name}`);
}
// ---------- scrying static ----------
_prepStatic() {
if (this._reduced) return;
const c = this.els.staticCanvas;
const w = c.width = 160, h = c.height = 90;
const ctx = c.getContext('2d');
for (let f = 0; f < 8; f++) {
const img = ctx.createImageData(w, h);
for (let i = 0; i < img.data.length; i += 4) {
const v = (Math.random() * 90) | 0;
img.data[i] = v + (Math.random() * 80 | 0); // red-tinged
img.data[i+1] = v * 0.3;
img.data[i+2] = v * 0.3;
img.data[i+3] = 255;
}
this._staticFrames.push(img);
}
}
_showStatic() {
const c = this.els.staticCanvas;
this._staticShownAt = performance.now();
if (this._reduced) {
// reduced-motion: brief red flash instead of noise
c.classList.add('show'); c.style.background = 'var(--red-deep)';
return;
}
c.style.background = '';
c.classList.add('show');
const ctx = c.getContext('2d');
let i = 0;
clearInterval(this._staticTimer);
this._staticTimer = setInterval(() => {
ctx.putImageData(this._staticFrames[i++ % this._staticFrames.length], 0, 0);
}, 1000 / 18);
}
_clearStatic() {
clearTimeout(this._safetyClear);
const elapsed = performance.now() - (this._staticShownAt || 0);
const wait = Math.max(0, STATIC_MIN - elapsed);
setTimeout(() => {
clearInterval(this._staticTimer);
this.els.staticCanvas.classList.remove('show');
}, wait);
}
}