diff options
author | Hailey <me@haileyok.com> | 2024-08-15 11:23:48 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-08-15 11:23:48 -0700 |
commit | 11061b628ef5b5805c6435155ca2a571001e4643 (patch) | |
tree | d1e3c672d225592af7e1341332c6c6aeb979f216 /src/components/VideoDownloadScreen.tsx | |
parent | b9975697e22ef729e60b9111883127961258445b (diff) | |
download | voidsky-11061b628ef5b5805c6435155ca2a571001e4643.tar.zst |
[Video] Download videos (#4886)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Diffstat (limited to 'src/components/VideoDownloadScreen.tsx')
-rw-r--r-- | src/components/VideoDownloadScreen.tsx | 215 |
1 files changed, 215 insertions, 0 deletions
diff --git a/src/components/VideoDownloadScreen.tsx b/src/components/VideoDownloadScreen.tsx new file mode 100644 index 000000000..3169d265d --- /dev/null +++ b/src/components/VideoDownloadScreen.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import {parse} from 'hls-parser' +import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types' + +interface PostMessageData { + action: 'progress' | 'error' + messageStr?: string + messageFloat?: number +} + +function postMessage(data: PostMessageData) { + // @ts-expect-error safari webview only + if (window?.webkit) { + // @ts-expect-error safari webview only + window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data)) + // @ts-expect-error android webview only + } else if (AndroidInterface) { + // @ts-expect-error android webview only + AndroidInterface.onMessage(JSON.stringify(data)) + } +} + +function createSegementUrl(originalUrl: string, newFile: string) { + const parts = originalUrl.split('/') + parts[parts.length - 1] = newFile + return parts.join('/') +} + +export function VideoDownloadScreen() { + const ffmpegRef = React.useRef<any>(null) + const fetchFileRef = React.useRef<any>(null) + + const [dataUrl, setDataUrl] = React.useState<any>(null) + + const load = React.useCallback(async () => { + const ffmpegLib = await import('@ffmpeg/ffmpeg') + const ffmpeg = new ffmpegLib.FFmpeg() + ffmpegRef.current = ffmpeg + + const ffmpegUtilLib = await import('@ffmpeg/util') + fetchFileRef.current = ffmpegUtilLib.fetchFile + + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm' + + await ffmpeg.load({ + coreURL: await ffmpegUtilLib.toBlobURL( + `${baseURL}/ffmpeg-core.js`, + 'text/javascript', + ), + wasmURL: await ffmpegUtilLib.toBlobURL( + `${baseURL}/ffmpeg-core.wasm`, + 'application/wasm', + ), + }) + }, []) + + const createMp4 = React.useCallback(async (videoUrl: string) => { + // Get the master playlist and find the best variant + const masterPlaylistRes = await fetch(videoUrl) + const masterPlaylistText = await masterPlaylistRes.text() + const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist + + // If URL given is not a master playlist, we probably cannot handle this. + if (!masterPlaylist.isMasterPlaylist) { + postMessage({ + action: 'error', + messageStr: 'A master playlist was not found in the provided playlist.', + }) + return + } + + // Figure out what the best quality is. These should generally be in order, but we'll check them all just in case + let bestVariant: Variant | undefined + for (const variant of masterPlaylist.variants) { + if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) { + bestVariant = variant + } + } + + // Should only happen if there was no variants at all given to us. Mostly for types. + if (!bestVariant) { + postMessage({ + action: 'error', + messageStr: 'No variants were found in the provided master playlist.', + }) + return + } + + const urlParts = videoUrl.split('/') + urlParts[urlParts.length - 1] = bestVariant?.uri + const bestVariantUrl = urlParts.join('/') + + // Download and parse m3u8 + const hlsFileRes = await fetch(bestVariantUrl) + const hlsPlainText = await hlsFileRes.text() + const playlist = parse(hlsPlainText) as MediaPlaylist + + // This one shouldn't be a master playlist - again just for types really + if (playlist.isMasterPlaylist) { + postMessage({ + action: 'error', + messageStr: 'An unknown error has occurred.', + }) + return + } + + const ffmpeg = ffmpegRef.current + + // Get the correctly ordered file names. We need to remove the tracking info from the end of the file name + const segments = playlist.segments.map(segment => { + return segment.uri.split('?')[0] + }) + + // Download each segment + let error: string | null = null + let completed = 0 + await Promise.all( + playlist.segments.map(async segment => { + const uri = createSegementUrl(bestVariantUrl, segment.uri) + const filename = segment.uri.split('?')[0] + + const res = await fetch(uri) + if (!res.ok) { + error = 'Failed to download playlist segment.' + } + + const blob = await res.blob() + try { + await ffmpeg.writeFile(filename, await fetchFileRef.current(blob)) + } catch (e: unknown) { + error = 'Failed to write file.' + } finally { + completed++ + const progress = completed / playlist.segments.length + postMessage({ + action: 'progress', + messageFloat: progress, + }) + } + }), + ) + + // Do something if there was an error + if (error) { + postMessage({ + action: 'error', + messageStr: error, + }) + return + } + + // Put the segments together + await ffmpeg.exec([ + '-i', + `concat:${segments.join('|')}`, + '-c:v', + 'copy', + 'output.mp4', + ]) + + const fileData = await ffmpeg.readFile('output.mp4') + const blob = new Blob([fileData.buffer], {type: 'video/mp4'}) + const dataUrl = await new Promise<string | null>(resolve => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.onerror = () => resolve(null) + reader.readAsDataURL(blob) + }) + return dataUrl + }, []) + + const download = React.useCallback( + async (videoUrl: string) => { + await load() + const mp4Res = await createMp4(videoUrl) + + if (!mp4Res) { + postMessage({ + action: 'error', + messageStr: 'An error occurred while creating the MP4.', + }) + return + } + + setDataUrl(mp4Res) + }, + [createMp4, load], + ) + + React.useEffect(() => { + const url = new URL(window.location.href) + const videoUrl = url.searchParams.get('videoUrl') + + if (!videoUrl) { + postMessage({action: 'error', messageStr: 'No video URL provided'}) + } else { + setDataUrl(null) + download(videoUrl) + } + }, [download]) + + if (!dataUrl) return null + + return ( + <div> + <a + href={dataUrl} + ref={el => { + el?.click() + }} + download="video.mp4" + /> + </div> + ) +} |