Add self-hosted Mimir live edge

This commit is contained in:
Meta Vens 2026-05-30 22:04:54 +01:00
commit 1e4ab94776
12 changed files with 610 additions and 0 deletions

View file

@ -1,6 +1,7 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Mimir.App/Mimir.App.csproj" />
<Project Path="src/Mimir.Broadcast/Mimir.Broadcast.csproj" />
<Project Path="src/Mimir.EveRelay/Mimir.EveRelay.csproj" />
<Project Path="src/Mimir.Runtime/Mimir.Runtime.csproj" />
<Project Path="src/Mimir.BufferSmoke/Mimir.BufferSmoke.csproj" />

View file

@ -0,0 +1,71 @@
# Self-Hosted Live Edge
Mimir's self-hosted broadcast path keeps OBS optional. Starfire owns the final
program frame and audio mix; Yggdrasil owns public byte distribution.
```mermaid
flowchart LR
A["Fensalir program texture"] --> B["Starfire NVENC encode"]
C["Faust/Mimir mix"] --> B
B --> D["SSH TCP forward"]
D --> E["Yggdrasil localhost RTMP ingest"]
E --> F["HLS segment ring"]
F --> G["nginx static HTTPS"]
G --> H["mimir.gamecult.org static viewer"]
```
## Authority Map
- Owner: `Mimir.Broadcast` owns the Starfire-side encoded push command.
- Inputs: final program media, FFmpeg/NVENC capability, and the SSH-forwarded
RTMP endpoint.
- Outputs: one CBR H.264/AAC RTMP stream to Yggdrasil.
- Derived state: Yggdrasil's HLS playlist and segments are derived from that
encoded stream. They do not own composition, timing, or transcoding.
- Forbidden writers: Yggdrasil must not transcode the program stream in the
first live edge. OBS is not the primary broadcast owner.
- Shared paths: synthetic smokes and real program pushes use the same RTMP URL:
`rtmp://127.0.0.1:11935/live/mimir`.
- Deletion line: if a later CDN or WebRTC edge appears, keep this path as the
simple origin proof or delete it. Do not keep two public stream authorities.
## First Smoke
On Starfire, with an SSH local forward active to Yggdrasil's localhost RTMP
port:
```powershell
dotnet run --project .\src\Mimir.Broadcast\Mimir.Broadcast.csproj -- --smoke
dotnet run --project .\src\Mimir.Broadcast\Mimir.Broadcast.csproj -- --print-command
```
The default push target is:
```text
rtmp://127.0.0.1:11935/live/mimir
```
The public HLS playlist is:
```text
https://live.mimir.gamecult.org/hls/mimir.m3u8
```
The static viewer lives at:
```text
https://mimir.gamecult.org/live
```
## Scaling Rule
Viewer capacity is approximately:
```text
available Yggdrasil egress / encoded bitrate
```
Micro-optimizations count only when they improve target-host measurements:
served Mbps, p95 segment latency, CPU, memory, syscalls, or stall rate on
Yggdrasil. The first edge uses nginx static serving and kernel file paths so
the hot path is byte distribution, not application code.

31
live.md Normal file
View file

@ -0,0 +1,31 @@
# Mimir Live
<link rel="stylesheet" href="/static/live/player.css">
<main class="mimir-live-shell">
<section class="mimir-live-stage" aria-label="Mimir live stream">
<video id="mimir-live-video" controls playsinline preload="metadata" poster="/Mimir.png"></video>
<div class="mimir-live-status" id="mimir-live-status">Waiting for the live edge.</div>
</section>
<section class="mimir-live-panel">
<p class="mimir-live-kicker">Self-hosted broadcast</p>
<h2>Mimir Live</h2>
<p>
Starfire encodes the final Mimir program once. Yggdrasil receives that
stream, rotates HLS segments, and serves this player over public HTTPS.
</p>
<dl>
<div>
<dt>Playlist</dt>
<dd><a id="mimir-live-playlist-link" href="https://live.mimir.gamecult.org/hls/mimir.m3u8">live.mimir.gamecult.org/hls/mimir.m3u8</a></dd>
</div>
<div>
<dt>Authority</dt>
<dd>Mimir encodes. Yggdrasil distributes. The static site only watches.</dd>
</div>
</dl>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js" crossorigin="anonymous"></script>
<script src="/static/live/player.js"></script>

