{"TotalCount":124,"Files":[{"Ident":"rexi.bimp","Path":"InputGlyph.cs","FileName":"InputGlyph.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System;\nusing Sandbox.UI;\n\nnamespace Bimp;\n\n/// \u003Csummary\u003E\n/// The button for an input action, drawn the way the player\u0027s device shows it: a key cap on keyboard, the pad\u0027s own\n/// button (Xbox, PlayStation, Nintendo...) on a controller. It follows them live as they switch device. Sized to the\n/// text around it (1.25em tall, as wide as the glyph).\n/// \u003C/summary\u003E\n[StyleSheet.Inline( \u0022bimp-inputglyph\u0022, Styles )]\npublic sealed class InputGlyph : Panel\n{\n\tconst string Styles = \u0022\u0022\u0022\n\t\t.inputglyph { height: 1.25em; aspect-ratio: 1; flex-shrink: 0; background-size: contain; background-repeat: no-repeat; background-position: center; }\n\t\t\u0022\u0022\u0022;\n\n\t/// \u003Csummary\u003E The input action to show the button of, e.g. \u0022use\u0022. \u003C/summary\u003E\n\tpublic string Action { get; set; }\n\n\t/// \u003Csummary\u003E An analog input to show instead of an action, by \u003Csee cref=\u0022InputAnalog\u0022/\u003E name, e.g. \u0022LeftStickY\u0022. \u003C/summary\u003E\n\tpublic string Analog { get; set; }\n\n\t/// \u003Csummary\u003E A keyboard key to show instead of an action, e.g. \u0022escape\u0022 (keyboard glyph whatever the device). \u003C/summary\u003E\n\tpublic string Key { get; set; }\n\n\t/// \u003Csummary\u003E Glyph resolution. Medium (128px) stays sharp on the big world screens. \u003C/summary\u003E\n\tpublic InputGlyphSize Size { get; set; } = InputGlyphSize.Medium;\n\n\tTexture shown;\n\n\tpublic InputGlyph()\n\t{\n\t\tAddClass( \u0022inputglyph\u0022 );\n\t}\n\n\tpublic override void Tick()\n\t{\n\t\tbase.Tick();\n\n\t\t// cheap, and the device can change any frame (the engine\u0027s own advice for glyphs)\n\t\tvar texture = Glyph();\n\t\tif ( texture == shown ) return;\n\n\t\tshown = texture;\n\t\tStyle.SetBackgroundImage( texture );\n\t\tStyle.AspectRatio = texture is { Height: \u003E 0 } ? texture.Width / (float)texture.Height : 1;\n\t}\n\n\tTexture Glyph()\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( Key ) ) return Input.Keyboard.GetGlyph( Key, Size );\n\t\tif ( Enum.TryParse\u003CInputAnalog\u003E( Analog, true, out var analog ) ) return Input.GetGlyph( analog, Size );\n\t\tif ( !string.IsNullOrWhiteSpace( Action ) ) return Input.GetGlyph( Action, Size, false );\n\t\treturn null;\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Resolver/Extractors/DashExtractor.cs","FileName":"DashExtractor.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System.Threading;\nusing Bimp.Resolver.Live;\n\nnamespace Bimp.Resolver.Extractors;\n\n/// \u003Csummary\u003E\n/// Any .mpd link: a finished (static) DASH manifest plays as a seekable video, remuxed from a start time like a finished\n/// HLS playlist (see \u003Csee cref=\u0022MpdPlaylists\u0022/\u003E, which turns the manifest into playlists \u003Csee cref=\u0022HlsReader\u0022/\u003E reads).\n/// \u003C/summary\u003E\npublic sealed class DashExtractor : IExtractor\n{\n\tpublic string Key =\u003E \u0022dash\u0022;\n\n\tpublic bool CanHandle( Uri url ) =\u003E MpdPlaylists.IsMpd( url );\n\n\tpublic async Task\u003CMediaInfo\u003E GetInfoAsync( Uri url, bool audioOnly, CancellationToken ct )\n\t{\n\t\tvar mpd = await MpdPlaylists.LoadAsync( url, ct );\n\t\tvar name = Uri.UnescapeDataString( System.IO.Path.GetFileNameWithoutExtension( url.AbsolutePath ) );\n\n\t\treturn new MediaInfo\n\t\t{\n\t\t\tExtractor = Key,\n\t\t\tId = url.ToString(),\n\t\t\tTitle = !string.IsNullOrWhiteSpace( mpd.Title ) ? mpd.Title : name is \u0022manifest\u0022 or \u0022index\u0022 or \u0022stream\u0022 or \u0022\u0022 ? url.Host : $\u0022{url.Host} - {name}\u0022,\n\t\t\tDuration = (float)mpd.Duration,\n\t\t\tAudioOnly = audioOnly || mpd.Heights.Count == 0,\n\t\t\tMerged = true,\n\t\t\tQualities = audioOnly || mpd.Heights.Count \u003C 2 ? new List\u003Cint\u003E() : mpd.Heights,\n\t\t};\n\t}\n\n\tpublic Task\u003CStreamPlan\u003E GetStreamsAsync( string id, StreamRequest request, CancellationToken ct )\n\t\t=\u003E Task.FromResult( new StreamPlan { Kind = StreamKind.Hls, DirectUrl = id } );\n}\n"},{"Ident":"rexi.bimp","Path":"Resolver/Live/Mp4Writer.cs","FileName":"Mp4Writer.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System.Buffers.Binary;\r\nusing System.IO;\r\nusing System.Text;\r\n\r\nnamespace Bimp.Resolver.Live;\r\n\r\n/// \u003Csummary\u003E One video access unit (timestamps in 90 kHz units). \u003C/summary\u003E\r\npublic sealed class VideoSample\r\n{\r\n\tpublic long Dts;\r\n\tpublic long Pts;\r\n\tpublic bool Key;\r\n\t/// \u003Csummary\u003E H.264: AVCC (NAL units with 4 byte lengths). AV1: a temporal unit of sized OBUs. \u003C/summary\u003E\r\n\tpublic byte[] Data;\r\n}\r\n\r\n/// \u003Csummary\u003E A video track\u0027s codec and decoder configuration: what its MP4 sample entry says. \u003C/summary\u003E\r\npublic sealed class VideoFormat\r\n{\r\n\tpublic string Codec { get; private init; }\r\n\tpublic int Width { get; private init; }\r\n\tpublic int Height { get; private init; }\r\n\r\n\t/// \u003Csummary\u003E The sample entry\u0027s four character code and its configuration box. \u003C/summary\u003E\r\n\tinternal string Fourcc { get; private init; }\r\n\tinternal string ConfigBox { get; private init; }\r\n\tinternal byte[] Config { get; private init; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The engine\u0027s H.264 decoder (Media Foundation) holds back the last 22 frames of a file; its AV1 decoder shows\r\n\t/// every frame (see \u003Csee cref=\u0022Media.Av1\u0022/\u003E).\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool HoldsBackFrames =\u003E Fourcc == \u0022avc1\u0022;\r\n\r\n\tpublic bool IsH264 =\u003E Fourcc == \u0022avc1\u0022;\r\n\r\n\tpublic static VideoFormat H264( byte[] sps, byte[] pps )\r\n\t{\r\n\t\tvar info = Live.H264.ParseSps( sps );\r\n\t\treturn new VideoFormat { Codec = \u0022H.264\u0022, Width = info.Width, Height = info.Height, Fourcc = \u0022avc1\u0022, ConfigBox = \u0022avcC\u0022, Config = Live.H264.AvcConfig( sps, pps ) };\r\n\t}\r\n\r\n\tpublic static VideoFormat Av1( Media.Av1.SequenceHeader seq )\r\n\t\t=\u003E new() { Codec = \u0022AV1\u0022, Width = seq.Width, Height = seq.Height, Fourcc = \u0022av01\u0022, ConfigBox = \u0022av1C\u0022, Config = Media.Av1.ConfigRecord( seq ) };\r\n\r\n\tpublic override string ToString() =\u003E $\u0022{Codec} {Width}x{Height}\u0022;\r\n}\r\n\r\n/// \u003Csummary\u003E One raw AAC frame (no ADTS header), 1024 samples. Pts in 90 kHz units. \u003C/summary\u003E\r\npublic sealed class AudioSample\r\n{\r\n\tpublic long Pts;\r\n\tpublic byte[] Data;\r\n}\r\n\r\n/// \u003Csummary\u003E An AAC track\u0027s decoder configuration. \u003C/summary\u003E\r\npublic sealed class AacConfig\r\n{\r\n\t/// \u003Csummary\u003E AudioSpecificConfig. \u003C/summary\u003E\r\n\tpublic byte[] Asc;\r\n\tpublic int SampleRate;\r\n\tpublic int Channels;\r\n\r\n\tstatic readonly int[] Rates = { 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 };\r\n\r\n\tpublic static int RateIndex( int rate ) =\u003E Array.IndexOf( Rates, rate );\r\n\tpublic static int RateOf( int index ) =\u003E index \u003E= 0 \u0026\u0026 index \u003C Rates.Length ? Rates[index] : 0;\r\n\r\n\t/// \u003Csummary\u003E Build from ADTS header fields. \u003C/summary\u003E\r\n\tpublic static AacConfig FromAdts( int profile, int rateIndex, int channelConfig )\r\n\t{\r\n\t\tvar objectType = profile \u002B 1;\r\n\t\tvar asc = new byte[]\r\n\t\t{\r\n\t\t\t(byte)((objectType \u003C\u003C 3) | (rateIndex \u003E\u003E 1)),\r\n\t\t\t(byte)(((rateIndex \u0026 1) \u003C\u003C 7) | (channelConfig \u003C\u003C 3)),\r\n\t\t};\r\n\t\treturn new AacConfig { Asc = asc, SampleRate = RateOf( rateIndex ), Channels = channelConfig == 7 ? 8 : channelConfig };\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Parse an AudioSpecificConfig (from SDP \u0022config=\u0022 or an esds). \u003C/summary\u003E\r\n\tpublic static AacConfig FromAsc( byte[] asc )\r\n\t{\r\n\t\tif ( asc is null || asc.Length \u003C 2 ) return null;\r\n\t\tvar v = (asc[0] \u003C\u003C 8) | asc[1];\r\n\t\tvar rateIndex = (v \u003E\u003E 7) \u0026 0xF;\r\n\t\tvar channelConfig = (v \u003E\u003E 3) \u0026 0xF;\r\n\t\treturn new AacConfig { Asc = asc, SampleRate = RateOf( rateIndex ), Channels = channelConfig == 7 ? 8 : Math.Max( 1, channelConfig ) };\r\n\t}\r\n\r\n\tpublic bool SameAs( AacConfig o ) =\u003E o is not null \u0026\u0026 Asc.AsSpan().SequenceEqual( o.Asc );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The rate the mp4a sample entry declares. The engine\u0027s AAC decoder (Media Foundation) won\u0027t open audio below\r\n\t/// 32 kHz - measured 2026-10-01: 16, 22.05 and 24 kHz AAC-LC play nothing, 32/44.1/48 kHz play - and an HE-AAC\r\n\t/// stream\u0027s core (all the ASC says when the SBR is implicit, as from ADTS) is half its real rate. Declaring twice\r\n\t/// the core is how Media Foundation is told the stream is HE-AAC: it then opens it at the doubled rate.\r\n\t/// \u003C/summary\u003E\r\n\tpublic int OutputRate =\u003E SampleRate is \u003E 0 and \u003C 32000 ? SampleRate * 2 : SampleRate;\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Writes a finished, moov-first MP4 (H.264 and/or AAC) - the only kind of file the engine opens instantly\r\n/// (it walks every fragment of a fragmented MP4 on open, and needs the index before the data).\r\n/// The file\u0027s timeline starts at \u003Csee cref=\u0022Write\u0022/\u003E\u0027s \u003Cc\u003Ezero\u003C/c\u003E.\r\n/// \u003C/summary\u003E\r\npublic static class Mp4Writer\r\n{\r\n\tconst uint MovieTimescale = 90000;\r\n\r\n\tpublic static void Write( Stream output, long zero,\r\n\t\tList\u003CVideoSample\u003E video, byte[] sps, byte[] pps,\r\n\t\tList\u003CAudioSample\u003E audio, AacConfig aac )\r\n\t\t=\u003E Write( output, zero, video, sps is null || pps is null ? null : VideoFormat.H264( sps, pps ), audio, aac );\r\n\r\n\tpublic static void Write( Stream output, long zero,\r\n\t\tList\u003CVideoSample\u003E video, VideoFormat format,\r\n\t\tList\u003CAudioSample\u003E audio, AacConfig aac )\r\n\t{\r\n\t\tvar hasVideo = video is { Count: \u003E 0 } \u0026\u0026 format is not null;\r\n\t\tvar hasAudio = audio is { Count: \u003E 0 } \u0026\u0026 aac is not null \u0026\u0026 aac.SampleRate \u003E 0;\r\n\r\n\t\tvar ftyp = Box( \u0022ftyp\u0022, Concat( Ascii( \u0022isom\u0022 ), U32( 512 ), Ascii( \u0022isom\u0022 ), Ascii( \u0022iso2\u0022 ), Ascii( format?.Fourcc ?? \u0022avc1\u0022 ), Ascii( \u0022mp41\u0022 ) ) );\r\n\r\n\t\t// sample data: all video, then all audio (one chunk each - it\u0027s a local file, no need to interleave)\r\n\t\tvar videoData = hasVideo ? video.Select( s =\u003E s.Data ).ToList() : new List\u003Cbyte[]\u003E();\r\n\t\tvar audioData = hasAudio ? audio.Select( s =\u003E s.Data ).ToList() : new List\u003Cbyte[]\u003E();\r\n\t\tlong videoBytes = videoData.Sum( d =\u003E (long)d.Length );\r\n\r\n\t\t// moov has a fixed size whatever the offsets are, so build it once to measure, then for real\r\n\t\tbyte[] Moov( uint videoOffset, uint audioOffset )\r\n\t\t{\r\n\t\t\tvar traks = new List\u003Cbyte[]\u003E();\r\n\t\t\tuint id = 1;\r\n\t\t\tif ( hasVideo ) traks.Add( VideoTrak( id\u002B\u002B, zero, video, format, videoOffset ) );\r\n\t\t\tif ( hasAudio ) traks.Add( AudioTrak( id\u002B\u002B, zero, audio, aac, audioOffset ) );\r\n\t\t\tvar duration = Math.Max( hasVideo ? VideoDuration( video, zero ) : 0, hasAudio ? AudioMovieDuration( audio, aac, zero ) : 0 );\r\n\t\t\treturn Box( \u0022moov\u0022, Concat( new[] { Mvhd( duration, id ) }.Concat( traks ).ToArray() ) );\r\n\t\t}\r\n\r\n\t\tvar moovSize = Moov( 0, 0 ).Length;\r\n\t\tvar dataStart = (uint)(ftyp.Length \u002B moovSize \u002B 8);\r\n\t\tvar moov = Moov( dataStart, (uint)(dataStart \u002B videoBytes) );\r\n\r\n\t\tvar mdatSize = 8 \u002B videoBytes \u002B audioData.Sum( d =\u003E (long)d.Length );\r\n\t\toutput.Write( ftyp );\r\n\t\toutput.Write( moov );\r\n\t\toutput.Write( U32( (uint)mdatSize ) );\r\n\t\toutput.Write( Ascii( \u0022mdat\u0022 ) );\r\n\t\tforeach ( var d in videoData ) output.Write( d );\r\n\t\tforeach ( var d in audioData ) output.Write( d );\r\n\t}\r\n\r\n\tstatic long VideoDuration( List\u003CVideoSample\u003E v, long zero )\r\n\t{\r\n\t\tvar last = v[^1];\r\n\t\tvar frame = v.Count \u003E 1 ? Math.Max( 1, last.Dts - v[^2].Dts ) : 3000;\r\n\t\treturn last.Dts \u002B frame - zero;\r\n\t}\r\n\r\n\tstatic long AudioMovieDuration( List\u003CAudioSample\u003E a, AacConfig aac, long zero )\r\n\t\t=\u003E Math.Max( 0, a[0].Pts - zero ) \u002B (long)a.Count * 1024 * MovieTimescale / aac.SampleRate;\r\n\r\n\tstatic byte[] VideoTrak( uint id, long zero, List\u003CVideoSample\u003E v, VideoFormat info, uint offset )\r\n\t{\r\n\t\t// durations between decode times; the last frame lasts as long as the one before it\r\n\t\tvar durations = new List\u003Cuint\u003E();\r\n\t\tfor ( int i = 0; i \u003C v.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar d = i \u002B 1 \u003C v.Count ? v[i \u002B 1].Dts - v[i].Dts : (v.Count \u003E 1 ? v[i].Dts - v[i - 1].Dts : 3000);\r\n\t\t\tdurations.Add( (uint)Math.Clamp( d, 1, 90000 ) );\r\n\t\t}\r\n\t\tvar mediaDuration = durations.Sum( d =\u003E (long)d );\r\n\t\tvar offsets = v.Select( s =\u003E s.Pts - s.Dts ).ToList();\r\n\t\tvar hasCtts = offsets.Any( o =\u003E o != 0 );\r\n\r\n\t\tvar stbl = new List\u003Cbyte[]\u003E\r\n\t\t{\r\n\t\t\tBox( \u0022stsd\u0022, Concat( U32( 0 ), U32( 1 ), VisualEntry( info ) ) ),\r\n\t\t\tStts( durations ),\r\n\t\t};\r\n\t\tif ( hasCtts ) stbl.Add( Ctts( offsets ) );\r\n\t\tstbl.Add( Stss( v ) );\r\n\t\tstbl.Add( Stsc( v.Count ) );\r\n\t\tstbl.Add( Stsz( v.Select( s =\u003E s.Data.Length ) ) );\r\n\t\tstbl.Add( Box( \u0022stco\u0022, Concat( U32( 0 ), U32( 1 ), U32( offset ) ) ) );\r\n\r\n\t\t// the first frame shows at its presentation time; start the track there\r\n\t\tvar elst = Elst( Math.Max( 0, v[0].Dts - zero ), mediaDuration, Math.Max( 0, offsets[0] ) );\r\n\r\n\t\treturn Box( \u0022trak\u0022, Concat(\r\n\t\t\tTkhd( id, mediaDuration \u002B Math.Max( 0, v[0].Dts - zero ), false, info.Width, info.Height ),\r\n\t\t\tBox( \u0022edts\u0022, elst ),\r\n\t\t\tBox( \u0022mdia\u0022, Concat(\r\n\t\t\t\tMdhd( 90000, mediaDuration ),\r\n\t\t\t\tHdlr( \u0022vide\u0022 ),\r\n\t\t\t\tBox( \u0022minf\u0022, Concat(\r\n\t\t\t\t\tFullBox( \u0022vmhd\u0022, 0, 1, new byte[8] ),\r\n\t\t\t\t\tDinf(),\r\n\t\t\t\t\tBox( \u0022stbl\u0022, Concat( stbl.ToArray() ) ) ) ) ) ) ) );\r\n\t}\r\n\r\n\tstatic byte[] AudioTrak( uint id, long zero, List\u003CAudioSample\u003E a, AacConfig aac, uint offset )\r\n\t{\r\n\t\tvar mediaDuration = (long)a.Count * 1024;\r\n\t\tvar startDelay = Math.Max( 0, a[0].Pts - zero ); // movie timescale\r\n\r\n\t\tvar stbl = Concat(\r\n\t\t\tBox( \u0022stsd\u0022, Concat( U32( 0 ), U32( 1 ), Mp4a( aac ) ) ),\r\n\t\t\tStts( Enumerable.Repeat( 1024u, a.Count ).ToList() ),\r\n\t\t\tStsc( a.Count ),\r\n\t\t\tStsz( a.Select( s =\u003E s.Data.Length ) ),\r\n\t\t\tBox( \u0022stco\u0022, Concat( U32( 0 ), U32( 1 ), U32( offset ) ) ) );\r\n\r\n\t\tvar movieDuration = mediaDuration * MovieTimescale / aac.SampleRate;\r\n\r\n\t\treturn Box( \u0022trak\u0022, Concat(\r\n\t\t\tTkhd( id, startDelay \u002B movieDuration, true, 0, 0 ),\r\n\t\t\tBox( \u0022edts\u0022, Elst( startDelay, movieDuration, 0 ) ),\r\n\t\t\tBox( \u0022mdia\u0022, Concat(\r\n\t\t\t\tMdhd( (uint)aac.SampleRate, mediaDuration ),\r\n\t\t\t\tHdlr( \u0022soun\u0022 ),\r\n\t\t\t\tBox( \u0022minf\u0022, Concat(\r\n\t\t\t\t\tFullBox( \u0022smhd\u0022, 0, 0, new byte[4] ),\r\n\t\t\t\t\tDinf(),\r\n\t\t\t\t\tBox( \u0022stbl\u0022, stbl ) ) ) ) ) ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E An edit list: an optional empty edit (delay), then the media from mediaTime. \u003C/summary\u003E\r\n\tstatic byte[] Elst( long delay, long movieDuration, long mediaTime )\r\n\t{\r\n\t\tvar entries = new List\u003Cbyte[]\u003E();\r\n\t\tif ( delay \u003E 0 ) entries.Add( Concat( U32( (uint)delay ), U32( 0xFFFFFFFF ), U32( 0x00010000 ) ) );\r\n\t\tentries.Add( Concat( U32( (uint)movieDuration ), U32( (uint)mediaTime ), U32( 0x00010000 ) ) );\r\n\t\treturn FullBox( \u0022elst\u0022, 0, 0, Concat( new[] { U32( (uint)entries.Count ) }.Concat( entries ).ToArray() ) );\r\n\t}\r\n\r\n\tstatic byte[] Mvhd( long duration, uint nextTrack )\r\n\t{\r\n\t\treturn FullBox( \u0022mvhd\u0022, 0, 0, Concat(\r\n\t\t\tU32( 0 ), U32( 0 ), U32( MovieTimescale ), U32( (uint)duration ),\r\n\t\t\tU32( 0x00010000 ), U16( 0x0100 ), new byte[10],\r\n\t\t\tMatrix(), new byte[24], U32( nextTrack ) ) );\r\n\t}\r\n\r\n\tstatic byte[] Tkhd( uint id, long duration, bool audio, int width, int height )\r\n\t{\r\n\t\treturn FullBox( \u0022tkhd\u0022, 0, 3, Concat(\r\n\t\t\tU32( 0 ), U32( 0 ), U32( id ), U32( 0 ), U32( (uint)duration ),\r\n\t\t\tnew byte[8], U16( 0 ), U16( 0 ), U16( (ushort)(audio ? 0x0100 : 0) ), U16( 0 ),\r\n\t\t\tMatrix(), U32( (uint)width \u003C\u003C 16 ), U32( (uint)height \u003C\u003C 16 ) ) );\r\n\t}\r\n\r\n\tstatic byte[] Mdhd( uint timescale, long duration )\r\n\t\t=\u003E FullBox( \u0022mdhd\u0022, 0, 0, Concat( U32( 0 ), U32( 0 ), U32( timescale ), U32( (uint)duration ), U16( 0x55C4 ), U16( 0 ) ) );\r\n\r\n\tstatic byte[] Hdlr( string type )\r\n\t\t=\u003E FullBox( \u0022hdlr\u0022, 0, 0, Concat( U32( 0 ), Ascii( type ), new byte[12], Encoding.ASCII.GetBytes( \u0022bimp\\0\u0022 ) ) );\r\n\r\n\tstatic byte[] Dinf() =\u003E Box( \u0022dinf\u0022, FullBox( \u0022dref\u0022, 0, 0, Concat( U32( 1 ), FullBox( \u0022url \u0022, 0, 1, Array.Empty\u003Cbyte\u003E() ) ) ) );\r\n\r\n\t/// \u003Csummary\u003E avc1 \u002B avcC, or av01 \u002B av1C. \u003C/summary\u003E\r\n\tstatic byte[] VisualEntry( VideoFormat f )\r\n\t{\r\n\t\treturn Box( f.Fourcc, Concat(\r\n\t\t\tnew byte[6], U16( 1 ), // reserved, data_reference_index\r\n\t\t\tnew byte[16],           // pre_defined, reserved, pre_defined[3]\r\n\t\t\tU16( (ushort)f.Width ), U16( (ushort)f.Height ),\r\n\t\t\tU32( 0x00480000 ), U32( 0x00480000 ), U32( 0 ), U16( 1 ),\r\n\t\t\tnew byte[32],           // compressorname\r\n\t\t\tU16( 0x0018 ), U16( 0xFFFF ),\r\n\t\t\tBox( f.ConfigBox, f.Config ) ) );\r\n\t}\r\n\r\n\tstatic byte[] Mp4a( AacConfig aac )\r\n\t{\r\n\t\t// ES_Descriptor \u003E DecoderConfigDescriptor (AAC, audio stream) \u003E DecoderSpecificInfo (the ASC), SLConfig\r\n\t\tvar dsi = Descriptor( 5, aac.Asc );\r\n\t\tvar dcd = Descriptor( 4, Concat( new byte[] { 0x40, 0x15, 0, 0, 0 }, U32( 0 ), U32( 0 ), dsi ) );\r\n\t\tvar es = Descriptor( 3, Concat( U16( 1 ), new byte[] { 0 }, dcd, Descriptor( 6, new byte[] { 2 } ) ) );\r\n\r\n\t\t// channelcount must match the ASC - the engine trusts this field (see Mp4ChannelFix)\r\n\t\treturn Box( \u0022mp4a\u0022, Concat(\r\n\t\t\tnew byte[6], U16( 1 ), new byte[8],\r\n\t\t\tU16( (ushort)aac.Channels ), U16( 16 ), U16( 0 ), U16( 0 ),\r\n\t\t\tU32( (uint)Math.Min( aac.OutputRate, 65535 ) \u003C\u003C 16 ),\r\n\t\t\tFullBox( \u0022esds\u0022, 0, 0, es ) ) );\r\n\t}\r\n\r\n\tstatic byte[] Descriptor( byte tag, byte[] body )\r\n\t{\r\n\t\t// 4 byte length form, like most muxers write it\r\n\t\tvar len = body.Length;\r\n\t\treturn Concat( new byte[] { tag, (byte)(0x80 | (len \u003E\u003E 21) \u0026 0x7F), (byte)(0x80 | (len \u003E\u003E 14) \u0026 0x7F), (byte)(0x80 | (len \u003E\u003E 7) \u0026 0x7F), (byte)(len \u0026 0x7F) }, body );\r\n\t}\r\n\r\n\tstatic byte[] Stts( List\u003Cuint\u003E durations )\r\n\t{\r\n\t\tvar runs = new List\u003C(uint count, uint delta)\u003E();\r\n\t\tforeach ( var d in durations )\r\n\t\t{\r\n\t\t\tif ( runs.Count \u003E 0 \u0026\u0026 runs[^1].delta == d ) runs[^1] = (runs[^1].count \u002B 1, d);\r\n\t\t\telse runs.Add( (1, d) );\r\n\t\t}\r\n\t\treturn FullBox( \u0022stts\u0022, 0, 0, Concat( new[] { U32( (uint)runs.Count ) }.Concat( runs.Select( r =\u003E Concat( U32( r.count ), U32( r.delta ) ) ) ).ToArray() ) );\r\n\t}\r\n\r\n\tstatic byte[] Ctts( List\u003Clong\u003E offsets )\r\n\t{\r\n\t\t// version 0 (unsigned) like normal muxers write it - B-frame offsets are never negative when decode time is the\r\n\t\t// earliest presentation; version 1 (signed) only if we really need it\r\n\t\tvar signed = offsets.Any( o =\u003E o \u003C 0 );\r\n\t\tvar runs = new List\u003C(uint count, long offset)\u003E();\r\n\t\tforeach ( var o in offsets )\r\n\t\t{\r\n\t\t\tif ( runs.Count \u003E 0 \u0026\u0026 runs[^1].offset == o ) runs[^1] = (runs[^1].count \u002B 1, o);\r\n\t\t\telse runs.Add( (1, o) );\r\n\t\t}\r\n\t\treturn FullBox( \u0022ctts\u0022, (byte)(signed ? 1 : 0), 0, Concat( new[] { U32( (uint)runs.Count ) }.Concat( runs.Select( r =\u003E Concat( U32( r.count ), U32( unchecked((uint)(int)r.offset) ) ) ) ).ToArray() ) );\r\n\t}\r\n\r\n\tstatic byte[] Stss( List\u003CVideoSample\u003E v )\r\n\t{\r\n\t\tvar keys = v.Select( ( s, i ) =\u003E (s, i) ).Where( x =\u003E x.s.Key ).Select( x =\u003E U32( (uint)x.i \u002B 1 ) ).ToList();\r\n\t\treturn FullBox( \u0022stss\u0022, 0, 0, Concat( new[] { U32( (uint)keys.Count ) }.Concat( keys ).ToArray() ) );\r\n\t}\r\n\r\n\tstatic byte[] Stsc( int samples ) =\u003E FullBox( \u0022stsc\u0022, 0, 0, Concat( U32( 1 ), U32( 1 ), U32( (uint)samples ), U32( 1 ) ) );\r\n\r\n\tstatic byte[] Stsz( IEnumerable\u003Cint\u003E sizes )\r\n\t{\r\n\t\tvar list = sizes.ToList();\r\n\t\treturn FullBox( \u0022stsz\u0022, 0, 0, Concat( new[] { U32( 0 ), U32( (uint)list.Count ) }.Concat( list.Select( s =\u003E U32( (uint)s ) ) ).ToArray() ) );\r\n\t}\r\n\r\n\tstatic byte[] Matrix() =\u003E Concat( U32( 0x00010000 ), U32( 0 ), U32( 0 ), U32( 0 ), U32( 0x00010000 ), U32( 0 ), U32( 0 ), U32( 0 ), U32( 0x40000000 ) );\r\n\r\n\tstatic byte[] Box( string type, byte[] body ) =\u003E Concat( U32( (uint)(8 \u002B body.Length) ), Ascii( type ), body );\r\n\r\n\tstatic byte[] FullBox( string type, byte version, uint flags, byte[] body )\r\n\t\t=\u003E Box( type, Concat( new[] { version, (byte)(flags \u003E\u003E 16), (byte)(flags \u003E\u003E 8), (byte)flags }, body ) );\r\n\r\n\tstatic byte[] Ascii( string s ) =\u003E Encoding.ASCII.GetBytes( s );\r\n\r\n\tstatic byte[] U32( uint v ) { var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian( b, v ); return b; }\r\n\tstatic byte[] U16( ushort v ) { var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian( b, v ); return b; }\r\n\r\n\tstatic byte[] Concat( params byte[][] parts ) =\u003E Media.Ebml.Concat( parts );\r\n}\r\n"},{"Ident":"rexi.bimp","Path":"Resolver/Media/Fmp4Reader.cs","FileName":"Fmp4Reader.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System.Buffers.Binary;\nusing System.Text;\n\nnamespace Bimp.Resolver.Media;\n\n/// \u003Csummary\u003E\n/// Reads fragmented MP4 (the CMAF / DASH / HLS fMP4 layout): the tracks in an init segment\u0027s moov, and the samples in\n/// moof \u002B mdat fragments. Shared by YouTube\u0027s AV1 files (\u003Csee cref=\u0022WebmSource.ProbeMp4Async\u0022/\u003E) and fMP4 HLS.\n/// \u003C/summary\u003E\npublic static class Fmp4Reader\n{\n\t/// \u003Csummary\u003E One track of an init segment. \u003C/summary\u003E\n\tpublic sealed class Track\n\t{\n\t\tpublic uint Id;\n\n\t\t/// \u003Csummary\u003E \u0022vide\u0022 or \u0022soun\u0022. \u003C/summary\u003E\n\t\tpublic string Handler;\n\t\tpublic long Timescale;\n\t\tpublic double DurationSeconds;\n\n\t\t/// \u003Csummary\u003E The sample entry\u0027s four character code: avc1, avc3, hvc1, hev1, av01, mp4a, Opus... \u003C/summary\u003E\n\t\tpublic string Codec;\n\n\t\t/// \u003Csummary\u003E avcC / av1C body, or the AAC AudioSpecificConfig from the esds. \u003C/summary\u003E\n\t\tpublic byte[] Config;\n\t\tpublic int Width, Height;\n\t\tpublic int Channels, SampleRate;\n\n\t\t// trex defaults, for fragments that don\u0027t set their own\n\t\tpublic uint DefaultDuration, DefaultSize, DefaultFlags;\n\n\t\tpublic bool IsVideo =\u003E Handler == \u0022vide\u0022;\n\t\tpublic bool IsAudio =\u003E Handler == \u0022soun\u0022;\n\t}\n\n\t/// \u003Csummary\u003E One sample of a fragment: where its data is in the buffer, and its times in the track\u0027s timescale. \u003C/summary\u003E\n\tpublic readonly record struct Sample( uint Track, long DecodeTime, int CompositionOffset, uint Duration, bool Key, int Offset, int Size );\n\n\t/// \u003Csummary\u003E The tracks of the moov in d[start..end] (an init segment, or the head of a DASH file). \u003C/summary\u003E\n\tpublic static List\u003CTrack\u003E ReadTracks( byte[] d, int start, int end )\n\t{\n\t\tvar moov = Find( d, start, end, \u0022moov\u0022 ) ?? throw new MergeException( \u0022no moov in the mp4\u0022 );\n\t\tvar tracks = new List\u003CTrack\u003E();\n\t\tvar trex = new Dictionary\u003Cuint, (uint duration, uint size, uint flags)\u003E();\n\t\tforeach ( var (at, size, type, h) in Children( d, moov.at \u002B moov.h, moov.at \u002B moov.size ) )\n\t\t{\n\t\t\tif ( type == \u0022trak\u0022 \u0026\u0026 ReadTrak( d, at, at \u002B size ) is { } t ) tracks.Add( t );\n\t\t\telse if ( type == \u0022mvex\u0022 )\n\t\t\t{\n\t\t\t\tforeach ( var (x, xs, xt, xh) in Children( d, at \u002B h, at \u002B size ) )\n\t\t\t\t{\n\t\t\t\t\tif ( xt != \u0022trex\u0022 ) continue;\n\t\t\t\t\t// full box: version/flags, track_ID, description index, duration, size, flags\n\t\t\t\t\tvar p = x \u002B xh \u002B 4;\n\t\t\t\t\ttrex[BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) )] = (\n\t\t\t\t\t\tBinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 8 ) ),\n\t\t\t\t\t\tBinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 12 ) ),\n\t\t\t\t\t\tBinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 16 ) ));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tforeach ( var t in tracks )\n\t\t{\n\t\t\tif ( trex.TryGetValue( t.Id, out var x ) ) (t.DefaultDuration, t.DefaultSize, t.DefaultFlags) = x;\n\t\t}\n\t\treturn tracks;\n\t}\n\n\tstatic Track ReadTrak( byte[] d, int trak, int end )\n\t{\n\t\tvar t = new Track();\n\t\tif ( Path( d, trak, end, \u0022tkhd\u0022 ) is { } tkhd )\n\t\t{\n\t\t\tvar p = tkhd.at \u002B tkhd.h;\n\t\t\tt.Id = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B (d[p] == 1 ? 20 : 12) ) );\n\t\t}\n\n\t\tvar mdhd = Path( d, trak, end, \u0022mdia\u0022, \u0022mdhd\u0022 );\n\t\tif ( mdhd is null ) return null;\n\t\tvar mp = mdhd.Value.at \u002B mdhd.Value.h;\n\t\tlong duration;\n\t\tif ( d[mp] == 1 )\n\t\t{\n\t\t\tt.Timescale = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( mp \u002B 20 ) );\n\t\t\tduration = (long)BinaryPrimitives.ReadUInt64BigEndian( d.AsSpan( mp \u002B 24 ) );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tt.Timescale = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( mp \u002B 12 ) );\n\t\t\tduration = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( mp \u002B 16 ) );\n\t\t}\n\t\tif ( t.Timescale \u003C= 0 ) return null;\n\t\tt.DurationSeconds = duration / (double)t.Timescale;\n\n\t\tif ( Path( d, trak, end, \u0022mdia\u0022, \u0022hdlr\u0022 ) is { } hdlr ) t.Handler = Encoding.ASCII.GetString( d, hdlr.at \u002B hdlr.h \u002B 8, 4 );\n\n\t\tvar stsd = Path( d, trak, end, \u0022mdia\u0022, \u0022minf\u0022, \u0022stbl\u0022, \u0022stsd\u0022 );\n\t\tif ( stsd is null ) return t;\n\t\tvar entry = stsd.Value.at \u002B stsd.Value.h \u002B 8; // version/flags, entry count\n\t\tvar (entrySize, codec, entryHead) = Box( d, entry, stsd.Value.at \u002B stsd.Value.size - entry );\n\t\tt.Codec = codec;\n\t\tvar entryEnd = entry \u002B (int)entrySize;\n\n\t\tif ( t.IsVideo )\n\t\t{\n\t\t\t// VisualSampleEntry: 6 reserved, data reference index, 16 bytes of pre-defined/reserved, then width and height\n\t\t\tt.Width = BinaryPrimitives.ReadUInt16BigEndian( d.AsSpan( entry \u002B entryHead \u002B 24 ) );\n\t\t\tt.Height = BinaryPrimitives.ReadUInt16BigEndian( d.AsSpan( entry \u002B entryHead \u002B 26 ) );\n\t\t\tvar configType = codec switch { \u0022avc1\u0022 or \u0022avc3\u0022 =\u003E \u0022avcC\u0022, \u0022av01\u0022 =\u003E \u0022av1C\u0022, \u0022hvc1\u0022 or \u0022hev1\u0022 =\u003E \u0022hvcC\u0022, _ =\u003E null };\n\t\t\tif ( configType is not null \u0026\u0026 Find( d, entry \u002B entryHead \u002B 78, entryEnd, configType ) is { } c )\n\t\t\t\tt.Config = d.AsSpan( c.at \u002B c.h, c.size - c.h ).ToArray();\n\t\t}\n\t\telse if ( t.IsAudio )\n\t\t{\n\t\t\t// AudioSampleEntry: 6 reserved, data reference index, 8 reserved, channels, sample size, 4 reserved, rate 16.16\n\t\t\tt.Channels = BinaryPrimitives.ReadUInt16BigEndian( d.AsSpan( entry \u002B entryHead \u002B 16 ) );\n\t\t\tt.SampleRate = BinaryPrimitives.ReadUInt16BigEndian( d.AsSpan( entry \u002B entryHead \u002B 24 ) );\n\t\t\tif ( codec == \u0022mp4a\u0022 \u0026\u0026 Find( d, entry \u002B entryHead \u002B 28, entryEnd, \u0022esds\u0022 ) is { } esds )\n\t\t\t\tt.Config = AudioSpecificConfig( d.AsSpan( esds.at \u002B esds.h \u002B 4, esds.size - esds.h - 4 ) );\n\t\t}\n\t\treturn t;\n\t}\n\n\t/// \u003Csummary\u003E The DecoderSpecificInfo (tag 5) inside an esds\u0027s descriptors: an AAC AudioSpecificConfig. \u003C/summary\u003E\n\tstatic byte[] AudioSpecificConfig( ReadOnlySpan\u003Cbyte\u003E d )\n\t{\n\t\tvar i = 0;\n\t\twhile ( i \u002B 2 \u003C= d.Length )\n\t\t{\n\t\t\tvar tag = d[i\u002B\u002B];\n\t\t\tvar length = 0;\n\t\t\tfor ( int k = 0; k \u003C 4 \u0026\u0026 i \u003C d.Length; k\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar b = d[i\u002B\u002B];\n\t\t\t\tlength = (length \u003C\u003C 7) | (b \u0026 0x7F);\n\t\t\t\tif ( (b \u0026 0x80) == 0 ) break;\n\t\t\t}\n\t\t\tif ( tag == 5 ) return d.Slice( i, Math.Min( length, d.Length - i ) ).ToArray();\n\t\t\tif ( tag == 3 )\n\t\t\t{\n\t\t\t\t// ES_Descriptor: ES_ID, flags, then optional fields - its children follow\n\t\t\t\tvar flags = d[i \u002B 2];\n\t\t\t\ti \u002B= 3;\n\t\t\t\tif ( (flags \u0026 0x80) != 0 ) i \u002B= 2;\n\t\t\t\tif ( (flags \u0026 0x40) != 0 ) i \u002B= 1 \u002B d[i];\n\t\t\t\tif ( (flags \u0026 0x20) != 0 ) i \u002B= 2;\n\t\t\t}\n\t\t\telse if ( tag == 4 ) i \u002B= 13; // DecoderConfigDescriptor: object type .. average bitrate, then children\n\t\t\telse i \u002B= length;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The samples of every fragment (moof \u002B mdat) in d[start..end], in order. \u003Cparamref name=\u0022fileOffset\u0022/\u003E is where\n\t/// d[0] is in the file (for base data offsets); offsets in the result are into d. \u003Cparamref name=\u0022partial\u0022/\u003E: the\n\t/// last fragment may be cut off - its samples stop where the data does.\n\t/// \u003C/summary\u003E\n\tpublic static List\u003CSample\u003E ReadSamples( byte[] d, int start, int end, IReadOnlyList\u003CTrack\u003E tracks, long fileOffset = 0, bool partial = false )\n\t{\n\t\tvar samples = new List\u003CSample\u003E();\n\t\tforeach ( var (moof, moofSize, type, moofHead) in Children( d, start, end ) )\n\t\t{\n\t\t\tif ( type != \u0022moof\u0022 ) continue;\n\t\t\tforeach ( var (traf, trafSize, trafType, trafHead) in Children( d, moof \u002B moofHead, moof \u002B moofSize ) )\n\t\t\t{\n\t\t\t\tif ( trafType != \u0022traf\u0022 ) continue;\n\t\t\t\tlong baseOffset = moof, decodeTime = 0;\n\t\t\t\tuint trackId = 0, duration = 0, size = 0, flags = 0;\n\t\t\t\tTrack track = null;\n\n\t\t\t\tforeach ( var (box, _, boxType, boxHead) in Children( d, traf \u002B trafHead, traf \u002B trafSize ) )\n\t\t\t\t{\n\t\t\t\t\tvar p = box \u002B boxHead;\n\t\t\t\t\tif ( boxType == \u0022tfhd\u0022 )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar f = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ) \u0026 0xFFFFFF;\n\t\t\t\t\t\ttrackId = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 4 ) );\n\t\t\t\t\t\ttrack = tracks.FirstOrDefault( t =\u003E t.Id == trackId ) ?? (tracks.Count == 1 ? tracks[0] : null);\n\t\t\t\t\t\tduration = track?.DefaultDuration ?? 0;\n\t\t\t\t\t\tsize = track?.DefaultSize ?? 0;\n\t\t\t\t\t\tflags = track?.DefaultFlags ?? 0;\n\t\t\t\t\t\tp \u002B= 8; // version/flags, track_ID\n\t\t\t\t\t\tif ( (f \u0026 0x01) != 0 ) { baseOffset = (long)BinaryPrimitives.ReadUInt64BigEndian( d.AsSpan( p ) ) - fileOffset; p \u002B= 8; }\n\t\t\t\t\t\tif ( (f \u0026 0x02) != 0 ) p \u002B= 4;\n\t\t\t\t\t\tif ( (f \u0026 0x08) != 0 ) { duration = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\tif ( (f \u0026 0x10) != 0 ) { size = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\tif ( (f \u0026 0x20) != 0 ) flags = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) );\n\t\t\t\t\t}\n\t\t\t\t\telse if ( boxType == \u0022tfdt\u0022 )\n\t\t\t\t\t{\n\t\t\t\t\t\tdecodeTime = d[p] == 1 ? (long)BinaryPrimitives.ReadUInt64BigEndian( d.AsSpan( p \u002B 4 ) ) : BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 4 ) );\n\t\t\t\t\t}\n\t\t\t\t\telse if ( boxType == \u0022trun\u0022 )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar version = d[p];\n\t\t\t\t\t\tvar f = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ) \u0026 0xFFFFFF;\n\t\t\t\t\t\tvar count = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p \u002B 4 ) );\n\t\t\t\t\t\tp \u002B= 8;\n\t\t\t\t\t\tlong data = baseOffset;\n\t\t\t\t\t\tif ( (f \u0026 0x01) != 0 ) { data = baseOffset \u002B BinaryPrimitives.ReadInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\tuint? firstFlags = null;\n\t\t\t\t\t\tif ( (f \u0026 0x04) != 0 ) { firstFlags = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\n\t\t\t\t\t\tfor ( uint i = 0; i \u003C count; i\u002B\u002B )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tuint sDuration = duration, sSize = size, sFlags = i == 0 \u0026\u0026 firstFlags is { } ff ? ff : flags;\n\t\t\t\t\t\t\tvar offset = 0;\n\t\t\t\t\t\t\tif ( (f \u0026 0x100) != 0 ) { sDuration = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\t\tif ( (f \u0026 0x200) != 0 ) { sSize = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\t\tif ( (f \u0026 0x400) != 0 ) { sFlags = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( p ) ); p \u002B= 4; }\n\t\t\t\t\t\t\tif ( (f \u0026 0x800) != 0 ) { offset = BinaryPrimitives.ReadInt32BigEndian( d.AsSpan( p ) ); if ( version == 0 ) offset = (int)Math.Min( (uint)offset, int.MaxValue ); p \u002B= 4; }\n\n\t\t\t\t\t\t\tif ( partial \u0026\u0026 data \u003E= 0 \u0026\u0026 data \u002B sSize \u003E Math.Min( end, d.Length ) ) return samples;\n\t\t\t\t\t\t\tif ( data \u003C 0 || data \u002B sSize \u003E d.Length ) throw new MergeException( \u0022mp4 sample outside the fragment\u0022 );\n\t\t\t\t\t\t\t// audio samples are all sync samples, whatever the flags say\n\t\t\t\t\t\t\tvar key = (sFlags \u0026 0x10000) == 0 || (track?.IsAudio ?? false);\n\t\t\t\t\t\t\tsamples.Add( new Sample( track?.Id ?? trackId, decodeTime, offset, sDuration, key, (int)data, (int)sSize ) );\n\n\t\t\t\t\t\t\tdata \u002B= sSize;\n\t\t\t\t\t\t\tdecodeTime \u002B= sDuration;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn samples;\n\t}\n\n\tpublic static (long size, string type, int headerLength) Box( byte[] d, int at, long remaining )\n\t{\n\t\tif ( at \u002B 8 \u003E d.Length ) return (0, null, 0);\n\t\tlong size = BinaryPrimitives.ReadUInt32BigEndian( d.AsSpan( at ) );\n\t\tvar type = Encoding.ASCII.GetString( d, at \u002B 4, 4 );\n\t\tvar h = 8;\n\t\tif ( size == 1 )\n\t\t{\n\t\t\tif ( at \u002B 16 \u003E d.Length ) return (0, null, 0);\n\t\t\tsize = (long)BinaryPrimitives.ReadUInt64BigEndian( d.AsSpan( at \u002B 8 ) );\n\t\t\th = 16;\n\t\t}\n\t\telse if ( size == 0 ) size = remaining;\n\t\treturn (size \u003C h ? 0 : size, type, h);\n\t}\n\n\tpublic static IEnumerable\u003C(int at, int size, string type, int head)\u003E Children( byte[] d, int start, int end )\n\t{\n\t\tfor ( var at = start; at \u002B 8 \u003C= end; )\n\t\t{\n\t\t\tvar (size, type, h) = Box( d, at, end - at );\n\t\t\tif ( size \u003C= 0 || at \u002B size \u003E end ) yield break;\n\t\t\tyield return (at, (int)size, type, h);\n\t\t\tat \u002B= (int)size;\n\t\t}\n\t}\n\n\tpublic static (int at, int size, int h)? Find( byte[] d, int start, int end, string type )\n\t{\n\t\tforeach ( var c in Children( d, start, end ) )\n\t\t\tif ( c.type == type ) return (c.at, c.size, c.head);\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003E A box nested under a box at \u003Cparamref name=\u0022at\u0022/\u003E, by the types of its ancestors below it. \u003C/summary\u003E\n\tpublic static (int at, int size, int h)? Path( byte[] d, int at, int end, params string[] types )\n\t{\n\t\tvar head = Box( d, at, end - at ).headerLength;\n\t\t(int at, int size, int h)? found = null;\n\t\tint s = at \u002B head, e = end;\n\t\tforeach ( var t in types )\n\t\t{\n\t\t\tfound = Find( d, s, e, t );\n\t\t\tif ( found is not { } f ) return null;\n\t\t\ts = f.at \u002B f.h;\n\t\t\te = f.at \u002B f.size;\n\t\t}\n\t\treturn found;\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Resolver/MediaInfo.cs","FileName":"MediaInfo.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System.Threading;\n\nnamespace Bimp.Resolver;\n\n/// \u003Csummary\u003E\n/// A user facing resolve failure (\u0022This video is private.\u0022).\n/// \u003C/summary\u003E\npublic class ResolveException : Exception\n{\n\tpublic ResolveException( string message ) : base( message ) { }\n}\n\n/// \u003Csummary\u003E\n/// What the host learns about a piece of media - shared with everyone through the synced queue item.\n/// Contains no stream urls: those are tied to the IP that asked for them, so every client gets its own.\n/// \u003C/summary\u003E\npublic sealed class MediaInfo\n{\n\t/// \u003Csummary\u003E The extractor that understood the url (\u003Csee cref=\u0022IExtractor.Key\u0022/\u003E). \u003C/summary\u003E\n\tpublic string Extractor { get; set; }\n\n\t/// \u003Csummary\u003E Extractor specific id (YouTube video id, SoundCloud url...). \u003C/summary\u003E\n\tpublic string Id { get; set; }\n\n\tpublic string Title { get; set; }\n\n\t/// \u003Csummary\u003E Seconds, 0 if unknown. \u003C/summary\u003E\n\tpublic float Duration { get; set; }\n\n\tpublic bool AudioOnly { get; set; }\n\tpublic string Thumbnail { get; set; }\n\n\t/// \u003Csummary\u003E The stream is merged into local files by each client - it seeks by reopening at a time. \u003C/summary\u003E\n\tpublic bool Merged { get; set; }\n\n\t/// \u003Csummary\u003E A live stream (a Twitch channel...): played near its live edge, no seeking, no duration. \u003C/summary\u003E\n\tpublic bool IsLive { get; set; }\n\n\t/// \u003Csummary\u003E Video heights clients can pick, highest first. \u003C/summary\u003E\n\tpublic List\u003Cint\u003E Qualities { get; set; } = new();\n\n\t/// \u003Csummary\u003E Audio tracks (dubs) clients can pick. Empty when there\u0027s no choice. \u003C/summary\u003E\n\tpublic List\u003CMediaAudioTrack\u003E AudioTracks { get; set; } = new();\n\n\t/// \u003Csummary\u003E Where to start playing (seconds), from the link (YouTube\u0027s \u003Cc\u003Et=\u003C/c\u003E). 0 = the beginning. \u003C/summary\u003E\n\tpublic float StartAt { get; set; }\n\n\t/// \u003Csummary\u003E Caption tracks clients can show. Empty when there are none. \u003C/summary\u003E\n\tpublic List\u003CMediaCaptionTrack\u003E CaptionTracks { get; set; } = new();\n}\n\n/// \u003Csummary\u003E\n/// What a client wants to play.\n/// \u003C/summary\u003E\npublic sealed class StreamRequest\n{\n\tpublic bool AudioOnly { get; set; }\n\n\t/// \u003Csummary\u003E The server\u0027s bimp_max_height. \u003C/summary\u003E\n\tpublic int MaxHeight { get; set; } = 720;\n\n\t/// \u003Csummary\u003E An exact height the client picked, 0 for automatic. \u003C/summary\u003E\n\tpublic int Height { get; set; }\n\n\t/// \u003Csummary\u003E Audio language the client picked, null for the original. \u003C/summary\u003E\n\tpublic string Language { get; set; }\n\n\t/// \u003Csummary\u003E A resolver client whose urls refused mid-download: re-resolving skips it, its fresh urls pass the probe and die the same way (see \u003Csee cref=\u0022StreamFile.ClientKey\u0022/\u003E). \u003C/summary\u003E\n\tpublic string AvoidClient { get; set; }\n\n\t/// \u003Csummary\u003E A copy of this request that avoids a client whose urls just refused. \u003C/summary\u003E\n\tpublic StreamRequest Avoiding( string clientKey ) =\u003E new() { AudioOnly = AudioOnly, MaxHeight = MaxHeight, Height = Height, Language = Language, AvoidClient = clientKey };\n}\n\npublic enum StreamKind\n{\n\t/// \u003Csummary\u003E A url the engine plays as-is (progressive mp4/webm/mp3). \u003C/summary\u003E\n\tDirect,\n\n\t/// \u003Csummary\u003E Separate video \u002B audio WebM files, merged locally into segment files. \u003C/summary\u003E\n\tMerge,\n\n\t/// \u003Csummary\u003E A single audio WebM, re-laid out locally (cues at the end) for audio only playback. \u003C/summary\u003E\n\tAudio,\n\n\t/// \u003Csummary\u003E A finished HLS playlist (\u003Csee cref=\u0022StreamPlan.DirectUrl\u0022/\u003E), remuxed locally from a start time. \u003C/summary\u003E\n\tHls,\n\n\t/// \u003Csummary\u003E A live stream url (\u003Csee cref=\u0022StreamPlan.DirectUrl\u0022/\u003E: HLS, MPEG-TS, RTSP), played like a pasted live link. \u003C/summary\u003E\n\tLive,\n}\n\n/// \u003Csummary\u003E\n/// One downloadable file of a stream.\n/// \u003C/summary\u003E\npublic sealed class StreamFile\n{\n\tpublic string Url { get; set; }\n\n\t/// \u003Csummary\u003E Bytes, 0 if unknown. \u003C/summary\u003E\n\tpublic long Size { get; set; }\n\n\t/// \u003Csummary\u003E Extractor specific id of the format (YouTube itag), to find the same file again after a refresh. \u003C/summary\u003E\n\tpublic string FormatId { get; set; }\n\n\t/// \u003Csummary\u003E \u0022webm\u0022, or \u0022mp4\u0022 for a fragmented MP4 (YouTube\u0027s AV1). \u003C/summary\u003E\n\tpublic string Container { get; set; } = \u0022webm\u0022;\n\n\t/// \u003Csummary\u003E The resolver client that minted Url (null off YouTube). Media GETs identify as it, and a refusal re-resolves past it - a dead client\u0027s fresh urls pass the probe all the same. \u003C/summary\u003E\n\tpublic string ClientKey { get; set; }\n}\n\n/// \u003Csummary\u003E\n/// How a client plays a piece of media, resolved on that client.\n/// \u003C/summary\u003E\npublic sealed class StreamPlan\n{\n\tpublic StreamKind Kind { get; set; }\n\tpublic StreamFile Video { get; set; }\n\tpublic StreamFile Audio { get; set; }\n\n\t/// \u003Csummary\u003E A video to use instead if \u003Csee cref=\u0022Video\u0022/\u003E can\u0027t be read (the VP9 copy of an AV1 choice). \u003C/summary\u003E\n\tpublic StreamFile FallbackVideo { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// A video to carry on with once \u003Csee cref=\u0022Video\u0022/\u003E runs out: YouTube serves its AV1 4K60 to the clients that get it\n\t/// for only ~88 MB (about a minute), so playback switches to VP9 1440p60 at a keyframe (see WebmSegmenter).\n\t/// \u003C/summary\u003E\n\tpublic StreamFile SwitchVideo { get; set; }\n\n\t/// \u003Csummary\u003E For \u003Csee cref=\u0022StreamKind.Direct\u0022/\u003E, \u003Csee cref=\u0022StreamKind.Hls\u0022/\u003E and \u003Csee cref=\u0022StreamKind.Live\u0022/\u003E. \u003C/summary\u003E\n\tpublic string DirectUrl { get; set; }\n\n\t/// \u003Csummary\u003E The client that minted the plan\u0027s urls - an AV1 \u003Csee cref=\u0022Video\u0022/\u003E can come from another one (see \u003Csee cref=\u0022StreamFile.ClientKey\u0022/\u003E). \u003C/summary\u003E\n\tpublic string ClientKey { get; set; }\n}\n\n/// \u003Csummary\u003E\n/// Understands one site. Stateless apart from caches, safe to share.\n/// \u003C/summary\u003E\npublic interface IExtractor\n{\n\t/// \u003Csummary\u003E Short name, used in play tokens (\u0022yt\u0022). \u003C/summary\u003E\n\tstring Key { get; }\n\n\tbool CanHandle( Uri url );\n\n\t/// \u003Csummary\u003E Host: title, duration, choices. Throws \u003Csee cref=\u0022ResolveException\u0022/\u003E. \u003C/summary\u003E\n\tTask\u003CMediaInfo\u003E GetInfoAsync( Uri url, bool audioOnly, CancellationToken ct );\n\n\t/// \u003Csummary\u003E Client: the files to play. Throws \u003Csee cref=\u0022ResolveException\u0022/\u003E. \u003C/summary\u003E\n\tTask\u003CStreamPlan\u003E GetStreamsAsync( string id, StreamRequest request, CancellationToken ct );\n}\n\n/// \u003Csummary\u003E An extractor whose media can have captions: \u003Csee cref=\u0022MediaInfo.CaptionTracks\u0022/\u003E lists them, this fetches one. \u003C/summary\u003E\npublic interface ICaptionExtractor\n{\n\t/// \u003Csummary\u003E\n\t/// Client: the raw caption file of one track (\u003Csee cref=\u0022MediaCaptionTrack.Id\u0022/\u003E), for \u003Csee cref=\u0022Captions.Parse\u0022/\u003E.\n\t/// Fetched per client because the urls can be tied to the requesting IP. Null when it can\u0027t be had.\n\t/// \u003C/summary\u003E\n\tTask\u003Cstring\u003E GetCaptionsAsync( string id, string trackId, CancellationToken ct );\n}\n\n/// \u003Csummary\u003E One video of a playlist. \u003C/summary\u003E\npublic sealed record PlaylistEntry( string Url, string Title, float Duration );\n\n/// \u003Csummary\u003E An extractor that can list the videos of a playlist link. \u003C/summary\u003E\npublic interface IPlaylistExtractor\n{\n\t/// \u003Csummary\u003E The entries (at most \u003Cparamref name=\u0022max\u0022/\u003E) if this url is a playlist without a single video picked, else null. \u003C/summary\u003E\n\tTask\u003CList\u003CPlaylistEntry\u003E\u003E GetPlaylistAsync( Uri url, int max, CancellationToken ct );\n}\n"},{"Ident":"rexi.bimp","Path":"SegmentPlayer.cs","FileName":"SegmentPlayer.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using Bimp.Resolver.Media;\n\nnamespace Bimp;\n\n/// \u003Csummary\u003E\n/// Plays a series of local segment files back to back: merged YouTube media (\u003Csee cref=\u0022WebmSegmenter\u0022/\u003E) or a\n/// live stream remuxed to MP4 (\u003Csee cref=\u0022Resolver.Live.LiveSegmenter\u0022/\u003E).\n/// \u003Cpara\u003E\n/// Segment N plays while N\u002B1 is started just in time in a second, hidden and silent player; at the boundary\n/// they swap, so the picture carries straight on. Segments keep absolute timestamps, so \u003Csee cref=\u0022Time\u0022/\u003E\n/// is the media time.\n/// \u003C/para\u003E\n/// \u003C/summary\u003E\npublic sealed class SegmentPlayer : IDisposable\n{\n\tclass Slot\n\t{\n\t\tpublic MediaSegment Segment;\n\t\tpublic VideoPlayer Player;\n\t\tpublic bool Loaded;\n\t\tpublic bool Finished;\n\t\tpublic bool Preload;    // waiting in the wings: silent until it\u0027s swapped in\n\t\tpublic bool FirstFrame; // set when it delivers its first frame (from OnTextureData, during Present)\n\t\tpublic bool Primed;     // its decoder has produced its first frame\n\t\tpublic int Steady;      // frames in a row it has delivered on time (see SettleFrames)\n\t\tpublic RealTimeSince SinceFrame;\n\t\tpublic bool Fading;     // swapped out: silent (its volume ramps down) and closed a moment later\n\t\tpublic float LastTime;\n\t\tpublic RealTimeSince SinceOpened;\n\t}\n\n\treadonly ISegmentFeed feed;\n\n\t/// \u003Csummary\u003E\n\t/// Live: start this many segments behind the newest finished one, so the next is always ready by the time the\n\t/// current one ends. Latency is roughly (JoinBehind \u002B 1) segments plus the overlap.\n\t/// \u003C/summary\u003E\n\tint JoinBehind =\u003E feed.LowLatency ? 0 : 1;\n\n\t/// \u003Csummary\u003E Live: further behind than this (slow machine, stalled download) jumps back to the live edge. \u003C/summary\u003E\n\tint MaxBehind =\u003E feed.LowLatency ? 1 : 3;\n\n\t/// \u003Csummary\u003E\n\t/// Low latency: join the newest segment this long after it\u0027s written, so the next one is ready a little before\n\t/// the current one ends (its start latency plus arrival jitter). A segment that\u0027s late anyway just holds the\n\t/// last picture (the filler frames) until it arrives.\n\t/// \u003C/summary\u003E\n\tconst double LowJoinMargin = 0.2;\n\n\t/// \u003Csummary\u003E\n\t/// Average time from opening a segment to its first decoded frame (measured as we go).\n\t/// \u003C/summary\u003E\n\tfloat startLatency;\n\n\t/// \u003Csummary\u003E\n\t/// How long before the current segment ends to start the next one, so its first frame lands on the boundary -\n\t/// and so the two players are close to in step for the audio crossfade. A player that isn\u0027t primed in time\n\t/// just swaps a moment later (the current one plays on into its overlap, if the file has one).\n\t/// \u003C/summary\u003E\n\tfloat StartLead =\u003E JustInTime\n\t\t? Math.Clamp( (startLatency \u003E 0 ? startLatency : 0.08f) \u002B 0.015f, 0.0f, 1.0f )\n\t\t: ClockLeadOnly ? Math.Clamp( clockLead, 0.0f, Math.Min( current.Segment.Overlap, 1.0f ) )\n\t\t: Math.Clamp( (startLatency \u003E 0 ? startLatency : 0.04f) \u002B leadCorrection, 0.0f, feed.IsLive ? 1.0f : 3.0f );\n\n\t/// \u003Csummary\u003E\n\t/// Media files that run on into the next segment (\u003Csee cref=\u0022MediaSegment.Overlap\u0022/\u003E): the current player shows real\n\t/// frames until the next one\u0027s first frame is up, however long that takes (150-520 ms at 1440p/4K), so the next one\n\t/// only has to be opened for its clock to be in step. Adding the start latency to that made it skip that far ahead\n\t/// at a swap, and correcting that back made the current one hold its last frame instead.\n\t/// \u003Cpara\u003E\n\t/// Merged WebM (VP9/AV1) only: MP4 segments (HLS VOD) kept waiting for the next H.264 player to settle made Media\n\t/// Foundation drop a sample (\u0022MFT deadlock\u0022) in every run, and the picture broke up until the next keyframe (3 of 3\n\t/// runs, against 0 of 3 with the old timing).\n\t/// \u003C/para\u003E\n\t/// \u003C/summary\u003E\n\tbool ClockLeadOnly =\u003E feed is WebmSegmenter \u0026\u0026 (current?.Segment.Overlap ?? 0) \u003E 0;\n\n\t/// \u003Csummary\u003E How long before the end to open the next player so its clock is in step (\u003Csee cref=\u0022ClockLeadOnly\u0022/\u003E), learned from each swap. \u003C/summary\u003E\n\tfloat clockLead = 0.05f;\n\n\t/// \u003Csummary\u003E\n\t/// \u003Csee cref=\u0022ClockLeadOnly\u0022/\u003E: swap once the next player has delivered this many frames in a row on time. Its first\n\t/// frame comes late, and a 1440p decoder is still catching up to its clock for a moment after it - swapping on the\n\t/// first frame put that on screen as a 80-120 ms gap (measured). The current one plays on into its overlap meanwhile.\n\t/// \u003C/summary\u003E\n\tconst int SettleFrames = 4;\n\tconst float SettleInterval = 0.045f;\n\n\t/// \u003Csummary\u003E\n\t/// Low latency files that end at their boundary (H.264, ending in skip frames): the next one just opens early enough\n\t/// for its first frame to be ready there. (Syncing clocks made every swap ~40 ms late: a new player\u0027s clock runs from\n\t/// Play(), its first frame comes ~60 ms later.)\n\t/// \u003C/summary\u003E\n\tbool JustInTime =\u003E feed.LowLatency \u0026\u0026 (current?.Segment.Overlap ?? 0) \u003C= 0;\n\n\t/// \u003Csummary\u003E\n\t/// Learned from each swap: how far the next player\u0027s clock was ahead of the current one\u0027s at the boundary.\n\t/// Starting it that much later next time brings them in step, which the audio crossfade needs - and so the next\n\t/// player neither skips its first frames nor makes the current one hold its last, while the current one plays on\n\t/// into its overlap (see \u003Csee cref=\u0022MediaSegment.Overlap\u0022/\u003E).\n\t/// \u003C/summary\u003E\n\tfloat leadCorrection;\n\n\t/// \u003Csummary\u003E\n\t/// How long the swapped-out player keeps running, silent, after a swap.\n\t/// \u003Cpara\u003E\n\t/// The engine\u0027s mixer runs every sound through Steam Audio\u0027s direct effect, whose gain interpolator ramps any\n\t/// volume change over the next mix frames. Unmuting the next player while closing the current one outright\n\t/// left a ~70 ms dip in the audio at every boundary (measured with a sine: down to 5%, every segment).\n\t/// Muting the old player at the same moment instead - both ramps at once - makes that a crossfade.\n\t/// \u003C/para\u003E\n\t/// \u003C/summary\u003E\n\tconst float FadeSeconds = 0.15f;\n\n\tSlot fading;\n\tRealTimeSince sinceFade;\n\n\tSlot current, next;\n\tbool paused;\n\n\t// audio settings, applied to both players\n\tVector3 audioPosition;\n\tbool audioSpatial;\n\tfloat audioVolume = 1;\n\tfloat audioDistance = 2500;\n\n\tstring error;\n\tpublic string Error =\u003E error ?? feed.Error;\n\n\t/// \u003Csummary\u003E The segment on screen, -1 before the first. \u003C/summary\u003E\n\tpublic int CurrentSeq =\u003E current?.Segment.Seq ?? -1;\n\n\t/// \u003Csummary\u003E Has the first segment started? \u003C/summary\u003E\n\tpublic bool Loaded =\u003E current is { Loaded: true } || (Mjpeg?.HasShown ?? false);\n\n\t/// \u003Csummary\u003E Motion JPEG: frames go straight to the texture, there are no segments to play. \u003C/summary\u003E\n\tResolver.Live.LiveMjpeg Mjpeg =\u003E (feed as Resolver.Live.LiveSegmenter)?.Mjpeg;\n\n\t/// \u003Csummary\u003E The last segment has played to its end. \u003C/summary\u003E\n\tpublic bool Finished { get; private set; }\n\n\t/// \u003Csummary\u003E Media time (VOD), or running stream time (live). \u003C/summary\u003E\n\tpublic float Time =\u003E (float)StreamTime;\n\n\t/// \u003Csummary\u003E\n\t/// Stream time (seconds) of what\u0027s on screen. WebM segments keep absolute timestamps (their StreamStart is 0); MP4\n\t/// segments (live, HLS VOD) each start their clock at 0.\n\t/// \u003C/summary\u003E\n\tpublic double StreamTime =\u003E Mjpeg is { } mjpeg ? mjpeg.ShownTime : (current?.Segment.StreamStart ?? 0) \u002B (current?.Player?.PlaybackTime ?? 0);\n\n\tpublic float Duration =\u003E current?.Player?.Duration ?? 0;\n\tpublic bool IsPaused =\u003E current?.Player?.IsPaused ?? paused;\n\n\t/// \u003Csummary\u003E Every segment\u0027s frames go to one texture (see \u003Csee cref=\u0022VideoFrameSink\u0022/\u003E). \u003C/summary\u003E\n\treadonly VideoFrameSink sink = new();\n\n\tpublic Texture Texture =\u003E sink.Texture;\n\tpublic int Width =\u003E sink.Width;\n\tpublic int Height =\u003E sink.Height;\n\tpublic bool HasVideo =\u003E sink.Texture is not null \u0026\u0026 Width \u003E 4 \u0026\u0026 Height \u003E 4;\n\tpublic (int rate, int channels) AudioFormat =\u003E (current?.Player?.SampleRate ?? 0, current?.Player?.Channels ?? 0);\n\n\t/// \u003Csummary\u003E\n\t/// Merged media: the synced media time, null while paused. A first segment that starts later than it (at the next\n\t/// keyframe - see \u003Csee cref=\u0022MediaBackend\u0022/\u003E) opens only when the clock gets there, so it never has to seek forward.\n\t/// \u003C/summary\u003E\n\tpublic float? SyncedTime { get; set; }\n\n\tpublic SegmentPlayer( ISegmentFeed feed )\n\t{\n\t\tthis.feed = feed;\n\t\tif ( !feed.IsLive ) feed.Want( 0 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Call every frame.\n\t/// \u003C/summary\u003E\n\tpublic void Update()\n\t{\n\t\tif ( Mjpeg is { } mjpeg )\n\t\t{\n\t\t\tif ( !paused ) mjpeg.Present( sink );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( current is null )\n\t\t{\n\t\t\tvar first = feed.IsLive ? LiveJoinSegment() : feed.TryGet( 0 );\n\t\t\tif ( first is null ) return;\n\t\t\tif ( !feed.IsLive \u0026\u0026 !paused \u0026\u0026 SyncedTime is { } now \u0026\u0026 first.StreamStart \u002B first.Start - now \u003E StartLead ) return;\n\t\t\tcurrent = Open( first );\n\t\t\tif ( paused ) current.Player?.Pause();\n\t\t}\n\n\t\t// Live and fell far behind? Jump to the live edge.\n\t\tif ( feed.IsLive \u0026\u0026 feed.Newest - current.Segment.Seq \u003E MaxBehind \u0026\u0026 LiveJoinSegment() is { } edge )\n\t\t{\n\t\t\tLog.Info( $\u0022[bimp] live: {feed.Newest - current.Segment.Seq} segments behind, jumping to the live edge\u0022 );\n\t\t\tClose( next ); next = null;\n\t\t\tClose( current );\n\t\t\tcurrent = Open( edge );\n\t\t}\n\n\t\tTimedPresent( current, \u0022current\u0022 );\n\n\t\t// the first segment\u0027s own start seeds the start latency, so the first swap isn\u0027t late on a slow (4K) decoder\n\t\tif ( startLatency \u003C= 0 \u0026\u0026 !current.Primed \u0026\u0026 current.FirstFrame )\n\t\t{\n\t\t\tcurrent.Primed = true;\n\t\t\tstartLatency = (float)current.SinceOpened;\n\t\t}\n\n\t\t// the swapped-out player finishes its fade, then goes\n\t\tif ( fading is not null )\n\t\t{\n\t\t\tTimedPresent( fading, \u0022fading\u0022 );\n\t\t\tif ( sinceFade \u003E FadeSeconds ) CloseFading();\n\t\t}\n\n\t\tvar p = current.Player;\n\t\tvar seg = current.Segment;\n\n\t\t// A player started fresh delivers its first frame ~0.25s after Play(), and one that was paused takes ~1s\n\t\t// after Resume() - so start the next segment just in time (hidden, silent, presented so it runs),\n\t\t// timed from the measured start latency, and swap which player is on screen. Nothing ever pauses.\n\t\tif ( next is null \u0026\u0026 !paused \u0026\u0026 !seg.Last \u0026\u0026 current.Loaded \u0026\u0026 p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End - StartLead )\n\t\t{\n\t\t\tvar upcoming = feed.TryGet( seg.Seq \u002B 1 );\n\t\t\tif ( upcoming is not null ) next = Open( upcoming, preload: true );\n\t\t}\n\n\t\tif ( next?.Player is not null )\n\t\t{\n\t\t\tTimedPresent( next, \u0022next\u0022 );\n\n\t\t\tif ( !next.Primed \u0026\u0026 next.FirstFrame )\n\t\t\t{\n\t\t\t\tnext.Primed = true;\n\t\t\t\tvar latency = (float)next.SinceOpened;\n\t\t\t\tstartLatency = startLatency \u003C= 0 ? latency : startLatency * 0.7f \u002B latency * 0.3f;\n\t\t\t\tMediaProbe.Note( $\u0022seg {next.Segment.Seq} first frame {latency * 1000:0}ms after opening (avg {startLatency * 1000:0}ms), current at {p?.PlaybackTime:0.000}/{seg.End:0.000}\u0022 );\n\t\t\t}\n\t\t}\n\n\t\t// Swap when the current segment\u0027s clock reaches its end. (OnFinished fires ~80ms early, while\n\t\t// frames are still queued - swapping on it cut off the last frames.)\n\t\tvar stuck = current.Finished \u0026\u0026 p is not null \u0026\u0026 MathF.Abs( p.PlaybackTime - current.LastTime ) \u003C 0.0001f;\n\t\tcurrent.LastTime = p?.PlaybackTime ?? 0;\n\t\tvar atEnd = (current.Loaded \u0026\u0026 p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End - 0.005f) || stuck;\n\t\tif ( !atEnd ) return;\n\n\t\tif ( seg.Last )\n\t\t{\n\t\t\tFinished = current.Finished || stuck;\n\t\t\treturn;\n\t\t}\n\n\t\t// a next player still catching up waits, while the current one has overlap left to play\n\t\tvar settled = !ClockLeadOnly || next is null || next.Steady \u003E= SettleFrames || (p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End \u002B seg.Overlap - 0.1f) || stuck;\n\t\tif ( next is { Primed: true } \u0026\u0026 settled )\n\t\t{\n\t\t\t// the next player\u0027s clock vs the current one\u0027s, in stream time: \u002B means it\u0027s ahead. (Merged media keeps\n\t\t\t// absolute timestamps; live segments each start their clock at 0.)\n\t\t\tvar ahead = (float)((next.Segment.StreamStart \u002B (next.Player?.PlaybackTime ?? 0)) - (seg.StreamStart \u002B (p?.PlaybackTime ?? seg.End)));\n\n\t\t\t// keep the two clocks in step (see leadCorrection)\n\t\t\tif ( ClockLeadOnly ) clockLead = Math.Clamp( clockLead - ahead * 0.7f, 0.0f, 1.0f );\n\t\t\telse if ( !JustInTime ) leadCorrection = Math.Clamp( leadCorrection - ahead * 0.7f, -0.2f, 0.2f );\n\t\t\tMediaProbe.Note( $\u0022swap {seg.Seq}-\u003E{next.Segment.Seq} at {p?.PlaybackTime:0.000}/{seg.End:0.000} (overlap {seg.Overlap * 1000:0}ms), next ahead by {ahead * 1000:0}ms, lead now {StartLead * 1000:0}ms\u0022 );\n\t\t\tnext.Preload = false;\n\t\t\tApplyAudio( next );\n\n\t\t\t// its frame from just before the boundary goes up now, not 33 ms later with its next one\n\t\t\tsink.ShowHeld( next.Player );\n\n\t\t\t// crossfade: the old player goes silent as the new one comes up (see FadeSeconds)\n\t\t\tCloseFading();\n\t\t\tfading = current;\n\t\t\tfading.Fading = true;\n\t\t\tApplyAudio( fading );\n\t\t\tsinceFade = 0;\n\n\t\t\tcurrent = next;\n\t\t\tnext = null;\n\t\t\tfeed.Want( current.Segment.Seq ); // keeps the one after it coming\n\t\t}\n\n\t\t// otherwise the next segment isn\u0027t downloaded yet - hold the last frame\n\t}\n\n\tvoid TimedPresent( Slot slot, string role )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar t = System.Diagnostics.Stopwatch.GetTimestamp();\n\t\tslot.Player.Present();\n\t\tMediaProbe.PresentTook( System.Diagnostics.Stopwatch.GetElapsedTime( t ).TotalMilliseconds, role, slot.Segment.Seq );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Live: the segment to start from - the newest one that\u0027s at least \u003Csee cref=\u0022JoinBehind\u0022/\u003E behind the newest\n\t/// finished segment and leaves the feed\u0027s start buffer of stream time after it. Null while there isn\u0027t enough yet.\n\t/// \u003C/summary\u003E\n\tMediaSegment LiveJoinSegment()\n\t{\n\t\tvar newest = feed.TryGet( feed.Newest );\n\t\tif ( newest is null ) return null;\n\t\tvar bufferedUntil = newest.StreamStart \u002B newest.End;\n\n\t\tif ( feed.LowLatency ) return RealTime.NowDouble - newest.ReadyAt \u003E= LowJoinMargin ? newest : null;\n\n\t\tfor ( var seq = newest.Seq - JoinBehind; seq \u003E= 0; seq-- )\n\t\t{\n\t\t\tvar s = feed.TryGet( seq );\n\t\t\tif ( s is null ) return null; // not enough on disk yet\n\t\t\tif ( bufferedUntil - s.StreamStart \u003E= feed.StartBufferSeconds ) return s;\n\t\t}\n\t\treturn null;\n\t}\n\n\tSlot Open( MediaSegment segment, bool preload = false )\n\t{\n\t\tMediaProbe.Note( $\u0022open seg {segment.Seq} ({segment.Start:0.00}-{segment.End:0.00}){(preload ? \u0022 (preload)\u0022 : \u0022\u0022)}\u0022 );\n\t\tvar slot = new Slot { Segment = segment, SinceOpened = 0, Preload = preload };\n\t\ttry\n\t\t{\n\t\t\tvar player = new VideoPlayer();\n\t\t\tplayer.OnLoaded \u002B= () =\u003E slot.Loaded = true;\n\t\t\tplayer.OnFinished \u002B= () =\u003E slot.Finished = true;\n\t\t\tslot.Player = player;\n\t\t\tApplyAudio( slot );\n\t\t\t// only the player on screen updates the texture; a preloading one just says it\u0027s ready\n\t\t\tsink.Attach( player, () =\u003E !slot.Preload \u0026\u0026 !slot.Fading, () =\u003E\n\t\t\t{\n\t\t\t\tslot.Steady = slot.FirstFrame \u0026\u0026 slot.SinceFrame \u003C SettleInterval ? slot.Steady \u002B 1 : 0;\n\t\t\t\tslot.SinceFrame = 0;\n\t\t\t\tslot.FirstFrame = true;\n\t\t\t}, $\u0022seg {segment.Seq}\u0022, holdWhileInactive: preload );\n\t\t\tvar playTimer = System.Diagnostics.Stopwatch.StartNew();\n\t\t\tplayer.Play( FileSystem.Data, segment.Path );\n\t\t\tMediaProbe.Note( $\u0022Play() seg {segment.Seq} took {playTimer.Elapsed.TotalMilliseconds:0.0}ms\u0022 );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\terror = e.Message;\n\t\t}\n\t\treturn slot;\n\t}\n\n\tvoid Close( Slot slot )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar timer = System.Diagnostics.Stopwatch.StartNew();\n\t\tsink.Detach( slot.Player );\n\t\tslot.Player.Dispose();\n\t\tMediaProbe.Note( $\u0022close seg {slot.Segment.Seq} took {timer.Elapsed.TotalMilliseconds:0.0}ms\u0022 );\n\t}\n\n\tvoid CloseFading()\n\t{\n\t\tif ( fading is null ) return;\n\t\tClose( fading );\n\t\tfeed.Release( fading.Segment.Seq );\n\t\tfading = null;\n\t}\n\n\tpublic void SetPaused( bool pause )\n\t{\n\t\tpaused = pause;\n\t\tif ( current?.Player is { } p )\n\t\t{\n\t\t\tif ( pause ) p.Pause(); else p.Resume();\n\t\t}\n\n\t\tif ( pause ) CloseFading();\n\n\t\t// a preloading player would carry on running - start it again when we resume\n\t\tif ( pause \u0026\u0026 next is not null )\n\t\t{\n\t\t\tClose( next );\n\t\t\tnext = null;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A short hop within the current segment (landing exactly on the synced time after opening).\n\t/// Anything further is done by opening a new segmenter at the time.\n\t/// \u003C/summary\u003E\n\tpublic void Seek( float time )\n\t{\n\t\tvar seg = current?.Segment;\n\t\tif ( seg is null ) return;\n\t\tcurrent.Player?.Seek( Math.Clamp( time - (float)seg.StreamStart, seg.Start, seg.End ) );\n\t\tFinished = false;\n\t}\n\n\tpublic void SetAudio( Vector3 position, bool spatial, float volume, float distance )\n\t{\n\t\taudioPosition = position;\n\t\taudioSpatial = spatial;\n\t\taudioVolume = volume;\n\t\taudioDistance = distance;\n\t\tApplyAudio( current );\n\t\tApplyAudio( next );\n\t}\n\n\tvoid ApplyAudio( Slot slot )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar a = slot.Player.Audio;\n\t\ta.ListenLocal = !audioSpatial;\n\t\ta.Position = audioSpatial ? audioPosition : Vector3.Forward * 64.0f;\n\t\ta.Volume = slot.Preload || slot.Fading ? 0 : audioVolume;\n\t\ta.Distance = audioDistance;\n\t\ta.Falloff = MediaPlayer.AudioFalloff;\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tCloseFading();\n\t\tClose( current );\n\t\tClose( next );\n\t\tcurrent = next = null;\n\t\tsink.Dispose();\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"UseHandled.cs","FileName":"UseHandled.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp;\n\n/// \u003Csummary\u003E\n/// One USE key press can reach us several ways in the same frame: the screen\u0027s own look-and-press\n/// (\u003Csee cref=\u0022MediaScreen\u0022/\u003E), the PlayerController pressing \u003Csee cref=\u0022MediaInteract\u0022/\u003E when in reach,\n/// and the remote\u0027s close-on-use. Whoever runs first handles it, the rest see it\u0027s taken.\n/// \u003C/summary\u003E\ninternal static class UseHandled\n{\n\tstatic float handledAt = -1;\n\n\t/// \u003Csummary\u003E\n\t/// Returns true if nobody has handled a USE press this frame (and marks it handled).\n\t/// \u003C/summary\u003E\n\tpublic static bool TryConsume()\n\t{\n\t\tif ( handledAt == Time.Now ) return false;\n\t\thandledAt = Time.Now;\n\t\treturn true;\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"MediaRemote.razor","FileName":"MediaRemote.razor","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"@using System\n@using System.Linq\n@using Sandbox\n@using Sandbox.UI\n@namespace Bimp\n@inherits PanelComponent\n@implements Component.ISceneStage\n\n@*\n\tThe full controls, shown on the local player\u0027s screen when they USE a media player.\n\tCreated on demand by MediaRemote.Open, never networked.\n*@\n\n\u003Croot class=\u0022mediaremote @(PadMode ? \u0022pad\u0022 : \u0022\u0022)\u0022\u003E\n\n\t@if ( Target is not null )\n\t{\n\t\t\u003Cdiv class=\u0022window\u0022\u003E\n\n\t\t\t\u003Cdiv class=\u0022header\u0022\u003E\n\t\t\t\t\u003Ci\u003E@(Target.AudioOnlyPlayer ? \u0022speaker\u0022 : \u0022smart_display\u0022)\u003C/i\u003E\n\t\t\t\t\u003Cdiv class=\u0022title\u0022\u003EMedia Player\u003C/div\u003E\n\t\t\t\t\u003Cdiv class=\u0022close nav\u0022 onclick=\u0022@Close\u0022\u003E\u003Ci\u003Eclose\u003C/i\u003E\u003C/div\u003E\n\t\t\t\u003C/div\u003E\n\n\t\t\t\u003Cdiv class=\u0022now\u0022\u003E\n\t\t\t\t@if ( Target.HasMedia )\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022label\u0022\u003ENow playing\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022track\u0022\u003E@Target.Title\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022sub\u0022\u003E\n\t\t\t\t\t\t@if ( !string.IsNullOrWhiteSpace( Target.RequestedBy ) )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\u003Cspan\u003ERequested by @Target.RequestedBy\u003C/span\u003E\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\u003Cspan class=\u0022time\u0022\u003E@TimeText\u003C/span\u003E\n\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022track dim\u0022\u003ENothing playing\u003C/div\u003E\n\t\t\t\t}\n\n\t\t\t\t@if ( !string.IsNullOrWhiteSpace( Target.Status ) )\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022status\u0022\u003E@Target.Status\u003C/div\u003E\n\t\t\t\t}\n\t\t\t\t@if ( !string.IsNullOrWhiteSpace( Target.LocalNotice ) )\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022notice\u0022\u003E@Target.LocalNotice\u003C/div\u003E\n\t\t\t\t}\n\t\t\t\u003C/div\u003E\n\n\t\t\t@if ( Target.HasMedia \u0026\u0026 !Target.IsLive \u0026\u0026 Target.MediaDuration \u003E 0 )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022seek @(Target.CanLocalControl ? \u0022nav\u0022 : \u0022disabled\u0022)\u0022\u003E\n\t\t\t\t\t\u003CSliderControl Min=\u0022@(0f)\u0022 Max=\u0022@Target.MediaDuration\u0022 Step=\u0022@(1f)\u0022 ShowValueTooltip=\u0022@false\u0022 Value=\u0022@SeekValue\u0022 OnValueChanged=\u0022@OnSeekChanged\u0022\u003E\u003C/SliderControl\u003E\n\t\t\t\t\u003C/div\u003E\n\t\t\t}\n\n\t\t\t@if ( Target.CanLocalControl \u0026\u0026 Target.HasMedia )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022transport\u0022\u003E\n\t\t\t\t\t@if ( !Target.IsLive )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022btn nav\u0022 onclick=\u0022@( () =\u003E Target.RequestSeek( Target.CurrentTime - 10 ) )\u0022\u003E\u003Ci\u003Ereplay_10\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\u003Cdiv class=\u0022btn big nav nav-default\u0022 onclick=\u0022@( () =\u003E Target.RequestSetPaused( !Target.Paused ) )\u0022\u003E\u003Ci\u003E@(Target.Paused ? \u0022play_arrow\u0022 : \u0022pause\u0022)\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\u003Cdiv class=\u0022btn nav\u0022 onclick=\u0022@( () =\u003E Target.RequestSeek( Target.CurrentTime \u002B 10 ) )\u0022\u003E\u003Ci\u003Eforward_10\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\t\u003Cdiv class=\u0022btn nav\u0022 onclick=\u0022@Target.RequestSkip\u0022\u003E\u003Ci\u003Eskip_next\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022btn nav\u0022 onclick=\u0022@Target.RequestStop\u0022\u003E\u003Ci\u003Estop\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\u003C/div\u003E\n\t\t\t}\n\n\t\t\t@if ( Target.HasMedia \u0026\u0026 (Target.AvailableQualities.Count \u003E 0 || Target.AvailableAudioTracks.Count \u003E 0 || Target.AvailableCaptionTracks.Count \u003E 0) )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022stream-options\u0022\u003E\n\t\t\t\t\t@if ( Target.AvailableQualities.Count \u003E 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022option\u0022\u003E\n\t\t\t\t\t\t\t\u003Ci\u003Ehigh_quality\u003C/i\u003E\n\t\t\t\t\t\t\t\u003CDropDown class=\u0022quality nav\u0022 Value=\u0022@QualityValue\u0022 ValueChanged=\u0022@OnQualityChanged\u0022 BuildOptions=\u0022@BuildQualityOptions\u0022\u003E\u003C/DropDown\u003E\n\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\t@if ( Target.AvailableAudioTracks.Count \u003E 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022option\u0022\u003E\n\t\t\t\t\t\t\t\u003Ci\u003Etranslate\u003C/i\u003E\n\t\t\t\t\t\t\t\u003CDropDown class=\u0022audio-track nav\u0022 Value=\u0022@AudioTrackValue\u0022 ValueChanged=\u0022@OnAudioTrackChanged\u0022 BuildOptions=\u0022@BuildAudioTrackOptions\u0022\u003E\u003C/DropDown\u003E\n\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\t@if ( Target.AvailableCaptionTracks.Count \u003E 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022option\u0022\u003E\n\t\t\t\t\t\t\t\u003Ci\u003Eclosed_caption\u003C/i\u003E\n\t\t\t\t\t\t\t\u003CDropDown class=\u0022audio-track nav\u0022 Value=\u0022@CaptionValue\u0022 ValueChanged=\u0022@OnCaptionChanged\u0022 BuildOptions=\u0022@BuildCaptionOptions\u0022\u003E\u003C/DropDown\u003E\n\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\t\u003Cdiv class=\u0022playing-as\u0022\u003E@PlayingAsText\u003C/div\u003E\n\t\t\t\t\u003C/div\u003E\n\t\t\t}\n\n\t\t\t@if ( (Target.CanLocalControl || Target.CanLocalQueue) \u0026\u0026 PadMode )\n\t\t\t{\n\t\t\t\t@* a pad can\u0027t type a link *@\n\t\t\t\t\u003Cdiv class=\u0022pad-note\u0022\u003E\u003Ci\u003Ekeyboard\u003C/i\u003E Use a keyboard to add a link\u003C/div\u003E\n\t\t\t}\n\t\t\telse if ( Target.CanLocalControl || Target.CanLocalQueue )\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022entry\u0022\u003E\n\t\t\t\t\t\u003CTextEntry @ref=\u0022UrlEntry\u0022 Placeholder=\u0022Paste a YouTube link or a media url (mp4, webm, mp3...)\u0022 onsubmit=\u0022@OnSubmit\u0022\u003E\u003C/TextEntry\u003E\n\t\t\t\t\t@if ( Target.CanLocalControl )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022action primary\u0022 onclick=\u0022@PlayNow\u0022\u003E\u003Ci\u003Eplay_arrow\u003C/i\u003E Play now\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\t@if ( Target.CanLocalQueue )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022action\u0022 onclick=\u0022@AddToQueue\u0022\u003E\u003Ci\u003Eplaylist_add\u003C/i\u003E Queue\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\u003C/div\u003E\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t\u003Cdiv class=\u0022locked\u0022\u003E\u003Ci\u003Elock\u003C/i\u003E You can\u0027t control this player.\u003C/div\u003E\n\t\t\t}\n\n\t\t\t\u003Cdiv class=\u0022queue\u0022\u003E\n\t\t\t\t\u003Cdiv class=\u0022queue-header\u0022\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022tab nav @(ShowRecent ? \u0022\u0022 : \u0022active\u0022)\u0022 onclick=\u0022@( () =\u003E ShowRecent = false )\u0022\u003EUp next \u003Cspan class=\u0022count\u0022\u003E@Target.Queue.Count / @Target.MaxQueue\u003C/span\u003E\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022tab nav @(ShowRecent ? \u0022active\u0022 : \u0022\u0022)\u0022 onclick=\u0022@( () =\u003E ShowRecent = true )\u0022\u003ERecent \u003Cspan class=\u0022count\u0022\u003E@RecentLinks.All.Count\u003C/span\u003E\u003C/div\u003E\n\t\t\t\t\t\u003Cdiv class=\u0022spacer\u0022\u003E\u003C/div\u003E\n\t\t\t\t\t@if ( !ShowRecent \u0026\u0026 Target.CanLocalControl \u0026\u0026 Target.Queue.Count \u003E 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022small-btn nav\u0022 onclick=\u0022@Target.RequestClearQueue\u0022\u003EClear\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\u003C/div\u003E\n\n\t\t\t\t\u003Cdiv class=\u0022queue-list\u0022\u003E\n\t\t\t\t\t@if ( ShowRecent )\n\t\t\t\t\t{\n\t\t\t\t\t\t@if ( RecentLinks.All.Count == 0 )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022empty\u0022\u003ELinks you play show up here.\u003C/div\u003E\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t@foreach ( var recent in RecentLinks.All )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar url = recent.Url;\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022queue-item recent\u0022\u003E\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022info\u0022\u003E\n\t\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022name\u0022\u003E@recent.Title\u003C/div\u003E\n\t\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022by\u0022\u003E@recent.Url\u003C/div\u003E\n\t\t\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022dur\u0022\u003E@(recent.IsLive ? \u0022Live\u0022 : recent.Duration \u003E 0 ? MediaSource.FormatTime( recent.Duration ) : \u0022\u0022)\u003C/div\u003E\n\t\t\t\t\t\t\t\t@if ( Target.CanLocalControl )\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022small-btn go nav\u0022 onclick=\u0022@( () =\u003E Target.RequestPlay( url ) )\u0022\u003E\u003Ci\u003Eplay_arrow\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t@if ( Target.CanLocalQueue )\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022small-btn go nav\u0022 onclick=\u0022@( () =\u003E Target.RequestEnqueue( url ) )\u0022\u003E\u003Ci\u003Eplaylist_add\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022small-btn nav\u0022 onclick=\u0022@( () =\u003E RecentLinks.Remove( url ) )\u0022\u003E\u003Ci\u003Eclose\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse if ( Target.Queue.Count == 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\t\u003Cdiv class=\u0022empty\u0022\u003EThe queue is empty.\u003C/div\u003E\n\t\t\t\t\t}\n\n\t\t\t\t\t@for ( int i = 0; !ShowRecent \u0026\u0026 i \u003C Target.Queue.Count; i\u002B\u002B )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar index = i;\n\t\t\t\t\t\tvar item = Target.Queue[i];\n\t\t\t\t\t\t\u003Cdiv class=\u0022queue-item\u0022\u003E\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022num\u0022\u003E@(index \u002B 1)\u003C/div\u003E\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022info\u0022\u003E\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022name\u0022\u003E@item.Title\u003C/div\u003E\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022by\u0022\u003E@item.RequestedBy\u003C/div\u003E\n\t\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t\t\t\u003Cdiv class=\u0022dur\u0022\u003E@(item.IsLive ? \u0022Live\u0022 : item.Duration \u003E 0 ? MediaSource.FormatTime( item.Duration ) : \u0022\u0022)\u003C/div\u003E\n\t\t\t\t\t\t\t@if ( Target.CanLocalControl )\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\u003Cdiv class=\u0022small-btn nav\u0022 onclick=\u0022@( () =\u003E Target.RequestRemove( index ) )\u0022\u003E\u003Ci\u003Eclose\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t\t}\n\t\t\t\t\u003C/div\u003E\n\t\t\t\u003C/div\u003E\n\n\t\t\t\u003Cdiv class=\u0022footer\u0022\u003E\n\t\t\t\t\u003Cdiv class=\u0022btn small nav\u0022 onclick=\u0022@ToggleMute\u0022\u003E\u003Ci\u003E@(MediaSettings.Mute ? \u0022volume_off\u0022 : \u0022volume_up\u0022)\u003C/i\u003E\u003C/div\u003E\n\t\t\t\t\u003CSliderControl class=\u0022volume nav\u0022 Min=\u0022@(0f)\u0022 Max=\u0022@(1f)\u0022 Step=\u0022@(0.01f)\u0022 ShowValueTooltip=\u0022@false\u0022 Value=\u0022@MediaSettings.Volume\u0022 OnValueChanged=\u0022@OnVolumeChanged\u0022\u003E\u003C/SliderControl\u003E\n\t\t\t\t\u003Cdiv class=\u0022btn small nav audio-mode @(MediaSettings.SpatialAudio ? \u0022\u0022 : \u0022flat\u0022)\u0022 onclick=\u0022@ToggleSpatial\u0022 tooltip=\u0022@(MediaSettings.SpatialAudio ? \u00223D audio: sound comes from the screen. Click for 2D\u0022 : \u00222D audio: no direction, still quieter further away. Click for 3D\u0022)\u0022\u003E\u003Ci\u003E@(MediaSettings.SpatialAudio ? \u0022surround_sound\u0022 : \u0022headphones\u0022)\u003C/i\u003E\u003Cspan\u003E@(MediaSettings.SpatialAudio ? \u00223D\u0022 : \u00222D\u0022)\u003C/span\u003E\u003C/div\u003E\n\t\t\t\t@if ( PadMode )\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022hint\u0022\u003E\n\t\t\t\t\t\t\u003CInputGlyph Analog=\u0022LeftStickY\u0022 Size=\u0022@InputGlyphSize.Small\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003EMove\u003C/span\u003E\n\t\t\t\t\t\t@if ( Pad.SelectAction is not null )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\u003CInputGlyph Action=\u0022@Pad.SelectAction\u0022 Size=\u0022@InputGlyphSize.Small\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003ESelect\u003C/span\u003E\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\u003CInputGlyph Action=\u0022@(Pad.BackAction ?? \u0022use\u0022)\u0022 Size=\u0022@InputGlyphSize.Small\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003EClose\u003C/span\u003E\n\t\t\t\t\t\u003C/div\u003E\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t\u003Cdiv class=\u0022hint\u0022\u003E\u003Cspan\u003EYour audio (all players) \u00B7\u003C/span\u003E\u003CInputGlyph Action=\u0022use\u0022 Size=\u0022@InputGlyphSize.Small\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003Eor\u003C/span\u003E\u003CInputGlyph Key=\u0022escape\u0022 Size=\u0022@InputGlyphSize.Small\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003Eto close\u003C/span\u003E\u003C/div\u003E\n\t\t\t\t}\n\t\t\t\u003C/div\u003E\n\n\t\t\u003C/div\u003E\n\t}\n\n\u003C/root\u003E\n\n@code\n{\n\t/// \u003Csummary\u003E\n\t/// The player being controlled.\n\t/// \u003C/summary\u003E\n\tpublic MediaPlayer Target { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// Close automatically when the camera gets further than this from the player.\n\t/// \u003C/summary\u003E\n\tpublic float MaxDistance { get; set; } = 1500.0f;\n\n\tTextEntry UrlEntry { get; set; }\n\n\t/// \u003Csummary\u003E The list shows the links this client has played (\u003Csee cref=\u0022RecentLinks\u0022/\u003E) instead of the queue. \u003C/summary\u003E\n\tbool ShowRecent { get; set; }\n\n\tRealTimeSince sinceOpened = 0;\n\tfloat? pendingSeek;\n\tRealTimeSince sincePendingSeek;\n\n\tfloat SeekValue =\u003E pendingSeek ?? Target?.CurrentTime ?? 0;\n\n\tstring TimeText\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Target is null || !Target.HasMedia ) return \u0022\u0022;\n\t\t\tif ( Target.IsLive ) return \u0022Live\u0022;\n\t\t\tvar cur = MediaSource.FormatTime( Target.CurrentTime );\n\t\t\tvar s = Target.MediaDuration \u003E 0 ? $\u0022{cur} / {MediaSource.FormatTime( Target.MediaDuration )}\u0022 : cur;\n\t\t\treturn Target.Paused ? $\u0022{s} (paused)\u0022 : s;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Open the remote for this player, or close it if it\u0027s already open for it.\n\t/// \u003C/summary\u003E\n\tpublic static void Toggle( MediaPlayer target )\n\t{\n\t\tif ( !target.IsValid() || Application.IsDedicatedServer ) return;\n\n\t\tvar existing = target.Scene.GetAllComponents\u003CMediaRemote\u003E().FirstOrDefault();\n\t\tif ( existing.IsValid() )\n\t\t{\n\t\t\tif ( existing.Target == target )\n\t\t\t{\n\t\t\t\texisting.Close();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\texisting.Target = target;\n\t\t\texisting.sinceOpened = 0;\n\t\t\texisting.StateHasChanged();\n\t\t\treturn;\n\t\t}\n\n\t\tvar go = target.Scene.CreateObject();\n\t\tgo.Name = \u0022bimp remote\u0022;\n\t\tgo.NetworkMode = NetworkMode.Never;\n\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;\n\n\t\tvar screen = go.Components.Create\u003CScreenPanel\u003E();\n\t\tscreen.ZIndex = 200;\n\n\t\tvar remote = go.Components.Create\u003CMediaRemote\u003E();\n\t\tremote.Target = target;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Is a remote open right now? Games can use this to e.g. hide their crosshair.\n\t/// \u003C/summary\u003E\n\tpublic static bool IsOpen( Scene scene ) =\u003E scene?.GetAllComponents\u003CMediaRemote\u003E().Any() ?? false;\n\n\tpublic void Close()\n\t{\n\t\tGameObject.Destroy();\n\t}\n\n\tprotected override void OnTreeFirstBuilt()\n\t{\n\t\tbase.OnTreeFirstBuilt();\n\t\tUrlEntry?.Focus();\n\t}\n\n\t//\n\t// Controller: the D-pad / left stick moves a highlight between the controls (the \u0022nav\u0022 ones), A presses,\n\t// left/right adjusts sliders and choices, B closes. See PadNavigation.\n\t//\n\n\t/// \u003Csummary\u003E Played with a controller right now (the engine switches as soon as they touch the keyboard or pad). \u003C/summary\u003E\n\tbool PadMode =\u003E Input.UsingController;\n\n\treadonly PadNavigation Pad = new();\n\n\t/// \u003Csummary\u003E The engine\u0027s virtual cursor (left stick moves a pointer) is off while we navigate; this was its setting. \u003C/summary\u003E\n\tbool? virtualCursorWas;\n\n\t/// \u003Csummary\u003E\n\t/// The very first thing in the frame, before anything (fixed update included) reads input: navigate, then suppress the\n\t/// frame\u0027s input, so the pad doesn\u0027t also move, jump or duck the player behind the remote.\n\t/// \u003C/summary\u003E\n\tvoid Component.ISceneStage.Start()\n\t{\n\t\tif ( !PadMode || !Target.IsValid() || Panel is null )\n\t\t{\n\t\t\tRestoreVirtualCursor();\n\t\t\treturn;\n\t\t}\n\n\t\tvirtualCursorWas ??= Input.EnableVirtualCursor;\n\t\tInput.EnableVirtualCursor = false;\n\n\t\tPad.Press ??= PressControl;\n\t\tPad.Adjust ??= AdjustControl;\n\t\tPad.Update( Panel );\n\n\t\t// B closes (unless it just closed a drop down\u0027s menu), and USE again, as on keyboard\n\t\tvar close = sinceOpened \u003E 0.2f \u0026\u0026 (Pad.BackPressed || Input.Pressed( \u0022use\u0022 ));\n\t\tInput.Suppressed = true;\n\t\tif ( close ) Close();\n\t}\n\n\t/// \u003Csummary\u003E The window\u0027s width in the stylesheet (.window), at 1080p. \u003C/summary\u003E\n\tconst float WindowWidth = 760;\n\n\t/// \u003Csummary\u003E\n\t/// The screen panel already scales the remote with the screen height (as if every screen were 1080 high); this sizes\n\t/// it up from there by bimp_ui_scale - at its design size it was small at every resolution - but never wider than\n\t/// 90% of the screen.\n\t/// \u003C/summary\u003E\n\tvoid FitToScreen()\n\t{\n\t\tif ( Components.Get\u003CScreenPanel\u003E() is not { } screen ) return;\n\n\t\tvar heightScale = Screen.Size.y / 1080.0f;\n\t\tvar widest = heightScale \u003E 0 ? Screen.Size.x * 0.9f / (WindowWidth * heightScale) : 1;\n\t\tvar scale = Math.Clamp( MediaSettings.RemoteScale, 0.5f, Math.Max( 0.5f, widest ) );\n\t\tif ( MathF.Abs( screen.Scale - scale ) \u003E 0.001f ) screen.Scale = scale;\n\t}\n\n\t/// \u003Csummary\u003E For probes: each option of an open menu, its classes, and * on the pad\u0027s selection. \u003C/summary\u003E\n\tIEnumerable\u003Cstring\u003E MenuOptions()\n\t{\n\t\tvar top = Panel;\n\t\twhile ( top?.Parent is not null ) top = top.Parent;\n\t\tvar menu = top?.Descendants.OfType\u003CPopup\u003E().FirstOrDefault();\n\t\treturn menu is null ? Enumerable.Empty\u003Cstring\u003E() : menu.Children.Select( c =\u003E $\u0022{(c as Button)?.Text}:{c.Classes}{(c == menu.SelectedChild ? \u0022*\u0022 : \u0022\u0022)}\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E For probes: the controller state and what\u0027s highlighted. \u003C/summary\u003E\n\tinternal string DescribePad() =\u003E $\u0022usingController={Input.UsingController} controllers={Input.ControllerCount} select={Pad.SelectAction ?? \u0022-\u0022} back={Pad.BackAction ?? \u0022-\u0022} virtualCursor={Input.EnableVirtualCursor} screen={Screen.Size} desktopScale={Screen.DesktopScale} uiScale={Panel?.ScaleToScreen} window={Panel?.Descendants.FirstOrDefault( p =\u003E p.HasClass( \u0022window\u0022 ) )?.Box.Rect} focused=[{Pad.Focused?.Classes}] menu=[{string.Join( \u0022 | \u0022, MenuOptions() )}]\u0022 \u002B \u0027\\n\u0027 \u002B\n\t\tstring.Join( \u0027\\n\u0027, Panel.Descendants.Where( p =\u003E p.HasClass( \u0022nav\u0022 ) ).Select( p =\u003E $\u0022  [{p.Classes}] visible={p.IsVisible} rect={p.Box.Rect}\u0022 ) );\n\n\t/// \u003Csummary\u003E For probes: move the highlight as the pad would (up, down, left, right, or \u0022a\u0022 to press it), and say where it went. \u003C/summary\u003E\n\tinternal string ProbeMove( string direction )\n\t{\n\t\tif ( direction == \u0022a\u0022 )\n\t\t{\n\t\t\tif ( Pad.Focused is { } focused ) PressControl( focused );\n\t\t\treturn $\u0022a on [{Pad.Focused?.Classes}]\u0022;\n\t\t}\n\n\t\tvar d = direction switch { \u0022up\u0022 =\u003E new Vector2( 0, -1 ), \u0022down\u0022 =\u003E new Vector2( 0, 1 ), \u0022left\u0022 =\u003E new Vector2( -1, 0 ), _ =\u003E new Vector2( 1, 0 ) };\n\t\tPad.ProbeStep( Panel, d );\n\t\treturn $\u0022{direction} -\u003E [{Pad.Focused?.Classes}] volume={MediaSettings.Volume:0.00}\u0022;\n\t}\n\n\tvoid RestoreVirtualCursor()\n\t{\n\t\tif ( virtualCursorWas is not { } was ) return;\n\t\tInput.EnableVirtualCursor = was;\n\t\tvirtualCursorWas = null;\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\tRestoreVirtualCursor();\n\t\tbase.OnDestroy();\n\t}\n\n\t/// \u003Csummary\u003E A on a control: a drop down opens its menu (navigated by PadNavigation), a button is clicked. Sliders only take left/right. \u003C/summary\u003E\n\tvoid PressControl( Panel control )\n\t{\n\t\tif ( control.HasClass( \u0022seek\u0022 ) || control.HasClass( \u0022volume\u0022 ) ) return;\n\n\t\tif ( control is DropDown dropDown )\n\t\t{\n\t\t\tdropDown.Open();\n\t\t\treturn;\n\t\t}\n\n\t\tcontrol.CreateEvent( new MousePanelEvent( \u0022onclick\u0022, control, \u0022mouseleft\u0022 ) );\n\t}\n\n\t/// \u003Csummary\u003E Left/right on a slider: seek by 10 s, volume by 5%. Anything else moves left/right. \u003C/summary\u003E\n\tbool AdjustControl( Panel control, int step )\n\t{\n\t\tif ( control.HasClass( \u0022seek\u0022 ) )\n\t\t{\n\t\t\tOnSeekChanged( Math.Clamp( SeekValue \u002B step * 10, 0, Target.MediaDuration ) );\n\t\t\treturn true;\n\t\t}\n\n\t\tif ( control.HasClass( \u0022volume\u0022 ) )\n\t\t{\n\t\t\tOnVolumeChanged( MathF.Round( (MediaSettings.Volume \u002B step * 0.05f) * 20 ) / 20 );\n\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( !Target.IsValid() )\n\t\t{\n\t\t\tClose();\n\t\t\treturn;\n\t\t}\n\n\t\tMenuStyle.Apply( Panel );\n\t\tFitToScreen();\n\n\t\tif ( Input.EscapePressed )\n\t\t{\n\t\t\tInput.EscapePressed = false;\n\t\t\tClose();\n\t\t\treturn;\n\t\t}\n\n\t\t// USE again closes it (not while typing - a focused text entry takes the keys)\n\t\tif ( sinceOpened \u003E 0.2f \u0026\u0026 Input.Pressed( \u0022use\u0022 ) \u0026\u0026 UseHandled.TryConsume() )\n\t\t{\n\t\t\tClose();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( Scene.Camera is not null \u0026\u0026 Scene.Camera.WorldPosition.Distance( Target.WorldPosition ) \u003E MaxDistance )\n\t\t{\n\t\t\tClose();\n\t\t\treturn;\n\t\t}\n\n\t\t// Seek once the slider has settled, rather than spamming the host while dragging\n\t\tif ( pendingSeek.HasValue \u0026\u0026 sincePendingSeek \u003E 0.3f )\n\t\t{\n\t\t\tTarget.RequestSeek( pendingSeek.Value );\n\t\t\tpendingSeek = null;\n\t\t}\n\t}\n\n\tvoid OnSeekChanged( float value )\n\t{\n\t\tif ( Target is null || !Target.CanLocalControl ) return;\n\t\tif ( MathF.Abs( value - Target.CurrentTime ) \u003C 1.5f \u0026\u0026 !pendingSeek.HasValue ) return; // our own refresh\n\n\t\tpendingSeek = value;\n\t\tsincePendingSeek = 0;\n\t}\n\n\t//\n\t// Resolution / audio track. These are this player\u0027s own preferences (saved convars), applied to every\n\t// media player - picking one reloads the local stream at the same synced time.\n\t//\n\n\tstring QualityValue =\u003E MediaSettings.Quality \u003C= 0 ? \u00220\u0022 : Target.SelectedQuality.ToString();\n\n\tList\u003COption\u003E BuildQualityOptions()\n\t{\n\t\tvar auto = $\u0022Auto (up to {MediaSettings.MaxVideoHeight}p)\u0022;\n\t\tvar list = new List\u003COption\u003E { new Option( auto, \u00220\u0022 ) };\n\t\tforeach ( var h in Target.AvailableQualities )\n\t\t\tlist.Add( new Option( MediaStreamOptions.QualityLabel( h ), h.ToString() ) );\n\t\treturn list;\n\t}\n\n\tvoid OnQualityChanged( string value )\n\t{\n\t\tif ( !int.TryParse( value, out var h ) ) return;\n\t\tif ( h == (MediaSettings.Quality \u003C= 0 ? 0 : Target.SelectedQuality) ) return;\n\t\tMediaSettings.Quality = h;\n\t}\n\n\tstring AudioTrackValue\n\t{\n\t\tget\n\t\t{\n\t\t\tvar selected = Target.SelectedAudioTrack ?? Target.AvailableAudioTracks.FirstOrDefault( t =\u003E t.IsDefault );\n\t\t\treturn selected?.Id ?? \u0022\u0022;\n\t\t}\n\t}\n\n\tList\u003COption\u003E BuildAudioTrackOptions()\n\t{\n\t\treturn Target.AvailableAudioTracks\n\t\t\t.Select( t =\u003E new Option( t.IsDefault \u0026\u0026 !t.Name.Contains( \u0022original\u0022, StringComparison.OrdinalIgnoreCase ) ? $\u0022{t.Name} (original)\u0022 : t.Name, t.Id ) )\n\t\t\t.ToList();\n\t}\n\n\tvoid OnAudioTrackChanged( string id )\n\t{\n\t\tif ( id == AudioTrackValue ) return;\n\t\tvar track = Target.AvailableAudioTracks.FirstOrDefault( t =\u003E t.Id == id );\n\t\t// picking the original clears the preference, so dubbed videos default to original again\n\t\tMediaSettings.AudioLanguage = track is null || track.IsDefault ? \u0022\u0022 : track.Id;\n\t}\n\n\t//\n\t// Captions: off, or a language. The choice is this player\u0027s own (saved convars) and applies to every media player.\n\t// Auto-generated tracks are only offered for languages without a written one - that\u0027s what picking a language gets.\n\t//\n\n\tIEnumerable\u003CMediaCaptionTrack\u003E ListedCaptionTracks\n\t\t=\u003E Target.AvailableCaptionTracks.Where( t =\u003E !t.IsAuto || !Target.AvailableCaptionTracks.Any( w =\u003E !w.IsAuto \u0026\u0026 w.Lang == t.Lang ) );\n\n\tstring CaptionValue =\u003E Target.SelectedCaptionTrack?.Id ?? \u0022\u0022;\n\n\tList\u003COption\u003E BuildCaptionOptions()\n\t{\n\t\tvar list = new List\u003COption\u003E { new Option( \u0022Captions off\u0022, \u0022\u0022 ) };\n\t\tforeach ( var t in ListedCaptionTracks )\n\t\t\tlist.Add( new Option( t.IsAuto \u0026\u0026 !t.Name.Contains( \u0022auto\u0022, StringComparison.OrdinalIgnoreCase ) ? $\u0022{t.Name} (auto-generated)\u0022 : t.Name, t.Id ) );\n\t\treturn list;\n\t}\n\n\tvoid OnCaptionChanged( string id )\n\t{\n\t\tif ( id == CaptionValue ) return;\n\n\t\tvar track = Target.AvailableCaptionTracks.FirstOrDefault( t =\u003E t.Id == id );\n\t\tMediaSettings.Subtitles = track is not null;\n\t\tif ( track is not null ) MediaSettings.SubtitleLanguage = track.Lang;\n\t}\n\n\tstring PlayingAsText\n\t{\n\t\tget\n\t\t{\n\t\t\tvar b = Target.Backend;\n\t\t\tif ( b is null || !b.Loaded ) return \u0022Loading...\u0022;\n\t\t\treturn b.HasVideo ? $\u0022Playing {b.Height}p\u0022 : \u0022\u0022;\n\t\t}\n\t}\n\n\tvoid OnVolumeChanged( float value )\n\t{\n\t\tMediaSettings.Volume = value.Clamp( 0, 1 );\n\t\tif ( value \u003E 0 ) MediaSettings.Mute = false;\n\t}\n\n\tvoid ToggleMute()\n\t{\n\t\tMediaSettings.Mute = !MediaSettings.Mute;\n\t}\n\n\t/// \u003Csummary\u003E 3D audio from every media player, or flat 2D for this player (bimp_spatial). \u003C/summary\u003E\n\tvoid ToggleSpatial()\n\t{\n\t\tMediaSettings.SpatialAudio = !MediaSettings.SpatialAudio;\n\t}\n\n\tstring TakeUrl()\n\t{\n\t\tvar text = UrlEntry?.Text?.Trim();\n\t\tif ( UrlEntry is not null ) UrlEntry.Text = \u0022\u0022;\n\t\treturn string.IsNullOrWhiteSpace( text ) ? null : text;\n\t}\n\n\tvoid OnSubmit()\n\t{\n\t\t// Enter plays right away if we can, otherwise queues\n\t\tif ( Target.CanLocalControl \u0026\u0026 !Target.HasMedia ) PlayNow();\n\t\telse if ( Target.CanLocalQueue ) AddToQueue();\n\t\telse PlayNow();\n\t}\n\n\tvoid PlayNow()\n\t{\n\t\tvar url = TakeUrl();\n\t\tif ( url is null ) return;\n\t\tTarget.RequestPlay( url );\n\t}\n\n\tvoid AddToQueue()\n\t{\n\t\tvar url = TakeUrl();\n\t\tif ( url is null ) return;\n\t\tTarget.RequestEnqueue( url );\n\t}\n\n\tprotected override int BuildHash()\n\t{\n\t\tif ( Target is null ) return 0;\n\n\t\treturn HashCode.Combine(\n\t\t\tHashCode.Combine( Target.PlayId, Target.Title, Target.Paused, Target.Status, Target.LocalNotice, (int)Target.CurrentTime, Target.MediaDuration ),\n\t\t\tHashCode.Combine( QueueHash(), MediaSettings.Mute, MediaSettings.Volume, MediaSettings.SpatialAudio, Target.CanLocalControl, pendingSeek, PadMode ),\n\t\t\tHashCode.Combine( Target.Qualities, Target.AudioTracks, MediaSettings.Quality, MediaSettings.AudioLanguage, PlayingAsText, ShowRecent, RecentLinks.Version ),\n\t\t\tHashCode.Combine( Target.CaptionTracks, MediaSettings.Subtitles, MediaSettings.SubtitleLanguage )\n\t\t);\n\t}\n\n\tint QueueHash()\n\t{\n\t\tvar h = new HashCode();\n\t\th.Add( Target.Queue.Count );\n\t\tforeach ( var item in Target.Queue ) h.Add( item?.Url );\n\t\treturn h.ToHashCode();\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"MediaSpeaker.razor","FileName":"MediaSpeaker.razor","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"@using System\n@using Sandbox\n@using Sandbox.UI\n@namespace Bimp\n@inherits PanelComponent\n@implements Component.ExecuteInEditor\n\n@*\n\tA small display for audio-only players (speakers, radios, jukeboxes).\n\tPut this on a GameObject with a WorldPanel (PanelSize ~800x400).\n*@\n\n\u003Croot class=\u0022mediaspeaker @(Player?.HasMedia == true ? \u0022playing\u0022 : \u0022idle\u0022)\u0022\u003E\n\n\t\u003Cdiv class=\u0022header\u0022\u003E\n\t\t\u003Ci\u003E@(Player?.HasMedia == true ? \u0022graphic_eq\u0022 : \u0022speaker\u0022)\u003C/i\u003E\n\t\t@if ( ShowUseHint )\n\t\t{\n\t\t\t\u003Cdiv class=\u0022title hint\u0022\u003E\u003Cspan\u003EPress\u003C/span\u003E\u003CInputGlyph Action=\u0022use\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003Eto play something\u003C/span\u003E\u003C/div\u003E\n\t\t}\n\t\telse\n\t\t{\n\t\t\t\u003CScrollingText class=\u0022title\u0022 Text=\u0022@TitleText\u0022\u003E\u003C/ScrollingText\u003E\n\t\t}\n\t\u003C/div\u003E\n\n\t\u003CSpectrumBars Player=\u0022@Player\u0022\u003E\u003C/SpectrumBars\u003E\n\n\t\u003Cdiv class=\u0022footer\u0022\u003E\n\t\t\u003Cdiv class=\u0022time\u0022\u003E@TimeText\u003C/div\u003E\n\t\t@if ( Player is not null \u0026\u0026 Player.Queue.Count \u003E 0 )\n\t\t{\n\t\t\t\u003Cdiv class=\u0022queue\u0022\u003E\u003Ci\u003Equeue_music\u003C/i\u003E @Player.Queue.Count\u003C/div\u003E\n\t\t}\n\t\u003C/div\u003E\n\n\t@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )\n\t{\n\t\t\u003Cdiv class=\u0022notice\u0022\u003E@Player.LocalNotice\u003C/div\u003E\n\t}\n\n\u003C/root\u003E\n\n@code\n{\n\t/// \u003Csummary\u003E\n\t/// The player this display shows. Found automatically in this object or its parents if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaPlayer Player { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// Scale the UI to fit the WorldPanel\u0027s PanelSize instead of getting cut off. Sets the WorldPanel\u0027s RenderScale.\n\t/// \u003C/summary\u003E\n\t[Property, Group( \u0022Layout\u0022 )] public bool FitToPanel { get; set; } = true;\n\n\t/// \u003Csummary\u003E\n\t/// The panel size the UI is designed for.\n\t/// \u003C/summary\u003E\n\t[Property, Group( \u0022Layout\u0022 ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );\n\n\tstring TitleText\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Player is null ) return \u0022\u0022;\n\t\t\tif ( Player.HasMedia ) return Player.Title;\n\t\t\tif ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;\n\t\t\treturn \u0022\u0022;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E Nothing playing or happening: show how to start something, with the USE button\u0027s glyph. \u003C/summary\u003E\n\tbool ShowUseHint =\u003E Player is not null \u0026\u0026 !Player.HasMedia \u0026\u0026 string.IsNullOrWhiteSpace( Player.Status );\n\n\tstring TimeText\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Player is null || !Player.HasMedia ) return \u0022\u0022;\n\t\t\tif ( Player.IsLive ) return \u0022Live\u0022;\n\t\t\tvar cur = MediaSource.FormatTime( Player.CurrentTime );\n\t\t\tvar s = Player.MediaDuration \u003E 0 ? $\u0022{cur} / {MediaSource.FormatTime( Player.MediaDuration )}\u0022 : cur;\n\t\t\treturn Player.Paused ? $\u0022{s}  (paused)\u0022 : s;\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( FitToPanel ) PanelFit.Apply( this, DesignSize );\n\t\tif ( Scene.IsEditor ) return;\n\n\t\tPlayer ??= GetComponentInParent\u003CMediaPlayer\u003E();\n\t}\n\n\tprotected override int BuildHash()\n\t{\n\t\tif ( Player is null ) return 0;\n\t\treturn HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Code/MediaInteract.cs","FileName":"MediaInteract.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp;\n\n/// \u003Csummary\u003E\n/// Lets players use the media player with the USE key (works with the engine\u0027s \u003Csee cref=\u0022PlayerController\u0022/\u003E,\n/// or anything else that calls \u003Csee cref=\u0022Component.IPressable\u0022/\u003E). Needs a collider on this GameObject or a child.\n/// \u003Cpara\u003E\n/// Aiming at one of the screen\u0027s on-screen buttons while the controls are showing presses that button.\n/// Anywhere else (or while the controls are hidden) opens the full remote.\n/// \u003C/para\u003E\n/// \u003C/summary\u003E\n[Title( \u0022Media Player Interact\u0022 ), Category( \u0022Media\u0022 ), Icon( \u0022touch_app\u0022 )]\npublic sealed class MediaInteract : Component, Component.IPressable\n{\n\t/// \u003Csummary\u003E\n\t/// The player to control. Found automatically in this object or its parents if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaPlayer Player { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// The screen whose on-screen buttons USE can press. Found automatically in this object or its children if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaScreen Screen { get; set; }\n\n\tMediaPlayer Target =\u003E Player.IsValid() ? Player : (Player = GetComponentInParent\u003CMediaPlayer\u003E());\n\tMediaScreen TargetScreen =\u003E Screen.IsValid() ? Screen : (Screen = GetComponentInChildren\u003CMediaScreen\u003E());\n\n\tpublic bool CanPress( IPressable.Event e ) =\u003E Target.IsValid();\n\n\tpublic bool Press( IPressable.Event e )\n\t{\n\t\tif ( !Target.IsValid() ) return false;\n\n\t\t// The screen may already have handled this USE press itself this frame\n\t\tif ( !UseHandled.TryConsume() ) return true;\n\n\t\t// Press runs on the client that pressed, so this only affects them (button presses go through the\n\t\t// player\u0027s permission-checked host requests)\n\t\tvar screen = TargetScreen;\n\t\tif ( screen.IsValid() \u0026\u0026 screen.TryPressAimedControl() )\n\t\t\treturn true;\n\n\t\tMediaRemote.Toggle( Target );\n\t\treturn true;\n\t}\n\n\tpublic IPressable.Tooltip? GetTooltip( IPressable.Event e )\n\t{\n\t\tif ( !Target.IsValid() ) return null;\n\n\t\tvar title = Target.HasMedia ? Target.Title : \u0022Media Player\u0022;\n\t\tvar icon = Target.AudioOnlyPlayer ? \u0022speaker\u0022 : \u0022smart_display\u0022;\n\n\t\tvar action = TargetScreen.IsValid() ? TargetScreen.AimedActionName : null;\n\t\tif ( action is not null )\n\t\t\treturn new IPressable.Tooltip( action, \u0022touch_app\u0022, title );\n\n\t\treturn new IPressable.Tooltip( title, icon, \u0022Open controls\u0022 );\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Code/MediaSpeaker.razor","FileName":"MediaSpeaker.razor","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"@using System\n@using Sandbox\n@using Sandbox.UI\n@namespace Bimp\n@inherits PanelComponent\n@implements Component.ExecuteInEditor\n\n@*\n\tA small display for audio-only players (speakers, radios, jukeboxes).\n\tPut this on a GameObject with a WorldPanel (PanelSize ~800x400).\n*@\n\n\u003Croot class=\u0022mediaspeaker @(Player?.HasMedia == true ? \u0022playing\u0022 : \u0022idle\u0022)\u0022\u003E\n\n\t\u003Cdiv class=\u0022header\u0022\u003E\n\t\t\u003Ci\u003E@(Player?.HasMedia == true ? \u0022graphic_eq\u0022 : \u0022speaker\u0022)\u003C/i\u003E\n\t\t@if ( ShowUseHint )\n\t\t{\n\t\t\t\u003Cdiv class=\u0022title hint\u0022\u003E\u003Cspan\u003EPress\u003C/span\u003E\u003CInputGlyph Action=\u0022use\u0022\u003E\u003C/InputGlyph\u003E\u003Cspan\u003Eto play something\u003C/span\u003E\u003C/div\u003E\n\t\t}\n\t\telse\n\t\t{\n\t\t\t\u003CScrollingText class=\u0022title\u0022 Text=\u0022@TitleText\u0022\u003E\u003C/ScrollingText\u003E\n\t\t}\n\t\u003C/div\u003E\n\n\t\u003CSpectrumBars Player=\u0022@Player\u0022\u003E\u003C/SpectrumBars\u003E\n\n\t\u003Cdiv class=\u0022footer\u0022\u003E\n\t\t\u003Cdiv class=\u0022time\u0022\u003E@TimeText\u003C/div\u003E\n\t\t@if ( Player is not null \u0026\u0026 Player.Queue.Count \u003E 0 )\n\t\t{\n\t\t\t\u003Cdiv class=\u0022queue\u0022\u003E\u003Ci\u003Equeue_music\u003C/i\u003E @Player.Queue.Count\u003C/div\u003E\n\t\t}\n\t\u003C/div\u003E\n\n\t@if ( !string.IsNullOrWhiteSpace( Player?.LocalNotice ) )\n\t{\n\t\t\u003Cdiv class=\u0022notice\u0022\u003E@Player.LocalNotice\u003C/div\u003E\n\t}\n\n\u003C/root\u003E\n\n@code\n{\n\t/// \u003Csummary\u003E\n\t/// The player this display shows. Found automatically in this object or its parents if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaPlayer Player { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// Scale the UI to fit the WorldPanel\u0027s PanelSize instead of getting cut off. Sets the WorldPanel\u0027s RenderScale.\n\t/// \u003C/summary\u003E\n\t[Property, Group( \u0022Layout\u0022 )] public bool FitToPanel { get; set; } = true;\n\n\t/// \u003Csummary\u003E\n\t/// The panel size the UI is designed for.\n\t/// \u003C/summary\u003E\n\t[Property, Group( \u0022Layout\u0022 ), ShowIf( nameof( FitToPanel ), true )] public Vector2 DesignSize { get; set; } = new Vector2( 560, 280 );\n\n\tstring TitleText\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Player is null ) return \u0022\u0022;\n\t\t\tif ( Player.HasMedia ) return Player.Title;\n\t\t\tif ( !string.IsNullOrWhiteSpace( Player.Status ) ) return Player.Status;\n\t\t\treturn \u0022\u0022;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E Nothing playing or happening: show how to start something, with the USE button\u0027s glyph. \u003C/summary\u003E\n\tbool ShowUseHint =\u003E Player is not null \u0026\u0026 !Player.HasMedia \u0026\u0026 string.IsNullOrWhiteSpace( Player.Status );\n\n\tstring TimeText\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Player is null || !Player.HasMedia ) return \u0022\u0022;\n\t\t\tif ( Player.IsLive ) return \u0022Live\u0022;\n\t\t\tvar cur = MediaSource.FormatTime( Player.CurrentTime );\n\t\t\tvar s = Player.MediaDuration \u003E 0 ? $\u0022{cur} / {MediaSource.FormatTime( Player.MediaDuration )}\u0022 : cur;\n\t\t\treturn Player.Paused ? $\u0022{s}  (paused)\u0022 : s;\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( FitToPanel ) PanelFit.Apply( this, DesignSize );\n\t\tif ( Scene.IsEditor ) return;\n\n\t\tPlayer ??= GetComponentInParent\u003CMediaPlayer\u003E();\n\t}\n\n\tprotected override int BuildHash()\n\t{\n\t\tif ( Player is null ) return 0;\n\t\treturn HashCode.Combine( Player.PlayId, Player.Title, Player.Status, Player.Paused, (int)Player.CurrentTime, Player.Queue.Count, Player.LocalNotice );\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Code/MediaSpeaker.razor.scss","FileName":"MediaSpeaker.razor.scss","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":".mediaspeaker\n{\n\twidth: 100%;\n\theight: 100%;\n\tflex-direction: column;\n\tpadding: 28px 36px;\n\tborder-radius: 24px;\n\tbackground: linear-gradient( to bottom, #151a24, #07090d );\n\tfont-family: Poppins;\n\tcolor: white;\n\tposition: relative;\n\n\ti\n\t{\n\t\tfont-family: \u0022Material Icons\u0022;\n\t}\n\n\t.header\n\t{\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t\tgap: 16px;\n\n\t\ti\n\t\t{\n\t\t\tfont-size: 56px;\n\t\t\tcolor: #3fa9ff;\n\t\t}\n\n\t\t.title\n\t\t{\n\t\t\tflex-grow: 1;\n\t\t\tflex-basis: 0;\n\t\t\tfont-size: 40px;\n\t\t\tfont-weight: 700;\n\t\t}\n\n\t\t.hint\n\t\t{\n\t\t\tflex-direction: row;\n\t\t\talign-items: center;\n\t\t\tgap: 10px;\n\t\t\tfont-size: 28px;\n\t\t\tfont-weight: 600;\n\t\t\topacity: 0.8;\n\t\t\twhite-space: nowrap;\n\n\t\t\tspan\n\t\t\t{\n\t\t\t\tflex-shrink: 0;\n\t\t\t}\n\t\t}\n\t}\n\n\t.spectrumbars\n\t{\n\t\tflex-grow: 1;\n\t\tmargin: 20px 0;\n\t}\n\n\t.footer\n\t{\n\t\tflex-direction: row;\n\t\tfont-size: 28px;\n\t\topacity: 0.75;\n\n\t\t.time\n\t\t{\n\t\t\tflex-grow: 1;\n\t\t\t// every digit the same width, so it doesn\u0027t jiggle as it counts (Poppins has no tabular digits, Inter does)\n\t\t\tfont-family: Inter;\n\t\t\tfont-variant-numeric: tabular-nums;\n\t\t}\n\n\t\t.queue\n\t\t{\n\t\t\talign-items: center;\n\t\t\tgap: 6px;\n\t\t}\n\t}\n\n\t.notice\n\t{\n\t\tposition: absolute;\n\t\tleft: 20px;\n\t\tright: 20px;\n\t\tbottom: 20px;\n\t\tpadding: 12px 20px;\n\t\tborder-radius: 12px;\n\t\tbackground-color: rgba( 180, 40, 40, 0.92 );\n\t\tfont-size: 26px;\n\t}\n\n\t\u0026.idle .spectrumbars\n\t{\n\t\topacity: 0.3;\n\t}\n}\n\n.spectrumbars\n{\n\tflex-direction: row;\n\talign-items: flex-end;\n\tgap: 6px;\n\n\t.bar\n\t{\n\t\tflex-grow: 1;\n\t\theight: 3%;\n\t\tborder-radius: 4px 4px 0 0;\n\t\tbackground: linear-gradient( to top, #3fa9ff, #a36bff );\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Code/Resolver/Live/AacDecoder.cs","FileName":"AacDecoder.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System;\r\n\r\nnamespace Bimp.Resolver.Live;\r\n\r\n/// \u003Csummary\u003E\r\n/// AAC-LC decoder (ISO/IEC 14496-3 GA, 1024-sample frames): one raw access unit in, interleaved 16-bit PCM out.\r\n/// The engine only decodes AAC inside finished MP4 files, which ties live audio to the segment latency - this\r\n/// lets low-latency live streams play their AAC beside the video, like G.711 (see \u003Csee cref=\u0022LivePcmAudio\u0022/\u003E).\r\n/// \u003Cpara\u003E\r\n/// Supports SCE/CPE/LFE, section and scalefactor data, pulse data, M/S and intensity stereo, PNS, TNS, and the\r\n/// long / start / eight-short / stop windows with sine and KBD shapes. HE-AAC decodes its AAC-LC core (half\r\n/// band, SBR ignored). Main/LTP prediction and coupling channels aren\u0027t supported. Output is mono or stereo\r\n/// (anything with more channels is folded down to its front three).\r\n/// \u003C/para\u003E\r\n/// \u003C/summary\u003E\r\npublic sealed class AacDecoder\r\n{\r\n\tconst int ZeroHcb = 0, EscHcb = 11, NoiseHcb = 13, IntensityHcb2 = 14, IntensityHcb = 15;\r\n\tconst int OnlyLong = 0, LongStart = 1, EightShort = 2, LongStop = 3;\r\n\r\n\tpublic int SampleRate { get; }\r\n\r\n\t/// \u003Csummary\u003E Output channels: 1 or 2. \u003C/summary\u003E\r\n\tpublic int Channels { get; }\r\n\r\n\treadonly int rateIndex;\r\n\treadonly short[] swbLong, swbShort;\r\n\treadonly int numSwbLong, numSwbShort;\r\n\r\n\t// per channel state: the second half of the last frame, and its window shape\r\n\treadonly float[][] overlap = { new float[1024], new float[1024], new float[1024], new float[1024] };\r\n\treadonly int[] previousShape = new int[4];\r\n\r\n\t// scratch\r\n\treadonly float[][] spectrum = { new float[1024], new float[1024] };\r\n\treadonly float[] timeBuffer = new float[2048];\r\n\treadonly float[][] pcm = { new float[1024], new float[1024], new float[1024], new float[1024] };\r\n\tuint noiseState = 0x1f2e3d4c;\r\n\treadonly Ics icsA = new(), icsB = new();\r\n\r\n\tpublic AacDecoder( AacConfig config )\r\n\t{\r\n\t\tvar asc = config.Asc;\r\n\t\tvar r = new BitReader( asc );\r\n\t\tvar objectType = ReadObjectType( r );\r\n\t\tvar index = r.Read( 4 );\r\n\t\tvar rate = index == 15 ? r.Read( 24 ) : AacConfig.RateOf( index );\r\n\t\tvar channelConfig = r.Read( 4 );\r\n\r\n\t\t// HE-AAC signalled explicitly: the core is described after the SBR rate\r\n\t\tif ( objectType is 5 or 29 )\r\n\t\t{\r\n\t\t\tvar extIndex = r.Read( 4 );\r\n\t\t\tif ( extIndex == 15 ) r.Read( 24 );\r\n\t\t\tobjectType = ReadObjectType( r );\r\n\t\t}\r\n\t\tif ( objectType != 2 ) throw new NotSupportedException( $\u0022AAC object type {objectType} (only AAC-LC)\u0022 );\r\n\t\tif ( r.Read( 1 ) == 1 ) throw new NotSupportedException( \u0022960-sample AAC frames\u0022 );\r\n\r\n\t\trateIndex = index == 15 ? NearestRateIndex( rate ) : index;\r\n\t\tSampleRate = rate \u003E 0 ? rate : AacConfig.RateOf( rateIndex );\r\n\t\tChannels = channelConfig == 1 ? 1 : 2;\r\n\r\n\t\t(swbLong, swbShort) = rateIndex switch\r\n\t\t{\r\n\t\t\t0 or 1 =\u003E (AacTables.Sfb96_1024, AacTables.Sfb96_128),\r\n\t\t\t2 =\u003E (AacTables.Sfb64_1024, AacTables.Sfb64_128),\r\n\t\t\t3 or 4 =\u003E (AacTables.Sfb48_1024, AacTables.Sfb48_128),\r\n\t\t\t5 =\u003E (AacTables.Sfb32_1024, AacTables.Sfb48_128),\r\n\t\t\t6 or 7 =\u003E (AacTables.Sfb24_1024, AacTables.Sfb24_128),\r\n\t\t\t8 or 9 or 10 =\u003E (AacTables.Sfb16_1024, AacTables.Sfb16_128),\r\n\t\t\t_ =\u003E (AacTables.Sfb8_1024, AacTables.Sfb8_128),\r\n\t\t};\r\n\t\tnumSwbLong = swbLong.Length - 1;\r\n\t\tnumSwbShort = swbShort.Length - 1;\r\n\t}\r\n\r\n\tstatic int ReadObjectType( BitReader r )\r\n\t{\r\n\t\tvar t = r.Read( 5 );\r\n\t\treturn t == 31 ? 32 \u002B r.Read( 6 ) : t;\r\n\t}\r\n\r\n\tstatic int NearestRateIndex( int rate )\r\n\t{\r\n\t\tint best = 3, bestDiff = int.MaxValue;\r\n\t\tfor ( int i = 0; i \u003C 12; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar d = Math.Abs( AacConfig.RateOf( i ) - rate );\r\n\t\t\tif ( d \u003C bestDiff ) { best = i; bestDiff = d; }\r\n\t\t}\r\n\t\treturn best;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Decode one access unit (a raw_data_block) to 1024 interleaved samples per channel. Null if the frame can\u0027t be\r\n\t/// decoded (it\u0027s skipped, the next one starts clean enough).\r\n\t/// \u003C/summary\u003E\r\n\tpublic short[] Decode( byte[] frame )\r\n\t{\r\n\t\tvar r = new BitReader( frame );\r\n\t\tvar outputs = 0; // decoded channels, in pcm[]\r\n\t\tint centre = -1, left = -1, right = -1;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\twhile ( r.BitsLeft \u003E= 3 )\r\n\t\t\t{\r\n\t\t\t\tvar id = r.Read( 3 );\r\n\t\t\t\tif ( id == 7 ) break; // END\r\n\r\n\t\t\t\tswitch ( id )\r\n\t\t\t\t{\r\n\t\t\t\t\tcase 0: // SCE\r\n\t\t\t\t\tcase 3: // LFE\r\n\t\t\t\t\t\tr.Read( 4 );\r\n\t\t\t\t\t\tif ( outputs \u003C 4 )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar ics = icsA;\r\n\t\t\t\t\t\t\tDecodeIcs( r, ics, false );\r\n\t\t\t\t\t\t\tReconstruct( ics, spectrum[0] );\r\n\t\t\t\t\t\t\tif ( id == 0 \u0026\u0026 centre \u003C 0 \u0026\u0026 left \u003C 0 ) centre = outputs;\r\n\t\t\t\t\t\t\tSynthesize( ics, spectrum[0], outputs\u002B\u002B );\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t\telse SkipIcs( r );\r\n\t\t\t\t\t\tbreak;\r\n\r\n\t\t\t\t\tcase 1: // CPE\r\n\t\t\t\t\t\tr.Read( 4 );\r\n\t\t\t\t\t\tDecodeCpe( r, outputs );\r\n\t\t\t\t\t\tif ( left \u003C 0 ) { left = outputs; right = outputs \u002B 1; }\r\n\t\t\t\t\t\toutputs \u002B= 2;\r\n\t\t\t\t\t\tbreak;\r\n\r\n\t\t\t\t\tcase 4: // DSE\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tr.Read( 4 );\r\n\t\t\t\t\t\t\tvar align = r.Read( 1 );\r\n\t\t\t\t\t\t\tvar count = r.Read( 8 );\r\n\t\t\t\t\t\t\tif ( count == 255 ) count \u002B= r.Read( 8 );\r\n\t\t\t\t\t\t\tif ( align == 1 ) r.ByteAlign();\r\n\t\t\t\t\t\t\tr.Skip( count * 8 );\r\n\t\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\tcase 5: // PCE\r\n\t\t\t\t\t\tSkipPce( r );\r\n\t\t\t\t\t\tbreak;\r\n\r\n\t\t\t\t\tcase 6: // FIL (SBR and other extensions: ignored)\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar count = r.Read( 4 );\r\n\t\t\t\t\t\t\tif ( count == 15 ) count \u002B= r.Read( 8 ) - 1;\r\n\t\t\t\t\t\t\tr.Skip( count * 8 );\r\n\t\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\tdefault: // CCE: coupling isn\u0027t supported\r\n\t\t\t\t\t\treturn null;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( IndexOutOfRangeException )\r\n\t\t{\r\n\t\t\treturn null; // truncated or corrupt frame\r\n\t\t}\r\n\r\n\t\tif ( outputs == 0 ) return null;\r\n\r\n\t\tvar result = new short[1024 * Channels];\r\n\t\tif ( Channels == 1 )\r\n\t\t{\r\n\t\t\tvar src = pcm[centre \u003E= 0 ? centre : 0];\r\n\t\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B ) result[i] = Clip( src[i] );\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tif ( left \u003C 0 )\r\n\t\t{\r\n\t\t\t// a mono stream without channel config 1: both sides the same\r\n\t\t\tvar src = pcm[centre \u003E= 0 ? centre : 0];\r\n\t\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B ) result[2 * i] = result[2 * i \u002B 1] = Clip( src[i] );\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tvar l = pcm[left];\r\n\t\tvar rr = pcm[right];\r\n\t\tvar c = centre \u003E= 0 ? pcm[centre] : null;\r\n\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar cc = c is null ? 0 : c[i] * 0.7071f;\r\n\t\t\tresult[2 * i] = Clip( l[i] \u002B cc );\r\n\t\t\tresult[2 * i \u002B 1] = Clip( rr[i] \u002B cc );\r\n\t\t}\r\n\t\treturn result;\r\n\t}\r\n\r\n\tstatic short Clip( float v ) =\u003E (short)Math.Clamp( (int)MathF.Round( v ), short.MinValue, short.MaxValue );\r\n\r\n\t//\r\n\t// bitstream\r\n\t//\r\n\r\n\tsealed class Ics\r\n\t{\r\n\t\tpublic int GlobalGain;\r\n\t\tpublic int WindowSequence, WindowShape, MaxSfb;\r\n\t\tpublic int NumWindows, NumWindowGroups;\r\n\t\tpublic readonly int[] WindowGroupLength = new int[8];\r\n\t\tpublic readonly int[] SfbCb = new int[8 * 64];\r\n\t\tpublic readonly int[] ScaleFactors = new int[8 * 64];\r\n\t\tpublic bool PulsePresent;\r\n\t\tpublic int PulseCount, PulseStartSfb;\r\n\t\tpublic readonly int[] PulseOffset = new int[4], PulseAmp = new int[4];\r\n\t\tpublic bool TnsPresent;\r\n\t\tpublic readonly int[] TnsFilters = new int[8];\r\n\t\tpublic readonly int[] TnsCoefRes = new int[8];\r\n\t\tpublic readonly int[,] TnsLength = new int[8, 4], TnsOrder = new int[8, 4], TnsDirection = new int[8, 4];\r\n\t\tpublic readonly float[,,] TnsLpc = new float[8, 4, 32];\r\n\t\tpublic readonly int[] Quant = new int[1024];\r\n\t\tpublic bool IsLong =\u003E WindowSequence != EightShort;\r\n\t}\r\n\r\n\tvoid ReadIcsInfo( BitReader r, Ics ics )\r\n\t{\r\n\t\tr.Read( 1 ); // ics_reserved_bit\r\n\t\tics.WindowSequence = r.Read( 2 );\r\n\t\tics.WindowShape = r.Read( 1 );\r\n\t\tif ( ics.WindowSequence == EightShort )\r\n\t\t{\r\n\t\t\tics.MaxSfb = r.Read( 4 );\r\n\t\t\tvar grouping = r.Read( 7 );\r\n\t\t\tics.NumWindows = 8;\r\n\t\t\tics.NumWindowGroups = 1;\r\n\t\t\tics.WindowGroupLength[0] = 1;\r\n\t\t\tfor ( int i = 0; i \u003C 7; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tif ( (grouping \u0026 (1 \u003C\u003C (6 - i))) != 0 ) ics.WindowGroupLength[ics.NumWindowGroups - 1]\u002B\u002B;\r\n\t\t\t\telse ics.WindowGroupLength[ics.NumWindowGroups\u002B\u002B] = 1;\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tics.MaxSfb = r.Read( 6 );\r\n\t\t\tics.NumWindows = 1;\r\n\t\t\tics.NumWindowGroups = 1;\r\n\t\t\tics.WindowGroupLength[0] = 1;\r\n\t\t\tif ( r.Read( 1 ) == 1 ) throw new IndexOutOfRangeException( \u0022prediction\u0022 ); // AAC Main only\r\n\t\t}\r\n\t\tvar numSwb = ics.IsLong ? numSwbLong : numSwbShort;\r\n\t\tif ( ics.MaxSfb \u003E numSwb ) throw new IndexOutOfRangeException( \u0022max_sfb\u0022 );\r\n\t}\r\n\r\n\tvoid DecodeIcs( BitReader r, Ics ics, bool commonWindow )\r\n\t{\r\n\t\tics.GlobalGain = r.Read( 8 );\r\n\t\tif ( !commonWindow ) ReadIcsInfo( r, ics );\r\n\t\tReadSections( r, ics );\r\n\t\tReadScaleFactors( r, ics );\r\n\r\n\t\tics.PulsePresent = r.Read( 1 ) == 1;\r\n\t\tif ( ics.PulsePresent )\r\n\t\t{\r\n\t\t\tif ( !ics.IsLong ) throw new IndexOutOfRangeException( \u0022pulse in short window\u0022 );\r\n\t\t\tics.PulseCount = r.Read( 2 ) \u002B 1;\r\n\t\t\tics.PulseStartSfb = r.Read( 6 );\r\n\t\t\tfor ( int i = 0; i \u003C ics.PulseCount; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tics.PulseOffset[i] = r.Read( 5 );\r\n\t\t\t\tics.PulseAmp[i] = r.Read( 4 );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tics.TnsPresent = r.Read( 1 ) == 1;\r\n\t\tif ( ics.TnsPresent ) ReadTns( r, ics );\r\n\r\n\t\tif ( r.Read( 1 ) == 1 ) throw new IndexOutOfRangeException( \u0022gain control\u0022 ); // AAC SSR only\r\n\r\n\t\tReadSpectrum( r, ics );\r\n\t}\r\n\r\n\tvoid ReadSections( BitReader r, Ics ics )\r\n\t{\r\n\t\tvar bits = ics.IsLong ? 5 : 3;\r\n\t\tvar escape = (1 \u003C\u003C bits) - 1;\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar k = 0;\r\n\t\t\twhile ( k \u003C ics.MaxSfb )\r\n\t\t\t{\r\n\t\t\t\tvar cb = r.Read( 4 );\r\n\t\t\t\tif ( cb == 12 ) throw new IndexOutOfRangeException( \u0022reserved codebook\u0022 );\r\n\t\t\t\tvar length = 0;\r\n\t\t\t\tint incr;\r\n\t\t\t\twhile ( (incr = r.Read( bits )) == escape ) length \u002B= escape;\r\n\t\t\t\tlength \u002B= incr;\r\n\t\t\t\tif ( k \u002B length \u003E ics.MaxSfb ) throw new IndexOutOfRangeException( \u0022section\u0022 );\r\n\t\t\t\tfor ( int s = k; s \u003C k \u002B length; s\u002B\u002B ) ics.SfbCb[g * 64 \u002B s] = cb;\r\n\t\t\t\tk \u002B= length;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ReadScaleFactors( BitReader r, Ics ics )\r\n\t{\r\n\t\tvar scaleFactor = ics.GlobalGain;\r\n\t\tvar isPosition = 0;\r\n\t\tvar noiseEnergy = ics.GlobalGain - 90;\r\n\t\tvar noisePcm = true;\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int sfb = 0; sfb \u003C ics.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar i = g * 64 \u002B sfb;\r\n\t\t\t\tswitch ( ics.SfbCb[i] )\r\n\t\t\t\t{\r\n\t\t\t\t\tcase ZeroHcb:\r\n\t\t\t\t\t\tics.ScaleFactors[i] = 0;\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\tcase IntensityHcb:\r\n\t\t\t\t\tcase IntensityHcb2:\r\n\t\t\t\t\t\tisPosition \u002B= Huffman.ScaleFactor.Decode( r ) - 60;\r\n\t\t\t\t\t\tics.ScaleFactors[i] = isPosition;\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\tcase NoiseHcb:\r\n\t\t\t\t\t\tif ( noisePcm ) { noisePcm = false; noiseEnergy \u002B= r.Read( 9 ) - 256; }\r\n\t\t\t\t\t\telse noiseEnergy \u002B= Huffman.ScaleFactor.Decode( r ) - 60;\r\n\t\t\t\t\t\tics.ScaleFactors[i] = noiseEnergy;\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\tdefault:\r\n\t\t\t\t\t\tscaleFactor \u002B= Huffman.ScaleFactor.Decode( r ) - 60;\r\n\t\t\t\t\t\tics.ScaleFactors[i] = scaleFactor;\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ReadTns( BitReader r, Ics ics )\r\n\t{\r\n\t\tvar isLong = ics.IsLong;\r\n\t\tSpan\u003Cfloat\u003E parcor = stackalloc float[32];\r\n\t\tSpan\u003Cfloat\u003E a = stackalloc float[33];\r\n\t\tSpan\u003Cfloat\u003E b = stackalloc float[33];\r\n\t\tfor ( int w = 0; w \u003C ics.NumWindows; w\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar filters = ics.TnsFilters[w] = r.Read( isLong ? 2 : 1 );\r\n\t\t\tif ( filters == 0 ) continue;\r\n\t\t\tvar coefRes = ics.TnsCoefRes[w] = r.Read( 1 );\r\n\t\t\tfor ( int f = 0; f \u003C filters; f\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tics.TnsLength[w, f] = r.Read( isLong ? 6 : 4 );\r\n\t\t\t\tvar order = ics.TnsOrder[w, f] = r.Read( isLong ? 5 : 3 );\r\n\t\t\t\tif ( order == 0 ) continue;\r\n\t\t\t\tics.TnsDirection[w, f] = r.Read( 1 );\r\n\t\t\t\tvar compress = r.Read( 1 );\r\n\t\t\t\tvar coefBits = coefRes \u002B 3 - compress;\r\n\r\n\t\t\t\t// the coefficients are quantized reflection coefficients: back to a direct form LPC\r\n\t\t\t\tvar iqfac = ((1 \u003C\u003C (coefRes \u002B 3 - 1)) - 0.5f) / (MathF.PI / 2);\r\n\t\t\t\tvar iqfacM = ((1 \u003C\u003C (coefRes \u002B 3 - 1)) \u002B 0.5f) / (MathF.PI / 2);\r\n\t\t\t\tfor ( int i = 0; i \u003C order; i\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar v = r.Read( coefBits );\r\n\t\t\t\t\tif ( (v \u0026 (1 \u003C\u003C (coefBits - 1))) != 0 ) v -= 1 \u003C\u003C coefBits;\r\n\t\t\t\t\tparcor[i] = MathF.Sin( v / (v \u003E= 0 ? iqfac : iqfacM) );\r\n\t\t\t\t}\r\n\r\n\t\t\t\ta.Clear();\r\n\t\t\t\ta[0] = 1;\r\n\t\t\t\tfor ( int m = 1; m \u003C= order; m\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tfor ( int i = 1; i \u003C m; i\u002B\u002B ) b[i] = a[i] \u002B parcor[m - 1] * a[m - i];\r\n\t\t\t\t\tfor ( int i = 1; i \u003C m; i\u002B\u002B ) a[i] = b[i];\r\n\t\t\t\t\ta[m] = parcor[m - 1];\r\n\t\t\t\t}\r\n\t\t\t\tfor ( int i = 0; i \u003C= order; i\u002B\u002B ) ics.TnsLpc[w, f, i] = a[i];\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ReadSpectrum( BitReader r, Ics ics )\r\n\t{\r\n\t\tArray.Clear( ics.Quant );\r\n\t\tvar swb = ics.IsLong ? swbLong : swbShort;\r\n\t\tvar groupStart = 0; // first window of the group\r\n\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar groupLength = ics.WindowGroupLength[g];\r\n\t\t\tfor ( int sfb = 0; sfb \u003C ics.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar cb = ics.SfbCb[g * 64 \u002B sfb];\r\n\t\t\t\tif ( cb is ZeroHcb or NoiseHcb or IntensityHcb or IntensityHcb2 ) continue;\r\n\t\t\t\tvar book = Huffman.Spectral[cb];\r\n\t\t\t\tvar width = swb[sfb \u002B 1] - swb[sfb];\r\n\r\n\t\t\t\t// within a group, a band\u0027s coefficients come window by window\r\n\t\t\t\tfor ( int w = 0; w \u003C groupLength; w\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar at = (groupStart \u002B w) * 128 \u002B swb[sfb];\r\n\t\t\t\t\tif ( ics.IsLong ) at = swb[sfb];\r\n\t\t\t\t\tfor ( int k = 0; k \u003C width; k \u002B= book.Dimension )\r\n\t\t\t\t\t\tbook.DecodeValues( r, ics.Quant, at \u002B k );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tgroupStart \u002B= groupLength;\r\n\t\t}\r\n\r\n\t\tif ( ics.PulsePresent )\r\n\t\t{\r\n\t\t\tint k = swbLong[Math.Min( ics.PulseStartSfb, numSwbLong )];\r\n\t\t\tfor ( int i = 0; i \u003C ics.PulseCount; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tk \u002B= ics.PulseOffset[i];\r\n\t\t\t\tif ( k \u003E= 1024 ) break;\r\n\t\t\t\tics.Quant[k] \u002B= ics.Quant[k] \u003E 0 ? ics.PulseAmp[i] : -ics.PulseAmp[i];\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid SkipIcs( BitReader r )\r\n\t{\r\n\t\t// an extra channel we won\u0027t play still has to be read through\r\n\t\tDecodeIcs( r, icsB, false );\r\n\t}\r\n\r\n\tvoid DecodeCpe( BitReader r, int outputs )\r\n\t{\r\n\t\tvar a = icsA;\r\n\t\tvar b = icsB;\r\n\t\tvar commonWindow = r.Read( 1 ) == 1;\r\n\t\tvar msMask = 0;\r\n\t\tSpan\u003Cbool\u003E msUsed = stackalloc bool[8 * 64];\r\n\t\tif ( commonWindow )\r\n\t\t{\r\n\t\t\tReadIcsInfo( r, a );\r\n\t\t\tCopyInfo( a, b );\r\n\t\t\tmsMask = r.Read( 2 );\r\n\t\t\tif ( msMask == 1 )\r\n\t\t\t\tfor ( int g = 0; g \u003C a.NumWindowGroups; g\u002B\u002B )\r\n\t\t\t\t\tfor ( int sfb = 0; sfb \u003C a.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t\t\t\tmsUsed[g * 64 \u002B sfb] = r.Read( 1 ) == 1;\r\n\t\t\telse if ( msMask == 2 ) msUsed.Fill( true );\r\n\t\t}\r\n\t\tDecodeIcs( r, a, commonWindow );\r\n\t\tDecodeIcs( r, b, commonWindow );\r\n\t\tif ( outputs \u002B 1 \u003E= 4 ) return;\r\n\r\n\t\tvar l = spectrum[0];\r\n\t\tvar rr = spectrum[1];\r\n\t\tDequantize( a, l );\r\n\t\tDequantize( b, rr );\r\n\r\n\t\t// PNS: a band that\u0027s noise in both channels with M/S on gets the same noise in both\r\n\t\tNoise( a, l, null, Span\u003Cbool\u003E.Empty );\r\n\t\tNoise( b, rr, commonWindow \u0026\u0026 msMask \u003E 0 ? a : null, commonWindow ? msUsed : Span\u003Cbool\u003E.Empty, l );\r\n\r\n\t\tif ( commonWindow \u0026\u0026 msMask \u003E 0 ) MidSide( a, b, l, rr, msUsed );\r\n\t\tIntensity( b, l, rr, msUsed, msMask );\r\n\r\n\t\tTns( a, l );\r\n\t\tTns( b, rr );\r\n\t\tSynthesize( a, l, outputs );\r\n\t\tSynthesize( b, rr, outputs \u002B 1 );\r\n\t}\r\n\r\n\tstatic void CopyInfo( Ics from, Ics to )\r\n\t{\r\n\t\tto.WindowSequence = from.WindowSequence;\r\n\t\tto.WindowShape = from.WindowShape;\r\n\t\tto.MaxSfb = from.MaxSfb;\r\n\t\tto.NumWindows = from.NumWindows;\r\n\t\tto.NumWindowGroups = from.NumWindowGroups;\r\n\t\tArray.Copy( from.WindowGroupLength, to.WindowGroupLength, 8 );\r\n\t}\r\n\r\n\tvoid SkipPce( BitReader r )\r\n\t{\r\n\t\tr.Read( 4 \u002B 2 \u002B 4 ); // tag, object type, sampling index\r\n\t\tint front = r.Read( 4 ), side = r.Read( 4 ), back = r.Read( 4 ), lfe = r.Read( 2 ), assoc = r.Read( 3 ), cc = r.Read( 4 );\r\n\t\tif ( r.Read( 1 ) == 1 ) r.Read( 4 ); // mono mixdown\r\n\t\tif ( r.Read( 1 ) == 1 ) r.Read( 4 ); // stereo mixdown\r\n\t\tif ( r.Read( 1 ) == 1 ) r.Read( 3 ); // matrix mixdown\r\n\t\tr.Skip( (front \u002B side \u002B back) * 5 \u002B lfe * 4 \u002B assoc * 4 \u002B cc * 5 );\r\n\t\tr.ByteAlign();\r\n\t\tr.Skip( r.Read( 8 ) * 8 ); // comment\r\n\t}\r\n\r\n\t//\r\n\t// spectral processing\r\n\t//\r\n\r\n\t/// \u003Csummary\u003E Inverse quantization and scaling of one channel into \u003Cparamref name=\u0022spec\u0022/\u003E (1024 values, window by window). \u003C/summary\u003E\r\n\tvoid Dequantize( Ics ics, float[] spec )\r\n\t{\r\n\t\tArray.Clear( spec );\r\n\t\tvar swb = ics.IsLong ? swbLong : swbShort;\r\n\t\tvar window = 0;\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int w = 0; w \u003C ics.WindowGroupLength[g]; w\u002B\u002B, window\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar baseIndex = window * 128;\r\n\t\t\t\tfor ( int sfb = 0; sfb \u003C ics.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar cb = ics.SfbCb[g * 64 \u002B sfb];\r\n\t\t\t\t\tif ( cb is ZeroHcb or NoiseHcb or IntensityHcb or IntensityHcb2 ) continue;\r\n\t\t\t\t\tvar gain = MathF.Pow( 2, 0.25f * (ics.ScaleFactors[g * 64 \u002B sfb] - 100) );\r\n\t\t\t\t\tfor ( int k = swb[sfb]; k \u003C swb[sfb \u002B 1]; k\u002B\u002B )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar q = ics.Quant[baseIndex \u002B k];\r\n\t\t\t\t\t\tif ( q != 0 ) spec[baseIndex \u002B k] = (q \u003E 0 ? Pow43( q ) : -Pow43( -q )) * gain;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tstatic readonly float[] Pow43Table = MakePow43();\r\n\tstatic float[] MakePow43()\r\n\t{\r\n\t\tvar t = new float[8192];\r\n\t\tfor ( int i = 0; i \u003C t.Length; i\u002B\u002B ) t[i] = MathF.Pow( i, 4.0f / 3.0f );\r\n\t\treturn t;\r\n\t}\r\n\tstatic float Pow43( int q ) =\u003E q \u003C 8192 ? Pow43Table[q] : MathF.Pow( q, 4.0f / 3.0f );\r\n\r\n\t/// \u003Csummary\u003E Only the single channel path: dequantize, noise, TNS. \u003C/summary\u003E\r\n\tvoid Reconstruct( Ics ics, float[] spec )\r\n\t{\r\n\t\tDequantize( ics, spec );\r\n\t\tNoise( ics, spec, null, Span\u003Cbool\u003E.Empty );\r\n\t\tTns( ics, spec );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Perceptual noise substitution: noise bands get random values of the coded energy. \u003C/summary\u003E\r\n\tvoid Noise( Ics ics, float[] spec, Ics other, ReadOnlySpan\u003Cbool\u003E msUsed, float[] otherSpec = null )\r\n\t{\r\n\t\tvar swb = ics.IsLong ? swbLong : swbShort;\r\n\t\tvar window = 0;\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int w = 0; w \u003C ics.WindowGroupLength[g]; w\u002B\u002B, window\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar baseIndex = window * 128;\r\n\t\t\t\tfor ( int sfb = 0; sfb \u003C ics.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar i = g * 64 \u002B sfb;\r\n\t\t\t\t\tif ( ics.SfbCb[i] != NoiseHcb ) continue;\r\n\t\t\t\t\tint from = baseIndex \u002B swb[sfb], to = baseIndex \u002B swb[sfb \u002B 1];\r\n\r\n\t\t\t\t\tvar correlated = other is not null \u0026\u0026 otherSpec is not null \u0026\u0026 other.SfbCb[i] == NoiseHcb \u0026\u0026 msUsed.Length \u003E i \u0026\u0026 msUsed[i];\r\n\t\t\t\t\tfloat energy = 0;\r\n\t\t\t\t\tfor ( int k = from; k \u003C to; k\u002B\u002B )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar v = correlated ? otherSpec[k] : NextNoise();\r\n\t\t\t\t\t\tspec[k] = v;\r\n\t\t\t\t\t\tenergy \u002B= v * v;\r\n\t\t\t\t\t}\r\n\t\t\t\t\tif ( energy \u003C= 0 ) continue;\r\n\t\t\t\t\tvar scale = MathF.Pow( 2, 0.25f * ics.ScaleFactors[i] ) / MathF.Sqrt( energy );\r\n\t\t\t\t\tfor ( int k = from; k \u003C to; k\u002B\u002B ) spec[k] *= scale;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tfloat NextNoise()\r\n\t{\r\n\t\tnoiseState = noiseState * 1664525u \u002B 1013904223u;\r\n\t\treturn (int)noiseState / 2147483648.0f;\r\n\t}\r\n\r\n\tvoid MidSide( Ics ics, Ics right, float[] l, float[] r, ReadOnlySpan\u003Cbool\u003E msUsed )\r\n\t{\r\n\t\tvar swb = ics.IsLong ? swbLong : swbShort;\r\n\t\tvar window = 0;\r\n\t\tfor ( int g = 0; g \u003C ics.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int w = 0; w \u003C ics.WindowGroupLength[g]; w\u002B\u002B, window\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfor ( int sfb = 0; sfb \u003C ics.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar i = g * 64 \u002B sfb;\r\n\t\t\t\t\tif ( !msUsed[i] || right.SfbCb[i] \u003E= NoiseHcb || ics.SfbCb[i] == NoiseHcb ) continue;\r\n\t\t\t\t\tfor ( int k = window * 128 \u002B swb[sfb]; k \u003C window * 128 \u002B swb[sfb \u002B 1]; k\u002B\u002B )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar m = l[k];\r\n\t\t\t\t\t\tvar s = r[k];\r\n\t\t\t\t\t\tl[k] = m \u002B s;\r\n\t\t\t\t\t\tr[k] = m - s;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Intensity( Ics right, float[] l, float[] r, ReadOnlySpan\u003Cbool\u003E msUsed, int msMask )\r\n\t{\r\n\t\tvar swb = right.IsLong ? swbLong : swbShort;\r\n\t\tvar window = 0;\r\n\t\tfor ( int g = 0; g \u003C right.NumWindowGroups; g\u002B\u002B )\r\n\t\t{\r\n\t\t\tfor ( int w = 0; w \u003C right.WindowGroupLength[g]; w\u002B\u002B, window\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfor ( int sfb = 0; sfb \u003C right.MaxSfb; sfb\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar i = g * 64 \u002B sfb;\r\n\t\t\t\t\tvar cb = right.SfbCb[i];\r\n\t\t\t\t\tif ( cb is not (IntensityHcb or IntensityHcb2) ) continue;\r\n\t\t\t\t\tvar scale = MathF.Pow( 0.5f, 0.25f * right.ScaleFactors[i] );\r\n\t\t\t\t\tif ( cb == IntensityHcb2 ) scale = -scale;\r\n\t\t\t\t\tif ( msMask \u003E 0 \u0026\u0026 msUsed[i] ) scale = -scale;\r\n\t\t\t\t\tfor ( int k = window * 128 \u002B swb[sfb]; k \u003C window * 128 \u002B swb[sfb \u002B 1]; k\u002B\u002B ) r[k] = l[k] * scale;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Tns( Ics ics, float[] spec )\r\n\t{\r\n\t\tif ( !ics.TnsPresent ) return;\r\n\t\tvar isLong = ics.IsLong;\r\n\t\tvar swb = isLong ? swbLong : swbShort;\r\n\t\tvar numSwb = isLong ? numSwbLong : numSwbShort;\r\n\t\tvar maxBands = Math.Min( (isLong ? AacTables.TnsMaxBandsLong : AacTables.TnsMaxBandsShort)[Math.Min( rateIndex, 11 )], ics.MaxSfb );\r\n\t\tSpan\u003Cfloat\u003E state = stackalloc float[20];\r\n\r\n\t\tfor ( int w = 0; w \u003C ics.NumWindows; w\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar bottom = numSwb;\r\n\t\t\tfor ( int f = 0; f \u003C ics.TnsFilters[w]; f\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar top = bottom;\r\n\t\t\t\tbottom = Math.Max( top - ics.TnsLength[w, f], 0 );\r\n\t\t\t\tvar order = Math.Min( ics.TnsOrder[w, f], isLong ? 20 : 7 );\r\n\t\t\t\tif ( order == 0 ) continue;\r\n\r\n\t\t\t\tvar start = swb[Math.Min( bottom, maxBands )];\r\n\t\t\t\tvar end = swb[Math.Min( top, maxBands )];\r\n\t\t\t\tvar size = end - start;\r\n\t\t\t\tif ( size \u003C= 0 ) continue;\r\n\r\n\t\t\t\tint at = w * 128 \u002B (ics.TnsDirection[w, f] == 1 ? end - 1 : start);\r\n\t\t\t\tvar step = ics.TnsDirection[w, f] == 1 ? -1 : 1;\r\n\t\t\t\tstate.Clear();\r\n\t\t\t\tfor ( int n = 0; n \u003C size; n\u002B\u002B, at \u002B= step )\r\n\t\t\t\t{\r\n\t\t\t\t\t// all-pole filter: y = x - sum(a[i] * y[n-i])\r\n\t\t\t\t\tvar y = spec[at];\r\n\t\t\t\t\tfor ( int i = 0; i \u003C order; i\u002B\u002B ) y -= state[i] * ics.TnsLpc[w, f, i \u002B 1];\r\n\t\t\t\t\tfor ( int i = order - 1; i \u003E 0; i-- ) state[i] = state[i - 1];\r\n\t\t\t\t\tstate[0] = y;\r\n\t\t\t\t\tspec[at] = y;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t//\r\n\t// filterbank\r\n\t//\r\n\r\n\tstatic readonly float[] SineLong = SineWindow( 2048 ), SineShort = SineWindow( 256 );\r\n\tstatic readonly float[] KbdLong = KbdWindow( 2048, 4 ), KbdShort = KbdWindow( 256, 6 );\r\n\r\n\tstatic float[] SineWindow( int n )\r\n\t{\r\n\t\tvar w = new float[n / 2];\r\n\t\tfor ( int i = 0; i \u003C n / 2; i\u002B\u002B ) w[i] = MathF.Sin( MathF.PI / n * (i \u002B 0.5f) );\r\n\t\treturn w;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E The rising half of a Kaiser-Bessel-derived window. \u003C/summary\u003E\r\n\tstatic float[] KbdWindow( int n, double alpha )\r\n\t{\r\n\t\tvar half = n / 2;\r\n\t\tvar kernel = new double[half \u002B 1];\r\n\t\tfor ( int i = 0; i \u003C= half; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar x = (i - half / 2.0) / (half / 2.0);\r\n\t\t\tkernel[i] = BesselI0( Math.PI * alpha * Math.Sqrt( Math.Max( 0, 1 - x * x ) ) );\r\n\t\t}\r\n\t\tvar total = 0.0;\r\n\t\tfor ( int i = 0; i \u003C= half; i\u002B\u002B ) total \u002B= kernel[i];\r\n\t\tvar w = new float[half];\r\n\t\tvar sum = 0.0;\r\n\t\tfor ( int i = 0; i \u003C half; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tsum \u002B= kernel[i];\r\n\t\t\tw[i] = (float)Math.Sqrt( sum / total );\r\n\t\t}\r\n\t\treturn w;\r\n\t}\r\n\r\n\tstatic double BesselI0( double x )\r\n\t{\r\n\t\tdouble sum = 1, term = 1;\r\n\t\tfor ( int k = 1; k \u003C 50; k\u002B\u002B )\r\n\t\t{\r\n\t\t\tterm *= (x / (2 * k)) * (x / (2 * k));\r\n\t\t\tsum \u002B= term;\r\n\t\t\tif ( term \u003C 1e-12 * sum ) break;\r\n\t\t}\r\n\t\treturn sum;\r\n\t}\r\n\r\n\tstatic float[] Rising( int shape, bool isLong ) =\u003E shape == 1 ? (isLong ? KbdLong : KbdShort) : (isLong ? SineLong : SineShort);\r\n\r\n\t/// \u003Csummary\u003E IMDCT, window, overlap-add: 1024 new samples for output channel \u003Cparamref name=\u0022ch\u0022/\u003E. \u003C/summary\u003E\r\n\tvoid Synthesize( Ics ics, float[] spec, int ch )\r\n\t{\r\n\t\tif ( ch \u003E= 4 ) return;\r\n\t\tvar t = timeBuffer;\r\n\t\tvar prevShape = previousShape[ch];\r\n\t\tvar shape = ics.WindowShape;\r\n\t\tvar longLeft = Rising( prevShape, true );\r\n\t\tvar longRight = Rising( shape, true );\r\n\t\tvar shortLeft = Rising( prevShape, false );\r\n\t\tvar shortRight = Rising( shape, false );\r\n\r\n\t\tif ( ics.WindowSequence == EightShort )\r\n\t\t{\r\n\t\t\tArray.Clear( t );\r\n\t\t\tSpan\u003Cfloat\u003E block = stackalloc float[256];\r\n\t\t\tfor ( int w = 0; w \u003C 8; w\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tImdct.Short.Inverse( spec.AsSpan( w * 128, 128 ), block );\r\n\t\t\t\tvar left = w == 0 ? shortLeft : shortRight;\r\n\t\t\t\tvar at = 448 \u002B w * 128;\r\n\t\t\t\tfor ( int i = 0; i \u003C 128; i\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tt[at \u002B i] \u002B= block[i] * left[i];\r\n\t\t\t\t\tt[at \u002B 128 \u002B i] \u002B= block[128 \u002B i] * shortRight[127 - i];\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tImdct.Long.Inverse( spec.AsSpan( 0, 1024 ), t );\r\n\t\t\tswitch ( ics.WindowSequence )\r\n\t\t\t{\r\n\t\t\t\tcase OnlyLong:\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B ) { t[i] *= longLeft[i]; t[1024 \u002B i] *= longRight[1023 - i]; }\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase LongStart:\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B ) t[i] *= longLeft[i];\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 128; i\u002B\u002B ) t[1472 \u002B i] *= shortRight[127 - i];\r\n\t\t\t\t\tfor ( int i = 1600; i \u003C 2048; i\u002B\u002B ) t[i] = 0;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase LongStop:\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 448; i\u002B\u002B ) t[i] = 0;\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 128; i\u002B\u002B ) t[448 \u002B i] *= shortLeft[i];\r\n\t\t\t\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B ) t[1024 \u002B i] *= longRight[1023 - i];\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar o = overlap[ch];\r\n\t\tvar output = pcm[ch];\r\n\t\tfor ( int i = 0; i \u003C 1024; i\u002B\u002B )\r\n\t\t{\r\n\t\t\toutput[i] = t[i] \u002B o[i];\r\n\t\t\to[i] = t[1024 \u002B i];\r\n\t\t}\r\n\t\tpreviousShape[ch] = shape;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Inverse MDCT through a DCT-IV of half the length, done with a complex FFT of a quarter. \u003C/summary\u003E\r\n\tsealed class Imdct\r\n\t{\r\n\t\tpublic static readonly Imdct Long = new( 2048 ), Short = new( 256 );\r\n\r\n\t\treadonly int n, m;        // window length, coefficient count (n / 2)\r\n\t\treadonly float[] twiddleCos, twiddleSin; // exp(-i pi (4k\u002B1) / 4m)\r\n\t\treadonly Fft fft;\r\n\t\treadonly float[] re, im, dct;\r\n\r\n\t\tImdct( int length )\r\n\t\t{\r\n\t\t\tn = length;\r\n\t\t\tm = length / 2;\r\n\t\t\ttwiddleCos = new float[m / 2];\r\n\t\t\ttwiddleSin = new float[m / 2];\r\n\t\t\tfor ( int k = 0; k \u003C m / 2; k\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar a = -Math.PI * (4 * k \u002B 1) / (4.0 * m);\r\n\t\t\t\ttwiddleCos[k] = (float)Math.Cos( a );\r\n\t\t\t\ttwiddleSin[k] = (float)Math.Sin( a );\r\n\t\t\t}\r\n\t\t\tfft = new Fft( m / 2 );\r\n\t\t\tre = new float[m / 2];\r\n\t\t\tim = new float[m / 2];\r\n\t\t\tdct = new float[m];\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003E \u003Cparamref name=\u0022spec\u0022/\u003E: m coefficients. \u003Cparamref name=\u0022output\u0022/\u003E: n samples, scaled by 2/n. \u003C/summary\u003E\r\n\t\tpublic void Inverse( ReadOnlySpan\u003Cfloat\u003E spec, Span\u003Cfloat\u003E output )\r\n\t\t{\r\n\t\t\tvar half = m / 2;\r\n\r\n\t\t\t// DCT-IV of spec into dct[]\r\n\t\t\tfor ( int k = 0; k \u003C half; k\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat xr = spec[2 * k], xi = spec[m - 1 - 2 * k];\r\n\t\t\t\tre[k] = xr * twiddleCos[k] - xi * twiddleSin[k];\r\n\t\t\t\tim[k] = xr * twiddleSin[k] \u002B xi * twiddleCos[k];\r\n\t\t\t}\r\n\t\t\tfft.Forward( re, im );\r\n\t\t\tfor ( int k = 0; k \u003C half; k\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tfloat yr = re[k] * twiddleCos[k] - im[k] * twiddleSin[k];\r\n\t\t\t\tfloat yi = re[k] * twiddleSin[k] \u002B im[k] * twiddleCos[k];\r\n\t\t\t\tdct[2 * k] = yr;\r\n\t\t\t\tdct[m - 1 - 2 * k] = -yi;\r\n\t\t\t}\r\n\r\n\t\t\t// unfold to the IMDCT\u0027s n outputs (phase n0 = m/2 \u002B 1/2)\r\n\t\t\tvar scale = 2.0f / n;\r\n\t\t\tfor ( int i = 0; i \u003C half; i\u002B\u002B ) output[i] = dct[i \u002B half] * scale;\r\n\t\t\tfor ( int i = half; i \u003C 3 * half; i\u002B\u002B ) output[i] = -dct[3 * half - 1 - i] * scale;\r\n\t\t\tfor ( int i = 3 * half; i \u003C n; i\u002B\u002B ) output[i] = -dct[i - 3 * half] * scale;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E In-place radix-2 complex FFT (forward, e^-i). \u003C/summary\u003E\r\n\tsealed class Fft\r\n\t{\r\n\t\treadonly int size;\r\n\t\treadonly int[] reverse;\r\n\t\treadonly float[] cos, sin;\r\n\r\n\t\tpublic Fft( int size )\r\n\t\t{\r\n\t\t\tthis.size = size;\r\n\t\t\treverse = new int[size];\r\n\t\t\tvar bits = 0;\r\n\t\t\twhile ( (1 \u003C\u003C bits) \u003C size ) bits\u002B\u002B;\r\n\t\t\tfor ( int i = 0; i \u003C size; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar r = 0;\r\n\t\t\t\tfor ( int b = 0; b \u003C bits; b\u002B\u002B ) if ( (i \u0026 (1 \u003C\u003C b)) != 0 ) r |= 1 \u003C\u003C (bits - 1 - b);\r\n\t\t\t\treverse[i] = r;\r\n\t\t\t}\r\n\t\t\tcos = new float[size / 2];\r\n\t\t\tsin = new float[size / 2];\r\n\t\t\tfor ( int i = 0; i \u003C size / 2; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tcos[i] = (float)Math.Cos( -2 * Math.PI * i / size );\r\n\t\t\t\tsin[i] = (float)Math.Sin( -2 * Math.PI * i / size );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tpublic void Forward( float[] re, float[] im )\r\n\t\t{\r\n\t\t\tfor ( int i = 0; i \u003C size; i\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar j = reverse[i];\r\n\t\t\t\tif ( j \u003C= i ) continue;\r\n\t\t\t\t(re[i], re[j]) = (re[j], re[i]);\r\n\t\t\t\t(im[i], im[j]) = (im[j], im[i]);\r\n\t\t\t}\r\n\t\t\tfor ( int len = 2; len \u003C= size; len \u003C\u003C= 1 )\r\n\t\t\t{\r\n\t\t\t\tvar halfLen = len / 2;\r\n\t\t\t\tvar step = size / len;\r\n\t\t\t\tfor ( int i = 0; i \u003C size; i \u002B= len )\r\n\t\t\t\t{\r\n\t\t\t\t\tfor ( int j = 0; j \u003C halfLen; j\u002B\u002B )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tfloat wr = cos[j * step], wi = sin[j * step];\r\n\t\t\t\t\t\tint a = i \u002B j, b = a \u002B halfLen;\r\n\t\t\t\t\t\tfloat tr = re[b] * wr - im[b] * wi;\r\n\t\t\t\t\t\tfloat ti = re[b] * wi \u002B im[b] * wr;\r\n\t\t\t\t\t\tre[b] = re[a] - tr;\r\n\t\t\t\t\t\tim[b] = im[a] - ti;\r\n\t\t\t\t\t\tre[a] \u002B= tr;\r\n\t\t\t\t\t\tim[a] \u002B= ti;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t//\r\n\t// Huffman\r\n\t//\r\n\r\n\t/// \u003Csummary\u003E A codebook as a binary tree, built once from the code / length tables. \u003C/summary\u003E\r\n\tsealed class Huffman\r\n\t{\r\n\t\tpublic static readonly Huffman ScaleFactor = new( AacTables.CodeScf, AacTables.LengthScf, 1, 0, false, false );\r\n\t\tpublic static readonly Huffman[] Spectral =\r\n\t\t{\r\n\t\t\tnull,\r\n\t\t\tnew( AacTables.Code1, AacTables.Length1, 4, 3, true, false ),\r\n\t\t\tnew( AacTables.Code2, AacTables.Length2, 4, 3, true, false ),\r\n\t\t\tnew( AacTables.Code3, AacTables.Length3, 4, 3, false, false ),\r\n\t\t\tnew( AacTables.Code4, AacTables.Length4, 4, 3, false, false ),\r\n\t\t\tnew( AacTables.Code5, AacTables.Length5, 2, 9, true, false ),\r\n\t\t\tnew( AacTables.Code6, AacTables.Length6, 2, 9, true, false ),\r\n\t\t\tnew( AacTables.Code7, AacTables.Length7, 2, 8, false, false ),\r\n\t\t\tnew( AacTables.Code8, AacTables.Length8, 2, 8, false, false ),\r\n\t\t\tnew( AacTables.Code9, AacTables.Length9, 2, 13, false, false ),\r\n\t\t\tnew( AacTables.Code10, AacTables.Length10, 2, 13, false, false ),\r\n\t\t\tnew( AacTables.Code11, AacTables.Length11, 2, 17, false, true ),\r\n\t\t};\r\n\r\n\t\tpublic readonly int Dimension;\r\n\t\treadonly int modulo;\r\n\t\treadonly bool signed, escape;\r\n\t\treadonly int[] tree; // node * 2 \u002B bit: \u003E 0 next node, \u003C= 0 leaf -(index)\r\n\r\n\t\tHuffman( uint[] codes, byte[] lengths, int dimension, int modulo, bool signed, bool escape )\r\n\t\t{\r\n\t\t\tDimension = dimension;\r\n\t\t\tthis.modulo = modulo;\r\n\t\t\tthis.signed = signed;\r\n\t\t\tthis.escape = escape;\r\n\t\t\tvar nodes = new List\u003Cint\u003E { 0, 0 };\r\n\t\t\tvar used = new List\u003Cbool\u003E { false, false };\r\n\t\t\tfor ( int index = 0; index \u003C codes.Length; index\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar node = 0;\r\n\t\t\t\tfor ( int b = lengths[index] - 1; b \u003E= 0; b-- )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar bit = (int)(codes[index] \u003E\u003E b) \u0026 1;\r\n\t\t\t\t\tvar slot = node * 2 \u002B bit;\r\n\t\t\t\t\tif ( b == 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tnodes[slot] = -index;\r\n\t\t\t\t\t\tused[slot] = true;\r\n\t\t\t\t\t\tbreak;\r\n\t\t\t\t\t}\r\n\t\t\t\t\tif ( !used[slot] )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tused[slot] = true;\r\n\t\t\t\t\t\tnodes[slot] = nodes.Count / 2;\r\n\t\t\t\t\t\tnodes.Add( 0 ); nodes.Add( 0 );\r\n\t\t\t\t\t\tused.Add( false ); used.Add( false );\r\n\t\t\t\t\t}\r\n\t\t\t\t\tnode = nodes[slot];\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\ttree = nodes.ToArray();\r\n\t\t}\r\n\r\n\t\tpublic int Decode( BitReader r )\r\n\t\t{\r\n\t\t\tvar node = 0;\r\n\t\t\twhile ( true )\r\n\t\t\t{\r\n\t\t\t\tvar next = tree[node * 2 \u002B r.Read( 1 )];\r\n\t\t\t\tif ( next \u003C= 0 ) return -next;\r\n\t\t\t\tnode = next;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t/// \u003Csummary\u003E One codeword\u0027s values (4 or 2), with their signs and escapes, into \u003Cparamref name=\u0022dest\u0022/\u003E. \u003C/summary\u003E\r\n\t\tpublic void DecodeValues( BitReader r, int[] dest, int at )\r\n\t\t{\r\n\t\t\tvar index = Decode( r );\r\n\t\t\tSpan\u003Cint\u003E v = stackalloc int[4];\r\n\t\t\tif ( Dimension == 4 )\r\n\t\t\t{\r\n\t\t\t\tv[0] = index / 27; v[1] = index / 9 % 3; v[2] = index / 3 % 3; v[3] = index % 3;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tv[0] = index / modulo; v[1] = index % modulo;\r\n\t\t\t}\r\n\r\n\t\t\tif ( signed )\r\n\t\t\t{\r\n\t\t\t\tvar offset = Dimension == 4 ? 1 : 4;\r\n\t\t\t\tfor ( int i = 0; i \u003C Dimension; i\u002B\u002B ) v[i] -= offset;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tfor ( int i = 0; i \u003C Dimension; i\u002B\u002B )\r\n\t\t\t\t\tif ( v[i] != 0 \u0026\u0026 r.Read( 1 ) == 1 ) v[i] = -v[i];\r\n\t\t\t}\r\n\r\n\t\t\tif ( escape )\r\n\t\t\t{\r\n\t\t\t\tfor ( int i = 0; i \u003C 2; i\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( Math.Abs( v[i] ) != 16 ) continue;\r\n\t\t\t\t\tvar n = 4;\r\n\t\t\t\t\twhile ( r.Read( 1 ) == 1 ) { n\u002B\u002B; if ( n \u003E 12 ) throw new IndexOutOfRangeException( \u0022escape\u0022 ); }\r\n\t\t\t\t\tvar value = (1 \u003C\u003C n) \u002B r.Read( n );\r\n\t\t\t\t\tv[i] = v[i] \u003C 0 ? -value : value;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\tfor ( int i = 0; i \u003C Dimension \u0026\u0026 at \u002B i \u003C dest.Length; i\u002B\u002B ) dest[at \u002B i] = v[i];\r\n\t\t}\r\n\t}\r\n\r\n\tsealed class BitReader\r\n\t{\r\n\t\treadonly byte[] data;\r\n\t\tint bit;\r\n\r\n\t\tpublic BitReader( byte[] data ) { this.data = data; }\r\n\r\n\t\tpublic int BitsLeft =\u003E data.Length * 8 - bit;\r\n\r\n\t\tpublic int Read( int n )\r\n\t\t{\r\n\t\t\tif ( n == 0 ) return 0;\r\n\t\t\tif ( bit \u002B n \u003E data.Length * 8 ) throw new IndexOutOfRangeException( \u0022end of frame\u0022 );\r\n\t\t\tvar v = 0;\r\n\t\t\tfor ( int i = 0; i \u003C n; i\u002B\u002B, bit\u002B\u002B )\r\n\t\t\t\tv = (v \u003C\u003C 1) | ((data[bit \u003E\u003E 3] \u003E\u003E (7 - (bit \u0026 7))) \u0026 1);\r\n\t\t\treturn v;\r\n\t\t}\r\n\r\n\t\tpublic void Skip( int n )\r\n\t\t{\r\n\t\t\tif ( bit \u002B n \u003E data.Length * 8 ) throw new IndexOutOfRangeException( \u0022end of frame\u0022 );\r\n\t\t\tbit \u002B= n;\r\n\t\t}\r\n\r\n\t\tpublic void ByteAlign() =\u003E bit = (bit \u002B 7) \u0026 ~7;\r\n\t}\r\n}\r\n"},{"Ident":"rexi.bimp","Path":"Code/Resolver/Media/ISegmentFeed.cs","FileName":"ISegmentFeed.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp.Resolver.Media;\r\n\r\n/// \u003Csummary\u003E\r\n/// A source of finished segment files for \u003Csee cref=\u0022SegmentPlayer\u0022/\u003E: merged YouTube media\r\n/// (\u003Csee cref=\u0022WebmSegmenter\u0022/\u003E) or a live stream (\u003Csee cref=\u0022Live.LiveSegmenter\u0022/\u003E).\r\n/// \u003C/summary\u003E\r\npublic interface ISegmentFeed\r\n{\r\n\t/// \u003Csummary\u003E Live: segments appear as the stream comes in, old ones go away, there\u0027s no end to seek in. \u003C/summary\u003E\r\n\tbool IsLive { get; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Live: how much stream time must be buffered past the start point before playback starts, so the next\r\n\t/// segment is always finished before the current one ends. Sources that deliver in chunks (HLS) need more.\r\n\t/// \u003C/summary\u003E\r\n\tdouble StartBufferSeconds =\u003E 0;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Live, low latency: short segments that end in filler frames instead of an overlap - join the newest one and\r\n\t/// keep close behind the live edge.\r\n\t/// \u003C/summary\u003E\r\n\tbool LowLatency =\u003E false;\r\n\r\n\t/// \u003Csummary\u003E The newest finished segment, -1 if none yet. \u003C/summary\u003E\r\n\tint Newest { get; }\r\n\r\n\t/// \u003Csummary\u003E The segment if it\u0027s finished (and still on disk), else null. \u003C/summary\u003E\r\n\tMediaSegment TryGet( int seq );\r\n\r\n\t/// \u003Csummary\u003E Playback reached this segment - prepare it and what follows. \u003C/summary\u003E\r\n\tvoid Want( int seq );\r\n\r\n\t/// \u003Csummary\u003E Done with a segment - its file can go. \u003C/summary\u003E\r\n\tvoid Release( int seq );\r\n\r\n\t/// \u003Csummary\u003E Something went wrong, user facing. \u003C/summary\u003E\r\n\tstring Error { get; }\r\n}\r\n"},{"Ident":"rexi.bimp","Path":"Code/SegmentPlayer.cs","FileName":"SegmentPlayer.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using Bimp.Resolver.Media;\n\nnamespace Bimp;\n\n/// \u003Csummary\u003E\n/// Plays a series of local segment files back to back: merged YouTube media (\u003Csee cref=\u0022WebmSegmenter\u0022/\u003E) or a\n/// live stream remuxed to MP4 (\u003Csee cref=\u0022Resolver.Live.LiveSegmenter\u0022/\u003E).\n/// \u003Cpara\u003E\n/// Segment N plays while N\u002B1 is started just in time in a second, hidden and silent player; at the boundary\n/// they swap, so the picture carries straight on. Segments keep absolute timestamps, so \u003Csee cref=\u0022Time\u0022/\u003E\n/// is the media time.\n/// \u003C/para\u003E\n/// \u003C/summary\u003E\npublic sealed class SegmentPlayer : IDisposable\n{\n\tclass Slot\n\t{\n\t\tpublic MediaSegment Segment;\n\t\tpublic VideoPlayer Player;\n\t\tpublic bool Loaded;\n\t\tpublic bool Finished;\n\t\tpublic bool Preload;    // waiting in the wings: silent until it\u0027s swapped in\n\t\tpublic bool FirstFrame; // set when it delivers its first frame (from OnTextureData, during Present)\n\t\tpublic bool Primed;     // its decoder has produced its first frame\n\t\tpublic int Steady;      // frames in a row it has delivered on time (see SettleFrames)\n\t\tpublic RealTimeSince SinceFrame;\n\t\tpublic bool Fading;     // swapped out: silent (its volume ramps down) and closed a moment later\n\t\tpublic float LastTime;\n\t\tpublic RealTimeSince SinceOpened;\n\t}\n\n\treadonly ISegmentFeed feed;\n\n\t/// \u003Csummary\u003E\n\t/// Live: start this many segments behind the newest finished one, so the next is always ready by the time the\n\t/// current one ends. Latency is roughly (JoinBehind \u002B 1) segments plus the overlap.\n\t/// \u003C/summary\u003E\n\tint JoinBehind =\u003E feed.LowLatency ? 0 : 1;\n\n\t/// \u003Csummary\u003E Live: further behind than this (slow machine, stalled download) jumps back to the live edge. \u003C/summary\u003E\n\tint MaxBehind =\u003E feed.LowLatency ? 1 : 3;\n\n\t/// \u003Csummary\u003E\n\t/// Low latency: join the newest segment this long after it\u0027s written, so the next one is ready a little before\n\t/// the current one ends (its start latency plus arrival jitter). A segment that\u0027s late anyway just holds the\n\t/// last picture (the filler frames) until it arrives.\n\t/// \u003C/summary\u003E\n\tconst double LowJoinMargin = 0.2;\n\n\t/// \u003Csummary\u003E\n\t/// Average time from opening a segment to its first decoded frame (measured as we go).\n\t/// \u003C/summary\u003E\n\tfloat startLatency;\n\n\t/// \u003Csummary\u003E\n\t/// How long before the current segment ends to start the next one, so its first frame lands on the boundary -\n\t/// and so the two players are close to in step for the audio crossfade. A player that isn\u0027t primed in time\n\t/// just swaps a moment later (the current one plays on into its overlap, if the file has one).\n\t/// \u003C/summary\u003E\n\tfloat StartLead =\u003E JustInTime\n\t\t? Math.Clamp( (startLatency \u003E 0 ? startLatency : 0.08f) \u002B 0.015f, 0.0f, 1.0f )\n\t\t: ClockLeadOnly ? Math.Clamp( clockLead, 0.0f, Math.Min( current.Segment.Overlap, 1.0f ) )\n\t\t: Math.Clamp( (startLatency \u003E 0 ? startLatency : 0.04f) \u002B leadCorrection, 0.0f, feed.IsLive ? 1.0f : 3.0f );\n\n\t/// \u003Csummary\u003E\n\t/// Media files that run on into the next segment (\u003Csee cref=\u0022MediaSegment.Overlap\u0022/\u003E): the current player shows real\n\t/// frames until the next one\u0027s first frame is up, however long that takes (150-520 ms at 1440p/4K), so the next one\n\t/// only has to be opened for its clock to be in step. Adding the start latency to that made it skip that far ahead\n\t/// at a swap, and correcting that back made the current one hold its last frame instead.\n\t/// \u003Cpara\u003E\n\t/// Merged WebM (VP9/AV1) only: MP4 segments (HLS VOD) kept waiting for the next H.264 player to settle made Media\n\t/// Foundation drop a sample (\u0022MFT deadlock\u0022) in every run, and the picture broke up until the next keyframe (3 of 3\n\t/// runs, against 0 of 3 with the old timing).\n\t/// \u003C/para\u003E\n\t/// \u003C/summary\u003E\n\tbool ClockLeadOnly =\u003E feed is WebmSegmenter \u0026\u0026 (current?.Segment.Overlap ?? 0) \u003E 0;\n\n\t/// \u003Csummary\u003E How long before the end to open the next player so its clock is in step (\u003Csee cref=\u0022ClockLeadOnly\u0022/\u003E), learned from each swap. \u003C/summary\u003E\n\tfloat clockLead = 0.05f;\n\n\t/// \u003Csummary\u003E\n\t/// \u003Csee cref=\u0022ClockLeadOnly\u0022/\u003E: swap once the next player has delivered this many frames in a row on time. Its first\n\t/// frame comes late, and a 1440p decoder is still catching up to its clock for a moment after it - swapping on the\n\t/// first frame put that on screen as a 80-120 ms gap (measured). The current one plays on into its overlap meanwhile.\n\t/// \u003C/summary\u003E\n\tconst int SettleFrames = 4;\n\tconst float SettleInterval = 0.045f;\n\n\t/// \u003Csummary\u003E\n\t/// Low latency files that end at their boundary (H.264, ending in skip frames): the next one just opens early enough\n\t/// for its first frame to be ready there. (Syncing clocks made every swap ~40 ms late: a new player\u0027s clock runs from\n\t/// Play(), its first frame comes ~60 ms later.)\n\t/// \u003C/summary\u003E\n\tbool JustInTime =\u003E feed.LowLatency \u0026\u0026 (current?.Segment.Overlap ?? 0) \u003C= 0;\n\n\t/// \u003Csummary\u003E\n\t/// Learned from each swap: how far the next player\u0027s clock was ahead of the current one\u0027s at the boundary.\n\t/// Starting it that much later next time brings them in step, which the audio crossfade needs - and so the next\n\t/// player neither skips its first frames nor makes the current one hold its last, while the current one plays on\n\t/// into its overlap (see \u003Csee cref=\u0022MediaSegment.Overlap\u0022/\u003E).\n\t/// \u003C/summary\u003E\n\tfloat leadCorrection;\n\n\t/// \u003Csummary\u003E\n\t/// How long the swapped-out player keeps running, silent, after a swap.\n\t/// \u003Cpara\u003E\n\t/// The engine\u0027s mixer runs every sound through Steam Audio\u0027s direct effect, whose gain interpolator ramps any\n\t/// volume change over the next mix frames. Unmuting the next player while closing the current one outright\n\t/// left a ~70 ms dip in the audio at every boundary (measured with a sine: down to 5%, every segment).\n\t/// Muting the old player at the same moment instead - both ramps at once - makes that a crossfade.\n\t/// \u003C/para\u003E\n\t/// \u003C/summary\u003E\n\tconst float FadeSeconds = 0.15f;\n\n\tSlot fading;\n\tRealTimeSince sinceFade;\n\n\tSlot current, next;\n\tbool paused;\n\n\t// audio settings, applied to both players\n\tVector3 audioPosition;\n\tbool audioSpatial;\n\tfloat audioVolume = 1;\n\tfloat audioDistance = 2500;\n\n\tstring error;\n\tpublic string Error =\u003E error ?? feed.Error;\n\n\t/// \u003Csummary\u003E The segment on screen, -1 before the first. \u003C/summary\u003E\n\tpublic int CurrentSeq =\u003E current?.Segment.Seq ?? -1;\n\n\t/// \u003Csummary\u003E Has the first segment started? \u003C/summary\u003E\n\tpublic bool Loaded =\u003E current is { Loaded: true } || (Mjpeg?.HasShown ?? false);\n\n\t/// \u003Csummary\u003E Motion JPEG: frames go straight to the texture, there are no segments to play. \u003C/summary\u003E\n\tResolver.Live.LiveMjpeg Mjpeg =\u003E (feed as Resolver.Live.LiveSegmenter)?.Mjpeg;\n\n\t/// \u003Csummary\u003E The last segment has played to its end. \u003C/summary\u003E\n\tpublic bool Finished { get; private set; }\n\n\t/// \u003Csummary\u003E Media time (VOD), or running stream time (live). \u003C/summary\u003E\n\tpublic float Time =\u003E (float)StreamTime;\n\n\t/// \u003Csummary\u003E\n\t/// Stream time (seconds) of what\u0027s on screen. WebM segments keep absolute timestamps (their StreamStart is 0); MP4\n\t/// segments (live, HLS VOD) each start their clock at 0.\n\t/// \u003C/summary\u003E\n\tpublic double StreamTime =\u003E Mjpeg is { } mjpeg ? mjpeg.ShownTime : (current?.Segment.StreamStart ?? 0) \u002B (current?.Player?.PlaybackTime ?? 0);\n\n\tpublic float Duration =\u003E current?.Player?.Duration ?? 0;\n\tpublic bool IsPaused =\u003E current?.Player?.IsPaused ?? paused;\n\n\t/// \u003Csummary\u003E Every segment\u0027s frames go to one texture (see \u003Csee cref=\u0022VideoFrameSink\u0022/\u003E). \u003C/summary\u003E\n\treadonly VideoFrameSink sink = new();\n\n\tpublic Texture Texture =\u003E sink.Texture;\n\tpublic int Width =\u003E sink.Width;\n\tpublic int Height =\u003E sink.Height;\n\tpublic bool HasVideo =\u003E sink.Texture is not null \u0026\u0026 Width \u003E 4 \u0026\u0026 Height \u003E 4;\n\tpublic (int rate, int channels) AudioFormat =\u003E (current?.Player?.SampleRate ?? 0, current?.Player?.Channels ?? 0);\n\n\t/// \u003Csummary\u003E\n\t/// Merged media: the synced media time, null while paused. A first segment that starts later than it (at the next\n\t/// keyframe - see \u003Csee cref=\u0022MediaBackend\u0022/\u003E) opens only when the clock gets there, so it never has to seek forward.\n\t/// \u003C/summary\u003E\n\tpublic float? SyncedTime { get; set; }\n\n\tpublic SegmentPlayer( ISegmentFeed feed )\n\t{\n\t\tthis.feed = feed;\n\t\tif ( !feed.IsLive ) feed.Want( 0 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Call every frame.\n\t/// \u003C/summary\u003E\n\tpublic void Update()\n\t{\n\t\tif ( Mjpeg is { } mjpeg )\n\t\t{\n\t\t\tif ( !paused ) mjpeg.Present( sink );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( current is null )\n\t\t{\n\t\t\tvar first = feed.IsLive ? LiveJoinSegment() : feed.TryGet( 0 );\n\t\t\tif ( first is null ) return;\n\t\t\tif ( !feed.IsLive \u0026\u0026 !paused \u0026\u0026 SyncedTime is { } now \u0026\u0026 first.StreamStart \u002B first.Start - now \u003E StartLead ) return;\n\t\t\tcurrent = Open( first );\n\t\t\tif ( paused ) current.Player?.Pause();\n\t\t}\n\n\t\t// Live and fell far behind? Jump to the live edge.\n\t\tif ( feed.IsLive \u0026\u0026 feed.Newest - current.Segment.Seq \u003E MaxBehind \u0026\u0026 LiveJoinSegment() is { } edge )\n\t\t{\n\t\t\tLog.Info( $\u0022[bimp] live: {feed.Newest - current.Segment.Seq} segments behind, jumping to the live edge\u0022 );\n\t\t\tClose( next ); next = null;\n\t\t\tClose( current );\n\t\t\tcurrent = Open( edge );\n\t\t}\n\n\t\tTimedPresent( current, \u0022current\u0022 );\n\n\t\t// the first segment\u0027s own start seeds the start latency, so the first swap isn\u0027t late on a slow (4K) decoder\n\t\tif ( startLatency \u003C= 0 \u0026\u0026 !current.Primed \u0026\u0026 current.FirstFrame )\n\t\t{\n\t\t\tcurrent.Primed = true;\n\t\t\tstartLatency = (float)current.SinceOpened;\n\t\t}\n\n\t\t// the swapped-out player finishes its fade, then goes\n\t\tif ( fading is not null )\n\t\t{\n\t\t\tTimedPresent( fading, \u0022fading\u0022 );\n\t\t\tif ( sinceFade \u003E FadeSeconds ) CloseFading();\n\t\t}\n\n\t\tvar p = current.Player;\n\t\tvar seg = current.Segment;\n\n\t\t// A player started fresh delivers its first frame ~0.25s after Play(), and one that was paused takes ~1s\n\t\t// after Resume() - so start the next segment just in time (hidden, silent, presented so it runs),\n\t\t// timed from the measured start latency, and swap which player is on screen. Nothing ever pauses.\n\t\tif ( next is null \u0026\u0026 !paused \u0026\u0026 !seg.Last \u0026\u0026 current.Loaded \u0026\u0026 p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End - StartLead )\n\t\t{\n\t\t\tvar upcoming = feed.TryGet( seg.Seq \u002B 1 );\n\t\t\tif ( upcoming is not null ) next = Open( upcoming, preload: true );\n\t\t}\n\n\t\tif ( next?.Player is not null )\n\t\t{\n\t\t\tTimedPresent( next, \u0022next\u0022 );\n\n\t\t\tif ( !next.Primed \u0026\u0026 next.FirstFrame )\n\t\t\t{\n\t\t\t\tnext.Primed = true;\n\t\t\t\tvar latency = (float)next.SinceOpened;\n\t\t\t\tstartLatency = startLatency \u003C= 0 ? latency : startLatency * 0.7f \u002B latency * 0.3f;\n\t\t\t\tMediaProbe.Note( $\u0022seg {next.Segment.Seq} first frame {latency * 1000:0}ms after opening (avg {startLatency * 1000:0}ms), current at {p?.PlaybackTime:0.000}/{seg.End:0.000}\u0022 );\n\t\t\t}\n\t\t}\n\n\t\t// Swap when the current segment\u0027s clock reaches its end. (OnFinished fires ~80ms early, while\n\t\t// frames are still queued - swapping on it cut off the last frames.)\n\t\tvar stuck = current.Finished \u0026\u0026 p is not null \u0026\u0026 MathF.Abs( p.PlaybackTime - current.LastTime ) \u003C 0.0001f;\n\t\tcurrent.LastTime = p?.PlaybackTime ?? 0;\n\t\tvar atEnd = (current.Loaded \u0026\u0026 p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End - 0.005f) || stuck;\n\t\tif ( !atEnd ) return;\n\n\t\tif ( seg.Last )\n\t\t{\n\t\t\tFinished = current.Finished || stuck;\n\t\t\treturn;\n\t\t}\n\n\t\t// a next player still catching up waits, while the current one has overlap left to play\n\t\tvar settled = !ClockLeadOnly || next is null || next.Steady \u003E= SettleFrames || (p is not null \u0026\u0026 p.PlaybackTime \u003E= seg.End \u002B seg.Overlap - 0.1f) || stuck;\n\t\tif ( next is { Primed: true } \u0026\u0026 settled )\n\t\t{\n\t\t\t// the next player\u0027s clock vs the current one\u0027s, in stream time: \u002B means it\u0027s ahead. (Merged media keeps\n\t\t\t// absolute timestamps; live segments each start their clock at 0.)\n\t\t\tvar ahead = (float)((next.Segment.StreamStart \u002B (next.Player?.PlaybackTime ?? 0)) - (seg.StreamStart \u002B (p?.PlaybackTime ?? seg.End)));\n\n\t\t\t// keep the two clocks in step (see leadCorrection)\n\t\t\tif ( ClockLeadOnly ) clockLead = Math.Clamp( clockLead - ahead * 0.7f, 0.0f, 1.0f );\n\t\t\telse if ( !JustInTime ) leadCorrection = Math.Clamp( leadCorrection - ahead * 0.7f, -0.2f, 0.2f );\n\t\t\tMediaProbe.Note( $\u0022swap {seg.Seq}-\u003E{next.Segment.Seq} at {p?.PlaybackTime:0.000}/{seg.End:0.000} (overlap {seg.Overlap * 1000:0}ms), next ahead by {ahead * 1000:0}ms, lead now {StartLead * 1000:0}ms\u0022 );\n\t\t\tnext.Preload = false;\n\t\t\tApplyAudio( next );\n\n\t\t\t// its frame from just before the boundary goes up now, not 33 ms later with its next one\n\t\t\tsink.ShowHeld( next.Player );\n\n\t\t\t// crossfade: the old player goes silent as the new one comes up (see FadeSeconds)\n\t\t\tCloseFading();\n\t\t\tfading = current;\n\t\t\tfading.Fading = true;\n\t\t\tApplyAudio( fading );\n\t\t\tsinceFade = 0;\n\n\t\t\tcurrent = next;\n\t\t\tnext = null;\n\t\t\tfeed.Want( current.Segment.Seq ); // keeps the one after it coming\n\t\t}\n\n\t\t// otherwise the next segment isn\u0027t downloaded yet - hold the last frame\n\t}\n\n\tvoid TimedPresent( Slot slot, string role )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar t = System.Diagnostics.Stopwatch.GetTimestamp();\n\t\tslot.Player.Present();\n\t\tMediaProbe.PresentTook( System.Diagnostics.Stopwatch.GetElapsedTime( t ).TotalMilliseconds, role, slot.Segment.Seq );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Live: the segment to start from - the newest one that\u0027s at least \u003Csee cref=\u0022JoinBehind\u0022/\u003E behind the newest\n\t/// finished segment and leaves the feed\u0027s start buffer of stream time after it. Null while there isn\u0027t enough yet.\n\t/// \u003C/summary\u003E\n\tMediaSegment LiveJoinSegment()\n\t{\n\t\tvar newest = feed.TryGet( feed.Newest );\n\t\tif ( newest is null ) return null;\n\t\tvar bufferedUntil = newest.StreamStart \u002B newest.End;\n\n\t\tif ( feed.LowLatency ) return RealTime.NowDouble - newest.ReadyAt \u003E= LowJoinMargin ? newest : null;\n\n\t\tfor ( var seq = newest.Seq - JoinBehind; seq \u003E= 0; seq-- )\n\t\t{\n\t\t\tvar s = feed.TryGet( seq );\n\t\t\tif ( s is null ) return null; // not enough on disk yet\n\t\t\tif ( bufferedUntil - s.StreamStart \u003E= feed.StartBufferSeconds ) return s;\n\t\t}\n\t\treturn null;\n\t}\n\n\tSlot Open( MediaSegment segment, bool preload = false )\n\t{\n\t\tMediaProbe.Note( $\u0022open seg {segment.Seq} ({segment.Start:0.00}-{segment.End:0.00}){(preload ? \u0022 (preload)\u0022 : \u0022\u0022)}\u0022 );\n\t\tvar slot = new Slot { Segment = segment, SinceOpened = 0, Preload = preload };\n\t\ttry\n\t\t{\n\t\t\tvar player = new VideoPlayer();\n\t\t\tplayer.OnLoaded \u002B= () =\u003E slot.Loaded = true;\n\t\t\tplayer.OnFinished \u002B= () =\u003E slot.Finished = true;\n\t\t\tslot.Player = player;\n\t\t\tApplyAudio( slot );\n\t\t\t// only the player on screen updates the texture; a preloading one just says it\u0027s ready\n\t\t\tsink.Attach( player, () =\u003E !slot.Preload \u0026\u0026 !slot.Fading, () =\u003E\n\t\t\t{\n\t\t\t\tslot.Steady = slot.FirstFrame \u0026\u0026 slot.SinceFrame \u003C SettleInterval ? slot.Steady \u002B 1 : 0;\n\t\t\t\tslot.SinceFrame = 0;\n\t\t\t\tslot.FirstFrame = true;\n\t\t\t}, $\u0022seg {segment.Seq}\u0022, holdWhileInactive: preload );\n\t\t\tvar playTimer = System.Diagnostics.Stopwatch.StartNew();\n\t\t\tplayer.Play( FileSystem.Data, segment.Path );\n\t\t\tMediaProbe.Note( $\u0022Play() seg {segment.Seq} took {playTimer.Elapsed.TotalMilliseconds:0.0}ms\u0022 );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\terror = e.Message;\n\t\t}\n\t\treturn slot;\n\t}\n\n\tvoid Close( Slot slot )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar timer = System.Diagnostics.Stopwatch.StartNew();\n\t\tsink.Detach( slot.Player );\n\t\tslot.Player.Dispose();\n\t\tMediaProbe.Note( $\u0022close seg {slot.Segment.Seq} took {timer.Elapsed.TotalMilliseconds:0.0}ms\u0022 );\n\t}\n\n\tvoid CloseFading()\n\t{\n\t\tif ( fading is null ) return;\n\t\tClose( fading );\n\t\tfeed.Release( fading.Segment.Seq );\n\t\tfading = null;\n\t}\n\n\tpublic void SetPaused( bool pause )\n\t{\n\t\tpaused = pause;\n\t\tif ( current?.Player is { } p )\n\t\t{\n\t\t\tif ( pause ) p.Pause(); else p.Resume();\n\t\t}\n\n\t\tif ( pause ) CloseFading();\n\n\t\t// a preloading player would carry on running - start it again when we resume\n\t\tif ( pause \u0026\u0026 next is not null )\n\t\t{\n\t\t\tClose( next );\n\t\t\tnext = null;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A short hop within the current segment (landing exactly on the synced time after opening).\n\t/// Anything further is done by opening a new segmenter at the time.\n\t/// \u003C/summary\u003E\n\tpublic void Seek( float time )\n\t{\n\t\tvar seg = current?.Segment;\n\t\tif ( seg is null ) return;\n\t\tcurrent.Player?.Seek( Math.Clamp( time - (float)seg.StreamStart, seg.Start, seg.End ) );\n\t\tFinished = false;\n\t}\n\n\tpublic void SetAudio( Vector3 position, bool spatial, float volume, float distance )\n\t{\n\t\taudioPosition = position;\n\t\taudioSpatial = spatial;\n\t\taudioVolume = volume;\n\t\taudioDistance = distance;\n\t\tApplyAudio( current );\n\t\tApplyAudio( next );\n\t}\n\n\tvoid ApplyAudio( Slot slot )\n\t{\n\t\tif ( slot?.Player is null ) return;\n\t\tvar a = slot.Player.Audio;\n\t\ta.ListenLocal = !audioSpatial;\n\t\ta.Position = audioSpatial ? audioPosition : Vector3.Forward * 64.0f;\n\t\ta.Volume = slot.Preload || slot.Fading ? 0 : audioVolume;\n\t\ta.Distance = audioDistance;\n\t\ta.Falloff = MediaPlayer.AudioFalloff;\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tCloseFading();\n\t\tClose( current );\n\t\tClose( next );\n\t\tcurrent = next = null;\n\t\tsink.Dispose();\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Code/Resolver/Extractors/Extractors.cs","FileName":"Extractors.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp.Resolver.Extractors;\n\n/// \u003Csummary\u003E\n/// Every site the native resolver understands.\n/// \u003C/summary\u003E\npublic static class ExtractorRegistry\n{\n\t// built on each call, not kept in a static: a hotload keeps statics, so new extractors never showed up (their caches\n\t// are statics of their own)\n\tpublic static IReadOnlyList\u003CIExtractor\u003E All =\u003E new IExtractor[]\n\t{\n\t\tnew YouTubeExtractor(),\n\t\tnew StreamableExtractor(),\n\t\tnew VimeoExtractor(),\n\t\tnew SoundCloudExtractor(),\n\t\tnew TwitchExtractor(),\n\t\tnew KickExtractor(),\n\t\tnew XExtractor(),\n\t\tnew ArchiveExtractor(),\n\t\tnew BandcampExtractor(),\n\t\tnew DailymotionExtractor(),\n\t\tnew HlsExtractor(), // any .m3u8 link\n\t\tnew DashExtractor(), // any .mpd link\n\t\tnew LabExtractor(), // [probe] loopback test media\n\t};\n\n\tpublic static IExtractor For( Uri url ) =\u003E All.FirstOrDefault( e =\u003E e.CanHandle( url ) );\n\n\tpublic static IExtractor ByKey( string key ) =\u003E All.FirstOrDefault( e =\u003E e.Key == key );\n}\n"},{"Ident":"rexi.bimp","Path":"Code/MediaPlayer.cs","FileName":"MediaPlayer.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Threading.Tasks;\r\nusing Bimp.Resolver;\r\n\r\nnamespace Bimp;\r\n\r\n/// \u003Csummary\u003E\r\n/// A networked media player. The host owns the state (what\u0027s playing, when it started, paused, queue)\r\n/// and every client plays it locally, keeping itself in sync with the host\u0027s clock.\r\n/// \u003Cpara\u003E\r\n/// Put this on a GameObject with a \u003Csee cref=\u0022WorldPanel\u0022/\u003E \u002B \u003Csee cref=\u0022MediaScreen\u0022/\u003E for a TV/screen,\r\n/// or with \u003Csee cref=\u0022MediaSpeaker\u0022/\u003E and \u003Csee cref=\u0022AudioOnlyPlayer\u0022/\u003E for a speaker. Add a collider and\r\n/// \u003Csee cref=\u0022MediaInteract\u0022/\u003E so players can open the controls with the use key.\r\n/// \u003C/para\u003E\r\n/// \u003C/summary\u003E\r\n[Title( \u0022Media Player\u0022 ), Category( \u0022Media\u0022 ), Icon( \u0022smart_display\u0022 )]\r\npublic sealed class MediaPlayer : Component\r\n{\r\n\t//\r\n\t// Inspector settings\r\n\t//\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Who can control playback. The host can always control it.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Access\u0022 )] public MediaPermission Permission { get; set; } = MediaPermission.Anyone;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// SteamIds allowed to control this player when \u003Csee cref=\u0022Permission\u0022/\u003E is Whitelist.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Access\u0022 ), ShowIf( nameof( Permission ), MediaPermission.Whitelist )]\r\n\tpublic List\u003Clong\u003E AllowedSteamIds { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Let anyone add to the queue, even if they can\u0027t control playback.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Access\u0022 )] public bool AnyoneCanQueue { get; set; } = true;\r\n\r\n\t[Property, Group( \u0022Queue\u0022 ), Range( 1, 200 )] public int MaxQueue { get; set; } = 30;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Replay the current item when it ends and the queue is empty.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Queue\u0022 )] public bool Loop { get; set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Played by the host when the scene starts.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Playback\u0022 )] public string DefaultUrl { get; set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Never show video, only play audio. For speakers, radios and jukeboxes.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Playback\u0022 )] public bool AudioOnlyPlayer { get; set; }\r\n\r\n\t[Property, Group( \u0022Playback\u0022 ), Range( 0, 1 )] public float Volume { get; set; } = 1.0f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Sound comes from this object\u0027s position in the world and falls off with distance.\r\n\t/// When off, everyone hears it at the same volume wherever they are.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Audio\u0022 )] public bool Spatial { get; set; } = true;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Distance at which the sound is fully faded out (see \u003Csee cref=\u0022AudioFalloff\u0022/\u003E).\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Audio\u0022 ), ShowIf( nameof( Spatial ), true )] public float AudioDistance { get; set; } = 4000.0f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How the sound fades out to \u003Csee cref=\u0022AudioDistance\u0022/\u003E: nearly full volume in the room with the screen, then a\r\n\t/// smooth fade. The engine\u0027s own curve is made for its 15000 unit default range; over a screen\u0027s few thousand units\r\n\t/// it was down to 22% three metres away and 4% at twelve.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static readonly Curve AudioFalloff = new( new( 0, 1 ), new( 0.15f, 0.92f ), new( 0.45f, 0.55f ), new( 0.75f, 0.2f ), new( 1, 0 ) );\r\n\r\n\t/// \u003Csummary\u003E 3D for this player: the player\u0027s own setting, unless they\u0027ve turned 3D audio off (bimp_spatial). \u003C/summary\u003E\r\n\tpublic bool EffectiveSpatial =\u003E Spatial \u0026\u0026 MediaSettings.SpatialAudio;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// 2D for this player (bimp_spatial off, on a player that\u0027s otherwise 3D): the sound has no direction, but still\r\n\t/// fades with the listener\u0027s distance from it, on the same curve as 3D. A player that isn\u0027t \u003Csee cref=\u0022Spatial\u0022/\u003E\r\n\t/// at all is the same volume everywhere.\r\n\t/// \u003C/summary\u003E\r\n\tbool FlatWithDistance =\u003E Spatial \u0026\u0026 !MediaSettings.SpatialAudio;\r\n\r\n\t/// \u003Csummary\u003E How loud this player is at a point, from \u003Csee cref=\u0022AudioFalloff\u0022/\u003E over \u003Csee cref=\u0022AudioDistance\u0022/\u003E. \u003C/summary\u003E\r\n\tfloat DistanceFade( Vector3 listener )\r\n\t{\r\n\t\tvar range = ScaledAudioDistance;\r\n\t\tif ( range \u003C= 0 ) return 1;\r\n\t\treturn AudioFalloff.Evaluate( (listener.Distance( SoundPositionFor( listener ) ) / range).Clamp( 0, 1 ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where the sound comes from. Defaults to this GameObject.\r\n\t/// \u003C/summary\u003E\r\n\t[Property, Group( \u0022Audio\u0022 )] public GameObject SoundOrigin { get; set; }\r\n\r\n\t//\r\n\t// Synced state - only the host writes these\r\n\t//\r\n\r\n\t/// \u003Csummary\u003E The url the user entered. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string CurrentUrl { get; set; }\r\n\r\n\t/// \u003Csummary\u003E The url clients actually play. Empty when nothing is playing. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string PlayUrl { get; set; }\r\n\r\n\t[Sync( SyncFlags.FromHost )] public string Title { get; set; }\r\n\t[Sync( SyncFlags.FromHost )] public string RequestedBy { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Media length in seconds, 0 if unknown or live. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public float MediaDuration { get; set; }\r\n\r\n\t[Sync( SyncFlags.FromHost )] public bool IsLive { get; set; }\r\n\t[Sync( SyncFlags.FromHost )] public bool AudioOnly { get; set; }\r\n\t[Sync( SyncFlags.FromHost )] public bool SeekByReload { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Host time (\u003Csee cref=\u0022Time.NowDouble\u0022/\u003E) at which media time 0 would have played. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public double StartTime { get; set; }\r\n\r\n\t[Sync( SyncFlags.FromHost )] public bool Paused { get; set; }\r\n\t[Sync( SyncFlags.FromHost )] public float PausedAt { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Increments every time something (re)starts, so replaying the same url reloads it. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public int PlayId { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Increments on every explicit seek, so clients apply it right away rather than treating it as drift. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public int SeekId { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Host status message: \u0022Loading...\u0022 or an error. \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string Status { get; set; }\r\n\r\n\t[Sync( SyncFlags.FromHost )] public NetList\u003CMediaQueueItem\u003E Queue { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003E Video heights the current media is available in (see \u003Csee cref=\u0022MediaQueueItem.Qualities\u0022/\u003E). \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string Qualities { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Audio tracks of the current media (see \u003Csee cref=\u0022MediaQueueItem.AudioTracks\u0022/\u003E). \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string AudioTracks { get; set; }\r\n\r\n\t/// \u003Csummary\u003E Caption tracks of the current media (see \u003Csee cref=\u0022MediaQueueItem.CaptionTracks\u0022/\u003E). \u003C/summary\u003E\r\n\t[Sync( SyncFlags.FromHost )] public string CaptionTracks { get; set; }\r\n\r\n\t//\r\n\t// Per-client stream choices. Everyone shares the timeline, but picks their own resolution / audio language.\r\n\t//\r\n\r\n\tpublic IReadOnlyList\u003Cint\u003E AvailableQualities =\u003E MediaStreamOptions.ParseQualities( Qualities );\r\n\tpublic IReadOnlyList\u003CMediaAudioTrack\u003E AvailableAudioTracks =\u003E MediaStreamOptions.ParseAudioTracks( AudioTracks );\r\n\r\n\t/// \u003Csummary\u003E The height this client streams, 0 for automatic. From the bimp_quality convar. \u003C/summary\u003E\r\n\tpublic int SelectedQuality =\u003E MediaStreamOptions.ChooseQuality( AvailableQualities, MediaSettings.Quality );\r\n\r\n\t/// \u003Csummary\u003E The audio track this client streams, null for the original. From the bimp_audio_lang convar. \u003C/summary\u003E\r\n\tpublic MediaAudioTrack SelectedAudioTrack =\u003E MediaStreamOptions.ChooseAudioTrack( AvailableAudioTracks, MediaSettings.AudioLanguage );\r\n\r\n\t/// \u003Csummary\u003E \u003Csee cref=\u0022PlayUrl\u0022/\u003E plus this client\u0027s choices. Changing a choice reloads playback at the same time. \u003C/summary\u003E\r\n\tpublic string LocalStreamUrl =\u003E MediaStreamOptions.ApplyTo( PlayUrl, SelectedQuality, SelectedAudioTrack );\r\n\r\n\tstring CurrentKey =\u003E KeyFor( PlayId, LocalStreamUrl );\r\n\r\n\t//\r\n\t// Captions. Every client fetches its own cues (the urls can be tied to the IP) and shows them against the shared timeline.\r\n\t//\r\n\r\n\tpublic IReadOnlyList\u003CMediaCaptionTrack\u003E AvailableCaptionTracks =\u003E Captions.ParseTracks( CaptionTracks );\r\n\r\n\t/// \u003Csummary\u003E The caption track this client shows, null when captions are off or there are none. From the bimp_subs / bimp_sub_lang convars. \u003C/summary\u003E\r\n\tpublic MediaCaptionTrack SelectedCaptionTrack =\u003E MediaSettings.Subtitles ? Captions.Choose( AvailableCaptionTracks, MediaSettings.SubtitleLanguage ) : null;\r\n\r\n\t/// \u003Csummary\u003E The caption line to show right now, null for none. \u003C/summary\u003E\r\n\tpublic string CurrentCaption =\u003E HasMedia ? Captions.TextAt( captionCues, CurrentTime ) : null;\r\n\r\n\tstring captionKey;\r\n\tint captionToken;\r\n\tList\u003CCaptionCue\u003E captionCues;\r\n\r\n\t/// \u003Csummary\u003E Fetch the cues when the media or the chosen track changes. \u003C/summary\u003E\r\n\tvoid UpdateCaptions()\r\n\t{\r\n\t\tvar track = HasMedia ? SelectedCaptionTrack : null;\r\n\t\tvar key = track is null ? null : $\u0022{PlayId}|{track.Id}\u0022;\r\n\t\tif ( key == captionKey ) return;\r\n\r\n\t\tcaptionKey = key;\r\n\t\tcaptionCues = null;\r\n\t\tvar token = \u002B\u002BcaptionToken;\r\n\t\tif ( track is not null ) LoadCaptionsAsync( token, track ).FireAndForget( \u0022captions\u0022 );\r\n\t}\r\n\r\n\tasync Task LoadCaptionsAsync( int token, MediaCaptionTrack track )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar play = Bimp.Resolver.PlayToken.Parse( PlayUrl );\r\n\t\t\tif ( Bimp.Resolver.Extractors.ExtractorRegistry.ByKey( play?.Extractor ) is not Bimp.Resolver.ICaptionExtractor source ) return;\r\n\r\n\t\t\tvar body = await source.GetCaptionsAsync( play.Id, track.Id, System.Threading.CancellationToken.None );\r\n\t\t\tif ( token != captionToken || !this.IsValid() ) return;\r\n\r\n\t\t\tvar cues = Captions.Parse( body );\r\n\t\t\tLog.Info( $\u0022[bimp] captions {track.Id} ({track.Name}): {cues.Count} cues\u0022 );\r\n\t\t\tcaptionCues = cues;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022[bimp] Couldn\u0027t load captions: {e.Message}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t//\r\n\t// Local state\r\n\t//\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The local playback. Null on a dedicated server or when nothing is playing.\r\n\t/// \u003C/summary\u003E\r\n\tpublic MediaBackend Backend { get; private set; }\r\n\r\n\tstring backendKey;\r\n\tRealTimeSince sinceCorrection;\r\n\tbool initialSeekPending;\r\n\tint reportedDurationFor = -1;\r\n\tint endedPlayId = -1;\r\n\tint resolveToken;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Local message for this client (permission denied, load failures), fades after a few seconds.\r\n\t/// \u003C/summary\u003E\r\n\tpublic string LocalNotice { get; private set; }\r\n\tRealTimeSince sinceNotice;\r\n\r\n\tpublic bool HasMedia =\u003E !string.IsNullOrEmpty( PlayUrl );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Are we the authority on the state? True for the host, or when not networked at all.\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool IsAuthority =\u003E !Networking.IsActive || Networking.IsHost;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where playback should be right now, according to the host.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float CurrentTime\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( !HasMedia ) return 0;\r\n\t\t\tvar t = Paused ? PausedAt : (float)Math.Max( 0, Time.NowDouble - StartTime );\r\n\t\t\tif ( MediaDuration \u003E 0 ) t = MathF.Min( t, MediaDuration );\r\n\t\t\treturn t;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The shared resolver (host only).\r\n\t/// \u003C/summary\u003E\r\n\tstatic BimpResolver Resolver =\u003E BimpResolver.Instance;\r\n\r\n\tpublic Vector3 SoundPosition =\u003E SoundOrigin.IsValid() ? SoundOrigin.WorldPosition : WorldPosition;\r\n\r\n\tBoxCollider soundBox;\r\n\r\n\t/// \u003Csummary\u003E The object the sound comes from: \u003Csee cref=\u0022SoundOrigin\u0022/\u003E, or this one. \u003C/summary\u003E\r\n\tGameObject SoundObject =\u003E SoundOrigin.IsValid() ? SoundOrigin : GameObject;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How much bigger (or smaller) than its prefab this player has been scaled, e.g. by the Sandbox resize tool.\r\n\t/// The audio range grows with it, so a bigger screen is heard from further away.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float SoundScale\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar s = SoundObject.WorldScale;\r\n\t\t\treturn MathF.Max( 0.01f, MathF.Max( MathF.Abs( s.x ), MathF.Max( MathF.Abs( s.y ), MathF.Abs( s.z ) ) ) );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E \u003Csee cref=\u0022AudioDistance\u0022/\u003E at the current size. \u003C/summary\u003E\r\n\tpublic float ScaledAudioDistance =\u003E AudioDistance * SoundScale;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where the sound is heard from for a listener. A scaled-up screen is a big surface, not a point, so the sound comes\r\n\t/// from the nearest point of its box (the listener\u0027s own position when inside it), and fades from there.\r\n\t/// \u003C/summary\u003E\r\n\tVector3 SoundPositionFor( Vector3 listener )\r\n\t{\r\n\t\tif ( SoundOrigin.IsValid() ) return SoundOrigin.WorldPosition;\r\n\r\n\t\tif ( !soundBox.IsValid() ) soundBox = GetComponent\u003CBoxCollider\u003E();\r\n\t\tif ( !soundBox.IsValid() ) return WorldPosition;\r\n\r\n\t\tvar tx = soundBox.GameObject.WorldTransform;\r\n\t\tvar half = soundBox.Scale * 0.5f;\r\n\t\tvar local = tx.PointToLocal( listener ) - soundBox.Center;\r\n\t\tlocal = new Vector3( local.x.Clamp( -half.x, half.x ), local.y.Clamp( -half.y, half.y ), local.z.Clamp( -half.z, half.z ) );\r\n\t\treturn tx.PointToWorld( local \u002B soundBox.Center );\r\n\t}\r\n\r\n\tprotected override void OnAwake()\r\n\t{\r\n\t\t// Sync\u0027d properties only work on network objects. Make that the default so mappers don\u0027t have to.\r\n\t\tif ( GameObject.NetworkMode == NetworkMode.Snapshot )\r\n\t\t\tGameObject.NetworkMode = NetworkMode.Object;\r\n\t}\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tif ( IsAuthority \u0026\u0026 !HasMedia \u0026\u0026 !string.IsNullOrWhiteSpace( DefaultUrl ) )\r\n\t\t{\r\n\t\t\tPlayNowAsync( DefaultUrl, \u0022Map\u0022 ).FireAndForget( \u0022map autoplay\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\tDisposeBackend();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tDisposeBackend();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( IsAuthority )\r\n\t\t\tUpdateAuthority();\r\n\r\n\t\tif ( !Application.IsDedicatedServer )\r\n\t\t{\r\n\t\t\tUpdatePlayback();\r\n\t\t\tUpdateCaptions();\r\n\t\t\tRecentLinks.Saw( this );\r\n\t\t}\r\n\r\n\t\tMediaProbe.PresentFile();\r\n\t\tLatencyLab.Tick();\r\n\t\tMediaProbe.SampleLatency( this );\r\n\t\tif ( MediaProbe.IsRecording( this ) )\r\n\t\t\tMediaProbe.Sample( this );\r\n\r\n\t\tif ( LocalNotice is not null \u0026\u0026 sinceNotice \u003E 6 )\r\n\t\t\tLocalNotice = null;\r\n\t}\r\n\r\n\t#region Permissions\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Can this connection control playback?\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool CanControl( Connection c )\r\n\t{\r\n\t\tif ( !Networking.IsActive ) return true;\r\n\t\tif ( c is null ) return false;\r\n\t\tif ( c.IsHost || c == Connection.Host ) return true;\r\n\r\n\t\treturn Permission switch\r\n\t\t{\r\n\t\t\tMediaPermission.Anyone =\u003E true,\r\n\t\t\tMediaPermission.Whitelist =\u003E AllowedSteamIds?.Contains( c.SteamId.Value ) ?? false,\r\n\t\t\t_ =\u003E false,\r\n\t\t};\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Can this connection add to the queue?\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool CanQueue( Connection c ) =\u003E AnyoneCanQueue || CanControl( c );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Can the local player control playback? Used by the UI.\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool CanLocalControl =\u003E CanControl( Connection.Local );\r\n\tpublic bool CanLocalQueue =\u003E CanQueue( Connection.Local );\r\n\r\n\tstatic string CallerName( Connection c ) =\u003E c?.DisplayName ?? \u0022Someone\u0022;\r\n\r\n\t#endregion\r\n\r\n\t#region Requests (called by anyone, run on the host)\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Play this url now, replacing whatever is playing.\r\n\t/// \u003C/summary\u003E\r\n\t[Rpc.Host]\r\n\tpublic void RequestPlay( string url )\r\n\t{\r\n\t\tvar caller = Rpc.Caller;\r\n\t\tif ( !CanControl( caller ) ) { Deny( caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\r\n\t\tPlayNowAsync( url, CallerName( caller ) ).FireAndForget( $\u0022play from {CallerName( caller )}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Add this url to the queue. Plays immediately if nothing is playing.\r\n\t/// \u003C/summary\u003E\r\n\t[Rpc.Host]\r\n\tpublic void RequestEnqueue( string url )\r\n\t{\r\n\t\tvar caller = Rpc.Caller;\r\n\t\tif ( !CanQueue( caller ) ) { Deny( caller, \u0022You don\u0027t have permission to queue on this player.\u0022 ); return; }\r\n\t\tif ( Queue.Count \u003E= MaxQueue ) { Deny( caller, \u0022The queue is full.\u0022 ); return; }\r\n\r\n\t\tEnqueueAsync( url, CallerName( caller ), caller ).FireAndForget( $\u0022enqueue from {CallerName( caller )}\u0022 );\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestSetPaused( bool paused )\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tSetPaused( paused );\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestSeek( float time )\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tSeek( time );\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestSkip()\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tPlayNext();\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestStop()\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tStop();\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestRemove( int index )\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tif ( index \u003C 0 || index \u003E= Queue.Count ) return;\r\n\t\tQueue.RemoveAt( index );\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestClearQueue()\r\n\t{\r\n\t\tif ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, \u0022You don\u0027t have permission to control this player.\u0022 ); return; }\r\n\t\tQueue.Clear();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A client learned the duration of a direct file after loading it. The host takes the first answer.\r\n\t/// \u003C/summary\u003E\r\n\t[Rpc.Host( NetFlags.Unreliable )]\r\n\tvoid ReportDuration( int playId, float duration )\r\n\t{\r\n\t\tif ( playId != PlayId || MediaDuration \u003E 0 || IsLive ) return;\r\n\t\tif ( duration \u003C= 0 || float.IsNaN( duration ) || float.IsInfinity( duration ) ) return;\r\n\t\tMediaDuration = duration;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Tell one client something went wrong with their request.\r\n\t/// \u003C/summary\u003E\r\n\tvoid Deny( Connection caller, string message )\r\n\t{\r\n\t\tif ( caller is null || caller == Connection.Local || !Networking.IsActive )\r\n\t\t{\r\n\t\t\tShowNotice( message );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tusing ( Rpc.FilterInclude( caller ) )\r\n\t\t{\r\n\t\t\tReceiveNotice( message );\r\n\t\t}\r\n\t}\r\n\r\n\t[Rpc.Broadcast( NetFlags.HostOnly )]\r\n\tvoid ReceiveNotice( string message )\r\n\t{\r\n\t\tShowNotice( message );\r\n\t}\r\n\r\n\tvoid ShowNotice( string message )\r\n\t{\r\n\t\tLocalNotice = message;\r\n\t\tsinceNotice = 0;\r\n\t}\r\n\r\n\t#endregion\r\n\r\n\t#region Authority (host) logic\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Resolve and play right away. Host only.\r\n\t/// \u003C/summary\u003E\r\n\tpublic async Task PlayNowAsync( string url, string requestedBy )\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\tvar token = \u002B\u002BresolveToken;\r\n\t\tStatus = \u0022Loading...\u0022;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// a playlist link: the first video plays now, the rest are queued\r\n\t\t\tvar playlist = await Resolver.ResolvePlaylistAsync( url, AudioOnlyPlayer, MaxQueue \u002B 1 );\r\n\t\t\tif ( token != resolveToken || !this.IsValid() ) return;\r\n\t\t\tif ( playlist is not null )\r\n\t\t\t{\r\n\t\t\t\tvar first = await ResolveLazyAsync( playlist[0], requestedBy );\r\n\t\t\t\tif ( token != resolveToken || !this.IsValid() ) return;\r\n\r\n\t\t\t\tStartItem( first );\r\n\t\t\t\tQueueLazy( playlist.Skip( 1 ), requestedBy, null );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar item = await Resolver.ResolveAsync( url, AudioOnlyPlayer );\r\n\t\t\tif ( token != resolveToken || !this.IsValid() ) return; // something newer was requested\r\n\r\n\t\t\titem.RequestedBy = requestedBy;\r\n\t\t\tStartItem( item );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( token != resolveToken || !this.IsValid() ) return;\r\n\t\t\tStatus = e is ResolveException ? e.Message : $\u0022Error: {e.Message}\u0022;\r\n\t\t\tLog.Warning( $\u0022[bimp] Couldn\u0027t play {url}: {(e is ResolveException ? e.Message : e.ToString())}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Resolve and add to the queue. Host only.\r\n\t/// \u003C/summary\u003E\r\n\tpublic async Task EnqueueAsync( string url, string requestedBy, Connection caller = null )\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar playlist = await Resolver.ResolvePlaylistAsync( url, AudioOnlyPlayer, MaxQueue \u002B 1 );\r\n\t\t\tif ( !this.IsValid() ) return;\r\n\t\t\tif ( playlist is not null )\r\n\t\t\t{\r\n\t\t\t\tvar rest = playlist.AsEnumerable();\r\n\t\t\t\tif ( !HasMedia \u0026\u0026 Status != \u0022Loading...\u0022 )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar first = await ResolveLazyAsync( playlist[0], requestedBy );\r\n\t\t\t\t\tif ( !this.IsValid() ) return;\r\n\t\t\t\t\tif ( !HasMedia \u0026\u0026 Status != \u0022Loading...\u0022 ) // nothing else started meanwhile\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tStartItem( first );\r\n\t\t\t\t\t\trest = playlist.Skip( 1 );\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\r\n\t\t\t\tQueueLazy( rest, requestedBy, caller );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar item = await Resolver.ResolveAsync( url, AudioOnlyPlayer );\r\n\t\t\tif ( !this.IsValid() ) return;\r\n\r\n\t\t\titem.RequestedBy = requestedBy;\r\n\r\n\t\t\tif ( !HasMedia \u0026\u0026 Status != \u0022Loading...\u0022 )\r\n\t\t\t{\r\n\t\t\t\tStartItem( item );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tif ( Queue.Count \u003E= MaxQueue ) { Deny( caller, \u0022The queue is full.\u0022 ); return; }\r\n\t\t\tQueue.Add( item );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( !this.IsValid() ) return;\r\n\t\t\tDeny( caller, e is ResolveException ? e.Message : $\u0022Error: {e.Message}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E A playlist entry resolved properly (qualities, dubs, live or not). Throws like \u003Csee cref=\u0022BimpResolver.ResolveAsync\u0022/\u003E. \u003C/summary\u003E\r\n\tasync Task\u003CMediaQueueItem\u003E ResolveLazyAsync( MediaQueueItem entry, string requestedBy )\r\n\t{\r\n\t\tvar item = await Resolver.ResolveAsync( entry.Url, AudioOnlyPlayer );\r\n\t\titem.RequestedBy = requestedBy;\r\n\t\treturn item;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Add playlist entries to the queue until it\u0027s full, and say so if some didn\u0027t fit. \u003C/summary\u003E\r\n\tvoid QueueLazy( IEnumerable\u003CMediaQueueItem\u003E entries, string requestedBy, Connection caller )\r\n\t{\r\n\t\tint added = 0, left = 0;\r\n\t\tforeach ( var entry in entries )\r\n\t\t{\r\n\t\t\tif ( Queue.Count \u003E= MaxQueue ) { left\u002B\u002B; continue; }\r\n\t\t\tentry.RequestedBy = requestedBy;\r\n\t\t\tQueue.Add( entry );\r\n\t\t\tadded\u002B\u002B;\r\n\t\t}\r\n\r\n\t\tif ( left \u003E 0 ) Deny( caller, $\u0022Queued {added} videos - the queue is full, {left} more didn\u0027t fit.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Play a queued playlist entry: resolve it first, and skip it (with a notice) if it can\u0027t be played. \u003C/summary\u003E\r\n\tasync Task StartLazyAsync( MediaQueueItem entry )\r\n\t{\r\n\t\tvar token = resolveToken;\r\n\t\tStatus = \u0022Loading...\u0022;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar item = await ResolveLazyAsync( entry, entry.RequestedBy );\r\n\t\t\tif ( token != resolveToken || !this.IsValid() ) return;\r\n\t\t\tStartItem( item );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( token != resolveToken || !this.IsValid() ) return;\r\n\r\n\t\t\tvar why = e is ResolveException ? e.Message : $\u0022Error: {e.Message}\u0022;\r\n\t\t\tLog.Warning( $\u0022[bimp] Couldn\u0027t play {entry.Url}: {e.Message}\u0022 );\r\n\r\n\t\t\tvar notice = $\u0022Skipped \\\u0022{entry.Title}\\\u0022: {why}\u0022;\r\n\t\t\tif ( Networking.IsActive ) ReceiveNotice( notice );\r\n\t\t\telse ShowNotice( notice );\r\n\r\n\t\t\tPlayNext();\r\n\t\t}\r\n\t}\r\n\r\n\tvoid StartItem( MediaQueueItem item )\r\n\t{\r\n\t\tCurrentUrl = item.Url;\r\n\t\tPlayUrl = item.PlayUrl;\r\n\t\tTitle = item.Title;\r\n\t\tRequestedBy = item.RequestedBy;\r\n\t\tMediaDuration = item.Duration;\r\n\t\tIsLive = item.IsLive;\r\n\t\tAudioOnly = item.AudioOnly || AudioOnlyPlayer;\r\n\t\tSeekByReload = item.SeekByReload;\r\n\t\tQualities = item.Qualities;\r\n\t\tAudioTracks = item.AudioTracks;\r\n\t\tCaptionTracks = item.CaptionTracks;\r\n\t\tPaused = false;\r\n\t\tPausedAt = 0;\r\n\t\t// a link with a start time (?t=90) plays from there, the way a late joiner starts mid-way\r\n\t\tStartTime = Time.NowDouble - (item.IsLive ? 0 : Math.Max( 0, item.StartAt ));\r\n\t\tStatus = null;\r\n\t\tPlayId\u002B\u002B;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Play the next item in the queue, or stop. Host only.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void PlayNext()\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\tresolveToken\u002B\u002B; // cancel any pending \u0022play now\u0022\r\n\r\n\t\tif ( Queue.Count \u003E 0 )\r\n\t\t{\r\n\t\t\tvar next = Queue[0];\r\n\t\t\tQueue.RemoveAt( 0 );\r\n\t\t\tif ( next.Lazy ) StartLazyAsync( next ).FireAndForget( $\u0022lazy start of {next.Title}\u0022 );\r\n\t\t\telse StartItem( next );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tStop();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Stop playback (the queue is kept). Host only.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void Stop()\r\n\t{\r\n\t\tif ( !IsAuthority ) return;\r\n\r\n\t\tresolveToken\u002B\u002B;\r\n\t\tCurrentUrl = null;\r\n\t\tPlayUrl = null;\r\n\t\tTitle = null;\r\n\t\tRequestedBy = null;\r\n\t\tQualities = null;\r\n\t\tAudioTracks = null;\r\n\t\tCaptionTracks = null;\r\n\t\tMediaDuration = 0;\r\n\t\tIsLive = false;\r\n\t\tPaused = false;\r\n\t\tPausedAt = 0;\r\n\t\tStatus = null;\r\n\t\tPlayId\u002B\u002B;\r\n\t}\r\n\r\n\tpublic void SetPaused( bool paused )\r\n\t{\r\n\t\tif ( !IsAuthority || !HasMedia || IsLive || Paused == paused ) return;\r\n\r\n\t\tif ( paused )\r\n\t\t{\r\n\t\t\t// Freeze the shared timeline where the host\u0027s picture actually is, so the host\u0027s own video\r\n\t\t\t// doesn\u0027t jump on pause. Clients are within their drift tolerance of it anyway.\r\n\t\t\tPausedAt = LocalPlaybackPosition() ?? CurrentTime;\r\n\t\t\tPaused = true;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tStartTime = Time.NowDouble - PausedAt;\r\n\t\t\tPaused = false;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Seek( float time )\r\n\t{\r\n\t\tif ( !IsAuthority || !HasMedia || IsLive ) return;\r\n\r\n\t\ttime = MathF.Max( 0, time );\r\n\t\tif ( MediaDuration \u003E 0 ) time = MathF.Min( time, MediaDuration - 0.5f );\r\n\r\n\t\tif ( Paused ) PausedAt = time;\r\n\t\telse StartTime = Time.NowDouble - time;\r\n\r\n\t\tSeekId\u002B\u002B;\r\n\r\n\t\t// Don\u0027t let an old \u0022ended\u0022 state trip us up after seeking back\r\n\t\tendedPlayId = -1;\r\n\t}\r\n\r\n\tvoid UpdateAuthority()\r\n\t{\r\n\t\tif ( !HasMedia || Paused || IsLive ) return;\r\n\t\tif ( endedPlayId == PlayId ) return;\r\n\r\n\t\tvar ended = false;\r\n\r\n\t\t// Known duration - use the clock, works on dedicated servers too\r\n\t\tif ( MediaDuration \u003E 0 \u0026\u0026 Time.NowDouble - StartTime \u003E= MediaDuration \u002B 1.0 )\r\n\t\t\tended = true;\r\n\r\n\t\t// Unknown duration - trust our own playback, if we have one\r\n\t\tif ( MediaDuration \u003C= 0 \u0026\u0026 Backend is not null \u0026\u0026 Backend.Finished \u0026\u0026 backendKey == CurrentKey )\r\n\t\t\tended = true;\r\n\r\n\t\tif ( !ended ) return;\r\n\r\n\t\tendedPlayId = PlayId;\r\n\t\tOnMediaEnded();\r\n\t}\r\n\r\n\tvoid OnMediaEnded()\r\n\t{\r\n\t\tif ( Queue.Count \u003E 0 )\r\n\t\t{\r\n\t\t\tPlayNext();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( Loop )\r\n\t\t{\r\n\t\t\tPaused = false;\r\n\t\t\tStartTime = Time.NowDouble;\r\n\t\t\tPlayId\u002B\u002B; // force everyone to reload, the decoder has stopped\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tStop();\r\n\t}\r\n\r\n\t#endregion\r\n\r\n\t#region Local playback\r\n\r\n\tstatic string KeyFor( int playId, string url ) =\u003E string.IsNullOrEmpty( url ) ? null : $\u0022{playId}|{url}\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Throw away local playback and start it again at the synced time.\r\n\t/// \u003C/summary\u003E\r\n\tinternal void ReloadBackend()\r\n\t{\r\n\t\tDisposeBackend();\r\n\t\tbackendKey = CurrentKey;\r\n\t\tif ( backendKey is not null ) CreateBackend();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Diagnostics: open merged media at t with an explicit time offset.\r\n\t/// \u003C/summary\u003E\r\n\tinternal void ReloadBackendAt( float t, float offset )\r\n\t{\r\n\t\tDisposeBackend();\r\n\t\tbackendKey = CurrentKey;\r\n\t\tif ( backendKey is null ) return;\r\n\t\tBackend = PlayToken.IsToken( LocalStreamUrl )\r\n\t\t\t? MediaBackend.CreateNative( LocalStreamUrl, AudioOnly, t )\r\n\t\t\t: MediaBackend.Create( LocalStreamUrl, AudioOnly, offset );\r\n\t\tsinceCorrection = 0;\r\n\t\tinitialSeekPending = false;\r\n\t\tappliedSeekId = SeekId;\r\n\t\tlastAppliedPaused = !Paused;\r\n\t}\r\n\r\n\tvoid DisposeBackend()\r\n\t{\r\n\t\tBackend?.Dispose();\r\n\t\tBackend = null;\r\n\t\tbackendKey = null;\r\n\t}\r\n\r\n\tvoid CreateBackend()\r\n\t{\r\n\t\t// Merged media starts at the synced time: segment files are written from the cue point before it,\r\n\t\t// with absolute timestamps, so there\u0027s no time offset. Everything else starts at 0 and seeks once loaded.\r\n\t\tvar t = CurrentTime;\r\n\t\tvar url = LocalStreamUrl;\r\n\r\n\t\tBackend = IsLive ? MediaBackend.CreateLive( url, Math.Clamp( MediaSettings.MaxVideoHeight, 144, 2160 ), AudioOnly )\r\n\t\t\t: PlayToken.IsToken( url ) ? MediaBackend.CreateNative( url, AudioOnly, SeekByReload \u0026\u0026 !IsLive ? t : 0 )\r\n\t\t\t: MediaBackend.Create( url, AudioOnly, 0 );\r\n\t\tsinceCorrection = 0;\r\n\t\tinitialSeekPending = !IsLive; // once loaded, land exactly on the synced time (see CorrectDrift)\r\n\t\tappliedSeekId = SeekId; // a fresh backend starts at the right time anyway (initial seek)\r\n\t\tlastAppliedPaused = !Paused; // make sure the new backend gets told the pause state\r\n\r\n\t\tif ( Backend.Error is not null )\r\n\t\t{\r\n\t\t\tnotifiedError = Backend;\r\n\t\t\tShowNotice( $\u0022Can\u0027t play this: {Backend.Error}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid UpdatePlayback()\r\n\t{\r\n\t\tvar wantKey = CurrentKey;\r\n\r\n\t\tif ( wantKey != backendKey )\r\n\t\t{\r\n\t\t\tDisposeBackend();\r\n\t\t\tbackendKey = wantKey;\r\n\r\n\t\t\tif ( wantKey is not null )\r\n\t\t\t\tCreateBackend();\r\n\t\t}\r\n\r\n\t\tif ( Backend is null ) return;\r\n\r\n\t\tBackend.SyncedTime = Paused || IsLive ? null : CurrentTime;\r\n\t\tBackend.Present();\r\n\t\tvar volume = Volume * MediaSettings.EffectiveVolume;\r\n\t\tif ( FlatWithDistance ) volume *= DistanceFade( Sound.Listener.Position );\r\n\t\tBackend.SetAudio( SoundPositionFor( Sound.Listener.Position ), EffectiveSpatial, volume, ScaledAudioDistance );\r\n\r\n\t\tif ( Backend.Error is not null )\r\n\t\t{\r\n\t\t\t// the stream\u0027s urls were refused (403/410) even after a refresh - re-resolve from the next client,\r\n\t\t\t// the same way a seek reopens, instead of sitting on the error\r\n\t\t\tif ( Backend.Refused \u0026\u0026 TryRefusalReload() )\r\n\t\t\t\treturn;\r\n\r\n\t\t\t// native media fails after the backend was created (resolving / downloading) - say so once\r\n\t\t\tif ( notifiedError != Backend )\r\n\t\t\t{\r\n\t\t\t\tnotifiedError = Backend;\r\n\t\t\t\tShowNotice( $\u0022Can\u0027t play this: {Backend.Error}\u0022 );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !Backend.Loaded )\r\n\t\t{\r\n\t\t\tif ( Backend.SinceCreated \u003E 20 \u0026\u0026 LocalNotice is null )\r\n\t\t\t\tShowNotice( \u0022This is taking a while to load...\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Tell the host the duration of direct files, it can\u0027t know until something loads them\r\n\t\tif ( MediaDuration \u003C= 0 \u0026\u0026 !IsLive \u0026\u0026 !SeekByReload \u0026\u0026 reportedDurationFor != PlayId \u0026\u0026 Backend.Duration \u003E 0 )\r\n\t\t{\r\n\t\t\treportedDurationFor = PlayId;\r\n\t\t\tReportDuration( PlayId, Backend.Duration );\r\n\t\t}\r\n\r\n\t\t// Pausing/resuming gets a moment to settle (the decoder refills its buffers on resume) before\r\n\t\t// anything is judged as drift or a stall - correcting then is what caused stutter after unpausing\r\n\t\t// A freshly opened player (after a seek, join or reload) runs until it has landed on the synced time,\r\n\t\t// even when paused - otherwise a seek while paused leaves the screen without the new frame\r\n\t\tvar wantPaused = Paused \u0026\u0026 !initialSeekPending;\r\n\t\tif ( wantPaused != lastAppliedPaused )\r\n\t\t{\r\n\t\t\tlastAppliedPaused = wantPaused;\r\n\t\t\tsinceCorrection = 0;\r\n\t\t\tResetStallTimer();\r\n\t\t}\r\n\r\n\t\tBackend.SetPaused( wantPaused );\r\n\r\n\t\t// A stuck live stream (source hiccup, dropped connection) reconnects at the live edge\r\n\t\tif ( CheckStalled() ) return;\r\n\r\n\t\tif ( IsLive ) return; // nothing to sync to - everyone watches the live edge\r\n\r\n\t\tCorrectDrift();\r\n\t}\r\n\r\n\tbool lastAppliedPaused;\r\n\tint appliedSeekId;\r\n\tMediaBackend notifiedError;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where this client\u0027s playback actually is, if it\u0027s loaded and roughly in sync (null otherwise).\r\n\t/// \u003C/summary\u003E\r\n\tfloat? LocalPlaybackPosition()\r\n\t{\r\n\t\tif ( Backend is null || Backend.Error is not null || !Backend.Loaded || backendKey != CurrentKey ) return null;\r\n\r\n\t\tvar t = Backend.Time;\r\n\t\tif ( MathF.Abs( t - CurrentTime ) \u003E 3.0f ) return null; // way off (stalled / still seeking) - don\u0027t trust it\r\n\t\tif ( MediaDuration \u003E 0 ) t = MathF.Min( t, MediaDuration );\r\n\t\treturn MathF.Max( 0, t );\r\n\t}\r\n\r\n\tconst float StallSeconds = 5.0f;\r\n\tconst int MaxStallReloads = 3;\r\n\r\n\tfloat lastProgressTime;\r\n\tRealTimeSince sinceProgress;\r\n\tint stallReloads;\r\n\tint stallPlayId = -1;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// If playback should be moving but hasn\u0027t for a while (the decoder got stuck after a seek,\r\n\t/// the connection dropped...), throw the backend away and start again at the right time.\r\n\t/// Returns true if it reloaded.\r\n\t/// \u003C/summary\u003E\r\n\tbool CheckStalled()\r\n\t{\r\n\t\tif ( stallPlayId != PlayId )\r\n\t\t{\r\n\t\t\tstallPlayId = PlayId;\r\n\t\t\tstallReloads = 0;\r\n\t\t\tResetStallTimer();\r\n\t\t}\r\n\r\n\t\tvar t = Backend.Time;\r\n\t\tif ( Paused || MathF.Abs( t - lastProgressTime ) \u003E 0.05f )\r\n\t\t{\r\n\t\t\tlastProgressTime = t;\r\n\t\t\tsinceProgress = 0;\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// Reached the end, not stuck\r\n\t\tif ( MediaDuration \u003E 0 \u0026\u0026 t \u003E= MediaDuration - 1.0f ) return false;\r\n\r\n\t\tif ( sinceProgress \u003C StallSeconds || stallReloads \u003E= MaxStallReloads ) return false;\r\n\r\n\t\tstallReloads\u002B\u002B;\r\n\t\tLog.Warning( $\u0022[bimp] Playback stalled at {t:0.0}s, reloading ({stallReloads}/{MaxStallReloads})\u0022 );\r\n\t\tShowNotice( \u0022Playback got stuck, reloading...\u0022 );\r\n\r\n\t\tDisposeBackend();\r\n\t\tbackendKey = CurrentKey;\r\n\t\tCreateBackend();\r\n\t\tResetStallTimer();\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid ResetStallTimer()\r\n\t{\r\n\t\tlastProgressTime = -1;\r\n\t\tsinceProgress = 0;\r\n\t}\r\n\r\n\tconst int MaxRefusalReloads = 2;\r\n\tint refusalReloads;\r\n\tint refusalPlayId = -1;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The stream\u0027s urls were refused even after a refresh: forget the cached session and reload, so it resolves\r\n\t/// fresh from the next source (StreamSessions skips the client that just died). Budgeted like a stall.\r\n\t/// \u003C/summary\u003E\r\n\tbool TryRefusalReload()\r\n\t{\r\n\t\tif ( refusalPlayId != PlayId ) { refusalPlayId = PlayId; refusalReloads = 0; }\r\n\t\tif ( refusalReloads \u003E= MaxRefusalReloads ) return false;\r\n\r\n\t\trefusalReloads\u002B\u002B;\r\n\t\tLog.Warning( $\u0022[bimp] stream refused at {Backend.Time:0.0}s, re-resolving from another source ({refusalReloads}/{MaxRefusalReloads})\u0022 );\r\n\t\tShowNotice( \u0022The stream stopped answering, switching source...\u0022 );\r\n\r\n\t\tBackend.InvalidateSession();\r\n\t\tReloadBackend();\r\n\t\tResetStallTimer();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Merged media lands within this of the synced time without seeking (the drift threshold). \u003C/summary\u003E\r\n\tconst float MergedLandingTolerance = 1.5f;\r\n\r\n\t/// \u003Csummary\u003E The play and seek that merged media last reopened for, after landing too far behind. \u003C/summary\u003E\r\n\t(int playId, int seekId) landingReloadFor = (-1, -1);\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Keep local playback close to where the host says it should be.\r\n\t/// \u003C/summary\u003E\r\n\tvoid CorrectDrift()\r\n\t{\r\n\t\tvar expected = CurrentTime;\r\n\t\tvar drift = Backend.Time - expected;\r\n\t\tvar absDrift = MathF.Abs( drift );\r\n\r\n\t\t// Just loaded: land exactly on the synced time. For merged media the first segment starts at the keyframe\r\n\t\t// cluster before it, so this is a short hop forward inside the first cluster (a few seconds at most),\r\n\t\t// which the engine\u0027s player gets through in a blink.\r\n\t\tif ( initialSeekPending )\r\n\t\t{\r\n\t\t\t// The loaded event can come a frame or two before the player\u0027s clock reports the segment\u0027s position\r\n\t\t\t// (it reads 0) - wait for it, or we\u0027d skip the hop and sit a second behind\r\n\t\t\tif ( SeekByReload \u0026\u0026 absDrift \u003E= 15.0f \u0026\u0026 Backend.SinceCreated \u003C 10.0f ) return;\r\n\r\n\t\t\tinitialSeekPending = false;\r\n\t\t\tappliedSeekId = SeekId;\r\n\t\t\t// Merged media started at a keyframe just behind the synced time (or waited for the next one): it keeps that\r\n\t\t\t// small lag rather than seeking forward, which means decoding everything in between while the sound runs\r\n\t\t\t// on - at 4K the picture then crawled behind for seconds (see MediaBackend.StartNative).\r\n\t\t\tMediaProbe.Note( $\u0022initial landing: drift {drift * 1000:0}ms\u0022 );\r\n\t\t\tvar tolerance = SeekByReload ? MergedLandingTolerance : 0.25f;\r\n\t\t\tif ( absDrift \u003E tolerance \u0026\u0026 (!SeekByReload || absDrift \u003C 15.0f) )\r\n\t\t\t{\r\n\t\t\t\t// Landed further behind than planned (a slow load): open again, now planned with the real load time,\r\n\t\t\t\t// rather than seek forward. Once per play or seek, so a load that\u0027s always slow can\u0027t loop.\r\n\t\t\t\tif ( SeekByReload \u0026\u0026 drift \u003C 0 \u0026\u0026 landingReloadFor != (PlayId, SeekId) )\r\n\t\t\t\t{\r\n\t\t\t\t\tlandingReloadFor = (PlayId, SeekId);\r\n\t\t\t\t\tMediaProbe.Note( \u0022landed too far behind: reopening\u0022 );\r\n\t\t\t\t\tReloadBackend();\r\n\t\t\t\t\treturn;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tBackend.Seek( expected );\r\n\t\t\t\tsinceCorrection = 0;\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Someone seeked (button / progress bar): apply it now, not as drift after a cooldown\r\n\t\tvar explicitSeek = appliedSeekId != SeekId;\r\n\t\tappliedSeekId = SeekId;\r\n\r\n\t\t// The same tolerance applies while paused - pausing must never seek (our playback normally trails\r\n\t\t// the synced clock by up to a second of load latency; correcting that on pause made it jump).\r\n\t\tconst float threshold = 1.5f;\r\n\t\tvar drifted = absDrift \u003E= threshold \u0026\u0026 sinceCorrection \u003E= 3.0f;\r\n\t\tif ( !(explicitSeek \u0026\u0026 absDrift \u003E 0.25f) \u0026\u0026 !drifted ) return;\r\n\r\n\t\tif ( SeekByReload )\r\n\t\t{\r\n\t\t\t// ROOT CAUSE of \u0022it keeps skipping forward frames\u0022: the engine\u0027s player can\u0027t seek forward in a\r\n\t\t\t// stream. Seek() jumps its clock but it keeps reading and decoding sequentially from where it was,\r\n\t\t\t// throwing away every frame as late until it catches up - fast forwarding for as long as the gap\r\n\t\t\t// was (minutes, for a big seek), while drift correction kept firing more seeks. So merged\r\n\t\t\t// media never asks it to jump: it reopens at the target, writing segment files that start there.\r\n\t\t\tif ( MediaDuration \u003E 0 \u0026\u0026 expected \u003E= MediaDuration - 2 ) return;\r\n\t\t\tReloadBackend();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Direct files: the native seek (fine for backward seeks and short hops)\r\n\t\tBackend.Seek( expected );\r\n\t\tsinceCorrection = 0;\r\n\t}\r\n\r\n\t#endregion\r\n}\r\n"},{"Ident":"rexi.bimp","Path":"MediaBackend.cs","FileName":"MediaBackend.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"using System;\r\nusing Bimp.Resolver;\r\nusing Bimp.Resolver.Media;\r\n\r\nnamespace Bimp;\r\n\r\n/// \u003Csummary\u003E\r\n/// Local playback of a single url or play token. Wraps the engine\u0027s \u003Csee cref=\u0022VideoPlayer\u0022/\u003E (video),\r\n/// \u003Csee cref=\u0022MusicPlayer\u0022/\u003E (audio only, gives us a spectrum) or a \u003Csee cref=\u0022SegmentPlayer\u0022/\u003E (merged\r\n/// YouTube media) behind one interface.\r\n/// Each client owns one of these per \u003Csee cref=\u0022MediaPlayer\u0022/\u003E, it is never networked.\r\n/// \u003C/summary\u003E\r\npublic sealed class MediaBackend : IDisposable\r\n{\r\n\tVideoPlayer video;\r\n\tMusicPlayer music;\r\n\r\n\t/// \u003Csummary\u003E The direct video player\u0027s frames (see \u003Csee cref=\u0022VideoFrameSink\u0022/\u003E). \u003C/summary\u003E\r\n\treadonly VideoFrameSink videoSink = new();\r\n\tSegmentPlayer segments;\r\n\tWebmSegmenter segmenter;\r\n\tResolver.Live.LiveStream live;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Natively resolved media being prepared (resolving, downloading the first segment).\r\n\t/// \u003C/summary\u003E\r\n\tTask\u003CStreamSession\u003E preparing;\r\n\tstring token;\r\n\tstring sessionClient;\r\n\tbool audioOnly;\r\n\tfloat startTime;\r\n\tbool disposed;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Merged media: how long from starting the segmenter to its first segment being ready (download \u002B write),\r\n\t/// averaged over every backend. A seek or join plans its first segment from where the synced time will be by then.\r\n\t/// \u003C/summary\u003E\r\n\tstatic float firstSegmentSeconds = 1.5f;\r\n\tRealTimeSince sinceSegmenter;\r\n\tbool firstSegmentTimed;\r\n\r\n\t/// \u003Csummary\u003E Checking / fixing an MP4 before playing it (see \u003Csee cref=\u0022Mp4ChannelFix\u0022/\u003E). \u003C/summary\u003E\r\n\tbool checkingMp4;\r\n\tstring localDirectory;\r\n\treadonly System.Threading.CancellationTokenSource cts = new();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// For streams that were started at an offset and whose clock starts at 0 - this is the offset.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float TimeOffset { get; private set; }\r\n\r\n\tpublic string Url { get; private set; }\r\n\tpublic bool IsAudioOnly =\u003E music is not null;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Still resolving / downloading before anything can play.\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool IsPreparing =\u003E preparing is not null || checkingMp4;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Something went wrong, user facing message.\r\n\t/// \u003C/summary\u003E\r\n\tstring error;\r\n\tpublic string Error { get =\u003E error ?? live?.Error ?? segments?.Error; private set =\u003E error = value; }\r\n\r\n\t/// \u003Csummary\u003E A segment build was refused (403/410) even after the url was refreshed - only a fresh session, from another client, helps. \u003C/summary\u003E\r\n\tpublic bool Refused =\u003E segmenter?.Refused ?? false;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Forget this media\u0027s cached session, so the next backend resolves fresh - skipping the client whose urls\r\n\t/// just refused (see MediaPlayer\u0027s refusal reload).\r\n\t/// \u003C/summary\u003E\r\n\tpublic void InvalidateSession()\r\n\t{\r\n\t\tif ( token is not null ) StreamSessions.Forget( token, sessionClient );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E Live: what we\u0027re connected to (\u0022RTSP H.264 1920x1080 \u002B G.711 mu-law\u0022). \u003C/summary\u003E\r\n\tpublic string LiveDescription =\u003E live is null ? null\r\n\t\t: $\u0022{live.Description} | behind newest frame {LiveBehindNewest:0.00}s{(LiveLatency is { } l ? $\u0022, sent {l:0.00}s ago\u0022 : \u0022, no sender clock\u0022)}{(live.Segmenter.Pcm is { } pcm ? $\u0022, audio {pcm.SampleRate}Hz {pcm.Channels}ch, A/V {pcm.Offset * 1000:\u002B0;-0}ms ({pcm.State})\u0022 : \u0022\u0022)}\u0022;\r\n\r\n\t/// \u003Csummary\u003E Live, decoded audio: its stream time minus the picture\u0027s (seconds, negative = audio late). \u003C/summary\u003E\r\n\tpublic double? LiveAvOffset =\u003E live?.Segmenter.Pcm?.Offset;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Live: how long ago the frame on screen was sent, by the sender\u0027s clock (RTCP sender reports) - the latency\r\n\t/// from the source\u0027s output to our screen. Null without sender reports (MPEG-TS, HLS, servers that send none).\r\n\t/// \u003C/summary\u003E\r\n\tpublic double? LiveLatency =\u003E live?.Segmenter.WallClockZero is { } zero \u0026\u0026 segments is { Loaded: true }\r\n\t\t? (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - (zero \u002B segments.StreamTime)\r\n\t\t: null;\r\n\r\n\t/// \u003Csummary\u003E Live: stream time from the frame on screen to the newest frame received. \u003C/summary\u003E\r\n\tpublic double LiveBehindNewest =\u003E live is null || segments is not { Loaded: true } ? 0 : live.Segmenter.NewestVideoTime - segments.StreamTime;\r\n\r\n\t/// \u003Csummary\u003E Merged media opened before this media time starts from the beginning (see \u003Csee cref=\u0022StartNative\u0022/\u003E). \u003C/summary\u003E\r\n\tconst float FreshStartSeconds = 1.0f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Merged media: start at the keyframe before the planned time if it\u0027s at most this far behind it, else at the next\r\n\t/// one (waiting for it). Stays inside the drift tolerance (\u003Csee cref=\u0022MediaPlayer\u0022/\u003E, 1.5 s) with the estimate\u0027s error.\r\n\t/// \u003C/summary\u003E\r\n\tconst float MaxStartLag = 1.0f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where the synced timeline is, while it\u0027s moving (null while paused). Merged media that starts at a later keyframe\r\n\t/// waits for it before opening its first segment.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float? SyncedTime { set { if ( segments is not null ) segments.SyncedTime = value; } }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Playback has reached the end.\r\n\t/// \u003C/summary\u003E\r\n\tbool finished;\r\n\tpublic bool Finished { get =\u003E finished || (segments?.Finished ?? false); private set =\u003E finished = value; }\r\n\r\n\tbool loaded;\r\n\tfloat lastPlaybackTime;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Has the media actually started (metadata loaded / audio is advancing)?\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool Loaded =\u003E segments?.Loaded ?? (!IsPreparing \u0026\u0026 (loaded || PlaybackTime \u003E 0.01f));\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Time since this backend was created.\r\n\t/// \u003C/summary\u003E\r\n\tpublic RealTimeSince SinceCreated { get; private set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Position within the media, taking the start offset into account.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float Time =\u003E TimeOffset \u002B PlaybackTime;\r\n\r\n\tpublic float PlaybackTime =\u003E segments?.Time ?? video?.PlaybackTime ?? music?.PlaybackTime ?? 0;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Duration as reported by the decoder.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float Duration =\u003E segments?.Duration ?? video?.Duration ?? music?.Duration ?? 0;\r\n\r\n\tpublic bool IsPaused =\u003E segments?.IsPaused ?? video?.IsPaused ?? music?.Paused ?? false;\r\n\r\n\tpublic Texture Texture =\u003E segments?.Texture ?? videoSink.Texture;\r\n\tpublic int Width =\u003E segments?.Width ?? (video is null ? 0 : videoSink.Width);\r\n\tpublic int Height =\u003E segments?.Height ?? (video is null ? 0 : videoSink.Height);\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Does the video have visible frames?\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool HasVideo =\u003E segments?.HasVideo ?? (video is not null \u0026\u0026 videoSink.Texture is not null \u0026\u0026 videoSink.Width \u003E 4 \u0026\u0026 videoSink.Height \u003E 4);\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// 512 FFT values for visualizers. Empty for video playback.\r\n\t/// \u003C/summary\u003E\r\n\tpublic ReadOnlySpan\u003Cfloat\u003E Spectrum =\u003E music is not null ? music.Spectrum : ReadOnlySpan\u003Cfloat\u003E.Empty;\r\n\r\n\tpublic float Amplitude =\u003E music?.Amplitude ?? 0;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The decoded audio format, e.g. \u002248000Hz 2ch\u0022 (for diagnostics). Empty until audio has started.\r\n\t/// \u003C/summary\u003E\r\n\tpublic string AudioFormat\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tvar (rate, ch) = segments is not null ? segments.AudioFormat : video is not null ? (video.SampleRate, video.Channels) : music is not null ? (music.SampleRate, music.Channels) : (0, 0);\r\n\t\t\treturn rate \u003E 0 ? $\u0022{rate}Hz {ch}ch\u0022 : \u0022\u0022;\r\n\t\t}\r\n\t}\r\n\r\n\tMediaBackend() { }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Start playing \u003Cparamref name=\u0022url\u0022/\u003E. Never throws, check \u003Csee cref=\u0022Error\u0022/\u003E.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static MediaBackend Create( string url, bool audioOnly, float timeOffset )\r\n\t{\r\n\t\tvar b = new MediaBackend\r\n\t\t{\r\n\t\t\tUrl = url,\r\n\t\t\tTimeOffset = timeOffset,\r\n\t\t\tSinceCreated = 0,\r\n\t\t};\r\n\r\n\t\tb.StartUrl( url, audioOnly );\r\n\t\treturn b;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Resolve a play token on this client and start playing it at \u003Cparamref name=\u0022startTime\u0022/\u003E (media seconds).\r\n\t/// Never throws, check \u003Csee cref=\u0022Error\u0022/\u003E.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static MediaBackend CreateNative( string token, bool audioOnly, float startTime )\r\n\t{\r\n\t\tvar b = new MediaBackend\r\n\t\t{\r\n\t\t\tUrl = token,\r\n\t\t\tSinceCreated = 0,\r\n\t\t\ttoken = token,\r\n\t\t\taudioOnly = audioOnly,\r\n\t\t\tstartTime = startTime,\r\n\t\t};\r\n\r\n\t\tb.preparing = StreamSessions.GetAsync( token );\r\n\t\treturn b;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Connect to a live stream (RTSP tunnelled over HTTP, MPEG-TS over HTTP, HLS) and play it near its live edge.\r\n\t/// Never throws, check \u003Csee cref=\u0022Error\u0022/\u003E.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static MediaBackend CreateLive( string url, int maxHeight, bool audioOnly = false )\r\n\t{\r\n\t\tvar b = new MediaBackend { Url = url, SinceCreated = 0, audioOnly = audioOnly, liveMaxHeight = audioOnly ? AudioOnlyHeight : maxHeight };\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// a live channel from an extractor (Twitch...): resolved on this client first (see StartNative)\r\n\t\t\tif ( PlayToken.IsToken( url ) )\r\n\t\t\t{\r\n\t\t\t\tb.token = url;\r\n\t\t\t\tb.preparing = StreamSessions.GetAsync( url );\r\n\t\t\t\treturn b;\r\n\t\t\t}\r\n\t\t\tb.StartLive( url, false );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tb.Error = e.Message;\r\n\t\t}\r\n\t\treturn b;\r\n\t}\r\n\r\n\tint liveMaxHeight = 720;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The variant height an audio-only player (a speaker) asks a live or HLS stream for: the audio comes with every\r\n\t/// variant, so the smallest one saves the download and the decoding of a picture nobody sees.\r\n\t/// \u003C/summary\u003E\r\n\tconst int AudioOnlyHeight = 144;\r\n\r\n\tvoid StartLive( string url, bool hls )\r\n\t{\r\n\t\tlive = new Resolver.Live.LiveStream( url, StreamSessions.NewCacheDirectory(), liveMaxHeight, hls );\r\n\t\tsegments = new SegmentPlayer( live.Segmenter );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E The height a play token asks for (its pick, else the server\u0027s limit). \u003C/summary\u003E\r\n\tstatic int TokenHeight( string token, int fallback )\r\n\t\t=\u003E PlayToken.Parse( token ) is { } t ? (t.Height \u003E 0 ? t.Height : t.MaxHeight) : fallback;\r\n\r\n\tvoid StartUrl( string url, bool audio )\r\n\t{\r\n\t\t// MP4s whose header mislabels mono audio as stereo crackle in the engine - check first\r\n\t\tif ( Mp4ChannelFix.Applies( url ) )\r\n\t\t{\r\n\t\t\t_ = StartMp4( url, audio );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tPlayUrlNow( url, audio );\r\n\t}\r\n\r\n\tasync Task StartMp4( string url, bool audio )\r\n\t{\r\n\t\tcheckingMp4 = true;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tlocalDirectory = StreamSessions.NewCacheDirectory();\r\n\t\t\tvar path = await Mp4ChannelFix.PrepareAsync( url, localDirectory, cts.Token );\r\n\t\t\tif ( disposed ) return;\r\n\r\n\t\t\tif ( path is null ) PlayUrlNow( url, audio );\r\n\t\t\telse PlayFile( path, audio );\r\n\t\t}\r\n\t\tcatch ( OperationCanceledException )\r\n\t\t{\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( disposed ) return;\r\n\t\t\t// couldn\u0027t fix it - play it as it is rather than not at all\r\n\t\t\tLog.Warning( $\u0022[bimp] Couldn\u0027t prepare {url}: {e.Message}\u0022 );\r\n\t\t\tPlayUrlNow( url, audio );\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tcheckingMp4 = false;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid PlayFile( string path, bool audio )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( audio )\r\n\t\t\t{\r\n\t\t\t\tmusic = MusicPlayer.Play( FileSystem.Data, path );\r\n\t\t\t\tmusic.OnFinished \u002B= () =\u003E Finished = true;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tvideo = new VideoPlayer();\r\n\t\t\t\tvideoSink.Attach( video, null, null, \u0022video\u0022 );\r\n\t\t\t\tvideo.OnLoaded \u002B= () =\u003E loaded = true;\r\n\t\t\t\tvideo.OnFinished \u002B= () =\u003E Finished = true;\r\n\t\t\t\tvideo.Play( FileSystem.Data, path );\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tError = e.Message;\r\n\t\t\tLog.Warning( $\u0022[bimp] Failed to play {path}: {e.Message}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid PlayUrlNow( string url, bool audio )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( audio )\r\n\t\t\t{\r\n\t\t\t\tmusic = MusicPlayer.PlayUrl( url );\r\n\t\t\t\tmusic.OnFinished \u002B= () =\u003E Finished = true;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tvideo = new VideoPlayer();\r\n\t\t\t\tvideoSink.Attach( video, null, null, \u0022video\u0022 );\r\n\t\t\t\tvideo.OnLoaded \u002B= () =\u003E loaded = true;\r\n\t\t\t\tvideo.OnFinished \u002B= () =\u003E Finished = true;\r\n\t\t\t\tvideo.Play( url );\r\n\t\t\t}\r\n\t\t\t_ = CheckReachable( url );\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tError = e.Message;\r\n\t\t\tLog.Warning( $\u0022[bimp] Failed to play {url}: {e.Message}\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The engine\u0027s players give up on a url that answers with an error page (a 403, a 404) without saying so - the\r\n\t/// screen would sit on \u0022loading\u0022 for good. Look at the response\u0027s status ourselves. Headers only: a radio stream\r\n\t/// never ends, and a buffered request would never return.\r\n\t/// \u003C/summary\u003E\r\n\tasync Task CheckReachable( string url )\r\n\t{\r\n\t\tif ( !url.StartsWith( \u0022http\u0022, StringComparison.OrdinalIgnoreCase ) ) return;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tusing var stream = await Http.RequestStreamAsync( url, cancellationToken: cts.Token );\r\n\t\t}\r\n\t\tcatch ( System.Net.Http.HttpRequestException e ) when ( e.StatusCode is { } status \u0026\u0026 !loaded \u0026\u0026 !disposed )\r\n\t\t{\r\n\t\t\tError = $\u0022The server refused the file ({(int)status}).\u0022;\r\n\t\t\tLog.Warning( $\u0022[bimp] {url}: {(int)status}\u0022 );\r\n\t\t}\r\n\t\tcatch ( Exception )\r\n\t\t{\r\n\t\t\t// unreachable, cancelled: the engine\u0027s own load decides\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The client side resolve finished - start the right kind of playback.\r\n\t/// \u003C/summary\u003E\r\n\tvoid StartNative( StreamSession session )\r\n\t{\r\n\t\tvar plan = session.Plan;\r\n\t\tsessionClient = plan.ClientKey;\r\n\r\n\t\tif ( plan.Kind == StreamKind.Direct )\r\n\t\t{\r\n\t\t\t// a progressive file - played straight from the site, seeked natively once loaded\r\n\t\t\tStartUrl( plan.DirectUrl, audioOnly );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( plan.Kind == StreamKind.Live )\r\n\t\t{\r\n\t\t\tliveMaxHeight = audioOnly ? AudioOnlyHeight : TokenHeight( token, liveMaxHeight );\r\n\t\t\tStartLive( plan.DirectUrl, true );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The engine can only seek forward by decoding every frame in between, while the audio runs on - a 4K video\r\n\t\t// can\u0027t decode much faster than it plays, so after a seek the picture crawled behind the sound for seconds. So\r\n\t\t// merged media starts at a keyframe close behind where the synced time will be once the first segment is\r\n\t\t// ready, or at the next one and waits there (see SegmentPlayer.SyncedTime) - it never seeks forward. The start\r\n\t\t// of a video just plays from the beginning, a load time behind.\r\n\t\tvar planAhead = plan.Kind is StreamKind.Merge or StreamKind.Hls \u0026\u0026 !audioOnly \u0026\u0026 startTime \u003E= FreshStartSeconds;\r\n\t\tvar plannedStart = planAhead ? startTime \u002B (float)SinceCreated \u002B firstSegmentSeconds : startTime;\r\n\t\tvar maxLag = planAhead ? MaxStartLag : float.MaxValue;\r\n\t\tsinceSegmenter = 0;\r\n\r\n\t\tif ( plan.Kind == StreamKind.Hls )\r\n\t\t{\r\n\t\t\t// a finished HLS playlist: remuxed from the segment at (or just after) the planned start, like merged media\r\n\t\t\tlive = new Resolver.Live.LiveStream( plan.DirectUrl, StreamSessions.NewCacheDirectory(), audioOnly ? AudioOnlyHeight : TokenHeight( token, 720 ), true, plannedStart, maxLag );\r\n\t\t\tsegments = new SegmentPlayer( live.Segmenter );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tsegmenter = new WebmSegmenter( plan.Kind == StreamKind.Merge ? session.Video : null, session.Audio, plannedStart, StreamSessions.NewCacheDirectory(), maxLag, session.SwitchVideo );\r\n\r\n\t\tif ( plan.Kind == StreamKind.Audio || audioOnly )\r\n\t\t{\r\n\t\t\t// one file from the cue point before the start time to the end, through MusicPlayer for the spectrum\r\n\t\t\t_ = StartAudioFile();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tsegments = new SegmentPlayer( segmenter );\r\n\t}\r\n\r\n\tasync Task StartAudioFile()\r\n\t{\r\n\t\tpreparing = null;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar segment = await segmenter.GetAsync( 0 );\r\n\t\t\tif ( disposed || segment is null ) return;\r\n\t\t\tmusic = MusicPlayer.Play( FileSystem.Data, segment.Path );\r\n\t\t\tmusic.OnFinished \u002B= () =\u003E Finished = true;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tif ( disposed ) return;\r\n\t\t\tError = e.Message;\r\n\t\t\tStreamSessions.Forget( token );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Call every frame. Pushes the latest video frame to the texture.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void Present()\r\n\t{\r\n\t\tif ( preparing is { IsCompleted: true } task )\r\n\t\t{\r\n\t\t\tpreparing = null;\r\n\t\t\tif ( task.IsCompletedSuccessfully )\r\n\t\t\t{\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tStartNative( task.Result );\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( Exception e )\r\n\t\t\t\t{\r\n\t\t\t\t\tError = e.Message;\r\n\t\t\t\t\tStreamSessions.Forget( token );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tvar e = task.Exception?.InnerException;\r\n\t\t\t\tError = e is ResolveException ? e.Message : $\u0022Couldn\u0027t load the stream: {e?.Message}\u0022;\r\n\t\t\t\tLog.Warning( $\u0022[bimp] Failed to resolve {token}: {e}\u0022 );\r\n\t\t\t\tStreamSessions.Forget( token );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !firstSegmentTimed \u0026\u0026 ((ISegmentFeed)segmenter ?? (live?.Segmenter is { IsLive: false } vod ? vod : null))?.TryGet( 0 ) is not null )\r\n\t\t{\r\n\t\t\tfirstSegmentTimed = true;\r\n\t\t\tfirstSegmentSeconds = Math.Clamp( firstSegmentSeconds * 0.5f \u002B sinceSegmenter * 0.5f, 0.3f, 10.0f );\r\n\t\t}\r\n\r\n\t\tsegments?.Update();\r\n\t\tif ( video is not null )\r\n\t\t{\r\n\t\t\tvar presentStart = System.Diagnostics.Stopwatch.GetTimestamp();\r\n\t\t\tvideo.Present();\r\n\t\t\tMediaProbe.PresentTook( System.Diagnostics.Stopwatch.GetElapsedTime( presentStart ).TotalMilliseconds, \u0022direct\u0022, 0 );\r\n\t\t}\r\n\r\n\t\tif ( live is not null \u0026\u0026 segments is not null )\r\n\t\t{\r\n\t\t\tlive.PlayingSeq = segments.CurrentSeq;\r\n\t\t\t// camera G.711 audio plays beside the video, lined up with the picture on screen\r\n\t\t\tlive.Segmenter.Pcm?.Update( segments.StreamTime, segments.Loaded \u0026\u0026 requestedPaused != true );\r\n\t\t}\r\n\r\n\t\t// MusicPlayer has no loaded event, so treat advancing time as loaded\r\n\t\tvar t = PlaybackTime;\r\n\t\tif ( t \u003E lastPlaybackTime \u002B 0.01f ) loaded = true;\r\n\t\tlastPlaybackTime = t;\r\n\t}\r\n\r\n\tbool? requestedPaused;\r\n\tRealTimeSince sincePauseRequest;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Pause or resume. Only calls into the native player when the requested state changes - not by\r\n\t/// polling its IsPaused every frame, which can lag a call and made us call Pause/Resume repeatedly\r\n\t/// (every extra Resume restarts the decoder\u0027s clock, which shows up as stutter).\r\n\t/// If the native player still disagrees a while later (e.g. it hadn\u0027t started yet), ask once more.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void SetPaused( bool paused )\r\n\t{\r\n\t\tif ( requestedPaused == paused )\r\n\t\t{\r\n\t\t\tif ( IsPaused == paused || sincePauseRequest \u003C 1.0f ) return;\r\n\t\t}\r\n\r\n\t\trequestedPaused = paused;\r\n\t\tsincePauseRequest = 0;\r\n\t\tMediaProbe.Note( paused ? \u0022native Pause()\u0022 : \u0022native Resume()\u0022 );\r\n\r\n\t\tsegments?.SetPaused( paused );\r\n\r\n\t\tif ( video is not null )\r\n\t\t{\r\n\t\t\tif ( paused ) video.Pause(); else video.Resume();\r\n\t\t}\r\n\r\n\t\tif ( music is not null )\r\n\t\t\tmusic.Paused = paused;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Seek to a position within the media (not accounting for \u003Csee cref=\u0022TimeOffset\u0022/\u003E - pass media time).\r\n\t/// \u003C/summary\u003E\r\n\tpublic void Seek( float mediaTime )\r\n\t{\r\n\t\tvar local = MathF.Max( 0, mediaTime - TimeOffset );\r\n\t\tMediaProbe.Note( $\u0022native Seek({local:0.000}) from {PlaybackTime:0.000}\u0022 );\r\n\t\tsegments?.Seek( local );\r\n\t\tvideo?.Seek( local );\r\n\t\tmusic?.Seek( local );\r\n\t\tFinished = false;\r\n\t}\r\n\r\n\tpublic void SetAudio( Vector3 position, bool spatial, float volume, float distance )\r\n\t{\r\n\t\tsegments?.SetAudio( position, spatial, volume, distance );\r\n\t\tlive?.Segmenter.Pcm?.SetAudio( position, spatial, volume, distance );\r\n\r\n\t\t// Non-spatial audio is played at the listener, slightly in front so it\u0027s centered\r\n\t\tvar pos = spatial ? position : Vector3.Forward * 64.0f;\r\n\r\n\t\tif ( video is not null )\r\n\t\t{\r\n\t\t\tvar a = video.Audio;\r\n\t\t\ta.ListenLocal = !spatial;\r\n\t\t\ta.Position = pos;\r\n\t\t\ta.Volume = volume;\r\n\t\t\ta.Distance = distance;\r\n\t\t\ta.Falloff = MediaPlayer.AudioFalloff;\r\n\t\t}\r\n\r\n\t\tif ( music is not null )\r\n\t\t{\r\n\t\t\tmusic.ListenLocal = !spatial;\r\n\t\t\tmusic.Position = pos;\r\n\t\t\tmusic.Volume = volume;\r\n\t\t\tmusic.Distance = distance;\r\n\t\t\tmusic.Falloff = MediaPlayer.AudioFalloff;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Dispose()\r\n\t{\r\n\t\tdisposed = true;\r\n\t\tpreparing = null;\r\n\t\tcts.Cancel();\r\n\r\n\t\tsegments?.Dispose();\r\n\t\tsegments = null;\r\n\r\n\t\tvideo?.Dispose();\r\n\t\tvideo = null;\r\n\r\n\t\tmusic?.Dispose();\r\n\t\tmusic = null;\r\n\r\n\t\tvideoSink.Dispose();\r\n\r\n\t\t// after the players, so the files aren\u0027t open anymore\r\n\t\tsegmenter?.Dispose();\r\n\t\tsegmenter = null;\r\n\r\n\t\tlive?.Dispose();\r\n\t\tlive = null;\r\n\r\n\t\tif ( localDirectory is not null )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tif ( FileSystem.Data.DirectoryExists( localDirectory ) ) FileSystem.Data.DeleteDirectory( localDirectory, true );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tLog.Trace( $\u0022[bimp] couldn\u0027t delete {localDirectory}: {e.Message}\u0022 );\r\n\t\t\t}\r\n\t\t\tlocalDirectory = null;\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"rexi.bimp","Path":"MediaInteract.cs","FileName":"MediaInteract.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp;\n\n/// \u003Csummary\u003E\n/// Lets players use the media player with the USE key (works with the engine\u0027s \u003Csee cref=\u0022PlayerController\u0022/\u003E,\n/// or anything else that calls \u003Csee cref=\u0022Component.IPressable\u0022/\u003E). Needs a collider on this GameObject or a child.\n/// \u003Cpara\u003E\n/// Aiming at one of the screen\u0027s on-screen buttons while the controls are showing presses that button.\n/// Anywhere else (or while the controls are hidden) opens the full remote.\n/// \u003C/para\u003E\n/// \u003C/summary\u003E\n[Title( \u0022Media Player Interact\u0022 ), Category( \u0022Media\u0022 ), Icon( \u0022touch_app\u0022 )]\npublic sealed class MediaInteract : Component, Component.IPressable\n{\n\t/// \u003Csummary\u003E\n\t/// The player to control. Found automatically in this object or its parents if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaPlayer Player { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// The screen whose on-screen buttons USE can press. Found automatically in this object or its children if unset.\n\t/// \u003C/summary\u003E\n\t[Property] public MediaScreen Screen { get; set; }\n\n\tMediaPlayer Target =\u003E Player.IsValid() ? Player : (Player = GetComponentInParent\u003CMediaPlayer\u003E());\n\tMediaScreen TargetScreen =\u003E Screen.IsValid() ? Screen : (Screen = GetComponentInChildren\u003CMediaScreen\u003E());\n\n\tpublic bool CanPress( IPressable.Event e ) =\u003E Target.IsValid();\n\n\tpublic bool Press( IPressable.Event e )\n\t{\n\t\tif ( !Target.IsValid() ) return false;\n\n\t\t// The screen may already have handled this USE press itself this frame\n\t\tif ( !UseHandled.TryConsume() ) return true;\n\n\t\t// Press runs on the client that pressed, so this only affects them (button presses go through the\n\t\t// player\u0027s permission-checked host requests)\n\t\tvar screen = TargetScreen;\n\t\tif ( screen.IsValid() \u0026\u0026 screen.TryPressAimedControl() )\n\t\t\treturn true;\n\n\t\tMediaRemote.Toggle( Target );\n\t\treturn true;\n\t}\n\n\tpublic IPressable.Tooltip? GetTooltip( IPressable.Event e )\n\t{\n\t\tif ( !Target.IsValid() ) return null;\n\n\t\tvar title = Target.HasMedia ? Target.Title : \u0022Media Player\u0022;\n\t\tvar icon = Target.AudioOnlyPlayer ? \u0022speaker\u0022 : \u0022smart_display\u0022;\n\n\t\tvar action = TargetScreen.IsValid() ? TargetScreen.AimedActionName : null;\n\t\tif ( action is not null )\n\t\t\treturn new IPressable.Tooltip( action, \u0022touch_app\u0022, title );\n\n\t\treturn new IPressable.Tooltip( title, icon, \u0022Open controls\u0022 );\n\t}\n}\n"},{"Ident":"rexi.bimp","Path":"Resolver/FormatSelector.cs","FileName":"FormatSelector.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":399292,"IsPrivate":false,"Code":"namespace Bimp.Resolver;\n\n/// \u003Csummary\u003E\n/// One format an extractor found.\n/// \u003C/summary\u003E\npublic sealed class MediaFormat\n{\n\tpublic string Id { get; set; }\n\tpublic string Url { get; set; }\n\n\t/// \u003Csummary\u003E \u0022webm\u0022, \u0022mp4\u0022, \u0022mp3\u0022... \u003C/summary\u003E\n\tpublic string Container { get; set; }\n\n\t/// \u003Csummary\u003E Lowercase codec name, null if the format has no video. \u003C/summary\u003E\n\tpublic string VideoCodec { get; set; }\n\n\t/// \u003Csummary\u003E Lowercase codec name, null if the format has no audio. \u003C/summary\u003E\n\tpublic string AudioCodec { get; set; }\n\n\tpublic int Height { get; set; }\n\tpublic float Fps { get; set; }\n\tpublic long Bitrate { get; set; }\n\tpublic long Size { get; set; }\n\tpublic bool Hdr { get; set; }\n\n\t/// \u003Csummary\u003E A dynamic range compressed copy of an audio track. \u003C/summary\u003E\n\tpublic bool Drc { get; set; }\n\n\t/// \u003Csummary\u003E Audio language (\u0022en\u0022, \u0022es-419\u0022), null if unlabelled. \u003C/summary\u003E\n\tpublic string Language { get; set; }\n\tpublic string LanguageName { get; set; }\n\n\t/// \u003Csummary\u003E This is the original audio track. \u003C/summary\u003E\n\tpublic bool LanguageDefault { get; set; }\n\n\tpublic bool HasVideo =\u003E VideoCodec is not null;\n\tpublic bool HasAudio =\u003E AudioCodec is not null;\n\tpublic bool IsVp9OrAv1 =\u003E VideoCodec is { } v \u0026\u0026 (v.StartsWith( \u0022vp9\u0022 ) || v.StartsWith( \u0022vp09\u0022 ) || v.StartsWith( \u0022av01\u0022 ) || v.StartsWith( \u0022av1\u0022 ));\n\tpublic bool IsOpus =\u003E AudioCodec?.StartsWith( \u0022opus\u0022 ) ?? false;\n\n\tpublic StreamFile ToFile() =\u003E new() { Url = Url, Size = Size, FormatId = Id, Container = Container };\n}\n\n/// \u003Csummary\u003E\n/// Picks formats the engine can play. A port of the old resolver\u0027s format picking:\n/// VP9/AV1 WebM video \u002B Opus WebM audio (merged locally), else a single muxed file.\n/// \u003C/summary\u003E\npublic static class FormatSelector\n{\n\t/// \u003Csummary\u003E Video-only VP9/AV1 WebM files - the ones we can merge without re-encoding. \u003C/summary\u003E\n\tpublic static List\u003CMediaFormat\u003E WebmVideo( IEnumerable\u003CMediaFormat\u003E formats )\n\t\t=\u003E formats.Where( f =\u003E f.Container == \u0022webm\u0022 \u0026\u0026 f.IsVp9OrAv1 \u0026\u0026 !f.HasAudio ).ToList();\n\n\t/// \u003Csummary\u003E Audio-only Opus WebM files (every language), without the dynamic-range-compressed copies. \u003C/summary\u003E\n\tpublic static List\u003CMediaFormat\u003E WebmAudio( IEnumerable\u003CMediaFormat\u003E formats )\n\t\t=\u003E formats.Where( f =\u003E f.Container == \u0022webm\u0022 \u0026\u0026 f.IsOpus \u0026\u0026 !f.HasVideo \u0026\u0026 !f.Drc ).ToList();\n\n\t/// \u003Csummary\u003E\n\t/// Tallest at or below the limit first, SDR before HDR (the engine\u0027s HDR output is untested and would look\n\t/// washed out anyway), then fps, then bitrate.\n\t/// \u003C/summary\u003E\n\tstatic (bool, bool, int, float, long) ScoreVideo( MediaFormat f, int limit )\n\t\t=\u003E (f.Height \u003C= limit, !f.Hdr, f.Height \u003C= limit ? f.Height : -f.Height, f.Fps, f.Bitrate);\n\n\t/// \u003Csummary\u003E\n\t/// VP9 at 4K and more than 30 fps: more than the engine\u0027s VP9 decoder keeps up with (measured 19-45 frames a second\n\t/// on a Ryzen 9 9900X3D; 1440p60 and 4K30 are fine).\n\t/// \u003C/summary\u003E\n\tpublic static bool TooHeavyForVp9( MediaFormat f ) =\u003E f.Height \u003E= 2160 \u0026\u0026 f.Fps \u003E 30;\n\n\t/// \u003Csummary\u003E An 8-bit SDR AV1 video-only MP4 with the same height and frame rate, highest bitrate first. \u003C/summary\u003E\n\tpublic static MediaFormat Av1Like( IEnumerable\u003CMediaFormat\u003E formats, MediaFormat like )\n\t\t=\u003E formats.Where( f =\u003E f.Container == \u0022mp4\u0022 \u0026\u0026 !f.HasAudio \u0026\u0026 !f.Hdr \u0026\u0026 f.Height == like.Height \u0026\u0026 MathF.Abs( f.Fps - like.Fps ) \u003C 1\n\t\t\t\t\u0026\u0026 f.VideoCodec is { } v \u0026\u0026 v.StartsWith( \u0022av01\u0022 ) \u0026\u0026 v.Split( \u0027.\u0027 ) is { Length: \u003E= 4 } p \u0026\u0026 p[3] == \u002208\u0022 )\n\t\t\t.OrderByDescending( f =\u003E f.Bitrate )\n\t\t\t.FirstOrDefault();\n\n\t/// \u003Csummary\u003E\n\t/// What a 4K60 AV1 choice carries on with once YouTube stops serving it: the tallest SDR VP9 below 4K at the same\n\t/// frame rate (1440p60 - the engine\u0027s VP9 decoder keeps up with that).\n\t/// \u003C/summary\u003E\n\tpublic static MediaFormat SwitchLike( IEnumerable\u003CMediaFormat\u003E formats, MediaFormat like )\n\t\t=\u003E WebmVideo( formats ).Where( f =\u003E !f.Hdr \u0026\u0026 f.Height \u003C 2160 \u0026\u0026 MathF.Abs( f.Fps - like.Fps ) \u003C 1 \u0026\u0026 f.VideoCodec is { } v \u0026\u0026 !v.StartsWith( \u0022av01\u0022 ) )\n\t\t\t.OrderByDescending( f =\u003E f.Height ).ThenByDescending( f =\u003E f.Bitrate )\n\t\t\t.FirstOrDefault();\n\n\tpublic static List\u003Cint\u003E Qualities( IEnumerable\u003CMediaFormat\u003E formats )\n\t{\n\t\tvar video = WebmVideo( formats );\n\t\tvar sdr = video.Where( f =\u003E !f.Hdr ).ToList();\n\t\tif ( sdr.Count == 0 ) sdr = video;\n\t\treturn sdr.Select( f =\u003E f.Height ).Where( h =\u003E h \u003E 0 ).Distinct().OrderByDescending( h =\u003E h ).ToList();\n\t}\n\n\t/// \u003Csummary\u003E The original audio track\u0027s language. \u003C/summary\u003E\n\tstatic string DefaultLanguage( List\u003CMediaFormat\u003E audio )\n\t{\n\t\tvar labelled = audio.Where( f =\u003E !string.IsNullOrEmpty( f.Language ) ).ToList();\n\t\tif ( labelled.Count == 0 ) return null;\n\t\treturn (labelled.FirstOrDefault( f =\u003E f.LanguageDefault ) ?? labelled[0]).Language;\n\t}\n\n\tpublic static List\u003CMediaAudioTrack\u003E AudioTracks( IEnumerable\u003CMediaFormat\u003E formats )\n\t{\n\t\tvar audio = WebmAudio( formats );\n\t\tvar def = DefaultLanguage( audio );\n\t\tvar tracks = new Dictionary\u003Cstring, MediaAudioTrack\u003E();\n\t\tforeach ( var f in audio )\n\t\t{\n\t\t\tif ( string.IsNullOrEmpty( f.Language ) || tracks.ContainsKey( f.Language ) ) continue;\n\t\t\ttracks[f.Language] = new MediaAudioTrack { Id = f.Language, Name = f.LanguageName ?? f.Language, IsDefault = f.Language == def };\n\t\t}\n\n\t\t// a single (or unlabelled) track isn\u0027t a choice\n\t\tif ( tracks.Count \u003C 2 ) return new List\u003CMediaAudioTrack\u003E();\n\t\treturn tracks.Values.OrderBy( t =\u003E !t.IsDefault ).ThenBy( t =\u003E t.Name.ToLowerInvariant() ).ToList();\n\t}\n\n\t/// \u003Csummary\u003E Do these formats have an audio track in this language, matched the way \u003Csee cref=\u0022PickAudio\u0022/\u003E matches it? \u003C/summary\u003E\n\tpublic static bool HasLanguage( IEnumerable\u003CMediaFormat\u003E formats, string lang )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( lang ) ) return true;\n\t\tvar primary = lang.Split( \u0027-\u0027 )[0];\n\t\treturn WebmAudio( formats ).Any( f =\u003E string.Equals( f.Language, lang, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| string.Equals( (f.Language ?? \u0022\u0022).Split( \u0027-\u0027 )[0], primary, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// \u003Csummary\u003E Audio formats for the requested language (\u0022es\u0022 finds \u0022es-419\u0022), falling back to the original track. \u003C/summary\u003E\n\tstatic List\u003CMediaFormat\u003E PickAudio( List\u003CMediaFormat\u003E audio, string lang )\n\t{\n\t\tif ( audio.Count == 0 ) return audio;\n\n\t\tif ( !string.IsNullOrWhiteSpace( lang ) )\n\t\t{\n\t\t\tvar exact = audio.Where( f =\u003E string.Equals( f.Language, lang, StringComparison.OrdinalIgnoreCase ) ).ToList();\n\t\t\tif ( exact.Count \u003E 0 ) return exact;\n\n\t\t\tvar primary = lang.Split( \u0027-\u0027 )[0];\n\t\t\tvar prefix = audio.Where( f =\u003E string.Equals( (f.Language ?? \u0022\u0022).Split( \u0027-\u0027 )[0], primary, StringComparison.OrdinalIgnoreCase ) ).ToList();\n\t\t\tif ( prefix.Count \u003E 0 ) return prefix;\n\t\t}\n\n\t\tvar def = DefaultLanguage( audio );\n\t\treturn def is null ? audio : audio.Where( f =\u003E f.Language == def ).ToList();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Best first. With an exact height, that height wins if it exists, else the nearest below it.\n\t/// \u003C/summary\u003E\n\tstatic List\u003CMediaFormat\u003E PickVideo( List\u003CMediaFormat\u003E video, int maxHeight, int height )\n\t{\n\t\tvar limit = height \u003E 0 ? height : maxHeight;\n\t\treturn video.OrderByDescending( f =\u003E ScoreVideo( f, limit ) ).ToList();\n\t}\n\n\tpublic static StreamPlan Plan( IReadOnlyList\u003CMediaFormat\u003E formats, StreamRequest request )\n\t{\n\t\tvar maxh = Math.Clamp( request.MaxHeight, 144, 2160 );\n\t\tvar audio = PickAudio( WebmAudio( formats ), request.Language ).OrderByDescending( f =\u003E f.Bitrate ).ToList();\n\n\t\tif ( request.AudioOnly )\n\t\t{\n\t\t\tif ( audio.Count \u003E 0 )\n\t\t\t\treturn new StreamPlan { Kind = StreamKind.Audio, Audio = audio[0].ToFile() };\n\n\t\t\t// anything with audio the engine can decode on its own\n\t\t\tvar anyAudio = formats.Where( f =\u003E f.HasAudio \u0026\u0026 !f.HasVideo \u0026\u0026 f.Container is \u0022mp3\u0022 or \u0022ogg\u0022 or \u0022m4a\u0022 )\n\t\t\t\t.OrderByDescending( f =\u003E f.Bitrate ).FirstOrDefault()\n\t\t\t\t?? formats.Where( f =\u003E f.HasAudio \u0026\u0026 f.Container is \u0022mp4\u0022 or \u0022webm\u0022 ).OrderBy( f =\u003E f.Height ).FirstOrDefault();\n\t\t\tif ( anyAudio is not null )\n\t\t\t\treturn new StreamPlan { Kind = StreamKind.Direct, DirectUrl = anyAudio.Url };\n\n\t\t\tthrow new ResolveException( \u0022No playable audio found.\u0022 );\n\t\t}\n\n\t\tvar video = PickVideo( WebmVideo( formats ), maxh, request.Height );\n\t\tif ( video.Count \u003E 0 \u0026\u0026 audio.Count \u003E 0 )\n\t\t\treturn new StreamPlan { Kind = StreamKind.Merge, Video = video[0].ToFile(), Audio = audio[0].ToFile() };\n\n\t\t// a single muxed file the engine can decode (VP9/AV1\u002BOpus WebM, or H.264/AAC MP4)\n\t\tvar muxed = formats.Where( f =\u003E f.HasVideo \u0026\u0026 f.HasAudio \u0026\u0026 f.Container is \u0022webm\u0022 or \u0022mp4\u0022 )\n\t\t\t.OrderByDescending( f =\u003E ScoreVideo( f, maxh ) )\n\t\t\t.ThenByDescending( f =\u003E f.Container == \u0022webm\u0022 )\n\t\t\t.FirstOrDefault();\n\t\tif ( muxed is not null )\n\t\t\treturn new StreamPlan { Kind = StreamKind.Direct, DirectUrl = muxed.Url };\n\n\t\tthrow new ResolveException( \u0022No playable formats found.\u0022 );\n\t}\n}\n"}]}