Last active
February 12, 2025 20:04
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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)">⏪︎ 5s</button> | |
<button onclick="seekOffset(-1)">⏪︎ 1s</button> | |
<button onclick="doPlay()">⏵︎</button> | |
<button onclick="doPause()">⏸︎</button> | |
<button onclick="seekOffset(1)">1s ⏩︎</button> | |
<button onclick="seekOffset(5)">5s ⏩︎</button> | |
<button onclick="seekOffsetEnd(3)">end-3s ⏩︎</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