View file

@ -366,6 +366,18 @@ complete: OBS consumes final Mimir/Fensalir surfaces and stems through the
native plugin, raw feeds remain debug inputs, and the ledger records the live
program texture, texture ring, dedicated producer fence, and consumer-fence
proofs.
The public self-hosted edge has its first deployable path. `src/Mimir.Broadcast`
builds the Starfire-side FFmpeg/NVENC push command for
`rtmp://127.0.0.1:11935/live/mimir`; the port is an SSH local forward to
Yggdrasil's localhost RTMP listener. Yggdrasil owns nginx RTMP ingest and HLS
segment serving only. It must not transcode or compose in v1. The static Mimir
viewer route is `https://mimir.gamecult.org/live`, backed by
`static/live/player.js` and `static/live/player.css`, and it reads
`https://live.mimir.gamecult.org/hls/mimir.m3u8`. On 2026-05-30 the deployed
Yggdrasil origin validated nginx, listened on `127.0.0.1:1935`, returned
`mimir-live-ok`, and generated four HLS segments plus `mimir.m3u8` from an
eight-second Starfire synthetic NVENC push. The unresolved blocker is public
DNS/TLS for `live.mimir.gamecult.org`.
`MimirSynchronizedBufferPlanner` is now the low-level aligned-buffer primitive:
it picks one canonical presentation time inside the retained rolling window and
returns per-stream slices for cameras, network display feeds, and audio. Timing

View file

