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 /modules | |
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 'modules')
9 files changed, 438 insertions, 2 deletions
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 000000000..786b84e41 --- /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 000000000..5f3082a81 --- /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<Map<String, Any>>, + val onError: ViewEventCallback<Map<String, Any>>, +) { + @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 4cdc11e99..04411ecf7 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 2cf4f36c5..67dc6ee60 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 000000000..a9b445e48 --- /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 000000000..591c09335 --- /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 000000000..92f26192e --- /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<any> + style: StyleProp<ViewStyle> + } +> = requireNativeViewManager('ExpoHLSDownload') + +export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> { + private nativeRef: React.RefObject<any> = React.createRef() + + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return NativeModule.isAvailable() + } + + async startDownloadAsync(sourceUrl: string): Promise<void> { + return await this.nativeRef.current.startDownloadAsync(sourceUrl) + } + + render() { + return ( + <NativeView + ref={this.nativeRef} + style={{height: 0, width: 0}} + {...this.props} + /> + ) + } +} 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 000000000..93c50497f --- /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<HLSDownloadViewProps> { + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return false + } + + async startDownloadAsync(sourceUrl: string): Promise<void> { + 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 000000000..6a474d282 --- /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 +} |