|
| 1 | +<script> |
| 2 | + // Not using types in file as mp4box doesn't export type modules |
| 3 | + import { onMount } from 'svelte' |
| 4 | + import MP4Box from 'mp4box' |
| 5 | +
|
| 6 | + export let url |
| 7 | + export let posterUrl |
| 8 | + export let videoClass |
| 9 | +
|
| 10 | + let videoElement |
| 11 | + let mediaSource |
| 12 | + let sourceBuffers = {} |
| 13 | + let mp4boxfile |
| 14 | + let pendingSegments = {} |
| 15 | +
|
| 16 | + // Configure chunk size (1MB). Adjust if needed for performance or latency. |
| 17 | + const CHUNK_SIZE = 1_000_000 |
| 18 | + let nextRangeStart = 0 |
| 19 | + let totalFileSize = 0 |
| 20 | + let isDownloading = false |
| 21 | +
|
| 22 | + onMount(() => { |
| 23 | + if (!url) return |
| 24 | +
|
| 25 | + mediaSource = new MediaSource() |
| 26 | + videoElement.src = URL.createObjectURL(mediaSource) |
| 27 | + mediaSource.addEventListener('sourceopen', onSourceOpen) |
| 28 | +
|
| 29 | + setupMp4Box() |
| 30 | + startDownload() |
| 31 | + }) |
| 32 | +
|
| 33 | + function onSourceOpen() { |
| 34 | + // MediaSource is ready to accept SourceBuffers |
| 35 | + // console.log('MediaSource opened') // Uncomment for debugging |
| 36 | + } |
| 37 | +
|
| 38 | + function setupMp4Box() { |
| 39 | + mp4boxfile = MP4Box.createFile() |
| 40 | +
|
| 41 | + // Fired when MP4Box starts parsing the "moov" box (movie metadata) |
| 42 | + mp4boxfile.onMoovStart = function () { |
| 43 | + // console.log('Parsing movie information...') |
| 44 | + } |
| 45 | +
|
| 46 | + // Fired when MP4Box has the "moov" box and all track info ready |
| 47 | + mp4boxfile.onReady = function (info) { |
| 48 | + // Instead of manually setting mediaSource.duration, rely on segment-based approach |
| 49 | + initializeTracksAndBuffers(info) |
| 50 | + const initSegs = mp4boxfile.initializeSegmentation() |
| 51 | +
|
| 52 | + // Create and append initial SourceBuffers based on track information |
| 53 | + initSegs.forEach((seg) => { |
| 54 | + const trackInfo = info.tracks.find((t) => t.id === seg.id) |
| 55 | + const codec = seg.codec || trackInfo?.codec |
| 56 | + if (!codec) { |
| 57 | + console.error(`Codec undefined for track ID: ${seg.id}`) |
| 58 | + return |
| 59 | + } |
| 60 | +
|
| 61 | + const mime = `video/mp4; codecs="${codec}"` |
| 62 | + if (MediaSource.isTypeSupported(mime)) { |
| 63 | + const sb = mediaSource.addSourceBuffer(mime) |
| 64 | + sourceBuffers[seg.id] = sb |
| 65 | +
|
| 66 | + // Handle subsequent segment appending after one finishes |
| 67 | + sb.addEventListener('updateend', () => onUpdateEnd(seg.id)) |
| 68 | +
|
| 69 | + // Append the initialization segment |
| 70 | + sb.appendBuffer(seg.buffer) |
| 71 | + // console.log(`SourceBuffer added with mime: ${mime}`) |
| 72 | + } else { |
| 73 | + console.error(`Unsupported MIME type: ${mime}`) |
| 74 | + } |
| 75 | + }) |
| 76 | +
|
| 77 | + // Start MP4Box file processing |
| 78 | + mp4boxfile.start() |
| 79 | + videoElement.value?.play().catch((e) => console.error('Play error:', e)) |
| 80 | + } |
| 81 | +
|
| 82 | + // Fired when a media segment is ready |
| 83 | + mp4boxfile.onSegment = function ( |
| 84 | + trackId, |
| 85 | + user, |
| 86 | + buffer, |
| 87 | + sampleNum, |
| 88 | + is_last |
| 89 | + ) { |
| 90 | + // If the corresponding SourceBuffer is ready, append immediately |
| 91 | + // Otherwise, queue it up in pendingSegments |
| 92 | + if (sourceBuffers[trackId] && !sourceBuffers[trackId].updating) { |
| 93 | + sourceBuffers[trackId].appendBuffer(buffer) |
| 94 | + } else { |
| 95 | + pendingSegments[trackId]?.push(buffer) |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | +
|
| 100 | + function initializeTracksAndBuffers(info) { |
| 101 | + info.tracks.forEach((track) => { |
| 102 | + // Define segmentation options: smaller durations lead to more frequent, smaller segments. |
| 103 | + mp4boxfile.setSegmentOptions(track.id, { duration: 2 }) |
| 104 | + pendingSegments[track.id] = [] // Initialize each track's queue |
| 105 | + }) |
| 106 | + } |
| 107 | +
|
| 108 | + function onUpdateEnd(trackId) { |
| 109 | + // After finishing appending to a SourceBuffer, |
| 110 | + // check if there are pending segments and append the next one if available. |
| 111 | + if ( |
| 112 | + pendingSegments[trackId]?.length > 0 && |
| 113 | + !sourceBuffers[trackId].updating |
| 114 | + ) { |
| 115 | + const nextBuffer = pendingSegments[trackId].shift() |
| 116 | + sourceBuffers[trackId].appendBuffer(nextBuffer) |
| 117 | + } |
| 118 | +
|
| 119 | + // Check if the entire stream can now be ended. |
| 120 | + maybeEndOfStream() |
| 121 | + } |
| 122 | +
|
| 123 | + async function startDownload() { |
| 124 | + isDownloading = true |
| 125 | + try { |
| 126 | + totalFileSize = await fetchFileSize() |
| 127 | + downloadChunk() |
| 128 | + } catch (err) { |
| 129 | + console.error('Could not fetch file size:', err) |
| 130 | + } |
| 131 | + } |
| 132 | +
|
| 133 | + function fetchFileSize() { |
| 134 | + return new Promise((resolve, reject) => { |
| 135 | + const xhr = new XMLHttpRequest() |
| 136 | + xhr.open('HEAD', url, true) |
| 137 | + xhr.onload = () => { |
| 138 | + if (xhr.status >= 200 && xhr.status < 300) { |
| 139 | + const length = parseInt( |
| 140 | + xhr.getResponseHeader('Content-Length') || '0', |
| 141 | + 10 |
| 142 | + ) |
| 143 | + resolve(length) |
| 144 | + } else { |
| 145 | + reject(new Error(`HEAD request failed with status ${xhr.status}`)) |
| 146 | + } |
| 147 | + } |
| 148 | + xhr.onerror = () => reject(new Error('Network error during HEAD request')) |
| 149 | + xhr.send() |
| 150 | + }) |
| 151 | + } |
| 152 | +
|
| 153 | + function downloadChunk() { |
| 154 | + if (!isDownloading) return |
| 155 | +
|
| 156 | + // If we've downloaded the entire file, flush MP4Box and possibly end the stream |
| 157 | + if (nextRangeStart >= totalFileSize) { |
| 158 | + mp4boxfile.flush() |
| 159 | + maybeEndOfStream() |
| 160 | + // Start playback after all data is processed |
| 161 | + videoElement.play().catch((e) => console.error('Play error:', e)) |
| 162 | + return |
| 163 | + } |
| 164 | +
|
| 165 | + const end = Math.min(nextRangeStart + CHUNK_SIZE - 1, totalFileSize - 1) |
| 166 | + const xhr = new XMLHttpRequest() |
| 167 | + xhr.open('GET', url, true) |
| 168 | + xhr.responseType = 'arraybuffer' |
| 169 | + xhr.setRequestHeader('Range', `bytes=${nextRangeStart}-${end}`) |
| 170 | +
|
| 171 | + xhr.onload = function () { |
| 172 | + if (xhr.status >= 200 && xhr.status < 300) { |
| 173 | + const buffer = xhr.response |
| 174 | + // MP4Box requires `fileStart` to know where this chunk fits in the file |
| 175 | + buffer.fileStart = nextRangeStart |
| 176 | + nextRangeStart = end + 1 |
| 177 | +
|
| 178 | + const next = mp4boxfile.appendBuffer(buffer) |
| 179 | + if (next) { |
| 180 | + // Giving some delay before requesting the next chunk can help throttle bandwidth |
| 181 | + setTimeout(downloadChunk, 100) |
| 182 | + } |
| 183 | + } else { |
| 184 | + console.error('Error downloading chunk. Status:', xhr.status) |
| 185 | + } |
| 186 | + } |
| 187 | +
|
| 188 | + xhr.onerror = function (e) { |
| 189 | + console.error('XHR error during chunk download:', e) |
| 190 | + } |
| 191 | + xhr.send() |
| 192 | + } |
| 193 | +
|
| 194 | + function maybeEndOfStream() { |
| 195 | + // Check if all data is downloaded |
| 196 | + if (nextRangeStart >= totalFileSize) { |
| 197 | + // Verify no pending segments and no buffers updating |
| 198 | + const noPending = Object.values(pendingSegments).every( |
| 199 | + (arr) => arr.length === 0 |
| 200 | + ) |
| 201 | + const noUpdating = Object.values(sourceBuffers).every( |
| 202 | + (sb) => !sb.updating |
| 203 | + ) |
| 204 | +
|
| 205 | + if (noPending && noUpdating && mediaSource.readyState === 'open') { |
| 206 | + // All segments have been appended successfully |
| 207 | + mediaSource.endOfStream() |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | +</script> |
| 212 | +
|
| 213 | +<video |
| 214 | + bind:this={videoElement} |
| 215 | + controlsList="nodownload" |
| 216 | + loop |
| 217 | + autoplay |
| 218 | + muted |
| 219 | + poster={posterUrl} |
| 220 | + class={videoClass} |
| 221 | +> |
| 222 | + <track kind="captions" /> |
| 223 | +</video> |
0 commit comments