@ -107,6 +107,14 @@ Get-Content .\state\evidence.jsonl -Tail 8
over `/stream`; EveCanvas decodes with `AVSampleBufferDisplayLayer`. The
launch script uses an SSH reverse tunnel to Eve when Windows firewall blocks
direct inbound TCP.
- `src/Mimir.Broadcast` is the first self-hosted public sender. Starfire pushes
one final H.264/AAC NVENC RTMP stream to
`rtmp://127.0.0.1:11935/live/mimir` through an SSH local forward to
Yggdrasil. Yggdrasil's nginx RTMP/HLS origin is deployed and has produced
HLS segments from a bounded synthetic push; public activation still needs DNS
and TLS for `live.mimir.gamecult.org`. The static viewer route is
[[live|Mimir Live]] and reads
`https://live.mimir.gamecult.org/hls/mimir.m3u8`.
- Faust/native DSP owns hot audio alignment, separation, spatialization, and
synchronized stems.
- OBS receives final program surfaces; it does not own synchronization.

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>Mimir.Broadcast</AssemblyName>
<RootNamespace>Mimir.Broadcast</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,279 @@
using System.Diagnostics;
using System.Text;
namespace Mimir.Broadcast;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var options = MimirBroadcastOptions.Parse(args);
if (options.ShowHelp)
{
Console.WriteLine(MimirBroadcastOptions.HelpText);
return 0;
}
var plan = MimirBroadcastPlan.FromOptions(options);
if (options.PrintCommand || options.DryRun)
{
Console.WriteLine(plan.CommandLine);
}
if (options.Smoke)
{
return RunSmoke(plan);
}
if (options.DryRun || options.PrintCommand)
{
return 0;
}
return await RunFfmpegAsync(options, plan).ConfigureAwait(false);
}
private static int RunSmoke(MimirBroadcastPlan plan)
{
var command = plan.CommandLine;
var ok = command.Contains("h264_nvenc", StringComparison.Ordinal) &&
command.Contains("-f flv", StringComparison.Ordinal) &&
command.Contains("rtmp://127.0.0.1:11935/live/mimir", StringComparison.Ordinal) &&
command.Contains("-g 60", StringComparison.Ordinal) &&
command.Contains("-sc_threshold 0", StringComparison.Ordinal);
Console.WriteLine($"mimir-broadcast-smoke nvenc={command.Contains("h264_nvenc", StringComparison.Ordinal)} rtmp={command.Contains("rtmp://", StringComparison.Ordinal)} keyframeCadence={command.Contains("-g 60", StringComparison.Ordinal)}");
return ok ? 0 : 1;
}
private static async Task<int> RunFfmpegAsync(MimirBroadcastOptions options, MimirBroadcastPlan plan)
{
var start = new ProcessStartInfo
{
FileName = options.FfmpegPath,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var argument in plan.Arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start);
if (process == null)
{
Console.Error.WriteLine("Could not start ffmpeg.");
return 1;
}
var stdout = PumpAsync(process.StandardOutput, Console.Out);
var stderr = PumpAsync(process.StandardError, Console.Error);
await process.WaitForExitAsync().ConfigureAwait(false);
await Task.WhenAll(stdout, stderr).ConfigureAwait(false);
return process.ExitCode;
}
private static async Task PumpAsync(StreamReader reader, TextWriter writer)
{
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
writer.WriteLine(line);
}
}
}
internal sealed record MimirBroadcastPlan(IReadOnlyList<string> Arguments)
{
public string CommandLine => $"ffmpeg {string.Join(' ', Arguments.Select(Quote))}";
public static MimirBroadcastPlan FromOptions(MimirBroadcastOptions options)
{
var args = new List<string>();
if (options.Realtime)
{
args.Add("-re");
}
if (options.TestPattern)
{
args.AddRange(["-f", "lavfi", "-i", $"testsrc2=size={options.Width}x{options.Height}:rate={options.FrameRate}"]);
args.AddRange(["-f", "lavfi", "-i", $"sine=frequency=997:sample_rate={options.AudioSampleRate}"]);
}
else if (!string.IsNullOrWhiteSpace(options.InputPath))
{
args.AddRange(["-i", options.InputPath]);
}
else
{
throw new InvalidOperationException("Use --test-pattern or provide --input.");
}
var audioMap = options.TestPattern ? "1:a:0" : "0:a:0?";
args.AddRange([
"-map", "0:v:0",
"-map", audioMap,
"-c:v", "h264_nvenc",
"-preset", options.NvencPreset,
"-tune", "ll",
"-rc", "cbr",
"-b:v", $"{options.VideoBitrateKbps}k",
"-maxrate", $"{options.VideoBitrateKbps}k",
"-bufsize", $"{options.VideoBitrateKbps * 2}k",
"-g", options.GopFrames.ToStringInvariant(),
"-keyint_min", options.GopFrames.ToStringInvariant(),
"-sc_threshold", "0",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", $"{options.AudioBitrateKbps}k",
"-ar", options.AudioSampleRate.ToStringInvariant(),
]);
if (options.DurationSeconds > 0)
{
args.AddRange(["-t", options.DurationSeconds.ToStringInvariant()]);
}
args.AddRange([
"-f", "flv",
options.TargetUrl,
]);
return new MimirBroadcastPlan(args);
}
private static string Quote(string value)
{
if (value.Length == 0)
{
return "\"\"";
}
return value.Any(static c => char.IsWhiteSpace(c) || c == '"')
? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""
: value;
}
}
internal sealed record MimirBroadcastOptions
{
public string FfmpegPath { get; init; } = "ffmpeg";
public string TargetUrl { get; init; } = "rtmp://127.0.0.1:11935/live/mimir";
public string InputPath { get; init; } = "";
public bool TestPattern { get; init; } = true;
public bool Realtime { get; init; } = true;
public bool PrintCommand { get; init; }
public bool DryRun { get; init; }
public bool Smoke { get; init; }
public bool ShowHelp { get; init; }
public int Width { get; init; } = 1280;
public int Height { get; init; } = 720;
public int FrameRate { get; init; } = 30;
public int GopFrames { get; init; } = 60;
public int VideoBitrateKbps { get; init; } = 4500;
public int AudioSampleRate { get; init; } = 48_000;
public int AudioBitrateKbps { get; init; } = 160;
public string NvencPreset { get; init; } = "p4";
public int DurationSeconds { get; init; }
public static string HelpText => """
Mimir.Broadcast
Push a Starfire-encoded Mimir program stream to the Yggdrasil RTMP/HLS edge.
The default command is a synthetic NVENC smoke source:
dotnet run --project src/Mimir.Broadcast -- --print-command
Options:
--target <url> RTMP target. Default: rtmp://127.0.0.1:11935/live/mimir
--ffmpeg <path> FFmpeg executable. Default: ffmpeg
--input <path> Input media path instead of the synthetic test pattern.
--no-test-pattern Require --input.
--width <px> Synthetic width. Default: 1280
--height <px> Synthetic height. Default: 720
--fps <n> Synthetic frame rate. Default: 30
--video-kbps <n> CBR video bitrate. Default: 4500
--audio-kbps <n> AAC bitrate. Default: 160
--gop <frames> Keyframe cadence. Default: 60
--preset <name> NVENC preset. Default: p4
--duration-seconds <n> Stop after this many seconds. Default: run until stopped.
--print-command Print the generated FFmpeg command.
--dry-run Print command and exit.
--smoke Validate the generated command shape and exit.
""";
public static MimirBroadcastOptions Parse(IReadOnlyList<string> args)
{
var options = new MimirBroadcastOptions();
for (var index = 0; index < args.Count; index++)
{
var arg = args[index];
options = arg switch
{
"--help" or "-h" => options with { ShowHelp = true },
"--print-command" => options with { PrintCommand = true },
"--dry-run" => options with { DryRun = true, PrintCommand = true },
"--smoke" => options with { Smoke = true },
"--no-test-pattern" => options with { TestPattern = false },
"--target" => options with { TargetUrl = RequiredValue(args, ref index, arg) },
"--ffmpeg" => options with { FfmpegPath = RequiredValue(args, ref index, arg) },
"--input" => options with { InputPath = RequiredValue(args, ref index, arg), TestPattern = false },
"--width" => options with { Width = ParsePositiveInt(args, ref index, arg) },
"--height" => options with { Height = ParsePositiveInt(args, ref index, arg) },
"--fps" => options with { FrameRate = ParsePositiveInt(args, ref index, arg) },
"--video-kbps" => options with { VideoBitrateKbps = ParsePositiveInt(args, ref index, arg) },
"--audio-kbps" => options with { AudioBitrateKbps = ParsePositiveInt(args, ref index, arg) },
"--gop" => options with { GopFrames = ParsePositiveInt(args, ref index, arg) },
"--preset" => options with { NvencPreset = RequiredValue(args, ref index, arg) },
"--duration-seconds" => options with { DurationSeconds = ParsePositiveInt(args, ref index, arg) },
_ => throw new ArgumentException($"Unknown argument `{arg}`."),
};
}
return options;
}
private static string RequiredValue(IReadOnlyList<string> args, ref int index, string option)
{
if (index + 1 >= args.Count || args[index + 1].StartsWith("-", StringComparison.Ordinal))
{
throw new ArgumentException($"{option} requires a value.");
}
index++;
return args[index];
}
private static int ParsePositiveInt(IReadOnlyList<string> args, ref int index, string option)
{
var text = RequiredValue(args, ref index, option);
if (!int.TryParse(text, out var value) || value <= 0)
{
throw new ArgumentException($"{option} requires a positive integer.");
}
return value;
}
}
internal static class InvariantFormatting
{
public static string ToStringInvariant(this int value) =>
value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}

