using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.Networking; using UnityEngine.Video; /// /// A small HLS live player for Windows/desktop builds, where Unity's VideoPlayer can't read HLS or MPEG-TS. /// It polls the media playlist, downloads each new .ts segment, remuxes it to MP4 (TsToMp4, no re-encode), /// and plays the segments back to back with two VideoPlayers: one plays while the other prepares the next, /// then they swap. The current frame is shown on . Runs a few seconds behind live. /// public class HlsStreamPlayer : MonoBehaviour { [Tooltip("Master or media playlist URL (.m3u8).")] public string url = "https://jankorama.jankfoundry.com/hls/stream.m3u8"; [Tooltip("Renderer that shows the video (its material's base map is replaced by the stream).")] public Renderer screen; [Range(0f, 1f)] public float volume = 0.6f; [Tooltip("Segments behind the newest to start from; more = smoother start, further behind live.")] public int startBehind = 2; public bool playOnStart = true; [Tooltip("Fill the screen edge to edge (crops the video's sides or top/bottom if the shapes differ). Off = fit the whole picture.")] public bool fillScreen = false; VideoPlayer[] players = new VideoPlayer[2]; RenderTexture[] targets = new RenderTexture[2]; int active = -1; // index of the player on screen, -1 before the first segment readonly bool[] preparing = new bool[2]; // Prepare() called, not finished yet bool buffering; // current segment ended before the next was ready readonly Queue ready = new Queue(); // remuxed mp4 files waiting to play readonly List toDelete = new List(); long nextSequence = -1; string mediaUrl; float targetDuration = 3f; string cacheDir; Material screenMat; static readonly int BaseMap = Shader.PropertyToID("_BaseMap"), MainTex = Shader.PropertyToID("_MainTex"), EmissionMap = Shader.PropertyToID("_EmissionMap"); public string Status { get; private set; } = "Idle"; /// True while paused (e.g. Windows 95 is showing on the monitor instead). No fetching, no video, no audio. public bool IsPaused { get; private set; } Coroutine runCoroutine; /// /// Stops audio and playback immediately and halts the background segment fetch/convert loop, so nothing /// keeps playing under whatever else takes over the screen. Disabling this component is not enough on its /// own: a coroutine already started with StartCoroutine keeps running even while its component is disabled /// (only stops if the whole GameObject is deactivated), and the two child VideoPlayers are separate /// components that a disabled HlsStreamPlayer never touches, so their audio would otherwise keep playing. /// public void Pause() { if (IsPaused) return; IsPaused = true; if (runCoroutine != null) { StopCoroutine(runCoroutine); runCoroutine = null; } foreach (var vp in players) { if (vp == null) continue; vp.SetDirectAudioVolume(0, 0f); if (vp.isPlaying) vp.Pause(); } } /// Resumes fetching and playback after , picking up live rather than replaying old segments. public void Resume() { if (!IsPaused) return; IsPaused = false; ready.Clear(); toDelete.Clear(); nextSequence = -1; // re-derive a fresh "few segments behind live" starting point rather than replaying stale ones active = -1; foreach (var vp in players) { if (vp == null) continue; vp.Stop(); vp.url = ""; vp.SetDirectAudioVolume(0, volume); } runCoroutine = StartCoroutine(Run()); } void Start() { cacheDir = Path.Combine(Application.temporaryCachePath, "hls_" + System.Guid.NewGuid().ToString("N").Substring(0, 8)); Directory.CreateDirectory(cacheDir); for (int i = 0; i < 2; i++) { var go = new GameObject("HLS Player " + i); go.transform.SetParent(transform, false); var vp = go.AddComponent(); vp.playOnAwake = false; vp.source = VideoSource.Url; vp.renderMode = VideoRenderMode.RenderTexture; vp.audioOutputMode = VideoAudioOutputMode.Direct; vp.skipOnDrop = true; vp.waitForFirstFrame = true; vp.isLooping = false; int idx = i; vp.loopPointReached += _ => OnSegmentEnded(idx); vp.prepareCompleted += _ => { preparing[idx] = false; if (buffering && idx != active) { buffering = false; SwitchTo(idx); } }; vp.errorReceived += (_, msg) => { preparing[idx] = false; vp.url = ""; Status = "Video error: " + msg; Debug.LogWarning("HLS player: " + msg); }; players[i] = vp; } if (screen != null) screenMat = screen.material; if (playOnStart) runCoroutine = StartCoroutine(Run()); } void OnDestroy() { StopAllCoroutines(); foreach (var rt in targets) if (rt != null) rt.Release(); try { if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); } catch { } } IEnumerator Run() { Status = "Loading playlist"; // Master playlist -> first media playlist string master = null; yield return Fetch(url, t => master = t); if (master == null) { Status = "Couldn't load " + url; yield break; } mediaUrl = url; if (master.Contains("#EXT-X-STREAM-INF")) { var lines = master.Split('\n'); for (int i = 0; i < lines.Length; i++) if (lines[i].StartsWith("#EXT-X-STREAM-INF") && i + 1 < lines.Length) { mediaUrl = Resolve(url, lines[i + 1].Trim()); break; } } while (true) { string media = null; yield return Fetch(mediaUrl, t => media = t); if (media != null) yield return QueueNewSegments(media); StartPlaybackIfIdle(); yield return new WaitForSeconds(Mathf.Max(1f, targetDuration * 0.5f)); } } IEnumerator QueueNewSegments(string media) { long seq = 0; var segs = new List<(long seq, string uri)>(); foreach (var raw in media.Split('\n')) { var line = raw.Trim(); if (line.StartsWith("#EXT-X-TARGETDURATION:")) float.TryParse(line.Substring(22), out targetDuration); else if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:")) long.TryParse(line.Substring(22), out seq); else if (line.Length > 0 && !line.StartsWith("#")) { segs.Add((seq, Resolve(mediaUrl, line))); seq++; } } if (segs.Count == 0) yield break; if (nextSequence < 0) nextSequence = segs[Mathf.Max(0, segs.Count - 1 - startBehind)].seq; if (nextSequence < segs[0].seq) nextSequence = segs[0].seq; // fell too far behind: skip ahead foreach (var s in segs) { if (s.seq < nextSequence) continue; byte[] ts = null; yield return FetchBytes(s.uri, b => ts = b); nextSequence = s.seq + 1; if (ts == null) continue; byte[] mp4 = null; try { mp4 = TsToMp4.Convert(ts); } catch (System.Exception ex) { Debug.LogWarning("HLS remux failed: " + ex.Message); } if (mp4 == null) continue; string path = Path.Combine(cacheDir, "seg_" + s.seq + ".mp4"); File.WriteAllBytes(path, mp4); ready.Enqueue(path); PrepareNext(); } } // The idle player prepares the next queued segment so it can start instantly when the current one ends void PrepareNext() { int idle = active < 0 ? 0 : 1 - active; var vp = players[idle]; if (ready.Count == 0 || preparing[idle] || vp.isPrepared || vp.isPlaying) return; vp.url = ready.Dequeue(); preparing[idle] = true; vp.Prepare(); } void StartPlaybackIfIdle() { if (active >= 0) return; if (players[0].isPrepared) SwitchTo(0); else PrepareNext(); } void OnSegmentEnded(int idx) { if (idx != active) return; int next = 1 - active; if (players[next].isPrepared) SwitchTo(next); else { Status = "Buffering"; buffering = true; PrepareNext(); } // prepareCompleted switches as soon as it's ready } void SwitchTo(int idx) { var vp = players[idx]; if (targets[idx] == null || targets[idx].width != (int)vp.width || targets[idx].height != (int)vp.height) { if (targets[idx] != null) targets[idx].Release(); targets[idx] = new RenderTexture(Mathf.Max(16, (int)vp.width), Mathf.Max(16, (int)vp.height), 0); } vp.targetTexture = targets[idx]; vp.SetDirectAudioVolume(0, volume); vp.Play(); int prev = active; active = idx; if (screenMat != null) { // Match the video's shape to the screen's: crop to fill, or shrink to fit float screenAspect = screen.transform.lossyScale.x / Mathf.Max(0.0001f, screen.transform.lossyScale.y); float videoAspect = vp.height > 0 ? (float)vp.width / vp.height : 16f / 9f; Vector2 scale = Vector2.one; if (fillScreen) { if (screenAspect < videoAspect) scale.x = screenAspect / videoAspect; else scale.y = videoAspect / screenAspect; } else { if (screenAspect > videoAspect) scale.x = screenAspect / videoAspect; else scale.y = videoAspect / screenAspect; } Vector2 offset = (Vector2.one - scale) * 0.5f; foreach (var prop in new[] { BaseMap, MainTex }) if (screenMat.HasProperty(prop)) { screenMat.SetTextureScale(prop, scale); screenMat.SetTextureOffset(prop, offset); } if (screenMat.HasProperty(BaseMap)) screenMat.SetTexture(BaseMap, targets[idx]); if (screenMat.HasProperty(MainTex)) screenMat.SetTexture(MainTex, targets[idx]); if (screenMat.HasProperty(EmissionMap)) screenMat.SetTexture(EmissionMap, targets[idx]); } Status = "Playing"; if (prev >= 0) { var old = players[prev]; string oldPath = old.url; old.Stop(); old.url = ""; if (!string.IsNullOrEmpty(oldPath)) toDelete.Add(oldPath); } CleanUp(); PrepareNext(); } void CleanUp() { for (int i = toDelete.Count - 1; i >= 0; i--) { try { if (File.Exists(toDelete[i])) File.Delete(toDelete[i]); toDelete.RemoveAt(i); } catch { } } } void Update() { // Keep the next segment prepared while one plays if (active >= 0 && ready.Count > 0 && !players[1 - active].isPrepared) PrepareNext(); } static string Resolve(string baseUrl, string relative) { if (relative.StartsWith("http://") || relative.StartsWith("https://")) return relative; int slash = baseUrl.LastIndexOf('/'); return (slash >= 0 ? baseUrl.Substring(0, slash + 1) : "") + relative; } static IEnumerator Fetch(string u, System.Action done) { using (var req = UnityWebRequest.Get(u)) { req.timeout = 10; yield return req.SendWebRequest(); done(req.result == UnityWebRequest.Result.Success ? req.downloadHandler.text : null); } } static IEnumerator FetchBytes(string u, System.Action done) { using (var req = UnityWebRequest.Get(u)) { req.timeout = 15; yield return req.SendWebRequest(); done(req.result == UnityWebRequest.Result.Success ? req.downloadHandler.data : null); } } }