Add page for testing m3u8 modifications and latency

This commit is contained in:
pixeltris 2020-12-28 06:22:10 +00:00
parent 9ef9d278eb
commit 47a17f47f3
3 changed files with 740 additions and 74 deletions

View File

@ -1 +1 @@
call %WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe utils.cs call %WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe -debug utils.cs

546
utils.cs
View File

@ -8,45 +8,61 @@ using System.Reflection;
using System.Threading; using System.Threading;
using System.Net; using System.Net;
using System.IO; using System.IO;
using System.Diagnostics;
namespace TwitchAdUtils namespace TwitchAdUtils
{ {
class Program class Program
{ {
public static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko"; static string ClientID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
public static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"; static string UserAgentChrome = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36";
public static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0"; static string UserAgentFirefox = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0";
public static string UserAgent = UserAgentChrome; static string UserAgent = UserAgentChrome;
public static bool UseOldAccessToken = false; static bool UseOldAccessToken = false;
public static bool UseAccessTokenTemplate = false; static bool UseAccessTokenTemplate = false;
public static bool ShouldNotifyAdWatched = true; static bool ShouldNotifyAdWatched = true;
public static string PlayerTypeRegular = "site";//embed static string PlayerTypeNormal = "site";//embed
public static string PlayerTypeMiniNoAd = "picture-by-picture";//thunderdome static string PlayerTypeMiniNoAd = "picture-by-picture";//"thunderdome";
public static string Platform = "web"; static string Platform = "web";
public static string PlayerBackend = "mediaplayer"; static string PlayerBackend = "mediaplayer";
public static string MainM3U8AdditionalParams = ""; static string MainM3U8AdditionalParams = "";
public static string AdSignifier = "stitched-ad"; static string AdSignifier = "stitched-ad";
public static TimeSpan LoopDelay = TimeSpan.FromSeconds(1); static string ProxyUrl = "http://choosen.dev/stream/twitch/";
static int TargetResolution = 480;
static TimeSpan LoopDelay = TimeSpan.FromSeconds(1);
enum RunnerMode enum RunnerMode
{ {
Regular, Normal,
MiniNoAd MiniNoAd,
Proxy
} }
static void Main(string[] args) static void Main(string[] args)
{ {
ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072;
ServicePointManager.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true;
if (args.Length >= 1 && args[0] == "build_scripts") if (args.Length >= 1 && args[0] == "build_scripts")
{ {
// This takes "base.user.js" and updates all of the other scripts based on the cfg values // This takes "base.user.js" and updates all of the other scripts based on the cfg values
BuildScripts(); BuildScripts();
return; return;
} }
ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; if (args.Length >= 1 && args[0] == "m3u8")
{
// Tests modifications of m3u8 files
Console.WriteLine("Starting local server (http://localhost)");
TwitchTestServer testServer = new TwitchTestServer();
testServer.Start(80);
Console.ReadLine();
return;
}
Console.Write("Enter channel name: "); Console.Write("Enter channel name: ");
string channel = Console.ReadLine().ToLower(); string channel = Console.ReadLine().ToLower();
Console.WriteLine("Fetching channel '" + channel + "'"); Console.WriteLine("Fetching channel '" + channel + "'");
RunImpl(RunnerMode.Regular, channel); RunImpl(RunnerMode.Normal, channel);
//RunImpl(RunnerMode.MiniNoAd, channel); //RunImpl(RunnerMode.MiniNoAd, channel);
} }
@ -156,11 +172,12 @@ namespace TwitchAdUtils
thread.Start(); thread.Start();
} }
static void RunImpl(RunnerMode mode, string channel) static string RunImpl(RunnerMode mode, string channel, bool isFetchingM3U8 = false, bool forceSkipAd = false)
{ {
string playerType = mode == RunnerMode.Regular ? PlayerTypeRegular : PlayerTypeMiniNoAd; string playerType = mode == RunnerMode.MiniNoAd ? PlayerTypeMiniNoAd : PlayerTypeNormal;
string cookies = null; string cookies = null;
string uniqueId = null; string uniqueId = null;
int cycle = 0;
while (true) while (true)
{ {
if (string.IsNullOrEmpty(cookies)) if (string.IsNullOrEmpty(cookies))
@ -177,12 +194,14 @@ namespace TwitchAdUtils
if (string.IsNullOrEmpty(uniqueId)) if (string.IsNullOrEmpty(uniqueId))
{ {
Console.WriteLine("unique_id is null"); Console.WriteLine("unique_id is null");
return; return null;
} }
using (WebClient wc = new WebClient()) using (WebClient wc = new WebClient())
{ {
string response = null, token = null, sig = null; string response = null, token = null, sig = null;
wc.Proxy = null; wc.Proxy = null;
if (mode != RunnerMode.Proxy)
{
if (UseOldAccessToken) if (UseOldAccessToken)
{ {
wc.Headers.Clear(); wc.Headers.Clear();
@ -238,9 +257,25 @@ namespace TwitchAdUtils
} }
} }
} }
if (!string.IsNullOrEmpty(token)) }
if (mode == RunnerMode.Proxy || !string.IsNullOrEmpty(token))
{ {
string url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token) + MainM3U8AdditionalParams; string url = null;
if (mode == RunnerMode.Proxy)
{
url = ProxyUrl + channel;
}
else
{
url = "https://usher.ttvnw.net/api/channel/hls/" + channel + ".m3u8?allow_source=true&sig=" + sig + "&token=" + System.Web.HttpUtility.UrlEncode(token) + MainM3U8AdditionalParams;
}
if (isFetchingM3U8)
{
if (!forceSkipAd || cycle > 0)
{
return url;
}
}
wc.Headers.Clear(); wc.Headers.Clear();
wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain"; wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain";
wc.Headers["host"] = "usher.ttvnw.net"; wc.Headers["host"] = "usher.ttvnw.net";
@ -260,7 +295,7 @@ namespace TwitchAdUtils
if (streamM3u8.Contains(AdSignifier)) if (streamM3u8.Contains(AdSignifier))
{ {
Console.WriteLine("has ad " + DateTime.Now.TimeOfDay); Console.WriteLine("has ad " + DateTime.Now.TimeOfDay);
if (!UseOldAccessToken && ShouldNotifyAdWatched) if (!UseOldAccessToken && (ShouldNotifyAdWatched || forceSkipAd))
{ {
NotifyWatchedAd(uniqueId, streamM3u8); NotifyWatchedAd(uniqueId, streamM3u8);
} }
@ -291,13 +326,26 @@ namespace TwitchAdUtils
} }
} }
Thread.Sleep(LoopDelay); Thread.Sleep(LoopDelay);
cycle++;
} }
} }
static Dictionary<string, string> ParseAttributes(string tag) static Dictionary<string, string> ParseAttributes(string tag)
{
string tagName;
return ParseAttributes(tag, out tagName);
}
static Dictionary<string, string> ParseAttributes(string tag, out string tagName)
{ {
// TODO: Improve this // TODO: Improve this
Dictionary<string, string> result = new Dictionary<string, string>(); Dictionary<string, string> result = new Dictionary<string, string>();
tagName = null;
int tagDataSplitIndex = tag.IndexOf(':');
if (tagDataSplitIndex > 0)
{
tagName = tag.Substring(0, tagDataSplitIndex);
tag = tag.Substring(tagDataSplitIndex + 1);
string[] splitted = tag.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); string[] splitted = tag.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string str in splitted) foreach (string str in splitted)
{ {
@ -307,6 +355,7 @@ namespace TwitchAdUtils
result[str.Substring(0, index)] = str.Substring(index + 1).Trim('\"'); result[str.Substring(0, index)] = str.Substring(index + 1).Trim('\"');
} }
} }
}
return result; return result;
} }
@ -487,6 +536,455 @@ namespace TwitchAdUtils
return TinyJson.JSONParser.FromJson<TType>(json); return TinyJson.JSONParser.FromJson<TType>(json);
} }
} }
class TwitchTestServer
{
const string RecordDir = "recordings";
Dictionary<string, State> states = new Dictionary<string, State>();
class State
{
public bool IsReplay = false;
public string RecordingName = null;
public string RecordingPath = null;
public string ChannelName = null;
public string UrlChRecName { get { return ChannelName + "|" + RecordingName; } }
public string M3U8Normal = null;
public string M3U8Mini = null;
public string M3U8Alt = null;
public Dictionary<string, Dictionary<string, string>> M3U8Map = new Dictionary<string, Dictionary<string, string>>();
public Stopwatch Stopwatch = new Stopwatch();
public State(string channelName, string name)
{
ChannelName = channelName;
RecordingName = name;
RecordingPath = Path.GetFullPath(Path.Combine(RecordDir, name));
try
{
if (!Directory.Exists(RecordingPath))
{
Directory.CreateDirectory(RecordingPath);
}
}
catch
{
}
}
public void Clear()
{
M3U8Map.Clear();
Stopwatch.Restart();
try
{
while (Directory.Exists(RecordingPath))
{
Directory.Delete(RecordingPath, true);
}
}
catch
{
}
try
{
if (!Directory.Exists(RecordingPath))
{
Directory.CreateDirectory(RecordingPath);
}
}
catch
{
}
}
public void Load()
{
M3U8Map.Clear();
Stopwatch.Restart();
IsReplay = true;
}
}
private Thread thread;
private HttpListener listener;
public void Start(int port)
{
Stop();
thread = new Thread(delegate()
{
listener = new HttpListener();
listener.Prefixes.Add("http://*:" + port + "/");
listener.Start();
while (listener != null)
{
try
{
HttpListenerContext context = listener.GetContext();
Process(context);
}
catch
{
}
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
public void Stop()
{
if (listener != null)
{
try
{
listener.Stop();
}
catch
{
}
listener = null;
}
if (thread != null)
{
try
{
thread.Abort();
}
catch
{
}
thread = null;
}
}
private void Process(HttpListenerContext context)
{
try
{
string url = context.Request.Url.OriginalString;
//Console.WriteLine("req " + DateTime.Now.TimeOfDay + " - " + url);
string response = string.Empty;
string contentType = "text/html";
if (url.Contains("favicon.ico"))
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.OutputStream.Close();
return;
}
byte[] responseBuffer = null;
if (context.Request.Url.Segments.Length == 1 && context.Request.Url.Segments[0] == "/")
{
response = "<html><script src='utils.js'></script></html>";
}
else if (context.Request.Url.Segments.Length == 2 && context.Request.Url.Segments[1] == "utils.js")
{
response = File.ReadAllText("utils.js");
}
else if (context.Request.Url.Segments.Length >= 3)
{
string[] reqTypeSplitted = context.Request.Url.Segments[1].Trim('/').Split('_');
string reqType = reqTypeSplitted[0].ToLower();
string reqStreamType = reqTypeSplitted.Length > 1 ? reqTypeSplitted[1] : null;
string[] splitted = context.Request.Url.Segments[2].Trim('/').ToLower().Replace("%7c", "|").Split('|');
string channelName = splitted[0];
string recordingName = splitted[1];
if (!string.IsNullOrEmpty(channelName) && !string.IsNullOrEmpty(recordingName))
{
State state;
if (!states.TryGetValue(recordingName, out state))
{
states[recordingName] = state = new State(channelName, recordingName);
}
switch (reqType)
{
case "record-begin":
{
state.Clear();
string normal = RunImpl(RunnerMode.Normal, channelName, true);
if (!string.IsNullOrEmpty(normal))
{
string mini = RunImpl(RunnerMode.MiniNoAd, channelName, true);
if (!string.IsNullOrEmpty(mini))
{
string alt = RunImpl(RunnerMode.Proxy, channelName, true);
//string alt = RunImpl(RunnerMode.Normal, channelName, true, true);
state.M3U8Normal = normal;
state.M3U8Mini = mini;
state.M3U8Alt = alt;
response = "ok";
}
}
}
break;
case "replay-begin":
{
DirectoryInfo dir = new DirectoryInfo(Path.Combine(RecordDir, recordingName));
if (dir.Exists && dir.GetFiles().Length > 0)
{
state.Load();
response = "ok";
}
}
break;
case "m3u8":
{
string type = reqTypeSplitted[1].ToLower();
string m3u8Url = null;
switch (type)
{
case "normal":
case "output":
m3u8Url = state.M3U8Normal;
break;
case "mini":
m3u8Url = state.M3U8Mini;
break;
case "alt":
m3u8Url = state.M3U8Alt;
break;
}
if (!string.IsNullOrEmpty(m3u8Url))
{
response = GetM3U8(state, m3u8Url, reqStreamType, true);
}
}
break;
case "m3u8-sub":
{
string type = reqTypeSplitted[1].ToLower();
string m3u8Url = null;
if (!state.IsReplay)
{
m3u8Url = GetM3U8Url(state, reqStreamType);
}
if (!string.IsNullOrEmpty(m3u8Url) || state.IsReplay)
{
response = GetM3U8(state, m3u8Url, reqStreamType, false);
}
}
break;
case "m3u8-seg":
{
// TODO: Load segment, return as binary file
}
break;
default:
Console.WriteLine("Unhandled request '" + reqType + "'");
break;
}
}
}
if (responseBuffer == null)
{
responseBuffer = Encoding.UTF8.GetBytes(response == null ? string.Empty : response.ToString());
}
context.Response.ContentType = contentType;
context.Response.ContentEncoding = Encoding.UTF8;
context.Response.ContentLength64 = responseBuffer.Length;
context.Response.OutputStream.Write(responseBuffer, 0, responseBuffer.Length);
context.Response.OutputStream.Flush();
context.Response.StatusCode = (int)HttpStatusCode.OK;
}
catch (Exception e)
{
Console.WriteLine(e);
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
context.Response.OutputStream.Close();
}
private string DownloadM3U8(string url)
{
try
{
using (WebClient wc = new WebClient())
{
wc.Proxy = null;
wc.Headers["accept"] = "application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain";
wc.Headers["host"] = "usher.ttvnw.net";
wc.Headers["cookie"] = "DNT=1;";
wc.Headers["DNT"] = "1";
wc.Headers["user-agent"] = UserAgent;
return wc.DownloadString(url);
}
}
catch (Exception e)
{
//Console.WriteLine(url);
Console.WriteLine(e);
return null;
}
}
private string GetM3U8Url(State state, string reqStreamType)
{
Dictionary<string, string> m3u8Map;
if (state.M3U8Map.TryGetValue(reqStreamType, out m3u8Map) && m3u8Map.Count > 0)
{
string resUrl = null;
int res = int.MaxValue;
string backupUrl = null;
foreach (KeyValuePair<string, string> mappedUrl in m3u8Map)
{
if (mappedUrl.Key.Contains("x"))
{
int val;
if (int.TryParse(mappedUrl.Key.Split('x')[1], out val))
{
if (backupUrl == null)
{
backupUrl = mappedUrl.Value;
}
if (val < res && val >= TargetResolution)
{
res = val;
resUrl = mappedUrl.Value;
}
}
}
}
if (string.IsNullOrEmpty(resUrl))
{
resUrl = backupUrl;
}
return resUrl;
}
return null;
}
private string GetM3U8(State state, string url, string reqStreamType, bool isMain)
{
string m3u8 = null;
string backupUrl = null;
if (state.IsReplay)
{
// TODO: Load replay m3u8
}
else
{
m3u8 = DownloadM3U8(url);
if (reqStreamType == "output")
{
backupUrl = GetM3U8Url(state, "mini");
}
}
if (string.IsNullOrEmpty(m3u8))
{
return null;
}
if (!state.M3U8Map.ContainsKey(reqStreamType))
{
state.M3U8Map[reqStreamType] = new Dictionary<string, string>();
}
m3u8 = m3u8.Replace("\r", string.Empty);
string prevRes = null;
string[] lines = m3u8.Split('\n');
string mainM3U8Name = "m3u8-sub_" + reqStreamType;
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i].Trim();
if (line.StartsWith("#"))
{
string tagName;
Dictionary<string, string> attr = ParseAttributes(line, out tagName);
if (tagName == "#EXT-X-STREAM-INF")
{
attr.TryGetValue("RESOLUTION", out prevRes);
}
}
else if (line.EndsWith(".m3u8"))
{
if (!string.IsNullOrEmpty(prevRes) && !state.M3U8Map[reqStreamType].ContainsKey(prevRes))
{
state.M3U8Map[reqStreamType][prevRes] = line;
}
lines[i] = "/" + mainM3U8Name + "/" + state.UrlChRecName;
}
else if (line.EndsWith(".ts"))
{
// TODO: Save seg
}
}
if (!isMain && m3u8.Contains("stitched-ad") && !string.IsNullOrEmpty(backupUrl))
{
string m3u8Backup = DownloadM3U8(backupUrl);
if (!string.IsNullOrEmpty(m3u8Backup))
{
m3u8Backup = m3u8Backup.Replace("\r", string.Empty);
string[] backupLines = m3u8Backup.Split('\n');
Dictionary<string, string> segmentMap = new Dictionary<string, string>();
Dictionary<long, string> segTimes = GetSegmentTimes(lines);
Dictionary<long, string> backupSegTimes = GetSegmentTimes(backupLines);
foreach (KeyValuePair<long, string> seg in segTimes)
{
//segmentMap[seg.Value] = backupSegTimes.Last().Value;
long closestTime = long.MaxValue;
long matchingBackupTime = long.MaxValue;
foreach (KeyValuePair<long, string> backupSeg in backupSegTimes)
{
long timeDiff = Math.Abs(seg.Key - backupSeg.Key);
if (timeDiff < closestTime)
{
closestTime = timeDiff;
matchingBackupTime = backupSeg.Key;
segmentMap[seg.Value] = backupSeg.Value;
}
}
if (closestTime != long.MaxValue)
{
backupSegTimes.Remove(matchingBackupTime);
}
}
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
if (line.Contains("stitched-ad"))
{
line = "";
}
if (line.StartsWith("#EXTINF:") && !line.Contains(",live"))
{
lines[i] = line.Substring(0, line.IndexOf(',')) + ",live";
string backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : "";
}
}
}
}
if (isMain)
{
File.WriteAllText(Path.Combine(state.RecordingPath, mainM3U8Name + "-original"), m3u8);
File.WriteAllLines(Path.Combine(state.RecordingPath, mainM3U8Name), lines);
}
// TODO: Save m3u8
return string.Join(Environment.NewLine, lines);
}
private Dictionary<long, string> GetSegmentTimes(string[] lines)
{
Dictionary<long, string> result = new Dictionary<long, string>();
long lastDate = 0;
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
if (line.StartsWith("#EXT-X-PROGRAM-DATE-TIME:"))
{
lastDate = DateTime.Parse(line.Substring(line.IndexOf(":") + 1)).Ticks;
}
else if (line.StartsWith("http"))
{
result[lastDate] = line;
}
}
return result;
}
}
} }
} }

