From 1e4ab94776ea8faa76bb2e3fbb448dfab06970b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 30 May 2026 22:04:54 +0100 Subject: [PATCH] Add self-hosted Mimir live edge --- Mimir.slnx | 1 + docs/self-hosted-live-edge.md | 71 ++++++ live.md | 31 +++ notes/current-system-map.md | 12 + notes/fresh-workspace-handoff.md | 8 + src/Mimir.Broadcast/Mimir.Broadcast.csproj | 12 + src/Mimir.Broadcast/Program.cs | 279 +++++++++++++++++++++ state/evidence.jsonl | 2 + state/map.yaml | 10 + static/live/index.html | 37 +++ static/live/player.css | 104 ++++++++ static/live/player.js | 43 ++++ 12 files changed, 610 insertions(+) create mode 100644 docs/self-hosted-live-edge.md create mode 100644 live.md create mode 100644 src/Mimir.Broadcast/Mimir.Broadcast.csproj create mode 100644 src/Mimir.Broadcast/Program.cs create mode 100644 static/live/index.html create mode 100644 static/live/player.css create mode 100644 static/live/player.js diff --git a/Mimir.slnx b/Mimir.slnx index dc580d3..5132556 100644 --- a/Mimir.slnx +++ b/Mimir.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/self-hosted-live-edge.md b/docs/self-hosted-live-edge.md new file mode 100644 index 0000000..5942597 --- /dev/null +++ b/docs/self-hosted-live-edge.md @@ -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. diff --git a/live.md b/live.md new file mode 100644 index 0000000..6bba1d9 --- /dev/null +++ b/live.md @@ -0,0 +1,31 @@ +# Mimir Live + + + +
+
+ +
Waiting for the live edge.
+
+
+

Self-hosted broadcast

+

Mimir Live

+

+ Starfire encodes the final Mimir program once. Yggdrasil receives that + stream, rotates HLS segments, and serves this player over public HTTPS. +

+
+ +
+
Authority
+
Mimir encodes. Yggdrasil distributes. The static site only watches.
+
+
+
+
+ + + diff --git a/notes/current-system-map.md b/notes/current-system-map.md index 314d190..aba515d 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -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 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 79b665e..75da82e 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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. diff --git a/src/Mimir.Broadcast/Mimir.Broadcast.csproj b/src/Mimir.Broadcast/Mimir.Broadcast.csproj new file mode 100644 index 0000000..0bf1495 --- /dev/null +++ b/src/Mimir.Broadcast/Mimir.Broadcast.csproj @@ -0,0 +1,12 @@ + + + + Exe + net10.0 + Mimir.Broadcast + Mimir.Broadcast + enable + enable + + + diff --git a/src/Mimir.Broadcast/Program.cs b/src/Mimir.Broadcast/Program.cs new file mode 100644 index 0000000..4022c85 --- /dev/null +++ b/src/Mimir.Broadcast/Program.cs @@ -0,0 +1,279 @@ +using System.Diagnostics; +using System.Text; + +namespace Mimir.Broadcast; + +internal static class Program +{ + public static async Task 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 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 Arguments) +{ + public string CommandLine => $"ffmpeg {string.Join(' ', Arguments.Select(Quote))}"; + + public static MimirBroadcastPlan FromOptions(MimirBroadcastOptions options) + { + var args = new List(); + 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 RTMP target. Default: rtmp://127.0.0.1:11935/live/mimir + --ffmpeg FFmpeg executable. Default: ffmpeg + --input Input media path instead of the synthetic test pattern. + --no-test-pattern Require --input. + --width Synthetic width. Default: 1280 + --height Synthetic height. Default: 720 + --fps Synthetic frame rate. Default: 30 + --video-kbps CBR video bitrate. Default: 4500 + --audio-kbps AAC bitrate. Default: 160 + --gop Keyframe cadence. Default: 60 + --preset NVENC preset. Default: p4 + --duration-seconds 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 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 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 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); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5f61cb1..14f8639 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index c287b11..1d2ce5c 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/static/live/index.html b/static/live/index.html new file mode 100644 index 0000000..7e432b8 --- /dev/null +++ b/static/live/index.html @@ -0,0 +1,37 @@ + + + + + + Mimir Live + + + +
+
+ +
Waiting for the live edge.
+
+
+

Self-hosted broadcast

+

Mimir Live

+

+ Starfire encodes the final Mimir program once. Yggdrasil receives that + stream, rotates HLS segments, and serves this player over public HTTPS. +

+
+ +
+
Authority
+
Mimir encodes. Yggdrasil distributes. The static site only watches.
+
+
+
+
+ + + + diff --git a/static/live/player.css b/static/live/player.css new file mode 100644 index 0000000..21b3eeb --- /dev/null +++ b/static/live/player.css @@ -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; + } +} diff --git a/static/live/player.js b/static/live/player.js new file mode 100644 index 0000000..205a1c2 --- /dev/null +++ b/static/live/player.js @@ -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.") +})()