diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Navigation.tsx | 6 | ||||
-rw-r--r-- | src/components/VideoDownloadScreen.native.tsx | 4 | ||||
-rw-r--r-- | src/components/VideoDownloadScreen.tsx | 215 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/routes.ts | 1 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 46 |
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]}> |