168
utils.js Normal file
View File

@ -0,0 +1,168 @@
var defaultChannel = '';
var defaultName = 'test1';
var inputWidth = '100px';
var playerWidth = '800px';
var defaultVolume = 1;
//
const STATE_STOPPED = 1;
const STATE_RECORDING = 2;
const STATE_REPLAYING = 3;
const STATE_WAITING = 4;
var state = STATE_STOPPED;
//
var channelElement = null;
var nameElement = null;
var stateInfoElement = null;
var streamNormal = null;
var streamMini = null;
var streamOutput = null;
var streamAlt = null;
var streams = [];
//
function updateStateInfo(newState) {
if (newState) {
state = newState;
}
if (!streamNormal || !streamMini || !streamOutput || !streamAlt) {
return;
}
var stateStr = '';
switch (state) {
case STATE_STOPPED: stateStr = 'stopped'; break;
case STATE_RECORDING: stateStr = 'recording'; break;
case STATE_REPLAYING: stateStr = 'replaying'; break;
case STATE_WAITING: stateStr = 'waiting'; break;
}
stateInfoElement.textContent = ' state: ' + stateStr;
}
function updateStreamInfo(stream) {
stream.InfoElement.textContent = '[' + stream.StreamName + ']';
}
function setStreamSrc(stream, src) {
stream.hls.loadSource('/' + src + '/' + channelElement.value + '|' + nameElement.value);
stream.hls.attachMedia(stream);
}
function recordOrReplayStream(newState, type) {
if (!channelElement.value) {
alert('Channel name textbox is empty');
return;
}
stopStream();
updateStateInfo(STATE_WAITING);
fetch('/' + type + '-begin/' + channelElement.value + '|' + nameElement.value).then(async function(response) {
if (response.status == 200) {
var str = await response.text();
if (str) {
updateStateInfo(newState);
setStreamSrc(streamNormal, 'm3u8_normal');
setStreamSrc(streamMini, 'm3u8_mini');
setStreamSrc(streamOutput, 'm3u8_output');
setStreamSrc(streamAlt, 'm3u8_alt');
} else {
stopStream();
}
} else {
stopStream();
}
});
}
function recordStream() {
recordOrReplayStream(STATE_RECORDING, 'record');
}
function replayStream() {
alert('TODO');
//recordOrReplayStream(STATE_REPLAYING, 'replay');
}
function stopStream() {
for (var i = 0; i < streams.length; i++) {
streams[i].hls.stopLoad();
streams[i].pause();
}
updateStateInfo(STATE_STOPPED);
}
function createStreamElement(name) {
/////////////////////////////////////////
var containerElement = document.createElement('div');
containerElement.style.display = 'inline-block';
containerElement.style.width = 'auto';
document.body.appendChild(containerElement);
/////////////////////////////////////////
var infoElement = document.createElement('span');
containerElement.appendChild(infoElement);
containerElement.appendChild(document.createElement('br'));
/////////////////////////////////////////
var stream = document.createElement('video');
stream.style.maxWidth = playerWidth;
stream.style.width = playerWidth;
stream.InfoElement = infoElement;
stream.StreamName = name;
stream.autoplay = true;
stream.volume = defaultVolume;
stream.hls = new Hls();
containerElement.appendChild(stream);
/////////////////////////////////////////
streams.push(stream);
updateStreamInfo(stream);
return stream;
}
function onHlsLoaded() {
/////////////////////////////////////////
var label1 = document.createElement('span');
label1.textContent = 'channel:';
document.body.appendChild(label1);
/////////////////////////////////////////
channelElement = document.createElement('input');
channelElement.value = defaultChannel;
channelElement.style.width = inputWidth;
document.body.appendChild(channelElement);
/////////////////////////////////////////
var label2 = document.createElement('span');
label2.textContent = 'name:';
document.body.appendChild(label2);
/////////////////////////////////////////
nameElement = document.createElement('input');
nameElement.value = defaultName;
nameElement.style.width = inputWidth;
document.body.appendChild(nameElement);
/////////////////////////////////////////
var recordBtn = document.createElement('button');
recordBtn.textContent = 'record';
recordBtn.onclick = recordStream;
document.body.appendChild(recordBtn);
/////////////////////////////////////////
var replayBtn = document.createElement('button');
replayBtn.textContent = 'replay';
replayBtn.onclick = replayStream;
document.body.appendChild(replayBtn);
/////////////////////////////////////////
var stopBtn = document.createElement('button');
stopBtn.textContent = 'stop';
stopBtn.onclick = stopStream;
document.body.appendChild(stopBtn);
/////////////////////////////////////////
stateInfoElement = document.createElement('span');
document.body.appendChild(stateInfoElement);
/////////////////////////////////////////
document.body.appendChild(document.createElement('br'));
/////////////////////////////////////////
streamNormal = createStreamElement('normal');
streamMini = createStreamElement('mini');
streamOutput = createStreamElement('output');
streamAlt = createStreamElement('alt');
updateStateInfo();
}
function onContentLoaded() {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
script.onload = function() {
onHlsLoaded();
};
document.head.appendChild(script);
}
if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
onContentLoaded();
} else {
window.addEventListener('DOMContentLoaded', function() {
onContentLoaded();
});
}