View file

@ -237,3 +237,5 @@
{"ts":"2026-05-30T00:44:38+01:00","type":"decision","status":"ok","note":"Fensalir now owns stereo-depth dispatch readiness accounting before the SGM kernel exists: it only counts a lowering ready when the Height claim is planned and left/right textures plus the UAV disparity SurfacePage resolve. Mimir remains responsible for real input texture resource identity; no placeholder depth output is written."}
{"ts":"2026-05-30T00:48:04+01:00","type":"implementation","status":"ok","note":"Stereo-depth profile/candidate/lowering now carries SGM constants through Mimir into the Fensalir sidecar: min disparity 0, 128 disparities, four aggregation paths, census radius 2, P1 8, and P2 96 for the libSGM-provenance D3D12 profile. Verified Mimir build, --stereo-depth-contract-smoke, and --perfect-machine-profile-smoke."}
{"ts":"2026-05-30T21:58:00+01:00","type":"implementation","status":"ok","note":"Mimir now lowers live Leap packed stereo IR into the stereo-depth FieldEvidence lane. MimirRuntime merges a Leap depth frame when a live LeapStereoIr rolling window has a declared GPU texture resource; BuildLeapPackedStereoDepthCandidateFrame treats the packed R8G8 texture as left/right resource identity with R/G observation lanes, emits the pending-calibration Height claim, UAV R16F disparity SurfacePage, R8 confidence texture, and AquariumFieldStereoDepthLowering sidecar. Verified with Mimir build, --leap-packed-stereo-depth-smoke, and --stereo-depth-contract-smoke."}
{"ts": "2026-05-30T22:01:42+01:00", "type": "implementation", "status": "ok", "note": "Self-hosted Mimir live edge has a deployed origin proof. Mimir.Broadcast builds the Starfire FFmpeg/NVENC RTMP push to rtmp://127.0.0.1:11935/live/mimir through an SSH local forward; Yggdrasil nginx now has libnginx-mod-rtmp, localhost RTMP ingest, /srv/mimir-live/hls, and a live.mimir.gamecult.org HTTP vhost with CORS for mimir.gamecult.org. An eight-second synthetic NVENC push on 2026-05-30 generated mimir.m3u8 and four TS segments on Yggdrasil. Public DNS/TLS for live.mimir.gamecult.org remains the activation blocker."}
{"ts": "2026-05-30T22:04:09+01:00", "type": "verification", "status": "ok", "note": "Yggdrasil target-host fanout smoke ran during an active Starfire synthetic NVENC push through the SSH RTMP tunnel. With LIVE_URL pinned to localhost via curl --resolve, the benchmark fetched a live HLS segment from nginx 320 times with CLIENTS=16 and REQUESTS_PER_CLIENT=20 in 1256 ms. wrk is not installed yet, so p95 latency remains future instrumentation."}

