Merge branch 'main' into v2

This commit is contained in:
Vanilagy
2026-07-07 21:00:13 +02:00
317 changed files with 55314 additions and 5009 deletions
+23 -6
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media player example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./media-player.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/media-player/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Media player example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta property="og:url" content="https://mediabunny.dev/examples/media-player/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Media player example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Media player"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2 h-svh">
@@ -31,13 +46,13 @@
</a>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<p class="text-xs opacity-60 mt-2 mx-auto text-center max-w-[36rem]" id="file-name"></p>
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="warning-element" class="text-amber-500 mb-1"></p>
<p id="loading-element" class="text-sm animate-pulse text-zinc-500" style="display: none;">Loading...</p>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></p>
<p id="warning-element" class="text-amber-500 mb-1 mx-auto text-center max-w-[36rem]"></p>
<p id="loading-element" class="text-sm animate-pulse text-zinc-500 mx-auto text-center max-w-[36rem]" style="display: none;">Loading...</p>
<div id="player" class="relative bg-black rounded-xl shrink min-h-14 min-w-0 w-full max-w-5xl overflow-hidden select-none" style="display: none;">
<canvas class="size-full object-contain" width="1280" height="720"></canvas>
@@ -78,6 +93,8 @@
<p id="duration" class="tabular-nums w-10 sm:w-24 text-sm"></p>
<button id="live-dot" title="Seek to live edge" style="display: none;" class="mx-2 size-3 rounded-full bg-red-500 cursor-pointer shrink-0 animate-pulse"></button>
<button class="p-2 group outline-none" id="fullscreen-button">
<div class="size-6 invert group-hover:scale-110">
<img src="../../docs/assets/fullscreen-icon.svg" class="size-full">
+102 -34
View File
@@ -29,6 +29,7 @@ const volumeBarContainer = document.querySelector('#volume-bar-container') as HT
const volumeBar = document.querySelector('#volume-bar') as HTMLDivElement;
const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTMLDivElement;
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
const liveDot = document.querySelector('#live-dot') as HTMLButtonElement;
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
@@ -42,7 +43,9 @@ let fileLoaded = false;
let videoCursor: VideoSampleCursor | null = null;
let audioCursor: AudioSampleCursor | null = null;
let totalDuration = 0;
let firstTimestamp = 0;
let endTimestamp = 0;
let isRelativeToUnixEpoch = false;
/** The value of the audio context's currentTime the moment the playback was started. */
let audioContextStartTime: number | null = null;
let playing = false;
@@ -51,6 +54,8 @@ let playbackTimeAtStart = 0;
const queuedAudioNodes: Set<AudioBufferSourceNode> = new Set();
let liveRefreshIntervalId = -1;
let draggingProgressBar = false;
let volume = 0.7;
let draggingVolumeBar = false;
@@ -76,27 +81,47 @@ const initMediaPlayer = async (resource: File | string) => {
playerContainer.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
liveDot.style.display = 'none';
clearTimeout(liveRefreshIntervalId);
// Create an Input from the resource
const source = resource instanceof File
? new BlobSource(resource)
: new UrlSource(resource);
const input = new Input({
source,
formats: ALL_FORMATS,
source: typeof resource === 'string'
? new UrlSource(resource)
: new BlobSource(resource),
formats: ALL_FORMATS, // Accept all formats
});
playbackTimeAtStart = 0;
totalDuration = await input.computeDuration();
durationElement.textContent = formatSeconds(totalDuration);
let videoTrack = await input.getPrimaryVideoTrack();
let audioTrack = await input.getPrimaryAudioTrack();
const tracks = [videoTrack, audioTrack].filter(t => t !== null);
firstTimestamp = Math.max(
await input.getFirstTimestamp(tracks),
0,
);
endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
?? await input.computeDuration(tracks, { skipLiveWait: true });
isRelativeToUnixEpoch = (await Promise.all(tracks.map(t => t.isRelativeToUnixEpoch()))).some(Boolean);
playbackTimeAtStart = firstTimestamp;
// Configure the time display elements accordingly
const timestampFontSize = isRelativeToUnixEpoch ? '12px' : '';
const timestampWhiteSpace = isRelativeToUnixEpoch ? 'pre' : '';
const timestampTextAlign = isRelativeToUnixEpoch ? 'center' : '';
currentTimeElement.style.fontSize = timestampFontSize;
currentTimeElement.style.whiteSpace = timestampWhiteSpace;
currentTimeElement.style.textAlign = timestampTextAlign;
durationElement.style.fontSize = timestampFontSize;
durationElement.style.whiteSpace = timestampWhiteSpace;
durationElement.style.textAlign = timestampTextAlign;
durationElement.textContent = formatTimestamp(endTimestamp);
let problemMessage = '';
if (videoTrack) {
if (videoTrack.codec === null) {
if (await videoTrack.getCodec() === null) {
problemMessage += 'Unsupported video codec. ';
videoTrack = null;
} else if (!(await videoTrack.canDecode())) {
@@ -106,7 +131,7 @@ const initMediaPlayer = async (resource: File | string) => {
}
if (audioTrack) {
if (audioTrack.codec === null) {
if (await audioTrack.getCodec() === null) {
problemMessage += 'Unsupported audio codec. ';
audioTrack = null;
} else if (!(await audioTrack.canDecode())) {
@@ -132,7 +157,7 @@ const initMediaPlayer = async (resource: File | string) => {
// We must create the audio context with the matching sample rate for correct acoustic results
// (especially for low-sample rate files)
audioContext = new AudioContext({ sampleRate: audioTrack?.sampleRate });
audioContext = new AudioContext({ sampleRate: await audioTrack?.getSampleRate() });
gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
updateVolume();
@@ -155,8 +180,8 @@ const initMediaPlayer = async (resource: File | string) => {
// Show the canvas if there's a video track, otherwise hide it
if (videoTrack) {
canvas.style.display = '';
canvas.width = videoTrack.displayWidth;
canvas.height = videoTrack.displayHeight;
canvas.width = await videoTrack.getDisplayWidth();
canvas.height = await videoTrack.getDisplayHeight();
} else {
canvas.style.display = 'none';
}
@@ -174,7 +199,7 @@ const initMediaPlayer = async (resource: File | string) => {
if (audioContext.state === 'running') {
// Start playback automatically if the audio context permits
await play();
// await play();
}
loadingElement.style.display = 'none';
@@ -186,6 +211,38 @@ const initMediaPlayer = async (resource: File | string) => {
controlsElement.style.pointerEvents = '';
playerContainer.style.cursor = '';
}
const refreshIntervals = await Promise.all(tracks.map(t => t.getLiveRefreshInterval()));
const nonNullIntervals = refreshIntervals.filter(x => x !== null);
if (nonNullIntervals.length > 0) {
// At least one track is live! This means that we'll need to continually refresh the end timestamp of the
// media to allow continuous live playback.
const interval = Math.min(...nonNullIntervals);
liveDot.style.display = '';
liveDot.onclick = () => {
void seekToTime(endTimestamp - interval * 1.5);
};
const scheduleLiveRefresh = () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
liveRefreshIntervalId = window.setTimeout(async () => {
endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
?? await input.computeDuration(tracks, { skipLiveWait: true });
durationElement.textContent = formatTimestamp(endTimestamp);
// Check if we're still live
const stillLive = await Promise.all(tracks.map(t => t.isLive()));
if (stillLive.every(live => !live)) {
liveDot.style.display = 'none';
} else {
scheduleLiveRefresh();
}
}, interval * 1000);
};
scheduleLiveRefresh();
}
} catch (error) {
console.error(error);
@@ -201,10 +258,10 @@ const initMediaPlayer = async (resource: File | string) => {
const render = (requestFrame = true) => {
if (fileLoaded) {
const playbackTime = getPlaybackTime();
if (playbackTime >= totalDuration) {
if (playbackTime >= endTimestamp) {
// Pause playback once the end is reached
pause();
playbackTimeAtStart = totalDuration;
playbackTimeAtStart = endTimestamp;
}
if (videoCursor) {
@@ -257,7 +314,9 @@ const runAudioIterator = async () => {
node.buffer = sample.toAudioBuffer();
node.connect(gainNode!);
const startTimestamp = audioContextStartTime! + sample.timestamp - playbackTimeAtStart;
let startTimestamp = audioContextStartTime! + sample.timestamp - playbackTimeAtStart;
// Round timestamp to the context's sample boundaries to prevent subsample audio glitches
startTimestamp = Math.round(audioContext!.sampleRate * startTimestamp) / audioContext!.sampleRate;
// Two cases: Either, the audio starts in the future or in the past
if (startTimestamp >= audioContext!.currentTime) {
@@ -311,10 +370,10 @@ const play = async () => {
await audioContext!.resume();
}
if (getPlaybackTime() === totalDuration) {
if (getPlaybackTime() === endTimestamp) {
// If we're at the end, let's snap back to the start
playbackTimeAtStart = 0;
await videoCursor?.seekTo(0);
playbackTimeAtStart = firstTimestamp;
await videoCursor?.seekTo(firstTimestamp);
}
audioContextStartTime = audioContext!.currentTime;
@@ -367,7 +426,7 @@ const seekToTime = async (seconds: number) => {
await videoCursor?.seekTo(seconds);
if (wasPlaying && playbackTimeAtStart < totalDuration) {
if (wasPlaying && playbackTimeAtStart < endTimestamp) {
void play();
}
};
@@ -375,8 +434,8 @@ const seekToTime = async (seconds: number) => {
/** === PROGRESS BAR LOGIC === */
const updateProgressBarTime = (seconds: number) => {
currentTimeElement.textContent = formatSeconds(seconds);
progressBar.style.width = `${(seconds / totalDuration) * 100}%`;
currentTimeElement.textContent = formatTimestamp(seconds);
progressBar.style.width = `${((seconds - firstTimestamp) / (endTimestamp - firstTimestamp)) * 100}%`;
};
progressBarContainer.addEventListener('pointerdown', (event) => {
@@ -385,7 +444,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
updateProgressBarTime(completion * totalDuration);
updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
clearTimeout(hideControlsTimeout);
@@ -395,7 +454,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
const newTime = completion * totalDuration;
const newTime = firstTimestamp + completion * (endTimestamp - firstTimestamp);
void seekToTime(newTime);
showControlsTemporarily();
@@ -406,7 +465,7 @@ progressBarContainer.addEventListener('pointermove', (event) => {
if (draggingProgressBar) {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
updateProgressBarTime(completion * totalDuration);
updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
}
});
@@ -523,10 +582,10 @@ window.addEventListener('keydown', (e) => {
} else if (e.code === 'KeyF') {
fullscreenButton.click();
} else if (e.code === 'ArrowLeft') {
const newTime = Math.max(getPlaybackTime() - 5, 0);
const newTime = Math.max(getPlaybackTime() - 5, firstTimestamp);
void seekToTime(newTime);
} else if (e.code === 'ArrowRight') {
const newTime = Math.min(getPlaybackTime() + 5, totalDuration);
const newTime = Math.min(getPlaybackTime() + 5, endTimestamp);
void seekToTime(newTime);
} else if (e.code === 'KeyM') {
volumeButton.click();
@@ -572,6 +631,15 @@ controlsElement.addEventListener('click', (event) => {
/** === UTILS === */
const formatTimestamp = (seconds: number) => {
if (isRelativeToUnixEpoch) {
const iso = new Date(seconds * 1000).toISOString();
return iso.replace('T', '\n');
}
return formatSeconds(seconds);
};
const formatSeconds = (seconds: number) => {
const showMilliseconds = window.innerWidth >= 640;
@@ -598,9 +666,9 @@ const formatSeconds = (seconds: number) => {
};
window.addEventListener('resize', () => {
if (totalDuration) {
if (endTimestamp) {
updateProgressBarTime(getPlaybackTime());
durationElement.textContent = formatSeconds(totalDuration);
durationElement.textContent = formatTimestamp(endTimestamp);
}
});
@@ -609,7 +677,7 @@ window.addEventListener('resize', () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -626,7 +694,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;