Skip to content

Commit 2cfeec5

Browse files
Merge pull request #1873 from dev-protocol/mp4box-videofetch
add: VideoFetch.vue & imgBlob to Checkout.vue & Result.vue + add: VideoFetch.svelte + 3.22.15
2 parents dde8cbc + c6b8e52 commit 2cfeec5

File tree

8 files changed

+513
-17
lines changed

8 files changed

+513
-17
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@devprotocol/clubs-core",
3-
"version": "3.22.9",
3+
"version": "3.22.15",
44
"description": "Core library for Clubs",
55
"main": "dist/index.mjs",
66
"exports": {
@@ -106,6 +106,7 @@
106106
"js-base64": "^3.7.2",
107107
"lit": "^3.0.0",
108108
"marked": "^10.0.0",
109+
"mp4box": "^0.5.3",
109110
"p-queue": "^8.0.1",
110111
"ramda": "^0.30.0",
111112
"rxjs": "^7.8.1",

src/ui/components/Checkout/Checkout.vue

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
<script lang="ts" setup>
2-
import { onMounted, onUnmounted, ref, type ComputedRef, computed } from 'vue'
2+
import {
3+
onMounted,
4+
onUnmounted,
5+
ref,
6+
useTemplateRef,
7+
type ComputedRef,
8+
computed,
9+
} from 'vue'
310
import { createErc20Contract } from '@devprotocol/dev-kit'
411
import {
512
positionsCreate,
@@ -41,6 +48,8 @@ import { fetchProfile } from '../../../profile'
4148
import IconSpinner from '../../vue/IconSpinner.vue'
4249
import IconInfo from '../../vue/IconInfo.vue'
4350
import IconCheckCircle from '../../vue/IconCheckCircle.vue'
51+
// @ts-ignore
52+
import VideoFetch from '../../vue/VideoFetch.vue'
4453
import IconBouncingArrowRight from '../../vue/IconBouncingArrowRight.vue'
4554
4655
let providerPool: UndefinedOr<ContractRunner>
@@ -51,6 +60,8 @@ const REGEX_DESC_EMAIL = /{EMAIL}/g
5160
const i18nBase = i18nFactory(Strings)
5261
let i18n = ref<ReturnType<typeof i18nBase>>(i18nBase(['en']))
5362
63+
const imageRef = useTemplateRef(`imageRef`)
64+
5465
type Props = {
5566
amount?: number
5667
destination?: string
@@ -537,6 +548,16 @@ onMounted(async () => {
537548
previewName.value = sTokens?.name
538549
}
539550
)
551+
try {
552+
if (previewImageSrc.value && imageRef.value) {
553+
const response = await fetch(previewImageSrc.value)
554+
const blob = await response.blob()
555+
const blobDataUrl = URL.createObjectURL(blob)
556+
imageRef.value.src = blobDataUrl
557+
}
558+
} catch (error) {
559+
console.error('Error loading image:', error)
560+
}
540561
})
541562
542563
onUnmounted(() => {
@@ -566,10 +587,10 @@ onUnmounted(() => {
566587
v-if="!previewImageSrc && previewVideoSrc"
567588
class="w-36 rounded-lg border border-black/20 bg-black/10 p-1"
568589
>
569-
<video class="w-full rounded-lg" autoplay muted>
570-
<source :src="previewVideoSrc" type="video/mp4" />
571-
Your browser does not support the video tag.
572-
</video>
590+
<VideoFetch
591+
:url="previewVideoSrc"
592+
:videoClass="`w-full rounded-lg`"
593+
/>
573594
</span>
574595

575596
<span
@@ -578,7 +599,7 @@ onUnmounted(() => {
578599
>
579600
<img
580601
v-if="previewImageSrc"
581-
:src="previewImageSrc"
602+
ref="imageRef"
582603
class="h-auto w-full rounded-lg object-cover object-center"
583604
/>
584605
<Skeleton

src/ui/components/Checkout/Result.vue

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts" setup>
2-
import { onMounted, ref, type ComputedRef, computed } from 'vue'
2+
import { onMounted, ref, useTemplateRef, type ComputedRef, computed } from 'vue'
33
import { type UndefinedOr, whenDefinedAll } from '@devprotocol/util-ts'
44
import { JsonRpcProvider } from 'ethers'
55
import Skeleton from '../Skeleton/Skeleton.vue'
@@ -10,10 +10,14 @@ import { i18nFactory } from '../../../i18n'
1010
import { markdownToHtml } from '../../../markdown'
1111
import Modal from '../Modal.vue'
1212
import ModalCheckout from './ModalCheckout.vue'
13+
// @ts-ignore
14+
import VideoFetch from '../../vue/VideoFetch.vue'
1315
1416
const i18nBase = i18nFactory(Strings)
1517
let i18n = i18nBase(['en'])
1618
19+
const imageRef = useTemplateRef(`imageRef`)
20+
1721
type Props = {
1822
eoa?: string
1923
id?: number | string
@@ -81,6 +85,17 @@ onMounted(async () => {
8185
8286
// Modal Open
8387
modalOpen()
88+
89+
try {
90+
if (props?.imageSrc && imageRef.value) {
91+
const response = await fetch(props?.imageSrc)
92+
const blob = await response.blob()
93+
const blobDataUrl = URL.createObjectURL(blob)
94+
imageRef.value.src = blobDataUrl
95+
}
96+
} catch (error) {
97+
console.error('Error loading image:', error)
98+
}
8499
})
85100
</script>
86101

@@ -113,19 +128,15 @@ onMounted(async () => {
113128
<div class="rounded-lg border border-black/20 bg-black/10 p-4">
114129
<img
115130
v-if="imageSrc"
116-
:src="imageSrc"
131+
ref="imageRef"
117132
class="h-auto w-full rounded object-cover object-center sm:h-full sm:w-full"
118133
/>
119134
<!-- video -->
120-
<video
135+
<VideoFetch
121136
v-if="!imageSrc && videoSrc"
122-
class="w-full rounded"
123-
autoplay
124-
muted
125-
>
126-
<source :src="videoSrc" type="video/mp4" />
127-
Your browser does not support the video tag.
128-
</video>
137+
:url="videoSrc"
138+
:videoClass="`w-full rounded`"
139+
/>
129140
</div>
130141
<span>
131142
<h3 class="break-all text-sm text-black/50">

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)