diff --git a/assets/fullscreen-icon.svg b/assets/fullscreen-icon.svg
new file mode 100644
index 0000000..95408a4
--- /dev/null
+++ b/assets/fullscreen-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/pause-icon.svg b/assets/pause-icon.svg
new file mode 100644
index 0000000..053c9d4
--- /dev/null
+++ b/assets/pause-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/play-icon.svg b/assets/play-icon.svg
new file mode 100644
index 0000000..f6130c9
--- /dev/null
+++ b/assets/play-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/volume-0-icon.svg b/assets/volume-0-icon.svg
new file mode 100644
index 0000000..4552e09
--- /dev/null
+++ b/assets/volume-0-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/volume-1-icon.svg b/assets/volume-1-icon.svg
new file mode 100644
index 0000000..6efb112
--- /dev/null
+++ b/assets/volume-1-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/volume-2-icon.svg b/assets/volume-2-icon.svg
new file mode 100644
index 0000000..8c412e0
--- /dev/null
+++ b/assets/volume-2-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/volume-off-icon.svg b/assets/volume-off-icon.svg
new file mode 100644
index 0000000..0c2ff1c
--- /dev/null
+++ b/assets/volume-off-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/volume-x-icon.svg b/assets/volume-x-icon.svg
new file mode 100644
index 0000000..0be0527
--- /dev/null
+++ b/assets/volume-x-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/examples.md b/docs/examples.md
index 910fbea..967b44e 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -7,15 +7,17 @@ hero:
features:
- 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
target: _self
- 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
target: _self
- - title: Feature C
- details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
+ - title: Media player (advanced)
+ 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
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
- title: Feature B
diff --git a/docs/index.md b/docs/index.md
index c9cb413..4191f11 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -40,7 +40,7 @@ things the guide needs to cover:
Examples:
✅ Extracting media file metadata
✅ Rendering thumbnails for a video / extracting a wave form?
-- Media player
+✅ Media player
- Conversion demo: File compressor
- Procedurally generated video (faster than real time)
- Video generated from live sources
diff --git a/examples/media-player/index.html b/examples/media-player/index.html
new file mode 100644
index 0000000..1c11ea0
--- /dev/null
+++ b/examples/media-player/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ Media player example | Mediakit
+
+
+
+
+
+
+ Media player example
+ Select or drop a media file, and a fully custom, Mediakit-powered player will appear.
+
+
+
+ Select media file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mediakit
+
+
+
+
+ View source code
+
+
+
diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts
new file mode 100644
index 0000000..625060a
--- /dev/null
+++ b/examples/media-player/media-player.ts
@@ -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 | null = null;
+let audioBufferIterator: AsyncGenerator | null = null;
+let nextFrame: WrappedCanvas | null = null;
+const queuedAudioNodes: Set = 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((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);
+ }
+});
diff --git a/examples/metadata-extraction/index.html b/examples/metadata-extraction/index.html
index 626a698..047a594 100644
--- a/examples/metadata-extraction/index.html
+++ b/examples/metadata-extraction/index.html
@@ -23,7 +23,7 @@
-
+
diff --git a/examples/metadata-extraction/metadata-extraction.ts b/examples/metadata-extraction/metadata-extraction.ts
index b83bd24..538f58d 100644
--- a/examples/metadata-extraction/metadata-extraction.ts
+++ b/examples/metadata-extraction/metadata-extraction.ts
@@ -1,10 +1,10 @@
import { Input, ALL_FORMATS, BlobSource } from 'mediakit';
-const selectMediaButton = document.querySelector('button')!;
-const fileNameElement = document.querySelector('#file-name')!;
-const horizontalRule = document.querySelector('hr')!;
-const bytesReadElement = document.querySelector('#bytes-read')!;
-const metadataContainer = document.querySelector('#metadata-container')!;
+const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
+const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
+const horizontalRule = document.querySelector('hr') as HTMLHRElement;
+const bytesReadElement = document.querySelector('#bytes-read') as HTMLParagraphElement;
+const metadataContainer = document.querySelector('#metadata-container') as HTMLDivElement;
const extractMetadata = (file: File) => {
// Create a new input from the file
@@ -84,35 +84,6 @@ const extractMetadata = (file: File) => {
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
const renderValue = (value: unknown) => {
if (Array.isArray(value)) {
@@ -175,3 +146,34 @@ const renderObject = (object: Record) => {
const shortDelay = () => {
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);
+ }
+});
diff --git a/examples/thumbnail-generation/index.html b/examples/thumbnail-generation/index.html
index a933f4c..47e05dc 100644
--- a/examples/thumbnail-generation/index.html
+++ b/examples/thumbnail-generation/index.html
@@ -23,7 +23,7 @@
-
+
diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts
index 0139536..ae2ac8d 100644
--- a/examples/thumbnail-generation/thumbnail-generation.ts
+++ b/examples/thumbnail-generation/thumbnail-generation.ts
@@ -1,10 +1,10 @@
import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediakit';
-const selectMediaButton = document.querySelector('button')!;
-const fileNameElement = document.querySelector('#file-name')!;
-const horizontalRule = document.querySelector('hr')!;
-const thumbnailContainer = document.querySelector('#thumbnail-container')!;
-const errorElement = document.querySelector('#error-element')!;
+const selectMediaButton = document.querySelector('button') as HTMLButtonElement;
+const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
+const horizontalRule = document.querySelector('hr') as HTMLHRElement;
+const thumbnailContainer = document.querySelector('#thumbnail-container') as HTMLDivElement;
+const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
const THUMBNAIL_COUNT = 16;
const THUMBNAIL_SIZE = 200;
@@ -91,6 +91,8 @@ const generateThumbnails = async (file: File) => {
}
};
+/** === FILE SELECTION LOGIC === */
+
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';