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:
+22
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.*
|
||||
|
||||
[](#tech-stack)
|
||||
[-e10000?style=flat-square)](#the-relay)
|
||||
[](#security-posture)
|
||||
[](#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 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0–9` | 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.
|
||||
Generated
+1184
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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', " ")
|
||||
}
|
||||
@@ -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, ¤t, 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
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 = [
|
||||
['0–9', '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
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
VERSION=1.5.17
|
||||
SHA256=484054e8cd03d3f6d1781fb7f402bdc318d8a4c527f933a95c624e27cc9a9470
|
||||
SRI=sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
|
||||
@@ -0,0 +1 @@
|
||||
sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
|
||||
+212
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user