Skip to content

Instantly share code, notes, and snippets.

@ntrrgc
Last active February 12, 2025 20:04
Show Gist options
  • Save ntrrgc/14b97d190324dbc3038fb3c1bf9900e9 to your computer and use it in GitHub Desktop.
Save ntrrgc/14b97d190324dbc3038fb3c1bf9900e9 to your computer and use it in GitHub Desktop.
Simple page with an HTMLMediaElement, event log and basic controls. You can use this as a template to explore the behavior of HTMLMediaElement and look for media playback bugs in web engines.
<!DOCTYPE html>
<!--
Simple page with an HTMLMediaElement, event log and basic controls.
You can use this as a template to explore the behavior of HTMLMediaElement and
look for media playback bugs in web engines.
-->
<html>
<head>
<title>Simple media player</title>
<meta charset="utf-8">
<style>
#txtSrc, #spnCurTime, #spnDuration, #spnReadyState,
#spnPaused, #spnEnded, #spnSeeking, #spnError {
display: inline-block;
width: 300px;
border: 1px solid lavender;
box-sizing: border-box;
}
button {
min-width: 80px;
}
#transportButtonRow button {
height: 40px;
font-size: 120%;
margin-bottom: 5px;
}
#lstLogs {
border: 1px solid black;
overflow-y: scroll;
width: 800px;
height: 300px;
}
.uiLabel {
display: inline-block;
width: 130px;
}
#lstLogs {
list-style-type: none;
padding-left: 0;
margin-top: 0;
}
#controlPanel {
display: flex;
}
.rangesBox {
box-sizing: border-box;
border: 1px solid black;
margin-left: 5px;
}
</style>
</head>
<body>
<div>
<video id="media"></video>
</div>
<div id="transportButtonRow">
<!-- U+FE0E (Variation Selector-15) indicates the previous character
should render as non-emoji. However, as of 2025-02-12, that doesn't work
in Safari or WebKitGTK. -->
<button onclick="seekOffset(-5)">⏪&#xFE0E; 5s</button>
<button onclick="seekOffset(-1)">⏪&#xFE0E; 1s</button>
<button onclick="doPlay()">⏵&#xFE0E;</button>
<button onclick="doPause()">⏸&#xFE0E;</button>
<button onclick="seekOffset(1)">1s ⏩&#xFE0E;</button>
<button onclick="seekOffset(5)">5s ⏩&#xFE0E;</button>
<button onclick="seekOffsetEnd(3)">end-3s ⏩&#xFE0E;</button>
</div>
<div id="controlPanel">
<div>
<div>
<span class="uiLabel">URL:</span>
<input id="txtSrc" type="text" value="video.mp4">
<button onclick="setSrc()">Set</button>
</div>
<div>
<span class="uiLabel">Ready state:</span>
<span id="spnReadyState">(unset)</span>
<button onclick="updateReadyState()">Update</button>
</div>
<div>
<span class="uiLabel">Duration:</span>
<span id="spnDuration">(unset)</span>
<button onclick="updateDuration()">Update</button>
</div>
<div>
<span class="uiLabel">Current time:</span>
<span id="spnCurTime">(unset)</span>
<button onclick="updateCurTime()">Update</button>
</div>
<div>
<span class="uiLabel">Paused:</span>
<span id="spnPaused">(unset)</span>
<button onclick="updatePaused()">Update</button>
</div>
<div>
<span class="uiLabel">Seeking:</span>
<span id="spnSeeking">(unset)</span>
<button onclick="updateSeeking()">Update</button>
</div>
<div>
<span class="uiLabel">Ended:</span>
<span id="spnEnded">(unset)</span>
<button onclick="updateEnded()">Update</button>
</div>
<div>
<span class="uiLabel">Error:</span>
<span id="spnError">(unset)</span>
<button onclick="updateError()">Update</button>
</div>
</div>
<div class="rangesBox">
<div>
Seekable:
<button onclick="updateSeekable()">Update</button>
<label>
<input type="checkbox" id="chkAutoUpdateSeekable"
onchange="setAutoUpdateRanges(updateSeekable, this.checked)">
Auto
</label>
</div>
<ul id="lstSeekable"></ul>
</div>
<div class="rangesBox">
<div>
Buffered:
<button onclick="updateBuffered()">Update</button>
<label>
<input type="checkbox" id="chkAutoUpdateBuffered"
onchange="setAutoUpdateRanges(updateBuffered, this.checked)">
Auto
</label>
</div>
<ul id="lstBuffered"></ul>
</div>
</div>
<div>
Log (most recent events at the top):
<ol id="lstLogs" reversed>
</ol>
</div>
<script>
function ensureElementById(id) {
const element = document.getElementById(id);
if (!element)
throw new Error(`couldn't get element with id: ${id}`);
return element;
}
txtSrc = ensureElementById("txtSrc");
media = ensureElementById("media");
spnCurTime = ensureElementById("spnCurTime");
spnDuration = ensureElementById("spnDuration");
spnReadyState = ensureElementById("spnReadyState");
spnPaused = ensureElementById("spnPaused");
spnSeeking = ensureElementById("spnSeeking");
spnEnded = ensureElementById("spnEnded");
spnError = ensureElementById("spnError");
lstLogs = ensureElementById("lstLogs");
lstBuffered = ensureElementById("lstBuffered");
lstSeekable = ensureElementById("lstSeekable");
chkAutoUpdateBuffered = ensureElementById("chkAutoUpdateBuffered");
chkAutoUpdateSeekable = ensureElementById("chkAutoUpdateSeekable");
function readyStateToString(readyState) {
const states = ["HAVE_NOTHING", "HAVE_METADATA",
"HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
if (readyState >= states.length || readyState < 0)
return `Invalid: ${readyState}`;
return states[readyState];
}
function addLog(msg) {
const prefix = new Date().toISOString().split("T")[1] + ": ";
const text = prefix + msg;
console.log(text);
const li = document.createElement("li");
li.innerText = text;
lstLogs.insertBefore(li, lstLogs.firstChild);
}
function doPlay() {
addLog("Requested play()");
media.play();
}
function doPause() {
addLog("Requested pause()");
media.pause();
}
function clamp(val, min, max) {
if (min > max)
throw new Error(`Invalid clamp limits: min=${min}, max=${max}`);
if (val < min)
return min;
if (val > max)
return max;
return val;
}
function seekOffset(offset) {
const curTime = media.currentTime;
const duration = media.duration;
const unclamped = curTime + offset;
const clamped = clamp(unclamped, 0, duration);
addLog(`Seeking with offset ${offset}: time ${curTime} -> ${unclamped} (clamped: ${clamped})`);
media.currentTime = clamped;
}
function seekOffsetEnd(offset) {
if (offset < 0)
throw new Error("Invalid offset for seekOffsetEnd: ${offset}");
const duration = media.duration;
const newTime = Math.max(0, media.duration - offset);
addLog(`Seeking to ${newTime}`);
media.currentTime = newTime;
}
function updateCurTime() {
spnCurTime.innerText = media.currentTime.toString();
}
function updateDuration() {
spnDuration.innerText = media.duration.toString();
}
function updateReadyState() {
spnReadyState.innerText = readyStateToString(media.readyState);
}
function updatePaused() {
spnPaused.innerText = media.paused.toString();
}
function updateSeeking() {
spnSeeking.innerText = media.seeking.toString();
}
function updateEnded() {
spnEnded.innerText = media.ended.toString();
}
function mediaErrorToString(error) {
if (!error)
return "(No error)";
return `${error.code}: ${error.message}`;
}
function updateError() {
spnError.innerText = mediaErrorToString(media.error);
}
function setSrc() {
const src = txtSrc.value;
addLog(`Setting src property: ${src}`);
media.src = src;
}
// Ranges rendering.
// Sadly there is no API to get notified of changes to the buffered or
// seekable ranges, so most we can do is use an interval timer to poll
// them.
function fillRanges(ul, ranges) {
ul.innerHTML = "";
for (let i = 0; i < ranges.length; i++) {
const li = document.createElement("li");
li.innerText = `${ranges.start(i)} ... ${ranges.end(i)}`;
ul.appendChild(li);
}
}
const timersByCallback = {}; // updateFn -> intervalID from setInterval()
const RANGES_POLL_INTERVAL_MS = 1000;
function updateBuffered() {
fillRanges(lstBuffered, media.buffered);
}
function updateSeekable() {
fillRanges(lstSeekable, media.seekable);
}
function setAutoUpdateRanges(updateFn, shouldAutoUpdate) {
if (updateFn in timersByCallback && !shouldAutoUpdate) {
clearInterval(timersByCallback[updateFn]);
delete timersByCallback[updateFn];
} else if (!(updateFn in timersByCallback) && shouldAutoUpdate) {
updateFn();
timersByCallback[updateFn] = setInterval(updateFn, RANGES_POLL_INTERVAL_MS);
}
}
media.addEventListener("durationchange", (ev) => {
addLog(`Event: durationchange duration=${media.duration}`);
updateDuration();
});
media.addEventListener("timeupdate", (ev) => {
updateCurTime();
});
function updateAllLabels() {
updatePaused();
updateSeeking();
updateEnded();
updateReadyState();
updateDuration();
updateCurTime();
updateError();
}
function installMediaEventHandler(eventName, extraAction) {
media.addEventListener(eventName, (ev) => {
addLog(`Event: ${eventName}`);
if (extraAction)
extraAction();
})
}
const eventsThatUpdateAllLabels = ["abort", "canplay", "canplaythrough",
"emptied", "encrypted", "ended", "error", "loadeddata",
"loadedmetadata", "loadstart", "pause", "play", "playing",
"progress", "ratechange", "seeked", "seeking", "stalled", "suspend",
"volumechange", "waiting", "waitingforkey"
];
eventsThatUpdateAllLabels.forEach(eventName => {
installMediaEventHandler(eventName, updateAllLabels);
});
function strToBool(str) {
str = str.toLowerCase();
// Empty is allowed as true, so you can do ?muted instead of ?muted=1
return (str == "true" || str == "yes" || str == "on" || str == "1" || str == "");
}
// The following settings can be controlled by passing URL arguments:
// e.g. ?src=myvideo.webm&loop&volume=0.5
// Some of these allow to customize attributes of the media element that
// are not usually changed after load, and some are convenience for the
// sake of not having to use the UI on every page refresh.
const paramHandlers = {
src: function handleParamSrc(val) {
txtSrc.value = val;
},
muted: function handleParamMuted(val) {
val = strToBool(val);
// defaultMuted doesn't change muted, so we update both.
media.defaultMuted = val; // <video muted>
media.muted = val; // whether the video is currently muted.
},
loop: function handleParamLoop(val) {
media.loop = strToBool(val);
},
autoplay: function handleParamAutoplay(val) {
media.autoplay = strToBool(val);
},
controls: function handleParamControls(val) {
media.controls = strToBool(val);
},
volume: function handleParamVolume(val) {
const valFloat = parseFloat(val);
if (isNaN(valFloat)) {
addLog(`WARNING: volume setting got an invalid value, not setting volume: ${val}`);
return;
}
media.volume = valFloat;
},
earlySeek: function handleParamEarlySeek(val) {
const targetPos = parseFloat(val);
if (isNaN(targetPos))
return; // Allows to pass a non-number as value to disable the setting, e.g. ?earlySeek=disable
media.addEventListener("loadedmetadata", (ev) => {
addLog(`Doing early seek to time ${targetPos}`);
media.currentTime = targetPos;
}, {once: true});
},
autoUpdateBuffered: function handleParamAutoUpdateBuffered(val) {
chkAutoUpdateBuffered.checked = strToBool(val);
setAutoUpdateRanges(updateBuffered, chkAutoUpdateBuffered.checked);
},
autoUpdateSeekable: function handleParamAutoUpdateSeekable(val) {
chkAutoUpdateSeekable.checked = strToBool(val);
setAutoUpdateRanges(updateSeekable, chkAutoUpdateSeekable.checked);
},
};
// Save the setting values preserving the last ones if keys are repeated.
const settings = {
autoUpdateBuffered: "true",
autoUpdateSeekable: "true",
};
for (const [key, val] of new URLSearchParams(location.search)) {
if (!(key in paramHandlers)) {
addLog(`WARNING: Received unsupported setting: ${key}=${val}`);
continue;
}
settings[key] = val;
}
// Apply the settings found in the URL.
for (const key in settings) {
const val = settings[key];
paramHandlers[key](val);
}
// Load whatever media URL is in the txtSrc text input, which may have
// been updated by the settings above.
setSrc();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment