Add media player example
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 7V5a2 2 0 0 1 2-2h2m10 0h2a2 2 0 0 1 2 2v2m0 10v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><rect width="10" height="8" x="7" y="8" rx="1"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 3l14 9l-14 9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/></svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6"/></svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6m3.364 3.364a9 9 0 0 0 0-12.728"/></svg>
|
||||||
|
After Width: | Height: | Size: 409 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 9a5 5 0 0 1 .95 2.293m2.414-5.657a9 9 0 0 1 1.889 9.96M2 2l20 20M7 7l-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11M9.828 4.172A.686.686 0 0 1 11 4.657v.686"/></svg>
|
||||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM22 9l-6 6m0-6l6 6"/></svg>
|
||||||
|
After Width: | Height: | Size: 377 B |
@@ -7,15 +7,17 @@ hero:
|
|||||||
|
|
||||||
features:
|
features:
|
||||||
- title: Metadata extraction
|
- title: Metadata extraction
|
||||||
details: Extract various metadata from an input media file
|
details: Extract various metadata from an input media file.
|
||||||
link: /examples/metadata-extraction
|
link: /examples/metadata-extraction
|
||||||
target: _self
|
target: _self
|
||||||
- title: Thumbnail generation
|
- title: Thumbnail generation
|
||||||
details: Generate multiple small thumbnails for a video track
|
details: Generate multiple small thumbnails for a video track.
|
||||||
link: /examples/thumbnail-generation
|
link: /examples/thumbnail-generation
|
||||||
target: _self
|
target: _self
|
||||||
- title: Feature C
|
- title: Media player (advanced)
|
||||||
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
|
details: "A full video & audio media player, implemented from scratch with Mediakit, with microsecond playback accuracy."
|
||||||
|
link: /examples/media-player
|
||||||
|
target: _self
|
||||||
- title: Feature A
|
- title: Feature A
|
||||||
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
|
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
|
||||||
- title: Feature B
|
- title: Feature B
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ things the guide needs to cover:
|
|||||||
Examples:
|
Examples:
|
||||||
✅ Extracting media file metadata
|
✅ Extracting media file metadata
|
||||||
✅ Rendering thumbnails for a video / extracting a wave form?
|
✅ Rendering thumbnails for a video / extracting a wave form?
|
||||||
- Media player
|
✅ Media player
|
||||||
- Conversion demo: File compressor
|
- Conversion demo: File compressor
|
||||||
- Procedurally generated video (faster than real time)
|
- Procedurally generated video (faster than real time)
|
||||||
- Video generated from live sources
|
- Video generated from live sources
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" translate="no">
|
||||||
|
<head>
|
||||||
|
<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 | Mediakit</title>
|
||||||
|
<script type="module" src="../base.ts"></script>
|
||||||
|
<script type="module" src="./media-player.ts"></script>
|
||||||
|
<link rel="stylesheet" href="../base.css">
|
||||||
|
</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">
|
||||||
|
<h1 class="text-3xl font-medium text-purple-800 dark:text-purple-200">Media player example</h1>
|
||||||
|
<p>Select or drop a media file, and a fully custom, Mediakit-powered player will appear.</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-1">
|
||||||
|
Select media file
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="../../assets/big-buck-bunny-trimmed.mp4" download class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 group grid place-items-center" title="Download sample file">
|
||||||
|
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80 dark:invert">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
|
||||||
|
|
||||||
|
<hr class="w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
|
||||||
|
|
||||||
|
<p id="error-element" class="text-red-500"></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>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900/50 absolute left-0 right-0 bottom-0 h-10 items-center flex m-2 rounded-lg text-white transition-opacity" id="controls" style="opacity: 0;">
|
||||||
|
<button class="p-2 group outline-none" id="play-button">
|
||||||
|
<div class="size-6 invert group-hover:scale-110">
|
||||||
|
<img src="../../assets/play-icon.svg" class="size-full" id="play-icon">
|
||||||
|
<img src="../../assets/pause-icon.svg" class="size-full" id="pause-icon" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="p-2 ml-4 mr-1" id="volume-button">
|
||||||
|
<div class="size-6 invert group-hover:scale-110" id="volume-icon-wrapper">
|
||||||
|
<img src="../../assets/volume-off-icon.svg" class="size-full">
|
||||||
|
<img src="../../assets/volume-x-icon.svg" class="size-full">
|
||||||
|
<img src="../../assets/volume-0-icon.svg" class="size-full">
|
||||||
|
<img src="../../assets/volume-1-icon.svg" class="size-full">
|
||||||
|
<img src="../../assets/volume-2-icon.svg" class="size-full">
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-16 mr-2 relative rounded-full bg-zinc-800/75 h-1.5 group" id="volume-bar-container">
|
||||||
|
<div class="absolute h-full top-0 left-0 rounded-full bg-zinc-200 group-hover:bg-white" id="volume-bar">
|
||||||
|
<div class="size-3 rounded-full bg-zinc-200 absolute top-1/2 right-0 -translate-y-1/2 translate-x-1/2 group-hover:scale-110 group-hover:bg-white group-hover:shadow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="current-time" class="tabular-nums w-24 text-right text-sm"></p>
|
||||||
|
|
||||||
|
<div class="flex-1 relative rounded-full bg-zinc-800/75 h-2 mx-4 group" id="progress-bar-container">
|
||||||
|
<div class="absolute h-full top-0 left-0 rounded-full bg-zinc-200 group-hover:bg-white" id="progress-bar">
|
||||||
|
<div class="size-4 rounded-full bg-zinc-200 absolute top-1/2 right-0 -translate-y-1/2 translate-x-1/2 group-hover:scale-110 group-hover:bg-white group-hover:shadow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="duration" class="tabular-nums w-24 text-sm"></p>
|
||||||
|
|
||||||
|
<button class="p-2 group outline-none" id="fullscreen-button">
|
||||||
|
<div class="size-6 invert group-hover:scale-110">
|
||||||
|
<img src="../../assets/fullscreen-icon.svg" class="size-full">
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed top-0 left-0 flex gap-2 py-2 px-5 items-center">
|
||||||
|
<img src="../../assets/mediakit-logo.svg" class="size-6 dark:invert">
|
||||||
|
<p class="text-sm font-semibold">Mediakit</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/Vanilagy/metamuxer/tree/main/examples/media-player"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-2 fixed top-0 right-0 py-2 px-5 bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-bl-xl"
|
||||||
|
>
|
||||||
|
<img src="../../assets/github-mark.svg" class="size-6 dark:invert">
|
||||||
|
<p>View source code</p>
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import {
|
||||||
|
ALL_FORMATS,
|
||||||
|
AudioBufferSink,
|
||||||
|
BlobSource,
|
||||||
|
CanvasSink,
|
||||||
|
Input,
|
||||||
|
WrappedAudioBuffer,
|
||||||
|
WrappedCanvas,
|
||||||
|
} from 'mediakit';
|
||||||
|
|
||||||
|
const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
|
||||||
|
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
|
||||||
|
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
|
||||||
|
const playerContainer = document.querySelector('#player') as HTMLDivElement;
|
||||||
|
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||||
|
const controlsElement = document.querySelector('#controls') as HTMLDivElement;
|
||||||
|
const playButton = document.querySelector('#play-button') as HTMLButtonElement;
|
||||||
|
const playIcon = document.querySelector('#play-icon') as HTMLSpanElement;
|
||||||
|
const pauseIcon = document.querySelector('#pause-icon') as HTMLSpanElement;
|
||||||
|
const currentTimeElement = document.querySelector('#current-time') as HTMLSpanElement;
|
||||||
|
const durationElement = document.querySelector('#duration') as HTMLSpanElement;
|
||||||
|
const progressBarContainer = document.querySelector('#progress-bar-container') as HTMLDivElement;
|
||||||
|
const progressBar = document.querySelector('#progress-bar') as HTMLDivElement;
|
||||||
|
const volumeBarContainer = document.querySelector('#volume-bar-container') as HTMLDivElement;
|
||||||
|
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 fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
|
||||||
|
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')!;
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
let fileLoaded = false;
|
||||||
|
let videoSink: CanvasSink | null = null;
|
||||||
|
let audioSink: AudioBufferSink | null = null;
|
||||||
|
|
||||||
|
let totalDuration = 0;
|
||||||
|
/** The value of the audio context's currentTime the moment the playback was started. */
|
||||||
|
let audioContextStartTime: number | null = null;
|
||||||
|
let playing = false;
|
||||||
|
/** The timestamp within the media file when the playback was started. */
|
||||||
|
let playbackTimeAtStart = 0;
|
||||||
|
|
||||||
|
let videoFrameIterator: AsyncGenerator<WrappedCanvas, void, unknown> | null = null;
|
||||||
|
let audioBufferIterator: AsyncGenerator<WrappedAudioBuffer, void, unknown> | null = null;
|
||||||
|
let nextFrame: WrappedCanvas | null = null;
|
||||||
|
const queuedAudioNodes: Set<AudioBufferSourceNode> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to prevent async race conditions. When seekId is incremented, already-running async functions will be prevented
|
||||||
|
* from having an effect.
|
||||||
|
*/
|
||||||
|
let asyncId = 0;
|
||||||
|
|
||||||
|
let draggingProgressBar = false;
|
||||||
|
let volume = 0.7;
|
||||||
|
let draggingVolumeBar = false;
|
||||||
|
let volumeMuted = false;
|
||||||
|
|
||||||
|
/** === INIT LOGIC === */
|
||||||
|
|
||||||
|
const initMediaPlayer = async (file: File) => {
|
||||||
|
try {
|
||||||
|
// First, dispose any ongoing playback:
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
void videoFrameIterator?.return();
|
||||||
|
void audioBufferIterator?.return();
|
||||||
|
asyncId++;
|
||||||
|
|
||||||
|
fileLoaded = false;
|
||||||
|
fileNameElement.textContent = file.name;
|
||||||
|
horizontalRule.style.display = '';
|
||||||
|
playerContainer.style.display = 'none';
|
||||||
|
|
||||||
|
if (audioContext.state === 'suspended') {
|
||||||
|
// Resume the audio context now that a user gesture has happened
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an Input from the file
|
||||||
|
const input = new Input({
|
||||||
|
source: new BlobSource(file),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
playbackTimeAtStart = 0;
|
||||||
|
totalDuration = await input.computeDuration();
|
||||||
|
durationElement.textContent = formatSeconds(totalDuration);
|
||||||
|
|
||||||
|
let videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
let audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
|
||||||
|
if (!(await videoTrack?.canDecode())) {
|
||||||
|
// We can't decode the video track, so treat it like there is no video track
|
||||||
|
videoTrack = null;
|
||||||
|
}
|
||||||
|
if (!(await audioTrack?.canDecode())) {
|
||||||
|
// We can't decode the audio track, so treat it like there is no audio track
|
||||||
|
audioTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoTrack && !audioTrack) {
|
||||||
|
throw new Error('Media file has no playable video or audio track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For video, let's use a CanvasSink as it handles rotation and closing video samples for us.
|
||||||
|
// Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases.
|
||||||
|
videoSink = videoTrack && new CanvasSink(videoTrack, { poolSize: 2 });
|
||||||
|
// For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API
|
||||||
|
audioSink = audioTrack && new AudioBufferSink(audioTrack);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
} else {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show volume controls if there's an audio track, otherwise hide them
|
||||||
|
if (audioTrack) {
|
||||||
|
volumeButton.style.display = '';
|
||||||
|
volumeBarContainer.style.display = '';
|
||||||
|
} else {
|
||||||
|
volumeButton.style.display = 'none';
|
||||||
|
volumeBarContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLoaded = true;
|
||||||
|
|
||||||
|
await startVideoIterator();
|
||||||
|
await play();
|
||||||
|
|
||||||
|
playerContainer.style.display = '';
|
||||||
|
|
||||||
|
if (!videoSink) {
|
||||||
|
// If there's only an audio track, always show the controls
|
||||||
|
controlsElement.style.opacity = '1';
|
||||||
|
playerContainer.style.cursor = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorElement.textContent = String(e);
|
||||||
|
playerContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** === VIDEO RENDERING LOGIC === */
|
||||||
|
|
||||||
|
/** Creates a new video frame iterator and renders the first video frame. */
|
||||||
|
const startVideoIterator = async () => {
|
||||||
|
if (!videoSink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncId++;
|
||||||
|
|
||||||
|
await videoFrameIterator?.return(); // Dispose of the current iterator
|
||||||
|
|
||||||
|
// Create a new iterator
|
||||||
|
videoFrameIterator = videoSink.canvases(getPlaybackTime());
|
||||||
|
|
||||||
|
// Get the first two frames
|
||||||
|
const firstFrame = (await videoFrameIterator.next()).value ?? null;
|
||||||
|
const secondFrame = (await videoFrameIterator.next()).value ?? null;
|
||||||
|
|
||||||
|
nextFrame = secondFrame;
|
||||||
|
|
||||||
|
if (firstFrame) {
|
||||||
|
// Draw the first frame
|
||||||
|
context.drawImage(firstFrame.canvas, 0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs every frame; updates the canvas if necessary. */
|
||||||
|
const render = (requestFrame = true) => {
|
||||||
|
if (fileLoaded) {
|
||||||
|
const playbackTime = getPlaybackTime();
|
||||||
|
if (playbackTime >= totalDuration) {
|
||||||
|
// Pause playback once the end is reached
|
||||||
|
pause();
|
||||||
|
playbackTimeAtStart = totalDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the current playback time has caught up to the next frame
|
||||||
|
if (nextFrame && nextFrame.timestamp <= playbackTime) {
|
||||||
|
context.drawImage(nextFrame.canvas, 0, 0);
|
||||||
|
nextFrame = null;
|
||||||
|
|
||||||
|
// Request the next frame
|
||||||
|
void updateNextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draggingProgressBar) {
|
||||||
|
updateProgressBarTime(playbackTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestFrame) {
|
||||||
|
requestAnimationFrame(() => render());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Also call the render function on an interval to make sure the video keeps updating even if the tab isn't visible
|
||||||
|
setInterval(() => render(false), 500);
|
||||||
|
|
||||||
|
/** Iterates over the video frame iterator until it finds a video frame in the future. */
|
||||||
|
const updateNextFrame = async () => {
|
||||||
|
const currentAsyncId = asyncId;
|
||||||
|
|
||||||
|
// We have a loop here because we may need to iterate over multiple frames until we reach a frame in the future
|
||||||
|
while (true) {
|
||||||
|
const newNextFrame = (await videoFrameIterator!.next()).value ?? null;
|
||||||
|
if (!newNextFrame) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAsyncId !== asyncId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackTime = getPlaybackTime();
|
||||||
|
if (newNextFrame.timestamp <= playbackTime) {
|
||||||
|
// Draw it immediately
|
||||||
|
context.drawImage(newNextFrame.canvas, 0, 0);
|
||||||
|
} else {
|
||||||
|
// Save it for later
|
||||||
|
nextFrame = newNextFrame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** === AUDIO PLAYBACK LOGIC === */
|
||||||
|
|
||||||
|
/** Loops over the audio buffer iterator, scheduling the audio to be played in the audio context. */
|
||||||
|
const runAudioIterator = async () => {
|
||||||
|
if (!audioSink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const { buffer, timestamp } of audioBufferIterator!) {
|
||||||
|
const node = audioContext.createBufferSource();
|
||||||
|
node.buffer = buffer;
|
||||||
|
node.connect(gainNode);
|
||||||
|
|
||||||
|
const startTimestamp = audioContextStartTime! + timestamp - playbackTimeAtStart;
|
||||||
|
|
||||||
|
// Two cases: Either, the audio starts in the future or in the past
|
||||||
|
if (startTimestamp >= audioContext.currentTime) {
|
||||||
|
// If the audio starts in the future, easy, we just schedule it
|
||||||
|
node.start(startTimestamp);
|
||||||
|
} else {
|
||||||
|
// If it starts in the past, then let's only play the audible section that remains from here on out
|
||||||
|
node.start(audioContext.currentTime, audioContext.currentTime - startTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedAudioNodes.add(node);
|
||||||
|
node.onended = () => {
|
||||||
|
queuedAudioNodes.delete(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we're more than a second ahead of the current playback time, let's slow down the loop until time has
|
||||||
|
// passed.
|
||||||
|
if (timestamp - getPlaybackTime() >= 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (timestamp - getPlaybackTime() < 1) {
|
||||||
|
clearInterval(id);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** === PLAYBACK CONTROL LOGIC === */
|
||||||
|
|
||||||
|
/** Returns the current playback time in the media file. */
|
||||||
|
const getPlaybackTime = () => {
|
||||||
|
if (playing) {
|
||||||
|
// To ensure perfect audio-video sync, we always use the audio context's clock to determine playback time, even
|
||||||
|
// when there is no audio track.
|
||||||
|
return audioContext.currentTime - audioContextStartTime! + playbackTimeAtStart;
|
||||||
|
} else {
|
||||||
|
return playbackTimeAtStart;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = async () => {
|
||||||
|
if (getPlaybackTime() === totalDuration) {
|
||||||
|
// If we're at the end, let's snap back to the start
|
||||||
|
playbackTimeAtStart = 0;
|
||||||
|
await startVideoIterator();
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContextStartTime = audioContext.currentTime;
|
||||||
|
playing = true;
|
||||||
|
|
||||||
|
if (audioSink) {
|
||||||
|
// Start the audio iterator
|
||||||
|
void audioBufferIterator?.return();
|
||||||
|
audioBufferIterator = audioSink?.buffers(getPlaybackTime());
|
||||||
|
void runAudioIterator();
|
||||||
|
}
|
||||||
|
|
||||||
|
playIcon.style.display = 'none';
|
||||||
|
pauseIcon.style.display = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
playbackTimeAtStart = getPlaybackTime();
|
||||||
|
playing = false;
|
||||||
|
void audioBufferIterator?.return(); // This stops any for-loops that are iterating the iterator
|
||||||
|
audioBufferIterator = null;
|
||||||
|
|
||||||
|
// Stop all audio nodes that were already queued to play
|
||||||
|
for (const node of queuedAudioNodes) {
|
||||||
|
node.stop();
|
||||||
|
}
|
||||||
|
queuedAudioNodes.clear();
|
||||||
|
|
||||||
|
playIcon.style.display = '';
|
||||||
|
pauseIcon.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (playing) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
void play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekToTime = async (seconds: number) => {
|
||||||
|
updateProgressBarTime(seconds);
|
||||||
|
|
||||||
|
const wasPlaying = playing;
|
||||||
|
|
||||||
|
if (wasPlaying) {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackTimeAtStart = seconds;
|
||||||
|
|
||||||
|
await startVideoIterator();
|
||||||
|
|
||||||
|
if (wasPlaying && playbackTimeAtStart < totalDuration) {
|
||||||
|
void play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** === PROGRESS BAR LOGIC === */
|
||||||
|
|
||||||
|
const updateProgressBarTime = (seconds: number) => {
|
||||||
|
currentTimeElement.textContent = formatSeconds(seconds);
|
||||||
|
progressBar.style.width = `${(seconds / totalDuration) * 100}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
progressBarContainer.addEventListener('pointerdown', (event) => {
|
||||||
|
draggingProgressBar = true;
|
||||||
|
|
||||||
|
const rect = progressBarContainer.getBoundingClientRect();
|
||||||
|
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
|
||||||
|
updateProgressBarTime(completion * totalDuration);
|
||||||
|
|
||||||
|
window.addEventListener('pointerup', (event) => {
|
||||||
|
draggingProgressBar = false;
|
||||||
|
|
||||||
|
const rect = progressBarContainer.getBoundingClientRect();
|
||||||
|
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
|
||||||
|
const newTime = completion * totalDuration;
|
||||||
|
|
||||||
|
void seekToTime(newTime);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** === VOLUME CONTROL LOGIC === */
|
||||||
|
|
||||||
|
const updateVolume = () => {
|
||||||
|
const actualVolume = volumeMuted ? 0 : volume;
|
||||||
|
|
||||||
|
volumeBar.style.width = `${actualVolume * 100}%`;
|
||||||
|
gainNode.gain.value = actualVolume ** 2; // Quadratic for more fine-grained control
|
||||||
|
|
||||||
|
const iconNumber = volumeMuted ? 0 : Math.ceil(1 + 3 * volume);
|
||||||
|
for (let i = 0; i < volumeIconWrapper.children.length; i++) {
|
||||||
|
const icon = volumeIconWrapper.children[i] as HTMLImageElement;
|
||||||
|
icon.style.display = i === iconNumber ? '' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateVolume();
|
||||||
|
|
||||||
|
volumeBarContainer.addEventListener('pointerdown', (event) => {
|
||||||
|
draggingVolumeBar = true;
|
||||||
|
|
||||||
|
const rect = volumeBarContainer.getBoundingClientRect();
|
||||||
|
volume = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
|
||||||
|
volumeMuted = false;
|
||||||
|
updateVolume();
|
||||||
|
|
||||||
|
window.addEventListener('pointerup', (event) => {
|
||||||
|
draggingVolumeBar = false;
|
||||||
|
|
||||||
|
const rect = volumeBarContainer.getBoundingClientRect();
|
||||||
|
volume = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
|
||||||
|
updateVolume();
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
volumeButton.addEventListener('click', () => {
|
||||||
|
volumeMuted = !volumeMuted;
|
||||||
|
updateVolume();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', (event) => {
|
||||||
|
if (draggingVolumeBar) {
|
||||||
|
const rect = volumeBarContainer.getBoundingClientRect();
|
||||||
|
volume = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
|
||||||
|
updateVolume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** === CONTROL UI LOGIC === */
|
||||||
|
|
||||||
|
const showControlsTemporarily = () => {
|
||||||
|
if (!videoSink) {
|
||||||
|
// Shouldn't run if there's only an audio track
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsElement.style.opacity = '1';
|
||||||
|
playerContainer.style.cursor = '';
|
||||||
|
|
||||||
|
clearTimeout(hideControlsTimeout);
|
||||||
|
hideControlsTimeout = window.setTimeout(() => {
|
||||||
|
controlsElement.style.opacity = '0';
|
||||||
|
playerContainer.style.cursor = 'none';
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
let hideControlsTimeout = -1;
|
||||||
|
playerContainer.addEventListener('pointermove', () => {
|
||||||
|
showControlsTemporarily();
|
||||||
|
});
|
||||||
|
playerContainer.addEventListener('pointerleave', () => {
|
||||||
|
if (!videoSink) {
|
||||||
|
// Shouldn't run if there's only an audio track
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsElement.style.opacity = '0';
|
||||||
|
clearTimeout(hideControlsTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** === EVENT LISTENERS === */
|
||||||
|
|
||||||
|
playButton.addEventListener('click', togglePlay);
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!fileLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.code === 'Space' || e.code === 'KeyK') {
|
||||||
|
togglePlay();
|
||||||
|
} else if (e.code === 'KeyF') {
|
||||||
|
fullscreenButton.click();
|
||||||
|
} else if (e.code === 'ArrowLeft') {
|
||||||
|
const newTime = Math.max(getPlaybackTime() - 5, 0);
|
||||||
|
void seekToTime(newTime);
|
||||||
|
} else if (e.code === 'ArrowRight') {
|
||||||
|
const newTime = Math.min(getPlaybackTime() + 5, totalDuration);
|
||||||
|
void seekToTime(newTime);
|
||||||
|
} else if (e.code === 'KeyM') {
|
||||||
|
volumeButton.click();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showControlsTemporarily();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenButton.addEventListener('click', () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
playerContainer.requestFullscreen().catch((e) => {
|
||||||
|
console.error('Failed to enter fullscreen mode:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerContainer.addEventListener('pointerdown', () => {
|
||||||
|
togglePlay();
|
||||||
|
});
|
||||||
|
controlsElement.addEventListener('pointerdown', (event) => {
|
||||||
|
// Make sure this does NOT toggle play
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** === UTILS === */
|
||||||
|
|
||||||
|
const formatSeconds = (seconds: number) => {
|
||||||
|
seconds = Math.round(seconds * 1000) / 1000; // Round to milliseconds
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
const millisecs = Math.floor(1000 * seconds % 1000).toString().padStart(3, '0');
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}`
|
||||||
|
+ `:${remainingSeconds.toString().padStart(2, '0')}.${millisecs}`;
|
||||||
|
}
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${millisecs}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** === FILE SELECTION LOGIC === */
|
||||||
|
|
||||||
|
selectMediaButton.addEventListener('click', () => {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void initMediaPlayer(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer!.dropEffect = 'copy';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('drop', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
const file = files && files.length > 0 ? files[0] : undefined;
|
||||||
|
if (file) {
|
||||||
|
void initMediaPlayer(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80 dark:invert">
|
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80 dark:invert">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs opacity-60 mt-0.5" id="file-name"></p>
|
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
|
||||||
|
|
||||||
<hr class="w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
|
<hr class="w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Input, ALL_FORMATS, BlobSource } from 'mediakit';
|
import { Input, ALL_FORMATS, BlobSource } from 'mediakit';
|
||||||
|
|
||||||
const selectMediaButton = document.querySelector('button')!;
|
const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
|
||||||
const fileNameElement = document.querySelector('#file-name')!;
|
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
|
||||||
const horizontalRule = document.querySelector('hr')!;
|
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
|
||||||
const bytesReadElement = document.querySelector('#bytes-read')!;
|
const bytesReadElement = document.querySelector('#bytes-read') as HTMLParagraphElement;
|
||||||
const metadataContainer = document.querySelector('#metadata-container')!;
|
const metadataContainer = document.querySelector('#metadata-container') as HTMLDivElement;
|
||||||
|
|
||||||
const extractMetadata = (file: File) => {
|
const extractMetadata = (file: File) => {
|
||||||
// Create a new input from the file
|
// Create a new input from the file
|
||||||
@@ -84,35 +84,6 @@ const extractMetadata = (file: File) => {
|
|||||||
metadataContainer.append(bytesReadElement, htmlElement);
|
metadataContainer.append(bytesReadElement, htmlElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
selectMediaButton.addEventListener('click', () => {
|
|
||||||
const fileInput = document.createElement('input');
|
|
||||||
fileInput.type = 'file';
|
|
||||||
fileInput.addEventListener('change', () => {
|
|
||||||
const file = fileInput.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
extractMetadata(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('dragover', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer!.dropEffect = 'copy';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('drop', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const files = event.dataTransfer?.files;
|
|
||||||
const file = files && files.length > 0 ? files[0] : undefined;
|
|
||||||
if (file) {
|
|
||||||
extractMetadata(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Creates an HTML element to display any given value
|
// Creates an HTML element to display any given value
|
||||||
const renderValue = (value: unknown) => {
|
const renderValue = (value: unknown) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -175,3 +146,34 @@ const renderObject = (object: Record<string, unknown>) => {
|
|||||||
const shortDelay = () => {
|
const shortDelay = () => {
|
||||||
return new Promise(resolve => setTimeout(resolve, 1000 / 60));
|
return new Promise(resolve => setTimeout(resolve, 1000 / 60));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** === FILE SELECTION LOGIC === */
|
||||||
|
|
||||||
|
selectMediaButton.addEventListener('click', () => {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractMetadata(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer!.dropEffect = 'copy';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('drop', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
const file = files && files.length > 0 ? files[0] : undefined;
|
||||||
|
if (file) {
|
||||||
|
extractMetadata(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80 dark:invert">
|
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80 dark:invert">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs opacity-60 mt-0.5" id="file-name"></p>
|
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
|
||||||
|
|
||||||
<hr class="w-96 my-4 border-gray-300 dark:border-zinc-700" style="display: none;">
|
<hr class="w-96 my-4 border-gray-300 dark:border-zinc-700" style="display: none;">
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediakit';
|
import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediakit';
|
||||||
|
|
||||||
const selectMediaButton = document.querySelector('button')!;
|
const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
|
||||||
const fileNameElement = document.querySelector('#file-name')!;
|
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
|
||||||
const horizontalRule = document.querySelector('hr')!;
|
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
|
||||||
const thumbnailContainer = document.querySelector('#thumbnail-container')!;
|
const thumbnailContainer = document.querySelector('#thumbnail-container') as HTMLDivElement;
|
||||||
const errorElement = document.querySelector('#error-element')!;
|
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
|
||||||
|
|
||||||
const THUMBNAIL_COUNT = 16;
|
const THUMBNAIL_COUNT = 16;
|
||||||
const THUMBNAIL_SIZE = 200;
|
const THUMBNAIL_SIZE = 200;
|
||||||
@@ -91,6 +91,8 @@ const generateThumbnails = async (file: File) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** === FILE SELECTION LOGIC === */
|
||||||
|
|
||||||
selectMediaButton.addEventListener('click', () => {
|
selectMediaButton.addEventListener('click', () => {
|
||||||
const fileInput = document.createElement('input');
|
const fileInput = document.createElement('input');
|
||||||
fileInput.type = 'file';
|
fileInput.type = 'file';
|
||||||
|
|||||||