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);
}
}
}