View file

@ -109,6 +109,16 @@ accepted_design:
decode/composite. The local launch script opens an SSH reverse tunnel to
Eve when Windows firewall policy blocks direct inbound TCP, and EveCanvas
tries localhost before the LAN host.
- `src/Mimir.Broadcast` is the first self-hosted public edge sender:
Starfire uses FFmpeg/NVENC to push one final H.264/AAC RTMP stream through
an SSH local forward to Yggdrasil. Yggdrasil owns nginx RTMP ingest and HLS
segment serving only; it does not transcode or compose. The public viewer
route is `https://mimir.gamecult.org/live`, and it reads
`https://live.mimir.gamecult.org/hls/mimir.m3u8`. The deployed Yggdrasil
origin already validates, listens on localhost RTMP `127.0.0.1:1935`, and
produced HLS segments from an eight-second Starfire synthetic NVENC push on
2026-05-30. Public DNS/TLS for `live.mimir.gamecult.org` remains the
activation blocker.
- `MimirAudioBlockDescriptor` carries sample rate, channels, sample format,
frame count, timestamp, and optional native handle.
- `MimirFrameEventProcessStreamSource` is a temporary diagnostic adapter for

37
static/live/index.html Normal file
View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mimir Live</title>
<link rel="stylesheet" href="/static/live/player.css">
</head>
<body class="mimir-live-page">
<main class="mimir-live-shell">
<section class="mimir-live-stage" aria-label="Mimir live stream">
<video id="mimir-live-video" controls playsinline preload="metadata" poster="/Mimir.png"></video>
<div class="mimir-live-status" id="mimir-live-status">Waiting for the live edge.</div>
</section>
<section class="mimir-live-panel">
<p class="mimir-live-kicker">Self-hosted broadcast</p>
<h1>Mimir Live</h1>
<p>
Starfire encodes the final Mimir program once. Yggdrasil receives that
stream, rotates HLS segments, and serves this player over public HTTPS.
</p>
<dl>
<div>
<dt>Playlist</dt>
<dd><a id="mimir-live-playlist-link" href="https://live.mimir.gamecult.org/hls/mimir.m3u8">live.mimir.gamecult.org/hls/mimir.m3u8</a></dd>
</div>
<div>
<dt>Authority</dt>
<dd>Mimir encodes. Yggdrasil distributes. The static site only watches.</dd>
</div>
</dl>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js" crossorigin="anonymous"></script>
<script src="/static/live/player.js"></script>
</body>
</html>

