Skip to content

Commit e571680

Browse files
add: VideoFetch.svelte
1 parent 3f4991e commit e571680

File tree

2 files changed

+225
-0
lines changed

2 files changed

+225
-0
lines changed

src/ui/svelte/VideoFetch.svelte

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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>

src/ui/svelte/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import IconMoon from './IconMoon.svelte'
1515
import IconPhone from './IconPhone.svelte'
1616
import IconSpinner from './IconSpinner.svelte'
1717
import IconSun from './IconSun.svelte'
18+
import VideoFetch from './VideoFetch.svelte'
1819

1920
export {
2021
IconArrowRight,
@@ -25,4 +26,5 @@ export {
2526
IconPhone,
2627
IconSpinner,
2728
IconSun,
29+
VideoFetch,
2830
}

0 commit comments

Comments
 (0)