Advance forge rollout, Ethereum rails, and NBC sources
This commit is contained in:
parent
be26313225
commit
7d84510eac
88 changed files with 11230 additions and 302 deletions
|
|
@ -11,10 +11,12 @@ blake3.workspace = true
|
|||
ec-crypto = { path = "../../crates/ec-crypto" }
|
||||
ec-core = { path = "../../crates/ec-core" }
|
||||
ec-chopper = { path = "../../crates/ec-chopper" }
|
||||
ec-eth = { path = "../../crates/ec-eth" }
|
||||
ec-hdhomerun = { path = "../../crates/ec-hdhomerun" }
|
||||
ec-linux-iptv = { path = "../../crates/ec-linux-iptv" }
|
||||
ec-iroh = { path = "../../crates/ec-iroh" }
|
||||
ec-moq = { path = "../../crates/ec-moq" }
|
||||
headless_chrome = "1"
|
||||
hex = "0.4"
|
||||
iroh = "0.96"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
|
@ -25,5 +27,16 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
|||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block2 = "0.6"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSBitmapImageRep", "NSImage"] }
|
||||
objc2-core-foundation = "0.3"
|
||||
objc2-foundation = { version = "0.3", features = ["NSData", "NSDictionary", "NSError", "NSString"] }
|
||||
objc2-web-kit = { version = "0.3", features = ["WKSnapshotConfiguration", "WKWebView", "block2", "objc2-app-kit"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
|||
[build]
|
||||
dist = "../dist"
|
||||
public_url = "/"
|
||||
public_url = "./"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,80 @@
|
|||
<link data-trunk rel="copy-dir" href="icons" />
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="boot-status"
|
||||
style="
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: #f7f4ef;
|
||||
color: #2b241d;
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
white-space: pre-wrap;
|
||||
z-index: 9999;
|
||||
"
|
||||
>
|
||||
Loading every.channel…
|
||||
</div>
|
||||
<div id="main"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const boot = document.getElementById("boot-status");
|
||||
const main = document.getElementById("main");
|
||||
if (!boot || !main) return;
|
||||
|
||||
const show = (message) => {
|
||||
boot.textContent = message;
|
||||
boot.style.display = "flex";
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
boot.remove();
|
||||
};
|
||||
|
||||
const mounted = () => {
|
||||
if (main.childElementCount > 0) {
|
||||
hide();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
new MutationObserver(() => {
|
||||
mounted();
|
||||
}).observe(main, { childList: true, subtree: true });
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const message =
|
||||
event?.error?.stack ||
|
||||
event?.error?.message ||
|
||||
event?.message ||
|
||||
"Unknown frontend boot error";
|
||||
show(`Frontend boot error\n\n${message}`);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
const message =
|
||||
reason?.stack ||
|
||||
reason?.message ||
|
||||
(typeof reason === "string" ? reason : JSON.stringify(reason, null, 2)) ||
|
||||
"Unknown unhandled rejection";
|
||||
show(`Frontend boot rejection\n\n${message}`);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!mounted()) {
|
||||
show("Loading every.channel is taking longer than expected…");
|
||||
}
|
||||
}, 4000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Installable app shell (PWA). Keep this tiny and resilient.
|
||||
if ("serviceWorker" in navigator) {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,24 @@ struct ProbeStreamArgs {
|
|||
input: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BootstrapNbcAuthArgs {
|
||||
input: Option<String>,
|
||||
stream_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BootstrapNbcAuthResult {
|
||||
input_url: String,
|
||||
stream_id: Option<String>,
|
||||
hidden_mode: bool,
|
||||
surfaced_auth: bool,
|
||||
data_dir: Option<String>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManualSourceOptions {
|
||||
|
|
@ -777,6 +795,13 @@ fn App() -> Element {
|
|||
let active_id = selected.read().as_ref().map(|s| s.id.clone());
|
||||
let playback_url = playback.read().as_ref().map(|p| p.url.clone());
|
||||
let now_playing = selected.read().clone();
|
||||
let bootstrap_stream = selected
|
||||
.read()
|
||||
.as_ref()
|
||||
.filter(|stream| stream_source_kind(stream) == "nbc")
|
||||
.cloned();
|
||||
let bootstrap_input_value = add_input.read().clone();
|
||||
let bootstrap_input_is_nbc = looks_like_nbc_input(&bootstrap_input_value);
|
||||
let current_share = share_info.read().clone();
|
||||
let source_list = sources.read().clone();
|
||||
|
||||
|
|
@ -889,11 +914,87 @@ fn App() -> Element {
|
|||
span { "{source.ip.clone().unwrap_or_default()}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "source-menu-action",
|
||||
onclick: move |_| refresh_sources(),
|
||||
"Refresh"
|
||||
button {
|
||||
class: "source-menu-action",
|
||||
onclick: move |_| refresh_sources(),
|
||||
"Refresh"
|
||||
}
|
||||
if bootstrap_stream.is_some() || bootstrap_input_is_nbc {
|
||||
div { class: "source-menu-divider" }
|
||||
div { class: "source-menu-section",
|
||||
div { class: "source-menu-title", "NBC auth" }
|
||||
div { class: "source-menu-status",
|
||||
"Warm a hidden NBC session. The window only appears if MVPD auth is needed."
|
||||
}
|
||||
if let Some(stream) = bootstrap_stream.clone() {
|
||||
button {
|
||||
class: "source-menu-action",
|
||||
onclick: move |_| {
|
||||
if !tauri_available() {
|
||||
status.set("Tauri backend not available (open the Tauri app)".to_string());
|
||||
return;
|
||||
}
|
||||
let stream_id = stream.id.clone();
|
||||
let stream_title = stream.title.clone();
|
||||
let args = BootstrapNbcAuthArgs {
|
||||
input: None,
|
||||
stream_id: Some(stream_id),
|
||||
};
|
||||
let mut status = status.clone();
|
||||
spawn(async move {
|
||||
status.set(format!("Bootstrapping NBC auth for {}", stream_title));
|
||||
match tauri_invoke::<BootstrapNbcAuthResult, _>("bootstrap_nbc_auth", &args).await {
|
||||
Ok(result) => {
|
||||
if result.surfaced_auth {
|
||||
status.set("NBC auth ready after interactive sign-in".to_string());
|
||||
} else {
|
||||
status.set("NBC auth ready in hidden mode".to_string());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
status.set(format!("NBC bootstrap error: {err}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
"Bootstrap selected NBC"
|
||||
}
|
||||
}
|
||||
if bootstrap_input_is_nbc {
|
||||
button {
|
||||
class: "source-menu-action",
|
||||
onclick: move |_| {
|
||||
if !tauri_available() {
|
||||
status.set("Tauri backend not available (open the Tauri app)".to_string());
|
||||
return;
|
||||
}
|
||||
let input = bootstrap_input_value.clone();
|
||||
let args = BootstrapNbcAuthArgs {
|
||||
input: Some(input.clone()),
|
||||
stream_id: None,
|
||||
};
|
||||
let mut status = status.clone();
|
||||
spawn(async move {
|
||||
status.set(format!("Bootstrapping NBC auth for {}", input));
|
||||
match tauri_invoke::<BootstrapNbcAuthResult, _>("bootstrap_nbc_auth", &args).await {
|
||||
Ok(result) => {
|
||||
if result.surfaced_auth {
|
||||
status.set("NBC auth ready after interactive sign-in".to_string());
|
||||
} else {
|
||||
status.set("NBC auth ready in hidden mode".to_string());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
status.set(format!("NBC bootstrap error: {err}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
"Bootstrap pasted NBC URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "source-menu-divider" }
|
||||
div { class: "source-menu-section",
|
||||
div { class: "source-menu-title", "Add stream" }
|
||||
|
|
@ -1707,6 +1808,7 @@ fn App() -> Element {
|
|||
option { value: "linux-dvb", "Linux DVB" }
|
||||
option { value: "hls", "HLS" }
|
||||
option { value: "ytdlp", "yt-dlp" }
|
||||
option { value: "nbc", "NBC" }
|
||||
option { value: "moq", "Link" }
|
||||
}
|
||||
button {
|
||||
|
|
@ -1753,6 +1855,8 @@ fn App() -> Element {
|
|||
.map(|id| id == &stream.id)
|
||||
.unwrap_or(false);
|
||||
let card_class = if is_active { "channel-card active" } else { "channel-card" };
|
||||
let channel_subtitle = stream_display_subtitle(stream);
|
||||
let channel_detail = stream_display_detail(stream);
|
||||
let moq_endpoint = stream
|
||||
.metadata
|
||||
.iter()
|
||||
|
|
@ -1835,8 +1939,11 @@ fn App() -> Element {
|
|||
});
|
||||
},
|
||||
div { class: "channel-title", "{stream.title}" }
|
||||
div { class: "channel-meta",
|
||||
{stream.number.clone().unwrap_or_default()}
|
||||
if let Some(channel_subtitle) = channel_subtitle.clone() {
|
||||
div { class: "channel-meta", "{channel_subtitle}" }
|
||||
}
|
||||
if let Some(channel_detail) = channel_detail.clone() {
|
||||
div { class: "channel-detail", "{channel_detail}" }
|
||||
}
|
||||
if !stream.source.is_empty() {
|
||||
div { class: "channel-badge source", "{stream.source}" }
|
||||
|
|
@ -2293,6 +2400,65 @@ fn stream_has_drm(metadata: &[StreamMetadata]) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
fn stream_metadata_value<'a>(stream: &'a StreamDescriptor, key: &str) -> Option<&'a str> {
|
||||
stream
|
||||
.metadata
|
||||
.iter()
|
||||
.find(|entry| entry.key == key)
|
||||
.map(|entry| entry.value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn stream_display_subtitle(stream: &StreamDescriptor) -> Option<String> {
|
||||
if stream_source_kind(stream) == "nbc" {
|
||||
if let Some(program) =
|
||||
stream_metadata_value(stream, "current_program").or_else(|| stream.number.as_deref())
|
||||
{
|
||||
return Some(format!("Now: {}", program.trim()));
|
||||
}
|
||||
if let Some(brand) = stream_metadata_value(stream, "nbc_brand") {
|
||||
return Some(brand.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
stream
|
||||
.number
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn stream_display_detail(stream: &StreamDescriptor) -> Option<String> {
|
||||
if stream_source_kind(stream) != "nbc" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if let Some(badge) = stream_metadata_value(stream, "badge") {
|
||||
parts.push(badge.to_string());
|
||||
}
|
||||
if let Some(entitlement) = stream_metadata_value(stream, "entitlement") {
|
||||
if !entitlement.eq_ignore_ascii_case("free") {
|
||||
parts.push("Adobe auth".to_string());
|
||||
}
|
||||
} else if stream_has_drm(&stream.metadata) {
|
||||
parts.push("Adobe auth".to_string());
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join(" • "))
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_nbc_input(value: &str) -> bool {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
value.starts_with("https://www.nbc.com/") || value.starts_with("https://nbc.com/")
|
||||
}
|
||||
|
||||
fn stream_source_kind(stream: &StreamDescriptor) -> String {
|
||||
let source = stream.source.trim();
|
||||
if !source.is_empty() {
|
||||
|
|
|
|||
|
|
@ -392,6 +392,13 @@ body::before {
|
|||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.channel-detail {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.source-status {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const HLS_MODULE_URLS = [
|
|||
"https://unpkg.com/hls.js@1.6.2/dist/hls.mjs",
|
||||
];
|
||||
const PUBLIC_STREAMS_PATH = "/api/public-streams";
|
||||
const LIVE_JITTER_MS = 750;
|
||||
let moqWatchModulePromise = null;
|
||||
let hlsModulePromise = null;
|
||||
let disposePlayerSignals = null;
|
||||
|
|
@ -163,6 +164,7 @@ function mountPlayer(relayUrl, name) {
|
|||
watch.setAttribute("path", name);
|
||||
watch.setAttribute("volume", "1");
|
||||
watch.setAttribute("muted", "");
|
||||
watch.setAttribute("jitter", String(LIVE_JITTER_MS));
|
||||
|
||||
// Force WebTransport in-browser; websocket fallback has shown degraded
|
||||
// media behavior (especially audio) against public relay paths.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue