about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/components/VideoDownloadScreen.native.tsx4
-rw-r--r--src/components/VideoDownloadScreen.tsx215
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/view/screens/Storybook/index.tsx46
6 files changed, 272 insertions, 1 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 79856879c..0d151427f 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -50,6 +50,7 @@ import {
   StarterPackScreenShort,
 } from '#/screens/StarterPack/StarterPackScreen'
 import {Wizard} from '#/screens/StarterPack/Wizard'
+import {VideoDownloadScreen} from '#/components/VideoDownloadScreen'
 import {Referrer} from '../modules/expo-bluesky-swiss-army'
 import {init as initAnalytics} from './lib/analytics/analytics'
 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
@@ -364,6 +365,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         getComponent={() => Wizard}
         options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
       />
+      <Stack.Screen
+        name="VideoDownload"
+        getComponent={() => VideoDownloadScreen}
+        options={{title: title(msg`Download video`)}}
+      />
     </>
   )
 }
diff --git a/src/components/VideoDownloadScreen.native.tsx b/src/components/VideoDownloadScreen.native.tsx
new file mode 100644
index 000000000..a1f6466fd
--- /dev/null
+++ b/src/components/VideoDownloadScreen.native.tsx
@@ -0,0 +1,4 @@
+export function VideoDownloadScreen() {
+  // @TODO redirect
+  return null
+}
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>
+  )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0cc83b475..77e7266a4 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -50,6 +50,7 @@ export type CommonNavigatorParams = {
   StarterPackShort: {code: string}
   StarterPackWizard: undefined
   StarterPackEdit: {rkey?: string}
+  VideoDownload: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index c9e23e08c..bda2d98e4 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -48,4 +48,5 @@ export const router = new Router({
   StarterPack: '/starter-pack/:name/:rkey',
   StarterPackShort: '/starter-pack-short/:code',
   StarterPackWizard: '/starter-pack/create',
+  VideoDownload: '/video-download',
 })
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 71dbe8839..c6da63314 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -1,12 +1,17 @@
 import React from 'react'
 import {ScrollView, View} from 'react-native'
+import {deleteAsync} from 'expo-file-system'
+import {saveToLibraryAsync} from 'expo-media-library'
 
 import {useSetThemePrefs} from '#/state/shell'
-import {isWeb} from 'platform/detection'
+import {useVideoLibraryPermission} from 'lib/hooks/usePermissions'
+import {isIOS, isWeb} from 'platform/detection'
 import {CenteredView} from '#/view/com/util/Views'
+import * as Toast from 'view/com/util/Toast'
 import {ListContained} from 'view/screens/Storybook/ListContained'
 import {atoms as a, ThemeProvider, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
+import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army'
 import {Breakpoints} from './Breakpoints'
 import {Buttons} from './Buttons'
 import {Dialogs} from './Dialogs'
@@ -33,10 +38,49 @@ function StorybookInner() {
   const t = useTheme()
   const {setColorMode, setDarkTheme} = useSetThemePrefs()
   const [showContainedList, setShowContainedList] = React.useState(false)
+  const hlsDownloadRef = React.useRef<HLSDownloadView>(null)
+
+  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
 
   return (
     <CenteredView style={[t.atoms.bg]}>
       <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 100}]}>
+        <HLSDownloadView
+          ref={hlsDownloadRef}
+          downloaderUrl={
+            isIOS
+              ? 'http://localhost:19006/video-download'
+              : 'http://10.0.2.2:19006/video-download'
+          }
+          onSuccess={async e => {
+            const uri = e.nativeEvent.uri
+            const permsRes = await requestVideoAccessIfNeeded()
+            if (!permsRes) return
+
+            await saveToLibraryAsync(uri)
+            try {
+              deleteAsync(uri)
+            } catch (err) {
+              console.error('Failed to delete file', err)
+            }
+            Toast.show('Video saved to library')
+          }}
+          onStart={() => console.log('Download is starting')}
+          onError={e => console.log(e.nativeEvent.message)}
+          onProgress={e => console.log(e.nativeEvent.progress)}
+        />
+        <Button
+          variant="solid"
+          color="primary"
+          size="small"
+          onPress={async () => {
+            hlsDownloadRef.current?.startDownloadAsync(
+              'https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?download=true',
+            )
+          }}
+          label="Video download test">
+          <ButtonText>Video download test</ButtonText>
+        </Button>
         {!showContainedList ? (
           <>
             <View style={[a.flex_row, a.align_start, a.gap_md]}>