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.

+ +
+ + + + + +
+

+ + + +

+ + +
+ +

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';