using System.Collections.Concurrent; using System.Threading.Channels; using Broadcast.Models; namespace Broadcast.Services; /// /// Singleton service that manages the live broadcast. /// Stores the WebM init segment and the most recent keyframe chunk so /// late-joining viewers always start at a clean decode boundary. /// Maintains one bounded Channel per viewer for fan-out delivery. /// public sealed class BroadcastHub { // The very first WebM chunk — EBML header + Segment/Info/Tracks. // Stored so late-joining viewers receive it before any media data. private VideoChunk? _initSegment; // The most recent chunk flagged as a keyframe by the broadcaster. // A late-joining viewer gets: init → _keyframeChunk → live stream. // This guarantees the viewer's decoder always starts at a valid I-frame. private VideoChunk? _keyframeChunk; private readonly object _stateLock = new(); // The exact mimeType string the broadcaster's MediaRecorder is using. // Viewers MUST use this same codec string for addSourceBuffer(). public string MimeType { get; private set; } = string.Empty; // Monotonically increasing sequence number. seq==1 identifies the init segment. private long _sequence = 0; // One bounded channel per connected viewer. private readonly ConcurrentDictionary> _viewers = new(); // 150 chunks × 250ms timeslice ≈ 37 seconds of buffer per viewer. // DropOldest: slow viewers skip forward rather than causing memory growth. private const int ViewerBufferCapacity = 150; /// /// Called by the broadcaster POST endpoint for each binary WebM chunk. /// isKeyframe comes from the broadcaster's own SimpleBlock flag inspection, /// which is more reliable than server-side EBML parsing. /// public Task BroadcastChunkAsync(byte[] data, string mimeType, bool isInit, bool isKeyframe, CancellationToken ct) { long seq = Interlocked.Increment(ref _sequence); var chunk = new VideoChunk { Data = data, IsInit = isInit, IsKeyframe = isKeyframe, SequenceNumber = seq }; lock (_stateLock) { if (isInit) { _initSegment = chunk; // always overwrite — broadcaster said so _keyframeChunk = null; // new session: discard old keyframe MimeType = mimeType; } else if (isKeyframe) { // Keep only the most recent keyframe chunk as the catch-up entry point. _keyframeChunk = chunk; } } // Fan out to all connected viewer channels. // TryWrite is non-blocking; DropOldest handles full channels automatically. foreach (var (_, channel) in _viewers) { channel.Writer.TryWrite(chunk); } return Task.CompletedTask; } /// /// Registers a new viewer. Pre-queues init and the most recent keyframe chunk /// so the viewer can start decoding immediately without waiting for the next keyframe. /// public (Guid viewerId, ChannelReader reader) AddViewer() { var viewerId = Guid.NewGuid(); var options = new BoundedChannelOptions(ViewerBufferCapacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false }; var channel = Channel.CreateBounded(options); _viewers[viewerId] = channel; VideoChunk? initSnap; VideoChunk? keySnap; lock (_stateLock) { initSnap = _initSegment; keySnap = _keyframeChunk; } if (initSnap is not null) { channel.Writer.TryWrite(initSnap); } return (viewerId, channel.Reader); } /// /// Removes a viewer's channel when their SSE connection closes. /// Completing the writer unblocks ReadAllAsync on the reader side. /// public void RemoveViewer(Guid viewerId) { if (_viewers.TryRemove(viewerId, out var channel)) { channel.Writer.TryComplete(); } } /// Number of currently connected viewers. public int ViewerCount => _viewers.Count; /// True once the broadcaster has sent at least one chunk. public bool IsBroadcastActive => _initSegment is not null; /// /// Resets the broadcast state so a new broadcast can start. /// public void ResetBroadcast() { lock (_stateLock) { _initSegment = null; _keyframeChunk = null; Interlocked.Exchange(ref _sequence, 0); MimeType = string.Empty; } } }