104
static/live/player.css Normal file
View file

@ -0,0 +1,104 @@
.mimir-live-page,
.mimir-live-shell {
color-scheme: dark;
font-family: Ubuntu, system-ui, sans-serif;
color: #f3fbff;
}
.mimir-live-page *,
.mimir-live-shell * {
box-sizing: border-box;
}
.mimir-live-page {
margin: 0;
min-height: 100vh;
background: #05090d;
}
.mimir-live-shell a {
color: #9bd887;
}
.mimir-live-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 380px);
gap: 24px;
align-items: start;
min-height: 100vh;
padding: clamp(16px, 3vw, 42px);
}
.mimir-live-stage {
display: grid;
gap: 10px;
}
.mimir-live-shell video {
width: 100%;
aspect-ratio: 16 / 9;
background: #020507;
border: 1px solid rgba(100, 183, 255, 0.28);
border-radius: 6px;
}
.mimir-live-status {
min-height: 1.4rem;
color: #c4dce5;
font-size: 0.92rem;
}
.mimir-live-panel {
display: grid;
gap: 14px;
padding-top: 4px;
}
.mimir-live-kicker {
margin: 0;
color: #64b7ff;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mimir-live-panel h1,
.mimir-live-panel h2 {
margin: 0;
font-family: Montserrat, system-ui, sans-serif;
font-size: clamp(2rem, 5vw, 4.2rem);
font-weight: 200;
letter-spacing: 0;
}
.mimir-live-panel p {
margin: 0;
color: #c4dce5;
line-height: 1.55;
}
.mimir-live-panel dl {
display: grid;
gap: 14px;
margin: 6px 0 0;
}
.mimir-live-panel dt {
color: #6d8792;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.mimir-live-panel dd {
margin: 4px 0 0;
color: #f3fbff;
overflow-wrap: anywhere;
}
@media (max-width: 840px) {
.mimir-live-shell {
grid-template-columns: 1fr;
}
}

43
static/live/player.js Normal file
View file

@ -0,0 +1,43 @@
(function () {
const playlistUrl = "https://live.mimir.gamecult.org/hls/mimir.m3u8"
const video = document.getElementById("mimir-live-video")
const status = document.getElementById("mimir-live-status")
const link = document.getElementById("mimir-live-playlist-link")
if (!video || !status || !link) {
return
}
link.href = playlistUrl
link.textContent = playlistUrl.replace(/^https?:\/\//, "")
function setStatus(message) {
status.textContent = message
}
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = playlistUrl
setStatus("Using native HLS playback.")
return
}
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 18,
})
hls.loadSource(playlistUrl)
hls.attachMedia(video)
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
setStatus("Live edge found. Playback is ready.")
})
hls.on(window.Hls.Events.ERROR, (_event, data) => {
const detail = data && data.details ? ` ${data.details}` : ""
setStatus(`Waiting for stream.${detail}`)
})
return
}
setStatus("This browser cannot play HLS here. Try Safari, Edge, or Chrome with hls.js available.")
})()