about summary refs log tree commit diff
path: root/src/components/VideoDownloadScreen.tsx
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-08-15 11:23:48 -0700
committerGitHub <noreply@github.com>2024-08-15 11:23:48 -0700
commit11061b628ef5b5805c6435155ca2a571001e4643 (patch)
treed1e3c672d225592af7e1341332c6c6aeb979f216 /src/components/VideoDownloadScreen.tsx
parentb9975697e22ef729e60b9111883127961258445b (diff)
downloadvoidsky-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.tsx215
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>
+  )
+}