From 11061b628ef5b5805c6435155ca2a571001e4643 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 15 Aug 2024 11:23:48 -0700 Subject: [PATCH] [Video] Download videos (#4886) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- bskyweb/cmd/bskyweb/server.go | 3 + bskyweb/static/robots.txt | 1 + .../hlsdownload/ExpoHLSDownloadModule.kt | 35 +++ .../hlsdownload/HLSDownloadView.kt | 141 ++++++++++++ .../expo-module.config.json | 4 +- modules/expo-bluesky-swiss-army/index.ts | 10 +- .../HLSDownload/ExpoHLSDownloadModule.swift | 31 +++ .../ios/HLSDownload/HLSDownloadView.swift | 148 ++++++++++++ .../src/HLSDownload/index.native.tsx | 39 ++++ .../src/HLSDownload/index.tsx | 22 ++ .../src/HLSDownload/types.ts | 10 + package.json | 4 + src/Navigation.tsx | 6 + src/components/VideoDownloadScreen.native.tsx | 4 + src/components/VideoDownloadScreen.tsx | 215 ++++++++++++++++++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/view/screens/Storybook/index.tsx | 46 +++- yarn.lock | 29 +++ 19 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt create mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift create mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts create mode 100644 src/components/VideoDownloadScreen.native.tsx create mode 100644 src/components/VideoDownloadScreen.tsx diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index fdef01ce..01f1a875 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -256,6 +256,9 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) + // video download + e.GET("/video-download", server.WebGeneric) + // starter packs e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) diff --git a/bskyweb/static/robots.txt b/bskyweb/static/robots.txt index 4f8510d1..d785755a 100644 --- a/bskyweb/static/robots.txt +++ b/bskyweb/static/robots.txt @@ -7,3 +7,4 @@ # be ok. User-Agent: * Allow: / +Disallow: /video-download diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt new file mode 100644 index 00000000..786b84e4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt @@ -0,0 +1,35 @@ +package expo.modules.blueskyswissarmy.hlsdownload + +import android.net.Uri +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoHLSDownloadModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoHLSDownload") + + Function("isAvailable") { + return@Function true + } + + View(HLSDownloadView::class) { + Events( + arrayOf( + "onStart", + "onError", + "onProgress", + "onSuccess", + ), + ) + + Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri -> + view.downloaderUrl = downloaderUrl + } + + AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri -> + view.startDownload(sourceUrl) + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt new file mode 100644 index 00000000..5f3082a8 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt @@ -0,0 +1,141 @@ +package expo.modules.blueskyswissarmy.hlsdownload + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.util.Base64 +import android.util.Log +import android.webkit.DownloadListener +import android.webkit.JavascriptInterface +import android.webkit.WebView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.viewevent.ViewEventCallback +import expo.modules.kotlin.views.ExpoView +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.util.UUID + +class HLSDownloadView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext), + DownloadListener { + private val webView = WebView(context) + + var downloaderUrl: Uri? = null + + private val onStart by EventDispatcher() + private val onError by EventDispatcher() + private val onProgress by EventDispatcher() + private val onSuccess by EventDispatcher() + + init { + this.setupWebView() + this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + val webSettings = this.webView.settings + webSettings.javaScriptEnabled = true + webSettings.domStorageEnabled = true + + webView.setDownloadListener(this) + webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface") + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + this.webView.stopLoading() + this.webView.clearHistory() + this.webView.removeAllViews() + this.webView.destroy() + } + + fun startDownload(sourceUrl: Uri) { + if (this.downloaderUrl == null) { + this.onError(mapOf(ERROR_KEY to "Downloader URL is not set.")) + return + } + + val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl") + this.webView.loadUrl(url.toString()) + this.onStart(mapOf()) + } + + override fun onDownloadStart( + url: String?, + userAgent: String?, + contentDisposition: String?, + mimeType: String?, + contentLength: Long, + ) { + if (url == null) { + this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview.")) + return + } + + val tempDir = context.cacheDir + val fileName = "${UUID.randomUUID()}.mp4" + val file = File(tempDir, fileName) + + val base64 = url.split(",")[1] + val bytes = Base64.decode(base64, Base64.DEFAULT) + + val fos = FileOutputStream(file) + try { + fos.write(bytes) + } catch (e: Exception) { + Log.e("FileDownload", "Error downloading file", e) + this.onError(mapOf(ERROR_KEY to e.message.toString())) + return + } finally { + fos.close() + } + + val uri = Uri.fromFile(file) + this.onSuccess(mapOf("uri" to uri.toString())) + } + + companion object { + const val ERROR_KEY = "message" + } +} + +public class WebAppInterface( + val onProgress: ViewEventCallback>, + val onError: ViewEventCallback>, +) { + @JavascriptInterface + public fun onMessage(message: String) { + val jsonObject = JSONObject(message) + val action = jsonObject.getString("action") + + when (action) { + "error" -> { + val messageStr = jsonObject.get("messageStr") + if (messageStr !is String) { + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) + return + } + this.onError(mapOf(ERROR_KEY to messageStr)) + } + "progress" -> { + val messageFloat = jsonObject.get("messageFloat") + if (messageFloat !is Number) { + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) + return + } + this.onProgress(mapOf(PROGRESS_KEY to messageFloat)) + } + } + } + + companion object { + const val PROGRESS_KEY = "progress" + const val ERROR_KEY = "message" + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 4cdc11e9..04411ecf 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -5,6 +5,7 @@ "ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoBlueskyVisibilityViewModule", + "ExpoHLSDownloadModule", "ExpoPlatformInfoModule" ] }, @@ -13,7 +14,8 @@ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", - "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule", + "expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 2cf4f36c..67dc6ee6 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,7 +1,15 @@ +import HLSDownloadView from './src/HLSDownload' import * as PlatformInfo from './src/PlatformInfo' import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' import VisibilityView from './src/VisibilityView' -export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} +export { + AudioCategory, + HLSDownloadView, + PlatformInfo, + Referrer, + SharedPrefs, + VisibilityView, +} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift new file mode 100644 index 00000000..a9b445e4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift @@ -0,0 +1,31 @@ +import ExpoModulesCore + +public class ExpoHLSDownloadModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoHLSDownload") + + Function("isAvailable") { + if #available(iOS 14.5, *) { + return true + } + return false + } + + View(HLSDownloadView.self) { + Events([ + "onStart", + "onError", + "onProgress", + "onSuccess" + ]) + + Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in + view.downloaderUrl = downloaderUrl + } + + AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in + view.startDownload(sourceUrl: sourceUrl) + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift new file mode 100644 index 00000000..591c0933 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift @@ -0,0 +1,148 @@ +import ExpoModulesCore +import WebKit + +class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate { + var webView: WKWebView! + var downloaderUrl: URL? + + private var onStart = EventDispatcher() + private var onError = EventDispatcher() + private var onProgress = EventDispatcher() + private var onSuccess = EventDispatcher() + + private var outputUrl: URL? + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + // controller for post message api + let contentController = WKUserContentController() + contentController.add(self, name: "onMessage") + let configuration = WKWebViewConfiguration() + configuration.userContentController = contentController + + // create webview + let webView = WKWebView(frame: .zero, configuration: configuration) + + // Use these for debugging, to see the webview itself + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.layer.masksToBounds = false + webView.backgroundColor = .clear + webView.contentMode = .scaleToFill + + webView.navigationDelegate = self + + self.addSubview(webView) + self.webView = webView + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - view functions + + func startDownload(sourceUrl: URL) { + guard let downloaderUrl = self.downloaderUrl, + let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else { + self.onError([ + "message": "Downloader URL is not set." + ]) + return + } + + self.onStart() + self.webView.load(URLRequest(url: url)) + } + + // webview message handling + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let response = message.body as? String, + let data = response.data(using: .utf8), + let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + + switch payload.action { + case .progress: + guard let progress = payload.messageFloat else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + self.onProgress([ + "progress": progress + ]) + case .error: + guard let messageStr = payload.messageStr else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + self.onError([ + "message": messageStr + ]) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + guard #available(iOS 14.5, *) else { + return .cancel + } + + if navigationAction.shouldPerformDownload { + return .download + } else { + return .allow + } + } + + // MARK: - wkdownloaddelegate + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + download.delegate = self + } + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + download.delegate = self + } + + @available(iOS 14.5, *) + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + let directory = NSTemporaryDirectory() + let fileName = "\(NSUUID().uuidString).mp4" + let url = NSURL.fileURL(withPathComponents: [directory, fileName]) + + self.outputUrl = url + completionHandler(url) + } + + @available(iOS 14.5, *) + func downloadDidFinish(_ download: WKDownload) { + guard let url = self.outputUrl else { + return + } + self.onSuccess([ + "uri": url.absoluteString + ]) + self.outputUrl = nil + } +} + +struct WebViewActionPayload: Decodable { + enum Action: String, Decodable { + case progress, error + } + + let action: Action + let messageStr: String? + let messageFloat: Float? +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx new file mode 100644 index 00000000..92f26192 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {HLSDownloadViewProps} from './types' + +const NativeModule = requireNativeModule('ExpoHLSDownload') +const NativeView: React.ComponentType< + HLSDownloadViewProps & { + ref: React.RefObject + style: StyleProp + } +> = requireNativeViewManager('ExpoHLSDownload') + +export default class HLSDownloadView extends React.PureComponent { + private nativeRef: React.RefObject = React.createRef() + + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return NativeModule.isAvailable() + } + + async startDownloadAsync(sourceUrl: string): Promise { + return await this.nativeRef.current.startDownloadAsync(sourceUrl) + } + + render() { + return ( + + ) + } +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx new file mode 100644 index 00000000..93c50497 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx @@ -0,0 +1,22 @@ +import React from 'react' + +import {NotImplementedError} from '../NotImplemented' +import {HLSDownloadViewProps} from './types' + +export default class HLSDownloadView extends React.PureComponent { + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return false + } + + async startDownloadAsync(sourceUrl: string): Promise { + throw new NotImplementedError({sourceUrl}) + } + + render() { + return null + } +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts new file mode 100644 index 00000000..6a474d28 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts @@ -0,0 +1,10 @@ +import {NativeSyntheticEvent} from 'react-native' + +export interface HLSDownloadViewProps { + downloaderUrl: string + onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void + + onStart?: () => void + onError?: (e: NativeSyntheticEvent<{message: string}>) => void + onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void +} diff --git a/package.json b/package.json index a4523d98..088f2faf 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", + "@ffmpeg/ffmpeg": "^0.12.10", + "@ffmpeg/util": "^0.12.1", "@floating-ui/dom": "^1.6.3", "@floating-ui/react-dom": "^2.0.8", "@formatjs/intl-locale": "^4.0.0", @@ -143,6 +145,7 @@ "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", + "hls-parser": "^0.13.3", "hls.js": "^1.5.11", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", @@ -224,6 +227,7 @@ "@testing-library/react-native": "^11.5.2", "@tsconfig/react-native": "^2.0.3", "@types/he": "^1.1.2", + "@types/hls-parser": "^0.8.7", "@types/jest": "^29.4.0", "@types/lodash.chunk": "^4.2.7", "@types/lodash.debounce": "^4.0.7", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 79856879..0d151427 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}} /> + 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 00000000..a1f6466f --- /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 00000000..3169d265 --- /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(null) + const fetchFileRef = React.useRef(null) + + const [dataUrl, setDataUrl] = React.useState(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(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 ( + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0cc83b47..77e7266a 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 c9e23e08..bda2d98e 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 71dbe883..c6da6331 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(null) + + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() return ( + { + 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)} + /> + {!showContainedList ? ( <> diff --git a/yarn.lock b/yarn.lock index cd0508d6..28308d95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3925,6 +3925,23 @@ resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== +"@ffmpeg/ffmpeg@^0.12.10": + version "0.12.10" + resolved "https://registry.yarnpkg.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz#e3cce21f21f11f33dfc1ec1d5ad5694f4a3073c9" + integrity sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ== + dependencies: + "@ffmpeg/types" "^0.12.2" + +"@ffmpeg/types@^0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@ffmpeg/types/-/types-0.12.2.tgz#bc7eef321ae50225c247091f1f23fd3087c6aa1d" + integrity sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA== + +"@ffmpeg/util@^0.12.1": + version "0.12.1" + resolved "https://registry.yarnpkg.com/@ffmpeg/util/-/util-0.12.1.tgz#98afa20d7b4c0821eebdb205ddcfa5d07b0a4f53" + integrity sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ== + "@floating-ui/core@^1.0.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" @@ -8007,6 +8024,13 @@ resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3" integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g== +"@types/hls-parser@^0.8.7": + version "0.8.7" + resolved "https://registry.yarnpkg.com/@types/hls-parser/-/hls-parser-0.8.7.tgz#26360493231ed8606ebe995976c63c69c3982657" + integrity sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ== + dependencies: + "@types/node" "*" + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -13456,6 +13480,11 @@ history@^5.3.0: dependencies: "@babel/runtime" "^7.7.6" +hls-parser@^0.13.3: + version "0.13.3" + resolved "https://registry.yarnpkg.com/hls-parser/-/hls-parser-0.13.3.tgz#5f7a305629cf462bbf16a4d080e03e0be714f1fe" + integrity sha512-DXqW7bwx9j2qFcAXS/LBJTDJWitxknb6oUnsnTvECHrecPvPbhRgIu45OgNDUU6gpwKxMJx40SHRRUUhdIM2gA== + hls.js@^1.5.11: version "1.5.11" resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"