mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 05:13:50 +02:00
Merge branch 'main' into v2
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user