mirror of
https://github.com/langhuihui/monibuca.git
synced 2026-04-23 01:07:03 +08:00
feat: add hls play
This commit is contained in:
@@ -17,3 +17,7 @@ mp4:
|
||||
onsub:
|
||||
pull:
|
||||
^vod/(.+)$: $1
|
||||
hls:
|
||||
onpub:
|
||||
transform:
|
||||
.* : 5s x 3
|
||||
|
||||
+6
-1
@@ -55,7 +55,12 @@ func (A *ADTS) Demux(ctx codec.ICodecCtx) (any, error) {
|
||||
}
|
||||
|
||||
func (A *ADTS) Mux(ctx codec.ICodecCtx, frame *AVFrame) {
|
||||
aacCtx := ctx.GetBase().(*codec.AACCtx)
|
||||
A.DTS = frame.Timestamp * 90 / time.Millisecond
|
||||
aacCtx, ok := ctx.GetBase().(*codec.AACCtx)
|
||||
if !ok {
|
||||
A.Append(frame.Raw.(util.Memory).Buffers...)
|
||||
return
|
||||
}
|
||||
A.InitRecycleIndexes(1)
|
||||
adts := A.NextN(7)
|
||||
raw := frame.Raw.(util.Memory)
|
||||
|
||||
@@ -155,6 +155,8 @@ func (a *AnnexB) Demux(codecCtx codec.ICodecCtx) (ret any, err error) {
|
||||
}
|
||||
|
||||
func (a *AnnexB) Mux(codecCtx codec.ICodecCtx, frame *AVFrame) {
|
||||
a.DTS = frame.Timestamp * 90 / time.Millisecond
|
||||
a.PTS = a.DTS + frame.CTS*90/time.Millisecond
|
||||
a.InitRecycleIndexes(0)
|
||||
delimiter2 := codec.NALU_Delimiter2[:]
|
||||
a.AppendOne(delimiter2)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Hls.js demo - basic usage</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="hls.js"></script>
|
||||
|
||||
<center>
|
||||
<h1>Hls.js demo - basic usage</h1>
|
||||
<video height="600" id="video" controls></video>
|
||||
</center>
|
||||
|
||||
<script>
|
||||
var video = document.getElementById('video');
|
||||
if (Hls.isSupported()) {
|
||||
var hls = new Hls({
|
||||
debug: true,
|
||||
});
|
||||
hls.loadSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8');
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, function () {
|
||||
video.muted = true;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
// hls.js is not supported on platforms that do not have Media Source Extensions (MSE) enabled.
|
||||
// When the browser has built-in HLS support (check using `canPlayType`), we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video element through the `src` property.
|
||||
// This is using the built-in support of the plain video element, without using hls.js.
|
||||
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
video.addEventListener('canplay', function () {
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="hls.js"></script>
|
||||
<video id="video" controls></video>
|
||||
<script>
|
||||
function parseQuery(queryString) {
|
||||
var query = {};
|
||||
var pairs = (
|
||||
queryString[0] === '?' ? queryString.slice(1) : queryString
|
||||
).split('&');
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var pair = pairs[i].split('=');
|
||||
query[decodeURIComponent(pair[0])] = decodeURIComponent(
|
||||
pair[1] || ''
|
||||
);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/* get stream from query string */
|
||||
function getParameterByName(name) {
|
||||
var query = parseQuery(window.location.search);
|
||||
return query.hasOwnProperty(name) ? query[name] : undefined;
|
||||
}
|
||||
|
||||
var stream =
|
||||
getParameterByName('stream') ||
|
||||
'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
</script>
|
||||
<script>
|
||||
if (Hls.isSupported()) {
|
||||
var video = document.getElementById('video');
|
||||
var hls = new Hls();
|
||||
hls.loadSource(stream);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, function () {
|
||||
video.muted = true;
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
var video = document.getElementById('video');
|
||||
window.onload = function () {
|
||||
var i = 0;
|
||||
var el = document.getElementById('update');
|
||||
function foo() {
|
||||
i++;
|
||||
el.innerHTML =
|
||||
'animation:' +
|
||||
i +
|
||||
',decoded:' +
|
||||
video.webkitDecodedFrameCount +
|
||||
',dropped:' +
|
||||
video.webkitDroppedFrameCount;
|
||||
window.requestAnimationFrame(foo);
|
||||
}
|
||||
foo();
|
||||
};
|
||||
</script>
|
||||
<div id="update"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,729 @@
|
||||
/* eslint no-var: 0, camelcase: 0 */
|
||||
|
||||
var eventLeftMargin = 180;
|
||||
var eventRightMargin = 0;
|
||||
|
||||
function canvasLoadEventUpdate(canvas, minTime, maxTime, events) {
|
||||
var event;
|
||||
var start;
|
||||
var ctx = canvas.getContext('2d');
|
||||
for (var i = 0, y_offset = 20; i < events.length; i++) {
|
||||
event = events[i];
|
||||
start = event.time;
|
||||
// var end = event.time + event.duration + event.latency;
|
||||
if (start >= minTime && start <= maxTime) {
|
||||
y_offset += 20;
|
||||
}
|
||||
}
|
||||
canvas.height = y_offset;
|
||||
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(0, 0, eventLeftMargin, canvas.height);
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// draw legend
|
||||
var x_offset = 5;
|
||||
ctx.font = '12px Arial';
|
||||
|
||||
var legend = 'load event';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset = eventLeftMargin + 5;
|
||||
|
||||
legend = 'start - end';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = '[latency';
|
||||
ctx.fillStyle = 'orange';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = 'loading';
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = 'parsing';
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = 'appending]';
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = 'size bitrate';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
|
||||
for (i = 0, y_offset = 20; i < events.length; i++) {
|
||||
event = events[i];
|
||||
start = Math.round(event.time);
|
||||
// var end = Math.round(event.time + event.duration + event.latency);
|
||||
if (start >= minTime && start <= maxTime) {
|
||||
canvasDrawLoadEvent(ctx, y_offset, event, minTime, maxTime);
|
||||
y_offset += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canvasVideoEventUpdate(canvas, minTime, maxTime, events) {
|
||||
var event;
|
||||
var start;
|
||||
var ctx = canvas.getContext('2d');
|
||||
for (var i = 0, y_offset = 20; i < events.length; i++) {
|
||||
event = events[i];
|
||||
start = event.time;
|
||||
// end = event.time;
|
||||
if (start >= minTime && start <= maxTime) {
|
||||
y_offset += 20;
|
||||
}
|
||||
}
|
||||
canvas.height = y_offset;
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(0, 0, eventLeftMargin, canvas.height);
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// draw legend
|
||||
var x_offset = 5;
|
||||
ctx.font = '12px Arial';
|
||||
|
||||
var legend = 'video event';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
|
||||
x_offset = eventLeftMargin + 5;
|
||||
legend = 'time';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
|
||||
x_offset += ctx.measureText(legend).width + 5;
|
||||
legend = '[duration]';
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillText(legend, x_offset, 15);
|
||||
|
||||
for (i = 0, y_offset = 20; i < events.length; i++) {
|
||||
event = events[i];
|
||||
start = Math.round(event.time);
|
||||
// end = Math.round(event.time);
|
||||
if (start >= minTime && start <= maxTime) {
|
||||
canvasDrawVideoEvent(ctx, y_offset, event, minTime, maxTime);
|
||||
y_offset += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canvasBufferWindowUpdate(canvas, minTime, maxTime, focusTime, events) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
var minTimeBuffer;
|
||||
var minTimePos;
|
||||
var focusTimeBuffer;
|
||||
var focusTimePos;
|
||||
var bufferChartStart = eventLeftMargin;
|
||||
var bufferChartWidth = ctx.canvas.width - eventLeftMargin - eventRightMargin;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(0, 0, eventLeftMargin, canvas.height);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// draw legend
|
||||
var x_offset = 5;
|
||||
var y_offset = 0;
|
||||
ctx.font = '15px Arial';
|
||||
|
||||
var maxBuffer = 0;
|
||||
var firstEventIdx = -1;
|
||||
var focusEventIdx = -1;
|
||||
var event;
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
event = events[i];
|
||||
maxBuffer = Math.max(maxBuffer, event.buffer + event.pos);
|
||||
if (firstEventIdx === -1 && event.time >= minTime) {
|
||||
firstEventIdx = Math.max(0, i - 1);
|
||||
}
|
||||
if (focusEventIdx === -1 && event.time >= focusTime) {
|
||||
focusEventIdx = Math.max(0, i - 1);
|
||||
}
|
||||
}
|
||||
// compute position and buffer length at pos minTime using linear approximation
|
||||
if (firstEventIdx + 1 < events.length) {
|
||||
minTimePos =
|
||||
events[firstEventIdx].pos +
|
||||
((minTime - events[firstEventIdx].time) *
|
||||
(events[firstEventIdx + 1].pos - events[firstEventIdx].pos)) /
|
||||
(events[firstEventIdx + 1].time - events[firstEventIdx].time);
|
||||
minTimeBuffer =
|
||||
minTimePos +
|
||||
events[firstEventIdx].buffer +
|
||||
((minTime - events[firstEventIdx].time) *
|
||||
(events[firstEventIdx + 1].buffer - events[firstEventIdx].buffer)) /
|
||||
(events[firstEventIdx + 1].time - events[firstEventIdx].time);
|
||||
} else {
|
||||
minTimeBuffer = 0;
|
||||
minTimePos = 0;
|
||||
}
|
||||
|
||||
// compute position and buffer length at pos focusTime using linear approximation
|
||||
if (focusEventIdx + 1 < events.length) {
|
||||
focusTimePos =
|
||||
events[focusEventIdx].pos +
|
||||
((focusTime - events[focusEventIdx].time) *
|
||||
(events[focusEventIdx + 1].pos - events[focusEventIdx].pos)) /
|
||||
(events[focusEventIdx + 1].time - events[focusEventIdx].time);
|
||||
focusTimeBuffer =
|
||||
events[focusEventIdx].buffer +
|
||||
((focusTime - events[focusEventIdx].time) *
|
||||
(events[focusEventIdx + 1].buffer - events[focusEventIdx].buffer)) /
|
||||
(events[focusEventIdx + 1].time - events[focusEventIdx].time);
|
||||
} else {
|
||||
focusTimePos = 0;
|
||||
focusTimeBuffer = 0;
|
||||
}
|
||||
|
||||
maxBuffer *= 1.1;
|
||||
|
||||
y_offset += 15;
|
||||
var legend = 'play pos/buffer zoomed';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = '[' + minTime + ',' + maxTime + ']';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'focus time:' + focusTime + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'focus position:' + Math.round(focusTimePos) + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'focus buffer:' + Math.round(focusTimeBuffer) + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bufferChartStart, ctx.canvas.height);
|
||||
ctx.lineTo(
|
||||
bufferChartStart,
|
||||
ctx.canvas.height * (1 - minTimeBuffer / maxBuffer)
|
||||
);
|
||||
for (var j = firstEventIdx + 1; j < events.length; j++) {
|
||||
event = events[j];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
y_offset = ctx.canvas.height * (1 - (event.buffer + event.pos) / maxBuffer);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(x_offset, canvas.height);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'brown';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bufferChartStart, ctx.canvas.height);
|
||||
ctx.lineTo(
|
||||
bufferChartStart,
|
||||
ctx.canvas.height * (1 - minTimePos / maxBuffer)
|
||||
);
|
||||
for (var k = firstEventIdx + 1; k < events.length; k++) {
|
||||
event = events[k];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
y_offset = ctx.canvas.height * (1 - event.pos / maxBuffer);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(x_offset, canvas.height);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.fillStyle = 'black';
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (focusTime - minTime)) / (maxTime - minTime);
|
||||
ctx.moveTo(x_offset, ctx.canvas.height);
|
||||
y_offset =
|
||||
ctx.canvas.height * (1 - (focusTimePos + focusTimeBuffer) / maxBuffer);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function canvasBufferTimeRangeUpdate(
|
||||
canvas,
|
||||
minTime,
|
||||
maxTime,
|
||||
windowMinTime,
|
||||
windowMaxTime,
|
||||
events
|
||||
) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
var bufferChartStart = eventLeftMargin;
|
||||
var bufferChartWidth = ctx.canvas.width - eventLeftMargin - eventRightMargin;
|
||||
var x_offset = 0;
|
||||
var y_offset = 0;
|
||||
var event;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(0, 0, eventLeftMargin, canvas.height);
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
x_offset = 5;
|
||||
y_offset = 15;
|
||||
var legend = 'play pos/buffer';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '15px Arial';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var maxBuffer = 0;
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
maxBuffer = Math.max(maxBuffer, events[i].buffer + events[i].pos);
|
||||
}
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'last pos:' + events[events.length - 1].pos + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'last buffer:' + events[events.length - 1].buffer + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'max buffer:' + maxBuffer + ' ms';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'nb samples:' + events.length;
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
maxBuffer *= 1.1;
|
||||
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bufferChartStart, ctx.canvas.height);
|
||||
for (var j = 0; j < events.length; j++) {
|
||||
event = events[j];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
y_offset = ctx.canvas.height * (1 - (event.buffer + event.pos) / maxBuffer);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(x_offset, canvas.height);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'brown';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bufferChartStart, ctx.canvas.height);
|
||||
for (var k = 0; k < events.length; k++) {
|
||||
event = events[k];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
y_offset = ctx.canvas.height * (1 - event.pos / maxBuffer);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(x_offset, canvas.height);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = 'grey';
|
||||
var x_start = bufferChartStart;
|
||||
var x_w =
|
||||
(bufferChartWidth * (windowMinTime - minTime)) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, 0, x_w, canvas.height);
|
||||
x_start =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (windowMaxTime - minTime)) / (maxTime - minTime);
|
||||
x_w = canvas.width - x_start - eventRightMargin;
|
||||
ctx.fillRect(x_start, 0, x_w, canvas.height);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function canvasBitrateEventUpdate(
|
||||
canvas,
|
||||
minTime,
|
||||
maxTime,
|
||||
windowMinTime,
|
||||
windowMaxTime,
|
||||
levelEvents,
|
||||
bitrateEvents
|
||||
) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
var bufferChartStart = eventLeftMargin;
|
||||
var bufferChartWidth = ctx.canvas.width - eventLeftMargin - eventRightMargin;
|
||||
var x_offset = 0;
|
||||
var y_offset = 0;
|
||||
var event;
|
||||
var maxLevel;
|
||||
var minLevel;
|
||||
var sumLevel;
|
||||
var maxBitrate;
|
||||
var minBitrate;
|
||||
var sumDuration;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (levelEvents.length === 0 || bitrateEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
maxBitrate = minBitrate = bitrateEvents[0].bitrate;
|
||||
sumLevel = sumDuration = 0;
|
||||
for (var i = 0; i < bitrateEvents.length; i++) {
|
||||
sumLevel += bitrateEvents[i].duration * bitrateEvents[i].level;
|
||||
sumDuration += bitrateEvents[i].duration;
|
||||
maxBitrate = Math.max(maxBitrate, bitrateEvents[i].bitrate);
|
||||
minBitrate = Math.min(minBitrate, bitrateEvents[i].bitrate);
|
||||
}
|
||||
|
||||
maxLevel = minLevel = levelEvents[0].id;
|
||||
for (var j = 0; j < levelEvents.length; j++) {
|
||||
maxLevel = Math.max(maxLevel, levelEvents[j].id);
|
||||
minLevel = Math.min(minLevel, levelEvents[j].id);
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillRect(0, 0, eventLeftMargin, canvas.height);
|
||||
ctx.fillRect(
|
||||
canvas.width - eventRightMargin,
|
||||
0,
|
||||
eventRightMargin,
|
||||
canvas.height
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
x_offset = 5;
|
||||
y_offset = 0;
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '15px Arial';
|
||||
|
||||
y_offset += 15;
|
||||
var legend =
|
||||
'last bitrate:' +
|
||||
(bitrateEvents[bitrateEvents.length - 1].bitrate / 1000).toFixed(2) +
|
||||
'Mb/s';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'min bitrate:' + (minBitrate / 1000).toFixed(2) + 'Mb/s';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'max bitrate:' + (maxBitrate / 1000).toFixed(2) + 'Mb/s';
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend =
|
||||
'min/last/max level:' +
|
||||
minLevel +
|
||||
'/' +
|
||||
levelEvents[levelEvents.length - 1].id +
|
||||
'/' +
|
||||
maxLevel;
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'nb level switch:' + (levelEvents.length - 1);
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
y_offset += 15;
|
||||
legend = 'average level:' + (sumLevel / sumDuration).toFixed(2);
|
||||
ctx.fillText(legend, x_offset, y_offset);
|
||||
|
||||
maxBitrate *= 1.1;
|
||||
|
||||
ctx.strokeStyle = 'blue';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bufferChartStart, ctx.canvas.height);
|
||||
for (var k = 0; k < bitrateEvents.length; k++) {
|
||||
event = bitrateEvents[k];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
y_offset = ctx.canvas.height * (1 - event.bitrate / maxBitrate);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(bufferChartStart + bufferChartWidth, y_offset);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.beginPath();
|
||||
x_offset = bufferChartStart;
|
||||
y_offset = ctx.canvas.height;
|
||||
ctx.moveTo(x_offset, y_offset);
|
||||
for (var l = 0; l < levelEvents.length; l++) {
|
||||
event = levelEvents[l];
|
||||
x_offset =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
y_offset = ctx.canvas.height * (1 - event.bitrate / maxBitrate);
|
||||
ctx.lineTo(x_offset, y_offset);
|
||||
}
|
||||
ctx.lineTo(bufferChartStart + bufferChartWidth, y_offset);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = 'grey';
|
||||
var x_start = bufferChartStart;
|
||||
var x_w =
|
||||
(bufferChartWidth * (windowMinTime - minTime)) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, 0, x_w, canvas.height);
|
||||
x_start =
|
||||
bufferChartStart +
|
||||
(bufferChartWidth * (windowMaxTime - minTime)) / (maxTime - minTime);
|
||||
x_w = canvas.width - x_start - eventRightMargin;
|
||||
ctx.fillRect(x_start, 0, x_w, canvas.height);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function canvasDrawLoadEvent(ctx, yoffset, event, minTime, maxTime) {
|
||||
var legend;
|
||||
var offset;
|
||||
var x_start;
|
||||
var x_w;
|
||||
var networkChartStart = eventLeftMargin;
|
||||
var networkChartWidth = ctx.canvas.width - eventLeftMargin - eventRightMargin;
|
||||
var tend = Math.round(event.time + event.duration + event.latency);
|
||||
|
||||
// draw start
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '12px Arial';
|
||||
legend = Math.round(event.time);
|
||||
offset = ctx.measureText(legend).width + 5;
|
||||
x_start =
|
||||
networkChartStart -
|
||||
offset +
|
||||
(networkChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
|
||||
// draw latency rectangle
|
||||
ctx.fillStyle = 'orange';
|
||||
x_start =
|
||||
networkChartStart +
|
||||
(networkChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
x_w = (networkChartWidth * event.latency) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, yoffset, x_w, 15);
|
||||
// draw download rectangle
|
||||
ctx.fillStyle = 'green';
|
||||
x_start =
|
||||
networkChartStart +
|
||||
(networkChartWidth * (event.time + event.latency - minTime)) /
|
||||
(maxTime - minTime);
|
||||
x_w = (networkChartWidth * event.load) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, yoffset, x_w, 15);
|
||||
|
||||
if (event.parsing) {
|
||||
// draw parsing rectangle
|
||||
ctx.fillStyle = 'blue';
|
||||
x_start =
|
||||
networkChartStart +
|
||||
(networkChartWidth *
|
||||
(event.time + event.latency + event.load - minTime)) /
|
||||
(maxTime - minTime);
|
||||
x_w = (networkChartWidth * event.parsing) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, yoffset, x_w, 15);
|
||||
|
||||
if (event.buffer) {
|
||||
// draw buffering rectangle
|
||||
ctx.fillStyle = 'red';
|
||||
x_start =
|
||||
networkChartStart +
|
||||
(networkChartWidth *
|
||||
(event.time + event.latency + event.load + event.parsing - minTime)) /
|
||||
(maxTime - minTime);
|
||||
x_w = (networkChartWidth * event.buffer) / (maxTime - minTime);
|
||||
ctx.fillRect(x_start, yoffset, x_w, 15);
|
||||
}
|
||||
}
|
||||
|
||||
// draw end time
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '12px Arial';
|
||||
legend = tend;
|
||||
x_start += x_w + 5;
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = '[' + Math.round(event.latency);
|
||||
ctx.fillStyle = 'orange';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = Math.round(event.load);
|
||||
if (!event.parsing) {
|
||||
legend += ']';
|
||||
}
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
|
||||
if (event.parsing) {
|
||||
legend = Math.round(event.parsing);
|
||||
if (!event.buffer) {
|
||||
legend += ']';
|
||||
}
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
|
||||
if (event.buffer) {
|
||||
legend = Math.round(event.buffer) + ']';
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.size) {
|
||||
if (event.size > 1000 * 1000) {
|
||||
legend = (event.size / 1000000).toFixed(1) + 'MB';
|
||||
} else {
|
||||
legend = Math.round(event.size / 1000) + 'kB';
|
||||
}
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
}
|
||||
|
||||
if (event.bw) {
|
||||
if (event.bw > 1000) {
|
||||
legend = (event.bw / 1000).toFixed(1) + 'Mbps';
|
||||
} else {
|
||||
legend = event.bw + ' kbps';
|
||||
}
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
}
|
||||
|
||||
// draw event name
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '15px Arial';
|
||||
legend = event.type;
|
||||
if (event.id2 !== undefined) {
|
||||
legend += ' ' + event.id2;
|
||||
}
|
||||
if (event.id3 !== undefined) {
|
||||
legend += '/' + event.id3;
|
||||
}
|
||||
if (event.id !== undefined) {
|
||||
if (event.type.indexOf('fragment') !== -1) {
|
||||
legend += ' @';
|
||||
}
|
||||
legend += ' ' + event.id;
|
||||
}
|
||||
if (event.start !== undefined) {
|
||||
legend += ' [' + event.start + ',' + event.end + ']';
|
||||
}
|
||||
ctx.fillText(legend, 5, yoffset + 15);
|
||||
}
|
||||
|
||||
function canvasDrawVideoEvent(ctx, yoffset, event, minTime, maxTime) {
|
||||
var legend;
|
||||
var offset;
|
||||
var x_start;
|
||||
var x_w;
|
||||
var networkChartStart = eventLeftMargin;
|
||||
var networkChartWidth = ctx.canvas.width - eventLeftMargin - eventRightMargin;
|
||||
|
||||
// draw event name
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '15px Arial';
|
||||
legend = event.type;
|
||||
if (event.name !== undefined) {
|
||||
legend += ':' + event.name;
|
||||
}
|
||||
ctx.fillText(legend, 5, yoffset + 15);
|
||||
|
||||
// draw start time
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '12px Arial';
|
||||
legend = Math.round(event.time);
|
||||
offset = ctx.measureText(legend).width + 5;
|
||||
x_start =
|
||||
networkChartStart -
|
||||
offset +
|
||||
(networkChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
|
||||
// draw event rectangle
|
||||
x_start =
|
||||
networkChartStart +
|
||||
(networkChartWidth * (event.time - minTime)) / (maxTime - minTime);
|
||||
if (event.duration) {
|
||||
x_w = (networkChartWidth * event.duration) / (maxTime - minTime);
|
||||
} else {
|
||||
x_w = 1;
|
||||
}
|
||||
ctx.fillRect(x_start, yoffset, x_w, 15);
|
||||
|
||||
if (event.duration) {
|
||||
// draw end time
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '12px Arial';
|
||||
legend = Math.round(event.time + event.duration);
|
||||
x_start += x_w + 5;
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
x_start += ctx.measureText(legend).width + 5;
|
||||
|
||||
legend = '[' + Math.round(event.duration) + ']';
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillText(legend, x_start, yoffset + 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import Chart from 'chart.js';
|
||||
|
||||
// Modify horizontalBar so that each dataset (fragments, timeRanges) draws on the same row (level, track or buffer)
|
||||
Chart.controllers.horizontalBar.prototype.calculateBarValuePixels = function (
|
||||
datasetIndex,
|
||||
index,
|
||||
options
|
||||
) {
|
||||
const chart = this.chart;
|
||||
const scale = this._getValueScale();
|
||||
const datasets = chart.data.datasets;
|
||||
if (!datasets) {
|
||||
throw new Error(`Chart datasets are ${datasets}`);
|
||||
}
|
||||
scale._parseValue = scaleParseValue;
|
||||
const obj = datasets[datasetIndex].data[index];
|
||||
const value = scale._parseValue(obj);
|
||||
const start =
|
||||
value.start === undefined
|
||||
? 0
|
||||
: value.max >= 0 && value.min >= 0
|
||||
? value.min
|
||||
: value.max;
|
||||
const length =
|
||||
value.start === undefined
|
||||
? value.end
|
||||
: value.max >= 0 && value.min >= 0
|
||||
? value.max - value.min
|
||||
: value.min - value.max;
|
||||
const base = scale.getPixelForValue(start);
|
||||
const head = scale.getPixelForValue(start + length);
|
||||
const size = head - base;
|
||||
|
||||
return {
|
||||
size: size,
|
||||
base: base,
|
||||
head: head,
|
||||
center: head + size / 2,
|
||||
};
|
||||
};
|
||||
|
||||
Chart.controllers.horizontalBar.prototype.calculateBarIndexPixels = function (
|
||||
datasetIndex,
|
||||
index,
|
||||
ruler,
|
||||
options
|
||||
) {
|
||||
const rowHeight = options.barThickness;
|
||||
const size = rowHeight * options.categoryPercentage;
|
||||
const center = ruler.start + (datasetIndex * rowHeight + rowHeight / 2);
|
||||
return {
|
||||
base: center - size / 2,
|
||||
head: center + size / 2,
|
||||
center,
|
||||
size,
|
||||
};
|
||||
};
|
||||
|
||||
Chart.controllers.horizontalBar.prototype.draw = function () {
|
||||
const rects = this.getMeta().data;
|
||||
const len = rects.length;
|
||||
const dataset = this.getDataset();
|
||||
if (len !== dataset.data.length) {
|
||||
// View does not match dataset (wait for redraw)
|
||||
return;
|
||||
}
|
||||
const chart = this.chart;
|
||||
const scale = this._getValueScale();
|
||||
scale._parseValue = scaleParseValue;
|
||||
const ctx: CanvasRenderingContext2D = chart.ctx;
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
Chart.helpers.canvas.clipArea(ctx, chartArea);
|
||||
if (!this.lineHeight) {
|
||||
this.lineHeight =
|
||||
Math.ceil(ctx.measureText('0').actualBoundingBoxAscent) + 2;
|
||||
}
|
||||
const lineHeight = this.lineHeight;
|
||||
let range = 0;
|
||||
for (let i = 0; i < len; ++i) {
|
||||
const rect = rects[i];
|
||||
const view = rect._view;
|
||||
if (!intersects(view.base, view.x, chartArea.left, chartArea.right)) {
|
||||
// Do not draw elements outside of the chart's viewport
|
||||
continue;
|
||||
}
|
||||
const obj = dataset.data[i];
|
||||
const val = scale._parseValue(obj);
|
||||
if (!isNaN(val.min) && !isNaN(val.max)) {
|
||||
const { dataType } = obj;
|
||||
let { stats } = obj;
|
||||
const isPart = dataType === 'part';
|
||||
const isFragmentHint = dataType === 'fragmentHint';
|
||||
const isFragment = dataType === 'fragment' || isPart || isFragmentHint;
|
||||
const isCue = dataType === 'cue';
|
||||
if (isCue) {
|
||||
view.y += view.height * 0.5 * (i % 2) - view.height * 0.25;
|
||||
} else if (isPart) {
|
||||
view.height -= 22;
|
||||
}
|
||||
const bounds = boundingRects(view);
|
||||
const drawText = bounds.w > lineHeight * 1.5 && !isFragmentHint;
|
||||
if (isFragment || isCue) {
|
||||
if (drawText) {
|
||||
view.borderWidth = 1;
|
||||
if (i === 0) {
|
||||
view.borderSkipped = false;
|
||||
}
|
||||
} else {
|
||||
range =
|
||||
range ||
|
||||
scale.getValueForPixel(chartArea.right) -
|
||||
scale.getValueForPixel(chartArea.left);
|
||||
if (range > 300 || isCue) {
|
||||
view.borderWidth = 0;
|
||||
}
|
||||
}
|
||||
if (isFragmentHint) {
|
||||
view.borderWidth = 0;
|
||||
view.backgroundColor = 'rgba(0, 0, 0, 0.1)';
|
||||
} else {
|
||||
view.backgroundColor = `rgba(0, 0, 0, ${0.05 + (i % 2) / 12})`;
|
||||
}
|
||||
}
|
||||
rect.draw();
|
||||
if (isFragment) {
|
||||
if (!stats) {
|
||||
stats = {};
|
||||
}
|
||||
if (isPart) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
}
|
||||
if (stats.aborted) {
|
||||
ctx.fillStyle = 'rgba(100, 0, 0, 0.3)';
|
||||
ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
}
|
||||
if (stats.loaded && stats.total) {
|
||||
ctx.fillStyle = 'rgba(50, 20, 100, 0.3)';
|
||||
ctx.fillRect(
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
(bounds.w * stats.loaded) / stats.total,
|
||||
bounds.h
|
||||
);
|
||||
}
|
||||
} else if (isCue) {
|
||||
if (obj.active) {
|
||||
ctx.fillStyle = 'rgba(100, 100, 10, 0.4)';
|
||||
ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
}
|
||||
}
|
||||
if (drawText) {
|
||||
const start = val.start; // obj.start;
|
||||
ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||
if (stats) {
|
||||
const snBounds = Object.assign({}, bounds);
|
||||
if (obj.cc) {
|
||||
const ccLabel = `cc:${obj.cc}`;
|
||||
const ccWidth = Math.min(
|
||||
ctx.measureText(ccLabel).width + 2,
|
||||
snBounds.w / 2 - 2
|
||||
);
|
||||
if (ccWidth) {
|
||||
ctx.fillText(
|
||||
ccLabel,
|
||||
snBounds.x + 2,
|
||||
snBounds.y + lineHeight,
|
||||
snBounds.w / 2 - 4
|
||||
);
|
||||
snBounds.x += ccWidth;
|
||||
snBounds.w -= ccWidth;
|
||||
}
|
||||
}
|
||||
const snLabel = isPart ? `part: ${obj.index}` : `sn: ${obj.sn}`;
|
||||
const textWidth = Math.min(
|
||||
ctx.measureText(snLabel).width + 2,
|
||||
snBounds.w - 2
|
||||
);
|
||||
ctx.fillText(
|
||||
snLabel,
|
||||
snBounds.x + snBounds.w - textWidth,
|
||||
snBounds.y + lineHeight,
|
||||
snBounds.w - 4
|
||||
);
|
||||
}
|
||||
if (isCue) {
|
||||
const strLength = Math.min(
|
||||
30,
|
||||
Math.ceil(bounds.w / (lineHeight / 3))
|
||||
);
|
||||
ctx.fillText(
|
||||
('' + obj.content).slice(0, strLength),
|
||||
bounds.x + 2,
|
||||
bounds.y + bounds.h - 3,
|
||||
bounds.w - 5
|
||||
);
|
||||
} else if (!isPart) {
|
||||
const float = start !== (start | 0);
|
||||
const fixedDigits = float
|
||||
? Math.min(5, Math.max(1, Math.floor(bounds.w / 10 - 1)))
|
||||
: 0;
|
||||
const startString = hhmmss(start, fixedDigits);
|
||||
ctx.fillText(
|
||||
startString,
|
||||
bounds.x + 2,
|
||||
bounds.y + bounds.h - 3,
|
||||
bounds.w - 5
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Chart.helpers.canvas.unclipArea(chart.ctx);
|
||||
};
|
||||
|
||||
export function applyChartInstanceOverrides(chart) {
|
||||
Object.keys(chart.scales).forEach((axis) => {
|
||||
const scale = chart.scales![axis];
|
||||
scale._parseValue = scaleParseValue;
|
||||
});
|
||||
}
|
||||
|
||||
function scaleParseValue(value: number[] | any) {
|
||||
if (value === undefined) {
|
||||
console.warn('Chart values undefined (update chart)');
|
||||
return {};
|
||||
}
|
||||
|
||||
let start;
|
||||
let end;
|
||||
let min;
|
||||
let max;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
start = +this.getRightValue(value[0]);
|
||||
end = +this.getRightValue(value[1]);
|
||||
min = Math.min(start, end);
|
||||
max = Math.max(start, end);
|
||||
} else {
|
||||
start = +this.getRightValue(value.start);
|
||||
if ('end' in value) {
|
||||
end = +this.getRightValue(value.end);
|
||||
} else {
|
||||
end = +this.getRightValue(value.start + value.duration);
|
||||
}
|
||||
min = Math.min(start, end);
|
||||
max = Math.max(start, end);
|
||||
}
|
||||
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
function intersects(x1, x2, x3, x4) {
|
||||
return x2 > x3 && x1 < x4;
|
||||
}
|
||||
|
||||
function boundingRects(vm) {
|
||||
const half = vm.height / 2;
|
||||
const left = Math.min(vm.x, vm.base);
|
||||
const right = Math.max(vm.x, vm.base);
|
||||
const top = vm.y - half;
|
||||
const bottom = vm.y + half;
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
w: right - left,
|
||||
h: bottom - top,
|
||||
};
|
||||
}
|
||||
|
||||
export function hhmmss(value, fixedDigits) {
|
||||
const h = (value / 3600) | 0;
|
||||
const m = ((value / 60) | 0) % 60;
|
||||
const s = value % 60;
|
||||
return `${h}:${pad(m, 2)}:${pad(
|
||||
s.toFixed(fixedDigits),
|
||||
fixedDigits ? fixedDigits + 3 : 2
|
||||
)}`.replace(/^(?:0+:?)*(\d.*?)(?:\.0*)?$/, '$1');
|
||||
}
|
||||
|
||||
function pad(str, length) {
|
||||
str = '' + str;
|
||||
while (str.length < length) {
|
||||
str = '0' + str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
import Chart from 'chart.js';
|
||||
import { applyChartInstanceOverrides, hhmmss } from './chartjs-horizontal-bar';
|
||||
import { Fragment } from '../../src/loader/fragment';
|
||||
import type { Level } from '../../src/types/level';
|
||||
import type { TrackSet } from '../../src/types/track';
|
||||
import type { MediaPlaylist } from '../../src/types/media-playlist';
|
||||
import type { LevelDetails } from '../../src/loader/level-details';
|
||||
import {
|
||||
FragChangedData,
|
||||
FragLoadedData,
|
||||
FragParsedData,
|
||||
} from '../../src/types/events';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Hls: any;
|
||||
hls: any;
|
||||
chart: any;
|
||||
}
|
||||
}
|
||||
|
||||
const X_AXIS_SECONDS = 'x-axis-seconds';
|
||||
|
||||
interface ChartScale {
|
||||
width: number;
|
||||
height: number;
|
||||
min: number;
|
||||
max: number;
|
||||
options: any;
|
||||
determineDataLimits: () => void;
|
||||
buildTicks: () => void;
|
||||
getLabelForIndex: (index: number, datasetIndex: number) => string;
|
||||
getPixelForTick: (index: number) => number;
|
||||
getPixelForValue: (
|
||||
value: number,
|
||||
index?: number,
|
||||
datasetIndex?: number
|
||||
) => number;
|
||||
getValueForPixel: (pixel: number) => number;
|
||||
}
|
||||
|
||||
export class TimelineChart {
|
||||
private readonly chart: Chart;
|
||||
private rafDebounceRequestId: number = -1;
|
||||
private imageDataBuffer: ImageData | null = null;
|
||||
private media: HTMLMediaElement | null = null;
|
||||
private tracksChangeHandler?: (e) => void;
|
||||
private cuesChangeHandler?: (e) => void;
|
||||
private hidden: boolean = true;
|
||||
private zoom100: number = 60;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, chartJsOptions?: any) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
`Could not get CanvasRenderingContext2D from canvas: ${canvas}`
|
||||
);
|
||||
}
|
||||
const chart =
|
||||
(this.chart =
|
||||
self.chart =
|
||||
new Chart(ctx, {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
},
|
||||
options: Object.assign(getChartOptions(), chartJsOptions),
|
||||
plugins: [
|
||||
{
|
||||
afterRender: (chart) => {
|
||||
this.imageDataBuffer = null;
|
||||
this.drawCurrentTime();
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
applyChartInstanceOverrides(chart);
|
||||
|
||||
canvas.ondblclick = (event: MouseEvent) => {
|
||||
const chart = this.chart;
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
const element = chart.getElementAtEvent(event);
|
||||
const pos = Chart.helpers.getRelativePosition(event, chart);
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
// zoom in when double clicking near elements in chart area
|
||||
if (element.length || pos.x > chartArea.left) {
|
||||
const amount = event.getModifierState('Shift') ? -1.0 : 0.5;
|
||||
this.zoom(scale, pos, amount);
|
||||
} else {
|
||||
scale.options.ticks.min = 0;
|
||||
scale.options.ticks.max = this.zoom100;
|
||||
}
|
||||
this.update();
|
||||
};
|
||||
|
||||
canvas.onwheel = (event: WheelEvent) => {
|
||||
if (event.deltaMode) {
|
||||
// exit if wheel is in page or line scrolling mode
|
||||
return;
|
||||
}
|
||||
const chart = this.chart;
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
const pos = Chart.helpers.getRelativePosition(event, chart);
|
||||
// zoom when scrolling over chart elements
|
||||
if (pos.x > chartArea.left - 11) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
if (event.deltaY) {
|
||||
const direction = -event.deltaY / Math.abs(event.deltaY);
|
||||
const normal = Math.min(333, Math.abs(event.deltaY)) / 1000;
|
||||
const ease = 1 - (1 - normal) * (1 - normal);
|
||||
this.zoom(scale, pos, ease * direction);
|
||||
} else if (event.deltaX) {
|
||||
this.pan(scale, event.deltaX / 10, scale.min, scale.max);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
let moved = false;
|
||||
let gestureScale = 1;
|
||||
canvas.onpointerdown = (downEvent: PointerEvent) => {
|
||||
if (!downEvent.isPrimary || gestureScale !== 1) {
|
||||
return;
|
||||
}
|
||||
const chart = this.chart;
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
const pos = Chart.helpers.getRelativePosition(downEvent, chart);
|
||||
// pan when dragging over chart elements
|
||||
if (pos.x > chartArea.left) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
const startX = downEvent.clientX;
|
||||
const { min, max } = scale;
|
||||
const xToVal = (max - min) / scale.width;
|
||||
moved = false;
|
||||
canvas.setPointerCapture(downEvent.pointerId);
|
||||
canvas.onpointermove = (moveEvent: PointerEvent) => {
|
||||
if (!downEvent.isPrimary || gestureScale !== 1) {
|
||||
return;
|
||||
}
|
||||
const movedX = startX - moveEvent.clientX;
|
||||
const movedValue = movedX * xToVal;
|
||||
moved = moved || Math.abs(movedX) > 8;
|
||||
this.pan(scale, movedValue, min, max);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
canvas.onpointerup = canvas.onpointercancel = (upEvent: PointerEvent) => {
|
||||
if (canvas.onpointermove) {
|
||||
canvas.onpointermove = null;
|
||||
canvas.releasePointerCapture(upEvent.pointerId);
|
||||
}
|
||||
if (!moved && upEvent.isPrimary) {
|
||||
this.click(upEvent);
|
||||
}
|
||||
};
|
||||
|
||||
// Gesture events are for iOS and easier to implement than pinch-zoom with multiple pointers for all browsers
|
||||
// @ts-ignore
|
||||
canvas.ongesturestart = (event) => {
|
||||
gestureScale = 1;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
canvas.ongestureend = (event) => {
|
||||
gestureScale = 1;
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
canvas.ongesturechange = (event) => {
|
||||
const chart = this.chart;
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
const pos = Chart.helpers.getRelativePosition(event, chart);
|
||||
// zoom when scrolling over chart elements
|
||||
if (pos.x > chartArea.left) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
const amount = event.scale - gestureScale;
|
||||
this.zoom(scale, pos, amount);
|
||||
gestureScale = event.scale;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private click(event: MouseEvent) {
|
||||
// Log object on click and seek to position
|
||||
const chart = this.chart;
|
||||
const element = chart.getElementAtEvent(event);
|
||||
if (element.length && chart.data.datasets) {
|
||||
const dataset = chart.data.datasets[(element[0] as any)._datasetIndex];
|
||||
const obj = dataset.data![(element[0] as any)._index];
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(obj);
|
||||
if (self.hls?.media) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
const pos = Chart.helpers.getRelativePosition(event, chart);
|
||||
self.hls.media.currentTime = scale.getValueForPixel(pos.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pan(scale: ChartScale, amount: number, min: number, max: number) {
|
||||
if (amount === 0) {
|
||||
return;
|
||||
}
|
||||
let pan = amount;
|
||||
if (amount > 0) {
|
||||
pan = Math.min(this.zoom100 + 10 - max, amount);
|
||||
} else {
|
||||
pan = Math.max(-10 - min, amount);
|
||||
}
|
||||
scale.options.ticks.min = min + pan;
|
||||
scale.options.ticks.max = max + pan;
|
||||
this.updateOnRepaint();
|
||||
}
|
||||
|
||||
private zoom(scale: ChartScale, pos: any, amount: number) {
|
||||
const range = scale.max - scale.min;
|
||||
const diff = range * amount;
|
||||
const minPercent = (scale.getValueForPixel(pos.x) - scale.min) / range;
|
||||
const maxPercent = 1 - minPercent;
|
||||
const minDelta = diff * minPercent;
|
||||
const maxDelta = diff * maxPercent;
|
||||
scale.options.ticks.min = Math.max(-10, scale.min + minDelta);
|
||||
scale.options.ticks.max = Math.min(this.zoom100 + 10, scale.max - maxDelta);
|
||||
this.updateOnRepaint();
|
||||
}
|
||||
|
||||
get chartScales(): { 'x-axis-seconds': ChartScale } {
|
||||
return (this.chart as any).scales;
|
||||
}
|
||||
|
||||
reset() {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
scale.options.ticks.min = 0;
|
||||
scale.options.ticks.max = 60;
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (labels && datasets) {
|
||||
labels.length = 0;
|
||||
datasets.length = 0;
|
||||
this.resize(datasets);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.hidden || !this.chart.ctx?.canvas.width) {
|
||||
return;
|
||||
}
|
||||
self.cancelAnimationFrame(this.rafDebounceRequestId);
|
||||
this.chart.update({
|
||||
duration: 0,
|
||||
lazy: true,
|
||||
});
|
||||
}
|
||||
|
||||
updateOnRepaint() {
|
||||
if (this.hidden) {
|
||||
return;
|
||||
}
|
||||
self.cancelAnimationFrame(this.rafDebounceRequestId);
|
||||
this.rafDebounceRequestId = self.requestAnimationFrame(() => this.update());
|
||||
}
|
||||
|
||||
resize(datasets?) {
|
||||
if (this.hidden) {
|
||||
return;
|
||||
}
|
||||
if (datasets?.length) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
const { top } = this.chart.chartArea;
|
||||
const height =
|
||||
top +
|
||||
datasets.reduce((val, dataset) => val + dataset.barThickness, 0) +
|
||||
scale.height +
|
||||
5;
|
||||
const container = this.chart.canvas?.parentElement;
|
||||
if (container) {
|
||||
container.style.height = `${height}px`;
|
||||
}
|
||||
}
|
||||
self.cancelAnimationFrame(this.rafDebounceRequestId);
|
||||
this.rafDebounceRequestId = self.requestAnimationFrame(() => {
|
||||
this.chart.resize();
|
||||
});
|
||||
}
|
||||
|
||||
show() {
|
||||
this.hidden = false;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.hidden = true;
|
||||
}
|
||||
|
||||
updateLevels(levels: Level[], levelSwitched) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
const { loadLevel, nextLoadLevel, nextAutoLevel } = self.hls;
|
||||
// eslint-disable-next-line no-undefined
|
||||
const currentLevel =
|
||||
levelSwitched !== undefined ? levelSwitched : self.hls.currentLevel;
|
||||
levels.forEach((level, i) => {
|
||||
const index = level.id || i;
|
||||
labels.push(getLevelName(level, index));
|
||||
let borderColor: string | null = null;
|
||||
if (currentLevel === i) {
|
||||
borderColor = 'rgba(32, 32, 240, 1.0)';
|
||||
} else if (loadLevel === i) {
|
||||
borderColor = 'rgba(255, 128, 0, 1.0)';
|
||||
} else if (nextLoadLevel === i) {
|
||||
borderColor = 'rgba(200, 200, 64, 1.0)';
|
||||
} else if (nextAutoLevel === i) {
|
||||
borderColor = 'rgba(160, 0, 160, 1.0)';
|
||||
}
|
||||
datasets.push(
|
||||
datasetWithDefaults({
|
||||
url: Array.isArray(level.url)
|
||||
? level.url[level.urlId || 0]
|
||||
: level.url,
|
||||
trackType: 'level',
|
||||
borderColor,
|
||||
level: index,
|
||||
})
|
||||
);
|
||||
if (level.details) {
|
||||
this.updateLevelOrTrack(level.details);
|
||||
}
|
||||
});
|
||||
this.resize(datasets);
|
||||
}
|
||||
|
||||
updateAudioTracks(audioTracks: MediaPlaylist[]) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
const { audioTrack } = self.hls;
|
||||
audioTracks.forEach((track: MediaPlaylist, i) => {
|
||||
labels.push(getAudioTrackName(track, i));
|
||||
datasets.push(
|
||||
datasetWithDefaults({
|
||||
url: track.url,
|
||||
trackType: 'audioTrack',
|
||||
borderColor: audioTrack === i ? 'rgba(32, 32, 240, 1.0)' : null,
|
||||
audioTrack: i,
|
||||
})
|
||||
);
|
||||
if (track.details) {
|
||||
this.updateLevelOrTrack(track.details);
|
||||
}
|
||||
});
|
||||
this.resize(datasets);
|
||||
}
|
||||
|
||||
updateSubtitleTracks(subtitles: MediaPlaylist[]) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
const { subtitleTrack } = self.hls;
|
||||
subtitles.forEach((track, i) => {
|
||||
labels.push(getSubtitlesName(track, i));
|
||||
datasets.push(
|
||||
datasetWithDefaults({
|
||||
url: track.url,
|
||||
trackType: 'subtitleTrack',
|
||||
borderColor: subtitleTrack === i ? 'rgba(32, 32, 240, 1.0)' : null,
|
||||
subtitleTrack: i,
|
||||
})
|
||||
);
|
||||
if (track.details) {
|
||||
this.updateLevelOrTrack(track.details);
|
||||
}
|
||||
});
|
||||
this.resize(datasets);
|
||||
}
|
||||
|
||||
removeType(
|
||||
trackType: 'level' | 'audioTrack' | 'subtitleTrack' | 'textTrack'
|
||||
) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
let i = datasets.length;
|
||||
while (i--) {
|
||||
if ((datasets[i] as any).trackType === trackType) {
|
||||
datasets.splice(i, 1);
|
||||
labels.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLevelOrTrack(details: LevelDetails) {
|
||||
const { targetduration, totalduration, url } = details;
|
||||
const { datasets } = this.chart.data;
|
||||
let levelDataSet = arrayFind(
|
||||
datasets,
|
||||
(dataset) =>
|
||||
stripDeliveryDirectives(url) ===
|
||||
stripDeliveryDirectives(dataset.url || '')
|
||||
);
|
||||
if (!levelDataSet) {
|
||||
levelDataSet = arrayFind(
|
||||
datasets,
|
||||
(dataset) => details.fragments[0]?.level === dataset.level
|
||||
);
|
||||
}
|
||||
if (!levelDataSet) {
|
||||
return;
|
||||
}
|
||||
const data = levelDataSet.data;
|
||||
data.length = 0;
|
||||
if (details.fragments) {
|
||||
details.fragments.forEach((fragment) => {
|
||||
// TODO: keep track of initial playlist start and duration so that we can show drift and pts offset
|
||||
// (Make that a feature of hls.js v1.0.0 fragments)
|
||||
const chartFragment = Object.assign(
|
||||
{
|
||||
dataType: 'fragment',
|
||||
},
|
||||
fragment,
|
||||
// Remove loader references for GC
|
||||
{ loader: null }
|
||||
);
|
||||
data.push(chartFragment);
|
||||
});
|
||||
}
|
||||
if (details.partList) {
|
||||
details.partList.forEach((part) => {
|
||||
const chartPart = Object.assign(
|
||||
{
|
||||
dataType: 'part',
|
||||
start: part.fragment.start + part.fragOffset,
|
||||
},
|
||||
part,
|
||||
{
|
||||
fragment: Object.assign({}, part.fragment, { loader: null }),
|
||||
}
|
||||
);
|
||||
data.push(chartPart);
|
||||
});
|
||||
if (details.fragmentHint) {
|
||||
const chartFragment = Object.assign(
|
||||
{
|
||||
dataType: 'fragmentHint',
|
||||
},
|
||||
details.fragmentHint,
|
||||
// Remove loader references for GC
|
||||
{ loader: null }
|
||||
);
|
||||
data.push(chartFragment);
|
||||
}
|
||||
}
|
||||
const start = getPlaylistStart(details);
|
||||
this.maxZoom = this.zoom100 = Math.max(
|
||||
start + totalduration + targetduration * 3,
|
||||
this.zoom100
|
||||
);
|
||||
this.updateOnRepaint();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get minZoom(): number {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
if (scale) {
|
||||
return scale.options.ticks.min;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get maxZoom(): number {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
if (scale) {
|
||||
return scale.options.ticks.max;
|
||||
}
|
||||
return this.zoom100;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
set maxZoom(x: number) {
|
||||
const currentZoom = this.maxZoom;
|
||||
const newZoom = Math.max(x, currentZoom);
|
||||
if (currentZoom === 60 && newZoom !== currentZoom) {
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
scale.options.ticks.max = newZoom;
|
||||
}
|
||||
}
|
||||
|
||||
updateFragment(data: FragLoadedData | FragParsedData | FragChangedData) {
|
||||
const { datasets } = this.chart.data;
|
||||
const frag: Fragment = data.frag;
|
||||
let levelDataSet = arrayFind(
|
||||
datasets,
|
||||
(dataset) => frag.baseurl === dataset.url
|
||||
);
|
||||
if (!levelDataSet) {
|
||||
levelDataSet = arrayFind(
|
||||
datasets,
|
||||
(dataset) => frag.level === dataset.level
|
||||
);
|
||||
}
|
||||
if (!levelDataSet) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const fragData = arrayFind(
|
||||
levelDataSet.data,
|
||||
(fragData) => fragData.relurl === frag.relurl && fragData.sn === frag.sn
|
||||
);
|
||||
if (fragData && fragData !== frag) {
|
||||
Object.assign(fragData, frag);
|
||||
}
|
||||
this.updateOnRepaint();
|
||||
}
|
||||
|
||||
updateSourceBuffers(tracks: TrackSet, media: HTMLMediaElement) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
const trackTypes = Object.keys(tracks).sort((type) =>
|
||||
type === 'video' ? 1 : -1
|
||||
);
|
||||
const mediaBufferData = [];
|
||||
|
||||
this.removeSourceBuffers();
|
||||
|
||||
this.media = media;
|
||||
|
||||
trackTypes.forEach((type) => {
|
||||
const track = tracks[type];
|
||||
const data = [];
|
||||
const sourceBuffer = track.buffer;
|
||||
const backgroundColor = {
|
||||
video: 'rgba(0, 0, 255, 0.2)',
|
||||
audio: 'rgba(128, 128, 0, 0.2)',
|
||||
audiovideo: 'rgba(128, 128, 255, 0.2)',
|
||||
}[type];
|
||||
labels.unshift(`${type} buffer (${track.id})`);
|
||||
datasets.unshift(
|
||||
datasetWithDefaults({
|
||||
data,
|
||||
categoryPercentage: 0.5,
|
||||
backgroundColor,
|
||||
sourceBuffer,
|
||||
})
|
||||
);
|
||||
sourceBuffer.addEventListener('update', () => {
|
||||
try {
|
||||
replaceTimeRangeTuples(sourceBuffer.buffered, data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
replaceTimeRangeTuples(media.buffered, mediaBufferData);
|
||||
this.update();
|
||||
});
|
||||
});
|
||||
|
||||
if (trackTypes.length === 0) {
|
||||
media.onprogress = () => {
|
||||
replaceTimeRangeTuples(media.buffered, mediaBufferData);
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
|
||||
labels.unshift('media buffer');
|
||||
datasets.unshift(
|
||||
datasetWithDefaults({
|
||||
data: mediaBufferData,
|
||||
categoryPercentage: 0.5,
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.2)',
|
||||
media,
|
||||
})
|
||||
);
|
||||
|
||||
media.ontimeupdate = () => this.drawCurrentTime();
|
||||
|
||||
// TextTrackList
|
||||
const { textTracks } = media;
|
||||
this.tracksChangeHandler =
|
||||
this.tracksChangeHandler || ((e) => this.setTextTracks(e.currentTarget));
|
||||
textTracks.removeEventListener('addtrack', this.tracksChangeHandler);
|
||||
textTracks.removeEventListener('removetrack', this.tracksChangeHandler);
|
||||
textTracks.removeEventListener('change', this.tracksChangeHandler);
|
||||
textTracks.addEventListener('addtrack', this.tracksChangeHandler);
|
||||
textTracks.addEventListener('removetrack', this.tracksChangeHandler);
|
||||
textTracks.addEventListener('change', this.tracksChangeHandler);
|
||||
this.setTextTracks(textTracks);
|
||||
this.resize(datasets);
|
||||
}
|
||||
|
||||
removeSourceBuffers() {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
let i = datasets.length;
|
||||
while (i--) {
|
||||
if ((labels[0] || '').toString().indexOf('buffer') > -1) {
|
||||
datasets.splice(i, 1);
|
||||
labels.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTextTracks(textTracks) {
|
||||
const { labels, datasets } = this.chart.data;
|
||||
if (!labels || !datasets) {
|
||||
return;
|
||||
}
|
||||
this.removeType('textTrack');
|
||||
[].forEach.call(textTracks, (textTrack, i) => {
|
||||
// Uncomment to disable rending of subtitle/caption cues in the timeline
|
||||
// if (textTrack.kind === 'subtitles' || textTrack.kind === 'captions') {
|
||||
// return;
|
||||
// }
|
||||
const data = [];
|
||||
labels.push(
|
||||
`${textTrack.name || textTrack.label} ${textTrack.kind} (${
|
||||
textTrack.mode
|
||||
})`
|
||||
);
|
||||
datasets.push(
|
||||
datasetWithDefaults({
|
||||
data,
|
||||
categoryPercentage: 0.5,
|
||||
url: '',
|
||||
trackType: 'textTrack',
|
||||
borderColor:
|
||||
(textTrack.mode !== 'hidden') === i
|
||||
? 'rgba(32, 32, 240, 1.0)'
|
||||
: null,
|
||||
textTrack: i,
|
||||
})
|
||||
);
|
||||
this.cuesChangeHandler =
|
||||
this.cuesChangeHandler ||
|
||||
((e) => this.updateTextTrackCues(e.currentTarget));
|
||||
textTrack._data = data;
|
||||
textTrack.removeEventListener('cuechange', this.cuesChangeHandler);
|
||||
textTrack.addEventListener('cuechange', this.cuesChangeHandler);
|
||||
this.updateTextTrackCues(textTrack);
|
||||
});
|
||||
this.resize(datasets);
|
||||
}
|
||||
|
||||
updateTextTrackCues(textTrack) {
|
||||
const data = textTrack._data;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const { activeCues, cues } = textTrack;
|
||||
data.length = 0;
|
||||
if (!cues) {
|
||||
return;
|
||||
}
|
||||
const length = cues.length;
|
||||
|
||||
let activeLength = 0;
|
||||
let activeMin = Infinity;
|
||||
let activeMax = 0;
|
||||
if (activeCues) {
|
||||
activeLength = activeCues.length;
|
||||
for (let i = 0; i < activeLength; i++) {
|
||||
let cue = activeCues[i];
|
||||
if (!cue && activeCues.item) {
|
||||
cue = activeCues.item(i);
|
||||
}
|
||||
if (cue) {
|
||||
activeMin = Math.min(activeMin, cue.startTime);
|
||||
activeMax = cue.endTime
|
||||
? Math.max(activeMax, cue.endTime)
|
||||
: activeMax;
|
||||
} else {
|
||||
activeLength--;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < length; i++) {
|
||||
let cue = cues[i];
|
||||
if (!cue && cues.item) {
|
||||
cue = cues.item(i);
|
||||
}
|
||||
if (!cue) {
|
||||
continue;
|
||||
}
|
||||
const start = cue.startTime;
|
||||
const end = cue.endTime;
|
||||
const content = getCueLabel(cue);
|
||||
let active = false;
|
||||
if (activeLength && end >= activeMin && start <= activeMax) {
|
||||
active = [].some.call(activeCues, (activeCue) =>
|
||||
cuesMatch(activeCue, cue)
|
||||
);
|
||||
}
|
||||
data.push({
|
||||
start,
|
||||
end,
|
||||
content,
|
||||
active,
|
||||
dataType: 'cue',
|
||||
});
|
||||
}
|
||||
this.updateOnRepaint();
|
||||
}
|
||||
|
||||
drawCurrentTime() {
|
||||
const chart = this.chart;
|
||||
if (self.hls?.media && chart.data.datasets!.length) {
|
||||
const currentTime = self.hls.media.currentTime;
|
||||
const scale = this.chartScales[X_AXIS_SECONDS];
|
||||
const ctx = chart.ctx;
|
||||
if (this.hidden || !ctx || !ctx.canvas.width) {
|
||||
return;
|
||||
}
|
||||
const chartArea: { left; top; right; bottom } = chart.chartArea;
|
||||
const x = scale.getPixelForValue(currentTime);
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
this.drawLineX(ctx, x, chartArea);
|
||||
if (x > chartArea.left && x < chartArea.right) {
|
||||
ctx.fillStyle = this.getCurrentTimeColor(self.hls.media);
|
||||
const y = chartArea.top + chart.data.datasets![0].barThickness + 1;
|
||||
ctx.fillText(hhmmss(currentTime, 5), x + 2, y, 100);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTimeColor(video: HTMLMediaElement): string {
|
||||
if (!video.readyState || video.ended) {
|
||||
return 'rgba(0, 0, 0, 0.9)';
|
||||
}
|
||||
if (video.seeking || video.readyState < 3) {
|
||||
return 'rgba(255, 128, 0, 0.9)';
|
||||
}
|
||||
if (video.paused) {
|
||||
return 'rgba(128, 0, 255, 0.9)';
|
||||
}
|
||||
return 'rgba(0, 0, 255, 0.9)';
|
||||
}
|
||||
|
||||
drawLineX(ctx, x: number, chartArea) {
|
||||
if (!this.imageDataBuffer) {
|
||||
const devicePixelRatio = self.devicePixelRatio || 1;
|
||||
this.imageDataBuffer = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
chartArea.right * devicePixelRatio,
|
||||
chartArea.bottom * devicePixelRatio
|
||||
);
|
||||
} else {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, chartArea.right, chartArea.bottom);
|
||||
ctx.putImageData(this.imageDataBuffer, 0, 0);
|
||||
}
|
||||
if (x > chartArea.left && x < chartArea.right) {
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = this.getCurrentTimeColor(self.hls.media); // alpha '0.5'
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, chartArea.top);
|
||||
ctx.lineTo(x, chartArea.bottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripDeliveryDirectives(url: string): string {
|
||||
if (url === '') {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
const webUrl: URL = new self.URL(url);
|
||||
webUrl.searchParams.delete('_HLS_msn');
|
||||
webUrl.searchParams.delete('_HLS_part');
|
||||
webUrl.searchParams.delete('_HLS_skip');
|
||||
webUrl.searchParams.sort();
|
||||
return webUrl.href;
|
||||
} catch (e) {
|
||||
return url.replace(/[?&]_HLS_(?:msn|part|skip)=[^?&]+/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function datasetWithDefaults(options) {
|
||||
return Object.assign(
|
||||
{
|
||||
data: [],
|
||||
xAxisID: X_AXIS_SECONDS,
|
||||
barThickness: 35,
|
||||
categoryPercentage: 1,
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
function getPlaylistStart(details: LevelDetails): number {
|
||||
return details.fragments?.length ? details.fragments[0].start : 0;
|
||||
}
|
||||
|
||||
function getLevelName(level: Level, index: number) {
|
||||
let label = '(main playlist)';
|
||||
if (level.attrs?.BANDWIDTH) {
|
||||
label = `${getMainLevelAttribute(level)}@${level.attrs.BANDWIDTH}`;
|
||||
if (level.name) {
|
||||
label = `${label} (${level.name})`;
|
||||
}
|
||||
} else if (level.name) {
|
||||
label = level.name;
|
||||
}
|
||||
return `${label} L-${index}`;
|
||||
}
|
||||
|
||||
function getMainLevelAttribute(level: Level) {
|
||||
return level.attrs.RESOLUTION || level.attrs.CODECS || level.attrs.AUDIO;
|
||||
}
|
||||
|
||||
function getAudioTrackName(track: MediaPlaylist, index: number) {
|
||||
const label = track.lang ? `${track.name}/${track.lang}` : track.name;
|
||||
return `${label} (${track.groupId || track.attrs['GROUP-ID']}) A-${index}`;
|
||||
}
|
||||
|
||||
function getSubtitlesName(track: MediaPlaylist, index: number) {
|
||||
const label = track.lang ? `${track.name}/${track.lang}` : track.name;
|
||||
return `${label} (${track.groupId || track.attrs['GROUP-ID']}) S-${index}`;
|
||||
}
|
||||
|
||||
function replaceTimeRangeTuples(timeRanges, data) {
|
||||
data.length = 0;
|
||||
const { length } = timeRanges;
|
||||
for (let i = 0; i < length; i++) {
|
||||
data.push([timeRanges.start(i), timeRanges.end(i)]);
|
||||
}
|
||||
}
|
||||
|
||||
function cuesMatch(cue1, cue2) {
|
||||
return (
|
||||
cue1.startTime === cue2.startTime &&
|
||||
cue1.endTime === cue2.endTime &&
|
||||
cue1.text === cue2.text &&
|
||||
cue1.data === cue2.data &&
|
||||
JSON.stringify(cue1.value) === JSON.stringify(cue2.value)
|
||||
);
|
||||
}
|
||||
|
||||
function getCueLabel(cue) {
|
||||
if (cue.text) {
|
||||
return cue.text;
|
||||
}
|
||||
const result = parseDataCue(cue);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function parseDataCue(cue) {
|
||||
const data = {};
|
||||
const { value } = cue;
|
||||
if (value) {
|
||||
if (value.info) {
|
||||
let collection = data[value.key];
|
||||
if (collection !== Object(collection)) {
|
||||
collection = {};
|
||||
data[value.key] = collection;
|
||||
}
|
||||
collection[value.info] = value.data;
|
||||
} else {
|
||||
data[value.key] = value.data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function getChartOptions() {
|
||||
return {
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
elements: {
|
||||
rectangle: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(20, 20, 20, 1)',
|
||||
},
|
||||
},
|
||||
events: ['click', 'touchstart'],
|
||||
hover: {
|
||||
mode: null,
|
||||
animationDuration: 0,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
|
||||
responsiveAnimationDuration: 0,
|
||||
scales: {
|
||||
// TODO: additional xAxes for PTS and PDT
|
||||
xAxes: [
|
||||
{
|
||||
id: X_AXIS_SECONDS,
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
sampleSize: 0,
|
||||
maxRotation: 0,
|
||||
callback: (tickValue, i, ticks) => {
|
||||
if (i === 0 || i === ticks.length - 1) {
|
||||
return tickValue ? '' : '0';
|
||||
} else {
|
||||
return hhmmss(tickValue, 2);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tooltips: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function arrayFind(array, predicate) {
|
||||
const len = array.length >>> 0;
|
||||
if (typeof predicate !== 'function') {
|
||||
throw TypeError('predicate must be a function');
|
||||
}
|
||||
const thisArg = arguments[2];
|
||||
let k = 0;
|
||||
while (k < len) {
|
||||
const kValue = array[k];
|
||||
if (predicate.call(thisArg, kValue, k, array)) {
|
||||
return kValue;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
// eslint-disable-next-line no-undefined
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function sortObject(obj) {
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
let temp = {};
|
||||
let keys = [];
|
||||
for (let key in obj) {
|
||||
keys.push(key);
|
||||
}
|
||||
keys.sort();
|
||||
for (let index in keys) {
|
||||
temp[keys[index]] = sortObject(obj[keys[index]]);
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
export function copyTextToClipboard(text) {
|
||||
let textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
let successful = document.execCommand('copy');
|
||||
let msg = successful ? 'successful' : 'unsuccessful';
|
||||
console.log('Copying text command was ' + msg);
|
||||
} catch (err) {
|
||||
console.log('Oops, unable to copy');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,675 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>hls.js demo</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap-theme.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.13.1/ace.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header-container">
|
||||
<header class="wrapper clearfix">
|
||||
<h1>
|
||||
<a target="_blank" href="https://github.com/video-dev/hls.js">
|
||||
<img
|
||||
src="https://cloud.githubusercontent.com/assets/616833/19739063/e10be95a-9bb9-11e6-8100-2896f8500138.png"
|
||||
/>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<h2 class="title">light demo</h2>
|
||||
|
||||
<h3>
|
||||
<a href="../api-docs">API docs | usage guide</a>
|
||||
</h3>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<header>
|
||||
<p>
|
||||
Test your HLS streams in all supported browsers
|
||||
(Chrome/Firefox/IE11/Edge/Safari).
|
||||
</p>
|
||||
<p>Advanced controls are available at the bottom of this page.</p>
|
||||
<p>
|
||||
<b
|
||||
>This page uses hls.light.js. It does not support EME, subs or
|
||||
alt-audio. Looking for the <i>complete hls.js</i> demo? Go
|
||||
<a href=".">here</a>.</b
|
||||
><br />
|
||||
</p>
|
||||
</header>
|
||||
<div id="controls">
|
||||
<div class="demo-controls-wrapper">
|
||||
<select id="streamSelect" class="innerControls">
|
||||
<option value="" selected>
|
||||
Select a test-stream from drop-down menu. Or enter custom URL
|
||||
below
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input id="streamURL" class="innerControls" type="text" value="" />
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="Uncheck this to disable loading of streams selected from the drop-down above."
|
||||
>
|
||||
Enable streaming:
|
||||
<input id="enableStreaming" type="checkbox" checked />
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="When a media error occurs, attempt to recover playback by calling `hls.recoverMediaError()`."
|
||||
>
|
||||
Auto-recover media-errors:
|
||||
<input id="autoRecoverError" type="checkbox" checked />
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="Stop loading and playback if playback under-buffer stalls. This can help debug stall errors."
|
||||
>
|
||||
Stop on first stall:
|
||||
<input id="stopOnStall" type="checkbox" unchecked />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Dump transmuxed fMP4 data:
|
||||
<input id="dumpfMP4" type="checkbox" unchecked />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Metrics history (max limit, -1 is unlimited):
|
||||
<input id="limitMetrics" style="width: 8em" type="number" />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
HTML video element width:
|
||||
<select id="videoSize" style="float: right">
|
||||
<option value="240px">240px</option>
|
||||
<option value="426px">426px</option>
|
||||
<option value="640px">640px</option>
|
||||
<option value="720px">720px</option>
|
||||
<option value="854px">854px</option>
|
||||
<option value="1280px">1280px</option>
|
||||
<option value="1920px">1920px</option>
|
||||
<option value="80%">Responsive (80%)</option>
|
||||
<option value="100%">Responsive (100%)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Current player size:
|
||||
<span id="currentSize"></span>
|
||||
</label>
|
||||
<label class="innerControls">
|
||||
Current video resolution:
|
||||
<span id="currentResolution"></span>
|
||||
</label>
|
||||
|
||||
<label class="innerControls permalink">
|
||||
Permalink: <span id="StreamPermalink"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-editor-wrapper">
|
||||
<div class="config-editor-container">
|
||||
<div id="config-editor">Loading...</div>
|
||||
</div>
|
||||
<div class="config-editor-commands">
|
||||
<label for="config-persistence">
|
||||
Persist
|
||||
<input
|
||||
name="config-persistence"
|
||||
id="config-persistence"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<button name="config-apply" onclick="applyConfigEditorValue()">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video
|
||||
id="video"
|
||||
controls
|
||||
autoplay
|
||||
class="videoCentered"
|
||||
style="width: 80%"
|
||||
></video>
|
||||
<br />
|
||||
<canvas
|
||||
id="bufferedCanvas"
|
||||
width="720"
|
||||
height="15"
|
||||
class="videoCentered"
|
||||
onclick="onClickBufferedRange(event);"
|
||||
style="height: fit-content"
|
||||
></canvas>
|
||||
<br />
|
||||
<br />
|
||||
<label class="center">Status:</label>
|
||||
<pre id="statusOut" class="center" style="white-space: pre-wrap"></pre>
|
||||
|
||||
<label class="center">Error:</label>
|
||||
<pre id="errorOut" class="center" style="white-space: pre-wrap"></pre>
|
||||
|
||||
<div
|
||||
class="center"
|
||||
style="text-align: center; display: none"
|
||||
id="toggleButtons"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="playbackControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Playback
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="timelineTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="qualityLevelControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Quality-levels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="audioTrackControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Audio-tracks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="metricsDisplayTab"
|
||||
onclick="toggleTabClick(this); showMetrics();"
|
||||
>
|
||||
Real-time metrics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="statsDisplayTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Buffer & Statistics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="playbackControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<br />
|
||||
<center>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.play()"
|
||||
onclick="$('#video')[0].play()"
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.pause()"
|
||||
onclick="$('#video')[0].pause()"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.playbackRate = text input"
|
||||
onclick="$('#video')[0].defaultPlaybackRate=$('#video')[0].playbackRate=$('#playback_rate').val();"
|
||||
>
|
||||
Playback rate
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value="1"
|
||||
id="playback_rate"
|
||||
size="8"
|
||||
style="width: 3em"
|
||||
onkeydown="if(window.event.keyCode=='13'){$('#video')[0].defaultPlaybackRate=$('#video')[0].playbackRate=$('#playback_rate').val();}"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime -= 10"
|
||||
onclick="$('#video')[0].currentTime-=10"
|
||||
>
|
||||
- 10 s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime += 10"
|
||||
onclick="$('#video')[0].currentTime+=10"
|
||||
>
|
||||
+ 10 s
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime = text input"
|
||||
onclick="$('#video')[0].currentTime=$('#seek_pos').val();"
|
||||
>
|
||||
Seek to
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id="seek_pos"
|
||||
size="8"
|
||||
style="width: 7em"
|
||||
onkeydown="if(window.event.keyCode=='13'){$('#video')[0].currentTime=$('#seek_pos').val();}"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.startLoad()"
|
||||
onclick="hls.startLoad()"
|
||||
>
|
||||
Start loading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.stopLoad()"
|
||||
onclick="hls.stopLoad()"
|
||||
>
|
||||
Stop loading
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.attachMedia(video)"
|
||||
onclick="hls.attachMedia($('#video')[0])"
|
||||
>
|
||||
Attach media
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.detachMedia()"
|
||||
onclick="hls.detachMedia()"
|
||||
>
|
||||
Detach media
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.recoverMediaError()"
|
||||
onclick="hls.recoverMediaError()"
|
||||
>
|
||||
Recover media-error
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.swapAudioCodec()"
|
||||
onclick="hls.swapAudioCodec()"
|
||||
>
|
||||
Swap audio codec
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default"
|
||||
onclick="$('#streamSelect')[0].selectedIndex++;$('#streamSelect').change()"
|
||||
>
|
||||
Next video
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default btn-dump"
|
||||
title="Save dumped audio mp4 appends"
|
||||
onclick="createfMP4('audio');"
|
||||
>
|
||||
Create audio-fmp4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default btn-dump"
|
||||
title="Save dumped video mp4 appends"
|
||||
onclick="createfMP4('video')"
|
||||
>
|
||||
Create video-fmp4
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
</center>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab demo-timeline-chart-container"
|
||||
id="timelineTab"
|
||||
style="display: none"
|
||||
>
|
||||
<canvas id="timeline-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="qualityLevelControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<center>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Currently played level:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="currentLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Next level loaded:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="nextLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Currently loaded level:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="loadLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Cap-limit level (maximum):</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="levelCappingControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="audioTrackControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Current audio-track:</td>
|
||||
<td><div id="audioTrackControl" style="display: inline"></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Language / Name:</td>
|
||||
<td>
|
||||
<div id="audioTrackLabel" style="display: inline">
|
||||
None selected
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="center demo-tab" id="metricsDisplayTab" style="display: none">
|
||||
<br />
|
||||
<div id="metricsButton">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="$('#metricsButtonWindow').toggle();$('#metricsButtonFixed').toggle();windowSliding=!windowSliding; refreshCanvas()"
|
||||
>
|
||||
toggle sliding/fixed window</button
|
||||
><br />
|
||||
<div id="metricsButtonWindow">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(0)"
|
||||
>
|
||||
window ALL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(2000)"
|
||||
>
|
||||
2s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(5000)"
|
||||
>
|
||||
5s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(10000)"
|
||||
>
|
||||
10s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(20000)"
|
||||
>
|
||||
20s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(30000)"
|
||||
>
|
||||
30s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(60000)"
|
||||
>
|
||||
60s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(120000)"
|
||||
>
|
||||
120s</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeZoomIn()"
|
||||
>
|
||||
Window Zoom In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeZoomOut()"
|
||||
>
|
||||
Window Zoom Out</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSlideLeft()"
|
||||
>
|
||||
<<< Window Slide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSlideRight()"
|
||||
>
|
||||
Window Slide >>></button
|
||||
><br />
|
||||
</div>
|
||||
<div id="metricsButtonFixed">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="windowStart=$('#windowStart').val()"
|
||||
>
|
||||
fixed window start(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowStart"
|
||||
defaultValue="0"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowStart=$('#windowStart').val();}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="windowEnd=$('#windowEnd').val()"
|
||||
>
|
||||
fixed window end(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowEnd"
|
||||
defaultValue="10000"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowEnd=$('#windowEnd').val();}"
|
||||
/><br />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="goToMetrics()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
metrics link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="goToMetricsPermaLink()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
metrics permalink
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="copyMetricsToClipBoard()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
copy metrics to clipboard
|
||||
</button>
|
||||
<canvas
|
||||
id="bufferTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousedown="timeRangeCanvasonMouseDown(event)"
|
||||
onmousemove="timeRangeCanvasonMouseMove(event)"
|
||||
onmouseup="timeRangeCanvasonMouseUp(event)"
|
||||
onmouseout="timeRangeCanvasonMouseOut(event);"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bitrateTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bufferWindow_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousemove="windowCanvasonMouseMove(event);"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="videoEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="loadEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas
|
||||
><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center demo-tab" id="statsDisplayTab" style="display: none">
|
||||
<br />
|
||||
<label>Buffer state:</label>
|
||||
<pre id="bufferedOut"></pre>
|
||||
<label>General stats:</label>
|
||||
<pre id="statisticsOut"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer><br /><br /><br /><br /><br /><br /></footer>
|
||||
|
||||
<!-- Demo page required libs -->
|
||||
<script src="canvas.js"></script>
|
||||
<script src="metrics.js"></script>
|
||||
|
||||
<!-- demo build -->
|
||||
<script src="hls.light.js"></script>
|
||||
<script src="hls-demo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,673 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>hls.js demo</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.4/dist/css/bootstrap.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.4/dist/css/bootstrap-theme.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.4/dist/js/bootstrap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.13.1/ace.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header-container">
|
||||
<header class="wrapper clearfix">
|
||||
<h1>
|
||||
<a target="_blank" href="https://github.com/video-dev/hls.js">
|
||||
<img
|
||||
src="https://cloud.githubusercontent.com/assets/616833/19739063/e10be95a-9bb9-11e6-8100-2896f8500138.png"
|
||||
/>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<h2 class="title">demo</h2>
|
||||
|
||||
<h3>
|
||||
<a href="../api-docs">API docs | usage guide</a>
|
||||
</h3>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<header>
|
||||
<p>
|
||||
Test your HLS streams in all supported browsers
|
||||
(Chrome/Firefox/IE11/Edge/Safari).
|
||||
</p>
|
||||
<p>Advanced controls are available at the bottom of this page.</p>
|
||||
<p>
|
||||
<b
|
||||
>Looking for a more <i>basic</i> usage example? Go
|
||||
<a href="basic-usage.html">here</a>.</b
|
||||
><br />
|
||||
</p>
|
||||
</header>
|
||||
<div id="controls">
|
||||
<div class="demo-controls-wrapper">
|
||||
<select id="streamSelect" class="innerControls">
|
||||
<option value="" selected>
|
||||
选择或输入地址
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input id="streamURL" class="innerControls" type="text" value="" />
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="Uncheck this to disable loading of streams selected from the drop-down above."
|
||||
>
|
||||
Enable streaming:
|
||||
<input id="enableStreaming" type="checkbox" checked />
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="When a media error occurs, attempt to recover playback by calling `hls.recoverMediaError()`."
|
||||
>
|
||||
Auto-recover media-errors:
|
||||
<input id="autoRecoverError" type="checkbox" checked />
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="innerControls"
|
||||
title="Stop loading and playback if playback under-buffer stalls. This can help debug stall errors."
|
||||
>
|
||||
Stop on first stall:
|
||||
<input id="stopOnStall" type="checkbox" unchecked />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Dump transmuxed fMP4 data:
|
||||
<input id="dumpfMP4" type="checkbox" unchecked />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Metrics history (max limit, -1 is unlimited):
|
||||
<input id="limitMetrics" style="width: 8em" type="number" />
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
HTML video element width:
|
||||
<select id="videoSize" style="float: right">
|
||||
<option value="240px">240px</option>
|
||||
<option value="426px">426px</option>
|
||||
<option value="640px">640px</option>
|
||||
<option value="720px">720px</option>
|
||||
<option value="854px">854px</option>
|
||||
<option value="1280px">1280px</option>
|
||||
<option value="1920px">1920px</option>
|
||||
<option value="80%">Responsive (80%)</option>
|
||||
<option value="100%">Responsive (100%)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="innerControls">
|
||||
Current player size:
|
||||
<span id="currentSize"></span>
|
||||
</label>
|
||||
<label class="innerControls">
|
||||
Current video resolution:
|
||||
<span id="currentResolution"></span>
|
||||
</label>
|
||||
|
||||
<label class="innerControls permalink">
|
||||
Permalink: <span id="StreamPermalink"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-editor-wrapper">
|
||||
<div class="config-editor-container">
|
||||
<div id="config-editor">Loading...</div>
|
||||
</div>
|
||||
<div class="config-editor-commands">
|
||||
<label for="config-persistence">
|
||||
Persist
|
||||
<input
|
||||
name="config-persistence"
|
||||
id="config-persistence"
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<button name="config-apply" onclick="applyConfigEditorValue()">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video
|
||||
id="video"
|
||||
controls
|
||||
autoplay
|
||||
class="videoCentered"
|
||||
style="width: 80%"
|
||||
></video>
|
||||
<br />
|
||||
<canvas
|
||||
id="bufferedCanvas"
|
||||
width="720"
|
||||
height="15"
|
||||
class="videoCentered"
|
||||
onclick="onClickBufferedRange(event);"
|
||||
style="height: fit-content"
|
||||
></canvas>
|
||||
<br />
|
||||
<br />
|
||||
<label class="center">Status:</label>
|
||||
<pre id="statusOut" class="center" style="white-space: pre-wrap"></pre>
|
||||
|
||||
<label class="center">Error:</label>
|
||||
<pre id="errorOut" class="center" style="white-space: pre-wrap"></pre>
|
||||
|
||||
<div
|
||||
class="center"
|
||||
style="text-align: center; display: none"
|
||||
id="toggleButtons"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="playbackControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Playback
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="timelineTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="qualityLevelControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Quality-levels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="audioTrackControlTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Audio-tracks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="metricsDisplayTab"
|
||||
onclick="toggleTabClick(this); showMetrics();"
|
||||
>
|
||||
Real-time metrics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm demo-tab-btn"
|
||||
data-tab="statsDisplayTab"
|
||||
onclick="toggleTabClick(this);"
|
||||
>
|
||||
Buffer & Statistics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="playbackControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<br />
|
||||
<center>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.play()"
|
||||
onclick="$('#video')[0].play()"
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.pause()"
|
||||
onclick="$('#video')[0].pause()"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.playbackRate = text input"
|
||||
onclick="$('#video')[0].defaultPlaybackRate=$('#video')[0].playbackRate=$('#playback_rate').val();"
|
||||
>
|
||||
Playback rate
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value="1"
|
||||
id="playback_rate"
|
||||
size="8"
|
||||
style="width: 3em"
|
||||
onkeydown="if(window.event.keyCode=='13'){$('#video')[0].defaultPlaybackRate=$('#video')[0].playbackRate=$('#playback_rate').val();}"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime -= 10"
|
||||
onclick="$('#video')[0].currentTime-=10"
|
||||
>
|
||||
- 10 s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime += 10"
|
||||
onclick="$('#video')[0].currentTime+=10"
|
||||
>
|
||||
+ 10 s
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info"
|
||||
title="video.currentTime = text input"
|
||||
onclick="$('#video')[0].currentTime=$('#seek_pos').val();"
|
||||
>
|
||||
Seek to
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id="seek_pos"
|
||||
size="8"
|
||||
style="width: 7em"
|
||||
onkeydown="if(window.event.keyCode=='13'){$('#video')[0].currentTime=$('#seek_pos').val();}"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.startLoad()"
|
||||
onclick="hls.startLoad()"
|
||||
>
|
||||
Start loading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.stopLoad()"
|
||||
onclick="hls.stopLoad()"
|
||||
>
|
||||
Stop loading
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.attachMedia(video)"
|
||||
onclick="hls.attachMedia($('#video')[0])"
|
||||
>
|
||||
Attach media
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.detachMedia()"
|
||||
onclick="hls.detachMedia()"
|
||||
>
|
||||
Detach media
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.recoverMediaError()"
|
||||
onclick="hls.recoverMediaError()"
|
||||
>
|
||||
Recover media-error
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="hls.swapAudioCodec()"
|
||||
onclick="hls.swapAudioCodec()"
|
||||
>
|
||||
Swap audio codec
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default"
|
||||
onclick="$('#streamSelect')[0].selectedIndex++;$('#streamSelect').change()"
|
||||
>
|
||||
Next video
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default btn-dump"
|
||||
title="Save dumped audio mp4 appends"
|
||||
onclick="createfMP4('audio');"
|
||||
>
|
||||
Create audio-fmp4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-default btn-dump"
|
||||
title="Save dumped video mp4 appends"
|
||||
onclick="createfMP4('video')"
|
||||
>
|
||||
Create video-fmp4
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
</center>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab demo-timeline-chart-container"
|
||||
id="timelineTab"
|
||||
style="display: none"
|
||||
>
|
||||
<canvas id="timeline-chart" style="touch-action: manipulation"></canvas>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="qualityLevelControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<center>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Currently played level:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="currentLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Next level loaded:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="nextLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Currently loaded level:</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="loadLevelControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Cap-limit level (maximum):</p>
|
||||
</td>
|
||||
<td>
|
||||
<div id="levelCappingControl" style="display: inline"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="center demo-tab"
|
||||
id="audioTrackControlTab"
|
||||
style="display: none"
|
||||
>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Current audio-track:</td>
|
||||
<td><div id="audioTrackControl" style="display: inline"></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Language / Name:</td>
|
||||
<td>
|
||||
<div id="audioTrackLabel" style="display: inline">
|
||||
None selected
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="center demo-tab" id="metricsDisplayTab" style="display: none">
|
||||
<br />
|
||||
<div id="metricsButton">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="$('#metricsButtonWindow').toggle();$('#metricsButtonFixed').toggle();windowSliding=!windowSliding; refreshCanvas()"
|
||||
>
|
||||
toggle sliding/fixed window</button
|
||||
><br />
|
||||
<div id="metricsButtonWindow">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(0)"
|
||||
>
|
||||
window ALL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(2000)"
|
||||
>
|
||||
2s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(5000)"
|
||||
>
|
||||
5s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(10000)"
|
||||
>
|
||||
10s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(20000)"
|
||||
>
|
||||
20s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(30000)"
|
||||
>
|
||||
30s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(60000)"
|
||||
>
|
||||
60s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSetSliding(120000)"
|
||||
>
|
||||
120s</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeZoomIn()"
|
||||
>
|
||||
Window Zoom In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeZoomOut()"
|
||||
>
|
||||
Window Zoom Out</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSlideLeft()"
|
||||
>
|
||||
<<< Window Slide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="timeRangeSlideRight()"
|
||||
>
|
||||
Window Slide >>></button
|
||||
><br />
|
||||
</div>
|
||||
<div id="metricsButtonFixed">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="windowStart=$('#windowStart').val()"
|
||||
>
|
||||
fixed window start(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowStart"
|
||||
defaultValue="0"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowStart=$('#windowStart').val();}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-info"
|
||||
onclick="windowEnd=$('#windowEnd').val()"
|
||||
>
|
||||
fixed window end(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowEnd"
|
||||
defaultValue="10000"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowEnd=$('#windowEnd').val();}"
|
||||
/><br />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="goToMetrics()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
metrics link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="goToMetricsPermaLink()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
metrics permalink
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-success"
|
||||
onclick="copyMetricsToClipBoard()"
|
||||
style="font-size: 18px"
|
||||
>
|
||||
copy metrics to clipboard
|
||||
</button>
|
||||
<canvas
|
||||
id="bufferTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousedown="timeRangeCanvasonMouseDown(event)"
|
||||
onmousemove="timeRangeCanvasonMouseMove(event)"
|
||||
onmouseup="timeRangeCanvasonMouseUp(event)"
|
||||
onmouseout="timeRangeCanvasonMouseOut(event);"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bitrateTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bufferWindow_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousemove="windowCanvasonMouseMove(event);"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="videoEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="loadEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
></canvas
|
||||
><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center demo-tab" id="statsDisplayTab" style="display: none">
|
||||
<br />
|
||||
<label>Buffer state:</label>
|
||||
<pre id="bufferedOut"></pre>
|
||||
<label>General stats:</label>
|
||||
<pre id="statisticsOut"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer><br /><br /><br /><br /><br /><br /></footer>
|
||||
|
||||
<!-- Demo page required libs -->
|
||||
<script src="canvas.js"></script>
|
||||
<script src="metrics.js"></script>
|
||||
|
||||
<!-- demo build -->
|
||||
<script src="hls.js"></script>
|
||||
<script src="hls-demo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<title>hls.js metrics page</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap-theme.min.css"
|
||||
/>
|
||||
<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
||||
<script src="../node_modules/jsonpack/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header-container">
|
||||
<header class="wrapper clearfix">
|
||||
<h1 class="title">hls.js metrics page</h1>
|
||||
</header>
|
||||
</div>
|
||||
<pre id="HlsDate"></pre>
|
||||
<pre id="StreamPermalink"></pre>
|
||||
<input id="metricsData" class="innerControls" type="text" value="" />
|
||||
window size
|
||||
<div id="metricsButton">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(0)"
|
||||
>
|
||||
window ALL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(2000)"
|
||||
>
|
||||
2s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(5000)"
|
||||
>
|
||||
5s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(10000)"
|
||||
>
|
||||
10s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(20000)"
|
||||
>
|
||||
20s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(30000)"
|
||||
>
|
||||
30s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(60000)"
|
||||
>
|
||||
60s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSetSliding(120000)"
|
||||
>
|
||||
120s</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeZoomIn()"
|
||||
>
|
||||
Window Zoom In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeZoomOut()"
|
||||
>
|
||||
Window Zoom Out</button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSlideLeft()"
|
||||
>
|
||||
<<< Window Slide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="timeRangeSlideRight()"
|
||||
>
|
||||
Window Slide >>></button
|
||||
><br />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="windowStart=$('#windowStart').val()"
|
||||
>
|
||||
fixed window start(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowStart"
|
||||
defaultValue="0"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowStart=$('#windowStart').val();}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
onclick="windowEnd=$('#windowEnd').val()"
|
||||
>
|
||||
fixed window end(ms)
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="windowEnd"
|
||||
defaultValue="10000"
|
||||
size="8"
|
||||
onkeydown="if(window.event.keyCode=='13'){windowEnd=$('#windowEnd').val();}"
|
||||
/><br />
|
||||
<canvas
|
||||
id="bufferTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousedown="timeRangeCanvasonMouseDown(event)"
|
||||
onmousemove="timeRangeCanvasonMouseMove(event)"
|
||||
onmouseup="timeRangeCanvasonMouseUp(event)"
|
||||
onmouseout="timeRangeCanvasonMouseOut(event)"
|
||||
;
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bitrateTimerange_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
;
|
||||
></canvas>
|
||||
<canvas
|
||||
id="bufferWindow_c"
|
||||
width="640"
|
||||
height="100"
|
||||
style="border: 1px solid #000000"
|
||||
onmousemove="windowCanvasonMouseMove(event)"
|
||||
;
|
||||
></canvas>
|
||||
<canvas
|
||||
id="videoEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
;
|
||||
></canvas>
|
||||
<canvas
|
||||
id="loadEvent_c"
|
||||
width="640"
|
||||
height="15"
|
||||
style="border: 1px solid #000000"
|
||||
;
|
||||
></canvas
|
||||
><br />
|
||||
</div>
|
||||
|
||||
<script src="canvas.js"></script>
|
||||
<script src="metrics.js"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('#metricsData').change(function () {
|
||||
events = jsonpack.unpack(atob($('#metricsData').val()));
|
||||
updateMetrics();
|
||||
});
|
||||
});
|
||||
|
||||
var data =
|
||||
location.search.split('data=')[1] || location.hash.split('data=')[1],
|
||||
events;
|
||||
if (data) {
|
||||
events = jsonpack.unpack(atob(decodeURIComponent(data)));
|
||||
updateMetrics();
|
||||
}
|
||||
|
||||
function updateMetrics() {
|
||||
var hlsLink = new URL(
|
||||
'index.html?src=' + encodeURIComponent(events.url),
|
||||
window.location.href
|
||||
).href;
|
||||
var playlistLink = document.createElement('a');
|
||||
playlistLink.setAttribute('href', events.url);
|
||||
playlistLink.textContent = events.url;
|
||||
|
||||
var replayLink = document.createElement('a');
|
||||
replayLink.setAttribute('href', hlsLink);
|
||||
replayLink.textContent = hlsLink;
|
||||
|
||||
var fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(document.createTextNode('playlist: '));
|
||||
fragment.appendChild(playlistLink);
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(document.createTextNode('replay: '));
|
||||
fragment.appendChild(replayLink);
|
||||
|
||||
$('#StreamPermalink').html(fragment);
|
||||
$('#HlsDate').text('session Start Date:' + new Date(events.t0));
|
||||
metricsDisplayed = true;
|
||||
showMetrics();
|
||||
refreshCanvas();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,266 @@
|
||||
/* global $, events, eventLeftMargin, canvasBufferTimeRangeUpdate, canvasBufferWindowUpdate, canvasBitrateEventUpdate,
|
||||
canvasVideoEventUpdate, canvasLoadEventUpdate */
|
||||
/* eslint no-var: 0 */
|
||||
var windowDuration = 20000;
|
||||
var windowSliding = true;
|
||||
var windowStart = 0;
|
||||
var windowEnd = 10000;
|
||||
var windowFocus;
|
||||
var metricsDisplayed = false;
|
||||
var timeRangeMouseDown = false;
|
||||
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
|
||||
function showMetrics() {
|
||||
metricsDisplayed = true;
|
||||
var width = window.innerWidth - 30;
|
||||
$('#bufferWindow_c')[0].width =
|
||||
$('#bitrateTimerange_c')[0].width =
|
||||
$('#bufferTimerange_c')[0].width =
|
||||
$('#videoEvent_c')[0].width =
|
||||
$('#metricsButton')[0].width =
|
||||
$('#loadEvent_c')[0].width =
|
||||
width;
|
||||
$('#bufferWindow_c').show();
|
||||
$('#bitrateTimerange_c').show();
|
||||
$('#bufferTimerange_c').show();
|
||||
$('#videoEvent_c').show();
|
||||
$('#metricsButton').show();
|
||||
$('#loadEvent_c').show();
|
||||
}
|
||||
|
||||
function hideMetrics() {
|
||||
metricsDisplayed = false;
|
||||
$('#bufferWindow_c').hide();
|
||||
$('#bitrateTimerange_c').hide();
|
||||
$('#bufferTimerange_c').hide();
|
||||
$('#videoEvent_c').hide();
|
||||
$('#metricsButton').hide();
|
||||
$('#loadEvent_c').hide();
|
||||
}
|
||||
|
||||
function timeRangeSetSliding(duration) {
|
||||
windowDuration = duration;
|
||||
windowSliding = true;
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function timeRangeCanvasonMouseDown(evt) {
|
||||
var canvas = evt.currentTarget;
|
||||
var bRect = canvas.getBoundingClientRect();
|
||||
var mouseX = Math.round(
|
||||
(evt.clientX - bRect.left) * (canvas.width / bRect.width)
|
||||
);
|
||||
windowStart = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((mouseX - eventLeftMargin) * getWindowTimeRange().now) /
|
||||
(canvas.width - eventLeftMargin)
|
||||
)
|
||||
);
|
||||
windowEnd = windowStart + 500;
|
||||
timeRangeMouseDown = true;
|
||||
windowSliding = false;
|
||||
// console.log('windowStart/windowEnd:' + '/' + windowStart + '/' + windowEnd);
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function timeRangeCanvasonMouseMove(evt) {
|
||||
if (timeRangeMouseDown) {
|
||||
var canvas = evt.currentTarget;
|
||||
var bRect = canvas.getBoundingClientRect();
|
||||
var mouseX = Math.round(
|
||||
(evt.clientX - bRect.left) * (canvas.width / bRect.width)
|
||||
);
|
||||
var pos = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((mouseX - eventLeftMargin) * getWindowTimeRange().now) /
|
||||
(canvas.width - eventLeftMargin)
|
||||
)
|
||||
);
|
||||
if (pos < windowStart) {
|
||||
windowStart = pos;
|
||||
} else {
|
||||
windowEnd = pos;
|
||||
}
|
||||
if (windowStart === windowEnd) {
|
||||
// to avoid division by zero ...
|
||||
windowEnd += 50;
|
||||
}
|
||||
// console.log('windowStart/windowEnd:' + '/' + windowStart + '/' + windowEnd);
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
function timeRangeCanvasonMouseUp(evt) {
|
||||
timeRangeMouseDown = false;
|
||||
}
|
||||
|
||||
function timeRangeCanvasonMouseOut(evt) {
|
||||
timeRangeMouseDown = false;
|
||||
}
|
||||
|
||||
function windowCanvasonMouseMove(evt) {
|
||||
var canvas = evt.currentTarget;
|
||||
var bRect = canvas.getBoundingClientRect();
|
||||
var mouseX = Math.round(
|
||||
(evt.clientX - bRect.left) * (canvas.width / bRect.width)
|
||||
);
|
||||
var timeRange = getWindowTimeRange();
|
||||
windowFocus =
|
||||
timeRange.min +
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((mouseX - eventLeftMargin) * (timeRange.max - timeRange.min)) /
|
||||
(canvas.width - eventLeftMargin)
|
||||
)
|
||||
);
|
||||
// console.log(windowFocus);
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function refreshCanvas() {
|
||||
if (metricsDisplayed) {
|
||||
try {
|
||||
var windowTime = getWindowTimeRange();
|
||||
canvasBufferTimeRangeUpdate(
|
||||
$('#bufferTimerange_c')[0],
|
||||
0,
|
||||
windowTime.now,
|
||||
windowTime.min,
|
||||
windowTime.max,
|
||||
events.buffer
|
||||
);
|
||||
if (windowTime.min !== 0 || windowTime.max !== windowTime.now) {
|
||||
$('#bufferWindow_c').show();
|
||||
canvasBufferWindowUpdate(
|
||||
$('#bufferWindow_c')[0],
|
||||
windowTime.min,
|
||||
windowTime.max,
|
||||
windowTime.focus,
|
||||
events.buffer
|
||||
);
|
||||
} else {
|
||||
$('#bufferWindow_c').hide();
|
||||
}
|
||||
canvasBitrateEventUpdate(
|
||||
$('#bitrateTimerange_c')[0],
|
||||
0,
|
||||
windowTime.now,
|
||||
windowTime.min,
|
||||
windowTime.max,
|
||||
events.level,
|
||||
events.bitrate
|
||||
);
|
||||
canvasVideoEventUpdate(
|
||||
$('#videoEvent_c')[0],
|
||||
windowTime.min,
|
||||
windowTime.max,
|
||||
events.video
|
||||
);
|
||||
canvasLoadEventUpdate(
|
||||
$('#loadEvent_c')[0],
|
||||
windowTime.min,
|
||||
windowTime.max,
|
||||
events.load
|
||||
);
|
||||
} catch (err) {
|
||||
console.log('refreshCanvas error:' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowTimeRange() {
|
||||
var tnow;
|
||||
var minTime;
|
||||
var maxTime;
|
||||
if (events.buffer.length) {
|
||||
tnow = events.buffer[events.buffer.length - 1].time;
|
||||
} else {
|
||||
tnow = 0;
|
||||
}
|
||||
if (windowSliding) {
|
||||
// let's show the requested window
|
||||
if (windowDuration) {
|
||||
minTime = Math.max(0, tnow - windowDuration);
|
||||
maxTime = Math.min(minTime + windowDuration, tnow);
|
||||
} else {
|
||||
minTime = 0;
|
||||
maxTime = tnow;
|
||||
}
|
||||
} else {
|
||||
minTime = windowStart;
|
||||
maxTime = windowEnd;
|
||||
}
|
||||
if (
|
||||
windowFocus === undefined ||
|
||||
windowFocus < minTime ||
|
||||
windowFocus > maxTime
|
||||
) {
|
||||
windowFocus = minTime;
|
||||
}
|
||||
return { min: minTime, max: maxTime, now: tnow, focus: windowFocus };
|
||||
}
|
||||
|
||||
function timeRangeZoomIn() {
|
||||
if (windowSliding) {
|
||||
windowDuration /= 2;
|
||||
} else {
|
||||
var duration = windowEnd - windowStart;
|
||||
windowStart += duration / 4;
|
||||
windowEnd -= duration / 4;
|
||||
if (windowStart === windowEnd) {
|
||||
windowEnd += 50;
|
||||
}
|
||||
}
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function timeRangeZoomOut() {
|
||||
if (windowSliding) {
|
||||
windowDuration *= 2;
|
||||
} else {
|
||||
var duration = windowEnd - windowStart;
|
||||
windowStart -= duration / 2;
|
||||
windowEnd += duration / 2;
|
||||
windowStart = Math.max(0, windowStart);
|
||||
windowEnd = Math.min(
|
||||
events.buffer[events.buffer.length - 1].time,
|
||||
windowEnd
|
||||
);
|
||||
}
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function timeRangeSlideLeft() {
|
||||
var duration = windowEnd - windowStart;
|
||||
windowStart -= duration / 4;
|
||||
windowEnd -= duration / 4;
|
||||
windowStart = Math.max(0, windowStart);
|
||||
windowEnd = Math.min(events.buffer[events.buffer.length - 1].time, windowEnd);
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
|
||||
function timeRangeSlideRight() {
|
||||
var duration = windowEnd - windowStart;
|
||||
windowStart += duration / 4;
|
||||
windowEnd += duration / 4;
|
||||
windowStart = Math.max(0, windowStart);
|
||||
windowEnd = Math.min(events.buffer[events.buffer.length - 1].time, windowEnd);
|
||||
$('#windowStart').val(windowStart);
|
||||
$('#windowEnd').val(windowEnd);
|
||||
refreshCanvas();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 2px 15px;
|
||||
background-color: rgb(181, 222, 255);
|
||||
font-weight: 600;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
select option {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.innerControls input {
|
||||
font-weight: normal;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.innerControls.permalink {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #ddd;
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
button,
|
||||
optgroup,
|
||||
select {
|
||||
background-color: rgb(0, 40, 70);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: initial;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #050505;
|
||||
border-color: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.ace-github {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ace-github .ace_gutter {
|
||||
background: #181818;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ace-github .ace_marker-layer .ace_active-line {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.ace-github.ace_focus .ace_marker-layer .ace_active-line {
|
||||
background: #321;
|
||||
}
|
||||
|
||||
div.config-editor-commands {
|
||||
background-color: #444;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
canvas {
|
||||
background: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
#controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 20px auto;
|
||||
border: 1px solid #606060;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-controls-wrapper {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
padding: 5px 5px 0 3px;
|
||||
}
|
||||
|
||||
.config-editor-wrapper {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: solid 1px #ccc;
|
||||
height: 256px;
|
||||
}
|
||||
|
||||
.config-editor-container {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#config-editor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.config-editor-commands {
|
||||
flex: 1 1 auto;
|
||||
background-color: #ddd;
|
||||
border-top: solid 1px #ccc;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-editor-commands label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-editor-commands button {
|
||||
padding: 5px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.innerControls {
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
padding-left: 5px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.videoCentered {
|
||||
width: 720px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: 70%;
|
||||
min-width: 615px;
|
||||
overflow: hidden;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#toggleButtons button {
|
||||
width: 16%;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
font-weight: bolder;
|
||||
background-color: rgb(181, 222, 255);
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#statusOut {
|
||||
height: auto;
|
||||
max-height: calc((17px * 3) + 19px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#errorOut {
|
||||
height: auto;
|
||||
max-height: calc((17px * 3) + 19px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#streamURL,
|
||||
#streamSelect {
|
||||
width: calc(100% - 4px);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
#streamURL {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
#streamSelect {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
#StreamPermalink {
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden; /* for IE11 */
|
||||
}
|
||||
|
||||
#StreamPermalink a {
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Small devices (portrait tablets and large phones, 600px and up) */
|
||||
@media only screen and (min-width: 600px) {
|
||||
#controls {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.demo-controls-wrapper {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.config-editor-wrapper {
|
||||
height: auto;
|
||||
border-top: 0;
|
||||
border-left: solid 1px #ccc;
|
||||
}
|
||||
}
|
||||
+107
-2
@@ -1,12 +1,117 @@
|
||||
package plugin_hls
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"m7s.live/v5"
|
||||
"m7s.live/v5/plugin/hls/pkg"
|
||||
hls "m7s.live/v5/plugin/hls/pkg"
|
||||
)
|
||||
|
||||
var _ = m7s.InstallPlugin[HLSPlugin](hls.NewPuller)
|
||||
var _ = m7s.InstallPlugin[HLSPlugin](hls.NewPuller, hls.NewTransform)
|
||||
|
||||
//go:embed hls.js
|
||||
var hls_js embed.FS
|
||||
|
||||
type HLSPlugin struct {
|
||||
m7s.Plugin
|
||||
}
|
||||
|
||||
func (p *HLSPlugin) OnDeviceAdd(device *m7s.Device) any {
|
||||
d := &hls.HLSDevice{}
|
||||
d.Device = device
|
||||
d.Plugin = &p.Plugin
|
||||
return d
|
||||
}
|
||||
|
||||
func (config *HLSPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := strings.TrimPrefix(r.URL.Path, "/")
|
||||
query := r.URL.Query()
|
||||
waitTimeout, err := time.ParseDuration(query.Get("timeout"))
|
||||
if err == nil {
|
||||
config.Debug("request", "fileName", fileName, "timeout", waitTimeout)
|
||||
} else {
|
||||
waitTimeout = time.Second * 10
|
||||
}
|
||||
waitStart := time.Now()
|
||||
if strings.HasSuffix(r.URL.Path, ".m3u8") {
|
||||
w.Header().Add("Content-Type", "application/vnd.apple.mpegurl")
|
||||
streamPath := strings.TrimSuffix(fileName, ".m3u8")
|
||||
for {
|
||||
if v, ok := hls.MemoryM3u8.Load(streamPath); ok {
|
||||
w.Write([]byte(v.(string)))
|
||||
return
|
||||
}
|
||||
if waitTimeout > 0 && time.Since(waitStart) < waitTimeout {
|
||||
config.Server.OnSubscribe(streamPath, r.URL.Query())
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
// w.Write([]byte(fmt.Sprintf(`#EXTM3U
|
||||
// #EXT-X-VERSION:3
|
||||
// #EXT-X-MEDIA-SEQUENCE:%d
|
||||
// #EXT-X-TARGETDURATION:%d
|
||||
// #EXT-X-DISCONTINUITY-SEQUENCE:%d
|
||||
// #EXT-X-DISCONTINUITY
|
||||
// #EXTINF:%.3f,
|
||||
// default.ts`, defaultSeq, int(math.Ceil(config.DefaultTSDuration.Seconds())), defaultSeq, config.DefaultTSDuration.Seconds())))
|
||||
} else if strings.HasSuffix(r.URL.Path, ".ts") {
|
||||
w.Header().Add("Content-Type", "video/mp2t") //video/mp2t
|
||||
streamPath := path.Dir(fileName)
|
||||
for {
|
||||
tsData, ok := hls.MemoryTs.Load(streamPath)
|
||||
if !ok {
|
||||
tsData, ok = hls.MemoryTs.Load(path.Dir(streamPath))
|
||||
if !ok {
|
||||
if waitTimeout > 0 && time.Since(waitStart) < waitTimeout {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
// w.Write(defaultTS)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
if tsData, ok := tsData.(hls.TsCacher).GetTs(fileName); ok {
|
||||
switch v := tsData.(type) {
|
||||
case *hls.TsInMemory:
|
||||
v.WriteTo(w)
|
||||
case []byte:
|
||||
w.Write(v)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
if waitTimeout > 0 && time.Since(waitStart) < waitTimeout {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
} else {
|
||||
// w.Write(defaultTS)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
f, err := hls_js.ReadFile("hls.js/" + fileName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
} else {
|
||||
w.Write(f)
|
||||
}
|
||||
// if file, err := hls_js.Open(fileName); err == nil {
|
||||
// defer file.Close()
|
||||
// if info, err := file.Stat(); err == nil {
|
||||
// http.ServeContent(w, r, fileName, info.ModTime(), file)
|
||||
// }
|
||||
// } else {
|
||||
// http.NotFound(w, r)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package hls
|
||||
|
||||
import "m7s.live/v5"
|
||||
|
||||
type HLSDevice = m7s.HTTPDevice
|
||||
@@ -11,9 +11,12 @@ import (
|
||||
"github.com/quangngotan95/go-m3u8/m3u8"
|
||||
)
|
||||
|
||||
var memoryM3u8 sync.Map
|
||||
var memoryTs sync.Map
|
||||
var MemoryM3u8 sync.Map
|
||||
var MemoryTs sync.Map
|
||||
|
||||
type TsCacher interface {
|
||||
GetTs(key string) (any, bool)
|
||||
}
|
||||
type M3u8Info struct {
|
||||
Req *http.Request
|
||||
M3U8Count int //一共拉取的m3u8文件数量
|
||||
|
||||
@@ -51,14 +51,14 @@ func (p *Puller) Start() (err error) {
|
||||
}
|
||||
p.PullJob.Publisher.Speed = 1
|
||||
if p.PullJob.PublishConfig.RelayMode != config.RelayModeRemux {
|
||||
p.memoryTs.Store(p.PullJob.StreamPath, p)
|
||||
MemoryTs.Store(p.PullJob.StreamPath, p)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Puller) Dispose() {
|
||||
if p.PullJob.PublishConfig.RelayMode == config.RelayModeRelay {
|
||||
memoryTs.Delete(p.PullJob.StreamPath)
|
||||
MemoryTs.Delete(p.PullJob.StreamPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ func (p *Puller) pull(info *M3u8Info) (err error) {
|
||||
if p.PullJob.PublishConfig.RelayMode != config.RelayModeRemux {
|
||||
m3u8 := string(plBuffer)
|
||||
p.Debug("write m3u8", "streamPath", p.PullJob.StreamPath, "m3u8", m3u8)
|
||||
memoryM3u8.Store(p.PullJob.StreamPath, m3u8)
|
||||
MemoryM3u8.Store(p.PullJob.StreamPath, m3u8)
|
||||
}
|
||||
} else {
|
||||
p.Error("readM3u8", "streamPath", p.PullJob.StreamPath, "err", err2)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
"m7s.live/v5/pkg/util"
|
||||
mpegts "m7s.live/v5/plugin/hls/pkg/ts"
|
||||
)
|
||||
|
||||
type TsInMemory struct {
|
||||
PMT util.Buffer
|
||||
util.RecyclableMemory
|
||||
}
|
||||
|
||||
func (ts *TsInMemory) WritePMTPacket(audio, video codec.FourCC) {
|
||||
ts.PMT.Reset()
|
||||
mpegts.WritePMTPacket(&ts.PMT, video, audio)
|
||||
}
|
||||
|
||||
func (ts *TsInMemory) WriteTo(w io.Writer) (int64, error) {
|
||||
w.Write(mpegts.DefaultPATPacket)
|
||||
w.Write(ts.PMT)
|
||||
cloneBuffers := slices.Clone(ts.Buffers)
|
||||
return cloneBuffers.WriteTo(w)
|
||||
}
|
||||
|
||||
func (ts *TsInMemory) WritePESPacket(frame *mpegts.MpegtsPESFrame, packet mpegts.MpegTsPESPacket) (err error) {
|
||||
if packet.Header.PacketStartCodePrefix != 0x000001 {
|
||||
err = errors.New("packetStartCodePrefix != 0x000001")
|
||||
return
|
||||
}
|
||||
var pesHeadItem util.Buffer = ts.GetAllocator().Borrow(32)
|
||||
pesHeadItem.Reset()
|
||||
_, err = mpegts.WritePESHeader(&pesHeadItem, packet.Header)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pesBuffers := append(net.Buffers{pesHeadItem}, packet.Buffers...)
|
||||
pesPktLength := int64(util.SizeOfBuffers(pesBuffers))
|
||||
var tsHeaderLength int
|
||||
for i := 0; pesPktLength > 0; i++ {
|
||||
var buffer util.Buffer = ts.NextN(mpegts.TS_PACKET_SIZE)
|
||||
bwTsHeader := &buffer
|
||||
bwTsHeader.Reset()
|
||||
tsHeader := mpegts.MpegTsHeader{
|
||||
SyncByte: 0x47,
|
||||
TransportErrorIndicator: 0,
|
||||
PayloadUnitStartIndicator: 0,
|
||||
TransportPriority: 0,
|
||||
Pid: frame.Pid,
|
||||
TransportScramblingControl: 0,
|
||||
AdaptionFieldControl: 1,
|
||||
ContinuityCounter: frame.ContinuityCounter,
|
||||
}
|
||||
|
||||
frame.ContinuityCounter++
|
||||
frame.ContinuityCounter = frame.ContinuityCounter % 16
|
||||
|
||||
// 每一帧的开头,当含有pcr的时候,包含调整字段
|
||||
if i == 0 {
|
||||
tsHeader.PayloadUnitStartIndicator = 1
|
||||
|
||||
// 当PCRFlag为1的时候,包含调整字段
|
||||
if frame.IsKeyFrame {
|
||||
tsHeader.AdaptionFieldControl = 0x03
|
||||
tsHeader.AdaptationFieldLength = 7
|
||||
tsHeader.PCRFlag = 1
|
||||
tsHeader.RandomAccessIndicator = 1
|
||||
tsHeader.ProgramClockReferenceBase = frame.ProgramClockReferenceBase
|
||||
}
|
||||
}
|
||||
|
||||
// 每一帧的结尾,当不满足188个字节的时候,包含调整字段
|
||||
if pesPktLength < mpegts.TS_PACKET_SIZE-4 {
|
||||
var tsStuffingLength uint8
|
||||
|
||||
tsHeader.AdaptionFieldControl = 0x03
|
||||
tsHeader.AdaptationFieldLength = uint8(mpegts.TS_PACKET_SIZE - 4 - 1 - pesPktLength)
|
||||
|
||||
// TODO:如果第一个TS包也是最后一个TS包,是不是需要考虑这个情况?
|
||||
// MpegTsHeader最少占6个字节.(前4个走字节 + AdaptationFieldLength(1 byte) + 3个指示符5个标志位(1 byte))
|
||||
if tsHeader.AdaptationFieldLength >= 1 {
|
||||
tsStuffingLength = tsHeader.AdaptationFieldLength - 1
|
||||
} else {
|
||||
tsStuffingLength = 0
|
||||
}
|
||||
// error
|
||||
tsHeaderLength, err = mpegts.WriteTsHeader(bwTsHeader, tsHeader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tsStuffingLength > 0 {
|
||||
if _, err = bwTsHeader.Write(mpegts.Stuffing[:tsStuffingLength]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
tsHeaderLength += int(tsStuffingLength)
|
||||
} else {
|
||||
|
||||
tsHeaderLength, err = mpegts.WriteTsHeader(bwTsHeader, tsHeader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tsPayloadLength := mpegts.TS_PACKET_SIZE - tsHeaderLength
|
||||
|
||||
//fmt.Println("tsPayloadLength :", tsPayloadLength)
|
||||
|
||||
// 这里不断的减少PES包
|
||||
written, _ := io.CopyN(bwTsHeader, &pesBuffers, int64(tsPayloadLength))
|
||||
// tmp := tsHeaderByte[3] << 2
|
||||
// tmp = tmp >> 6
|
||||
// if tmp == 2 {
|
||||
// fmt.Println("fuck you mother.")
|
||||
// }
|
||||
pesPktLength -= written
|
||||
tsPktByteLen := bwTsHeader.Len()
|
||||
|
||||
if tsPktByteLen != mpegts.TS_PACKET_SIZE {
|
||||
err = errors.New(fmt.Sprintf("%s, packet size=%d", "TS_PACKET_SIZE != 188,", tsPktByteLen))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TsInMemory) WriteAudioFrame(frame *pkg.ADTS, pes *mpegts.MpegtsPESFrame) (err error) {
|
||||
// packetLength = 原始音频流长度 + adts(7) + MpegTsOptionalPESHeader长度(8 bytes, 因为只含有pts)
|
||||
var packet mpegts.MpegTsPESPacket
|
||||
packet.Header.PesPacketLength = uint16(frame.Size + 8)
|
||||
packet.Buffers = slices.Clone(frame.Buffers)
|
||||
packet.Header.Pts = uint64(frame.DTS)
|
||||
packet.Header.PacketStartCodePrefix = 0x000001
|
||||
packet.Header.ConstTen = 0x80
|
||||
packet.Header.StreamID = mpegts.STREAM_ID_AUDIO
|
||||
pes.ProgramClockReferenceBase = packet.Header.Pts
|
||||
packet.Header.PtsDtsFlags = 0x80
|
||||
packet.Header.PesHeaderDataLength = 5
|
||||
return ts.WritePESPacket(pes, packet)
|
||||
}
|
||||
|
||||
func (ts *TsInMemory) WriteVideoFrame(frame *pkg.AnnexB, pes *mpegts.MpegtsPESFrame) (err error) {
|
||||
var buffer net.Buffers
|
||||
//需要对原始数据(ES),进行一些预处理,视频需要分割nalu(H264编码),并且打上sps,pps,nalu_aud信息.
|
||||
if frame.Hevc {
|
||||
buffer = append(buffer, codec.AudNalu)
|
||||
} else {
|
||||
buffer = append(buffer, codec.NALU_AUD_BYTE)
|
||||
}
|
||||
buffer = append(buffer, frame.Buffers...)
|
||||
pktLength := util.SizeOfBuffers(buffer) + 10 + 3
|
||||
if pktLength > 0xffff {
|
||||
pktLength = 0
|
||||
}
|
||||
|
||||
var packet mpegts.MpegTsPESPacket
|
||||
packet.Header.PacketStartCodePrefix = 0x000001
|
||||
packet.Header.ConstTen = 0x80
|
||||
packet.Header.StreamID = mpegts.STREAM_ID_VIDEO
|
||||
packet.Header.PesPacketLength = uint16(pktLength)
|
||||
packet.Header.Pts = uint64(frame.PTS)
|
||||
pes.ProgramClockReferenceBase = packet.Header.Pts
|
||||
packet.Header.Dts = uint64(frame.DTS)
|
||||
packet.Header.PtsDtsFlags = 0xC0
|
||||
packet.Header.PesHeaderDataLength = 10
|
||||
packet.Buffers = buffer
|
||||
return ts.WritePESPacket(pes, packet)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
"m7s.live/v5/pkg/util"
|
||||
mpegts "m7s.live/v5/plugin/hls/pkg/ts"
|
||||
)
|
||||
|
||||
func NewTransform() m7s.ITransformer {
|
||||
ret := &HLSWriter{
|
||||
Window: 3,
|
||||
Fragment: 5 * time.Second,
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type HLSWriter struct {
|
||||
m7s.DefaultTransformer
|
||||
Window int
|
||||
Fragment time.Duration
|
||||
M3u8 util.Buffer
|
||||
ts *TsInMemory
|
||||
pesAudio, pesVideo *mpegts.MpegtsPESFrame
|
||||
write_time time.Duration
|
||||
memoryTs sync.Map
|
||||
hls_segment_count uint32 // hls segment count
|
||||
playlist Playlist
|
||||
infoRing *ring.Ring
|
||||
hls_playlist_count uint32
|
||||
hls_segment_window uint32
|
||||
lastReadTime time.Time
|
||||
}
|
||||
|
||||
func (w *HLSWriter) Start() (err error) {
|
||||
return w.TransformJob.Subscribe()
|
||||
}
|
||||
|
||||
func (w *HLSWriter) GetTs(key string) (any, bool) {
|
||||
w.lastReadTime = time.Now()
|
||||
return w.memoryTs.Load(key)
|
||||
}
|
||||
|
||||
func (w *HLSWriter) Run() (err error) {
|
||||
if conf, ok := w.TransformJob.Config.Input.(string); ok {
|
||||
ss := strings.Split(conf, "x")
|
||||
if len(ss) != 2 {
|
||||
return fmt.Errorf("invalid input config %s", conf)
|
||||
}
|
||||
w.Fragment, err = time.ParseDuration(strings.TrimSpace(ss[0]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Window, err = strconv.Atoi(strings.TrimSpace(ss[1]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
subscriber := w.TransformJob.Subscriber
|
||||
w.hls_segment_window = uint32(w.Window) + 1
|
||||
w.infoRing = ring.New(w.Window + 1)
|
||||
w.playlist = Playlist{
|
||||
Writer: &w.M3u8,
|
||||
Version: 3,
|
||||
Sequence: 0,
|
||||
Targetduration: int(w.Fragment / time.Millisecond / 666), // hlsFragment * 1.5 / 1000
|
||||
}
|
||||
MemoryTs.Store(w.TransformJob.StreamPath, w)
|
||||
var audioCodec, videoCodec codec.FourCC
|
||||
if subscriber.Publisher.HasAudioTrack() {
|
||||
audioCodec = subscriber.Publisher.AudioTrack.FourCC()
|
||||
}
|
||||
if subscriber.Publisher.HasVideoTrack() {
|
||||
videoCodec = subscriber.Publisher.VideoTrack.FourCC()
|
||||
}
|
||||
w.ts = &TsInMemory{}
|
||||
w.pesAudio = &mpegts.MpegtsPESFrame{
|
||||
Pid: mpegts.PID_AUDIO,
|
||||
}
|
||||
w.pesVideo = &mpegts.MpegtsPESFrame{
|
||||
Pid: mpegts.PID_VIDEO,
|
||||
}
|
||||
w.ts.WritePMTPacket(audioCodec, videoCodec)
|
||||
return m7s.PlayBlock(subscriber, w.ProcessADTS, w.ProcessAnnexB)
|
||||
}
|
||||
|
||||
func (w *HLSWriter) ProcessADTS(audio *pkg.ADTS) (err error) {
|
||||
return w.ts.WriteAudioFrame(audio, w.pesAudio)
|
||||
}
|
||||
|
||||
func (w *HLSWriter) ProcessAnnexB(video *pkg.AnnexB) (err error) {
|
||||
if w.TransformJob.Subscriber.VideoReader.Value.IDR {
|
||||
if err = w.checkFragment(video.GetTimestamp()); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return w.ts.WriteVideoFrame(video, w.pesVideo)
|
||||
}
|
||||
|
||||
func (w *HLSWriter) checkFragment(ts time.Duration) (err error) {
|
||||
// 当前的时间戳减去上一个ts切片的时间戳
|
||||
if dur := ts - w.write_time; dur >= w.Fragment {
|
||||
streamPath := w.TransformJob.StreamPath
|
||||
ss := strings.Split(streamPath, "/")
|
||||
// fmt.Println("time :", video.Timestamp, tsSegmentTimestamp)
|
||||
if dur == ts && w.write_time == 0 { //时间戳不对的情况,首个默认为2s
|
||||
dur = time.Duration(2) * time.Second
|
||||
}
|
||||
num := w.hls_segment_count
|
||||
tsFilename := strconv.FormatInt(time.Now().Unix(), 10) + "_" + strconv.FormatUint(uint64(num), 10) + ".ts"
|
||||
tsFilePath := streamPath + "/" + tsFilename
|
||||
|
||||
// println(hls.currentTs.Length)
|
||||
|
||||
w.Debug("write ts", "tsFilePath", tsFilePath)
|
||||
w.memoryTs.Store(tsFilePath, w.ts)
|
||||
w.ts = &TsInMemory{
|
||||
PMT: w.ts.PMT,
|
||||
}
|
||||
if w.playlist.Targetduration < int(dur.Seconds()) {
|
||||
w.playlist.Targetduration = int(math.Ceil(dur.Seconds()))
|
||||
}
|
||||
if w.M3u8.Len() == 0 {
|
||||
w.playlist.Init()
|
||||
}
|
||||
inf := PlaylistInf{
|
||||
//浮点计算精度
|
||||
Duration: dur.Seconds(),
|
||||
Title: fmt.Sprintf("%s/%s", ss[len(ss)-1], tsFilename),
|
||||
FilePath: tsFilePath,
|
||||
}
|
||||
|
||||
if w.hls_segment_count > 0 {
|
||||
if w.hls_playlist_count >= uint32(w.Window) {
|
||||
w.M3u8.Reset()
|
||||
if err = w.playlist.Init(); err != nil {
|
||||
return
|
||||
}
|
||||
//playlist起点是ring.next,长度是len(ring)-1
|
||||
for p := w.infoRing.Next(); p != w.infoRing; p = p.Next() {
|
||||
w.playlist.WriteInf(p.Value.(PlaylistInf))
|
||||
}
|
||||
} else {
|
||||
if err = w.playlist.WriteInf(w.infoRing.Prev().Value.(PlaylistInf)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
MemoryM3u8.Store(w.TransformJob.StreamPath, string(w.M3u8))
|
||||
w.hls_playlist_count++
|
||||
}
|
||||
|
||||
if w.hls_segment_count >= w.hls_segment_window {
|
||||
if mts, loaded := w.memoryTs.LoadAndDelete(w.infoRing.Value.(PlaylistInf).FilePath); loaded {
|
||||
mts.(*TsInMemory).Recycle()
|
||||
}
|
||||
w.infoRing.Value = inf
|
||||
w.infoRing = w.infoRing.Next()
|
||||
} else {
|
||||
w.infoRing.Value = inf
|
||||
w.infoRing = w.infoRing.Next()
|
||||
}
|
||||
w.hls_segment_count++
|
||||
w.write_time = ts
|
||||
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (w *HLSWriter) Dispose() {
|
||||
MemoryM3u8.Delete(w.TransformJob.StreamPath)
|
||||
if ts, loaded := MemoryTs.LoadAndDelete(w.TransformJob.StreamPath); loaded {
|
||||
ts.(*HLSWriter).memoryTs.Range(func(key, value any) bool {
|
||||
value.(*TsInMemory).Recycle()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,14 +64,6 @@ func (s *Sender) sendADTS(audio *pkg.ADTS) (err error) {
|
||||
return s.sendAudio(packet)
|
||||
}
|
||||
|
||||
func (s *Sender) sendRawAudio(audio *pkg.RawAudio) (err error) {
|
||||
var packet mpegts.MpegTsPESPacket
|
||||
packet.Header.PesPacketLength = uint16(audio.Size + 8)
|
||||
packet.Buffers = audio.Buffers
|
||||
packet.Header.Pts = uint64(audio.Timestamp)
|
||||
return s.sendAudio(packet)
|
||||
}
|
||||
|
||||
func (s *Sender) sendVideo(video *pkg.AnnexB) (err error) {
|
||||
var buffer net.Buffers
|
||||
//需要对原始数据(ES),进行一些预处理,视频需要分割nalu(H264编码),并且打上sps,pps,nalu_aud信息.
|
||||
@@ -101,12 +93,7 @@ func (s *Sender) sendVideo(video *pkg.AnnexB) (err error) {
|
||||
}
|
||||
|
||||
func (s *Sender) Go() error {
|
||||
if s.Subscriber.Publisher.HasAudioTrack() {
|
||||
if s.Subscriber.Publisher.AudioTrack.FourCC() == codec.FourCC_MP4A {
|
||||
return m7s.PlayBlock(s.Subscriber, s.sendADTS, s.sendVideo)
|
||||
}
|
||||
}
|
||||
return m7s.PlayBlock(s.Subscriber, s.sendRawAudio, s.sendVideo)
|
||||
return m7s.PlayBlock(s.Subscriber, s.sendADTS, s.sendVideo)
|
||||
}
|
||||
|
||||
func (s *Sender) WritePESPacket(frame *mpegts.MpegtsPESFrame, pesPacket mpegts.MpegTsPESPacket) (err error) {
|
||||
|
||||
Reference in New Issue
Block a user