Bluesky app fork with some witchin' additions 💫

[Video] Download videos (#4886)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

authored by hailey.at

Samuel Newman and committed by
GitHub
11061b62 b9975697

+747 -3
+3
bskyweb/cmd/bskyweb/server.go
··· 256 256 e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) 257 257 e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) 258 258 259 + // video download 260 + e.GET("/video-download", server.WebGeneric) 261 + 259 262 // starter packs 260 263 e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) 261 264 e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
+1
bskyweb/static/robots.txt
··· 7 7 # be ok. 8 8 User-Agent: * 9 9 Allow: / 10 + Disallow: /video-download
+35
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt
··· 1 + package expo.modules.blueskyswissarmy.hlsdownload 2 + 3 + import android.net.Uri 4 + import expo.modules.kotlin.modules.Module 5 + import expo.modules.kotlin.modules.ModuleDefinition 6 + 7 + class ExpoHLSDownloadModule : Module() { 8 + override fun definition() = 9 + ModuleDefinition { 10 + Name("ExpoHLSDownload") 11 + 12 + Function("isAvailable") { 13 + return@Function true 14 + } 15 + 16 + View(HLSDownloadView::class) { 17 + Events( 18 + arrayOf( 19 + "onStart", 20 + "onError", 21 + "onProgress", 22 + "onSuccess", 23 + ), 24 + ) 25 + 26 + Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri -> 27 + view.downloaderUrl = downloaderUrl 28 + } 29 + 30 + AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri -> 31 + view.startDownload(sourceUrl) 32 + } 33 + } 34 + } 35 + }
+141
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt
··· 1 + package expo.modules.blueskyswissarmy.hlsdownload 2 + 3 + import android.annotation.SuppressLint 4 + import android.content.Context 5 + import android.net.Uri 6 + import android.util.Base64 7 + import android.util.Log 8 + import android.webkit.DownloadListener 9 + import android.webkit.JavascriptInterface 10 + import android.webkit.WebView 11 + import expo.modules.kotlin.AppContext 12 + import expo.modules.kotlin.viewevent.EventDispatcher 13 + import expo.modules.kotlin.viewevent.ViewEventCallback 14 + import expo.modules.kotlin.views.ExpoView 15 + import org.json.JSONObject 16 + import java.io.File 17 + import java.io.FileOutputStream 18 + import java.net.URI 19 + import java.util.UUID 20 + 21 + class HLSDownloadView( 22 + context: Context, 23 + appContext: AppContext, 24 + ) : ExpoView(context, appContext), 25 + DownloadListener { 26 + private val webView = WebView(context) 27 + 28 + var downloaderUrl: Uri? = null 29 + 30 + private val onStart by EventDispatcher() 31 + private val onError by EventDispatcher() 32 + private val onProgress by EventDispatcher() 33 + private val onSuccess by EventDispatcher() 34 + 35 + init { 36 + this.setupWebView() 37 + this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) 38 + } 39 + 40 + @SuppressLint("SetJavaScriptEnabled") 41 + private fun setupWebView() { 42 + val webSettings = this.webView.settings 43 + webSettings.javaScriptEnabled = true 44 + webSettings.domStorageEnabled = true 45 + 46 + webView.setDownloadListener(this) 47 + webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface") 48 + } 49 + 50 + override fun onDetachedFromWindow() { 51 + super.onDetachedFromWindow() 52 + this.webView.stopLoading() 53 + this.webView.clearHistory() 54 + this.webView.removeAllViews() 55 + this.webView.destroy() 56 + } 57 + 58 + fun startDownload(sourceUrl: Uri) { 59 + if (this.downloaderUrl == null) { 60 + this.onError(mapOf(ERROR_KEY to "Downloader URL is not set.")) 61 + return 62 + } 63 + 64 + val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl") 65 + this.webView.loadUrl(url.toString()) 66 + this.onStart(mapOf()) 67 + } 68 + 69 + override fun onDownloadStart( 70 + url: String?, 71 + userAgent: String?, 72 + contentDisposition: String?, 73 + mimeType: String?, 74 + contentLength: Long, 75 + ) { 76 + if (url == null) { 77 + this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview.")) 78 + return 79 + } 80 + 81 + val tempDir = context.cacheDir 82 + val fileName = "${UUID.randomUUID()}.mp4" 83 + val file = File(tempDir, fileName) 84 + 85 + val base64 = url.split(",")[1] 86 + val bytes = Base64.decode(base64, Base64.DEFAULT) 87 + 88 + val fos = FileOutputStream(file) 89 + try { 90 + fos.write(bytes) 91 + } catch (e: Exception) { 92 + Log.e("FileDownload", "Error downloading file", e) 93 + this.onError(mapOf(ERROR_KEY to e.message.toString())) 94 + return 95 + } finally { 96 + fos.close() 97 + } 98 + 99 + val uri = Uri.fromFile(file) 100 + this.onSuccess(mapOf("uri" to uri.toString())) 101 + } 102 + 103 + companion object { 104 + const val ERROR_KEY = "message" 105 + } 106 + } 107 + 108 + public class WebAppInterface( 109 + val onProgress: ViewEventCallback<Map<String, Any>>, 110 + val onError: ViewEventCallback<Map<String, Any>>, 111 + ) { 112 + @JavascriptInterface 113 + public fun onMessage(message: String) { 114 + val jsonObject = JSONObject(message) 115 + val action = jsonObject.getString("action") 116 + 117 + when (action) { 118 + "error" -> { 119 + val messageStr = jsonObject.get("messageStr") 120 + if (messageStr !is String) { 121 + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) 122 + return 123 + } 124 + this.onError(mapOf(ERROR_KEY to messageStr)) 125 + } 126 + "progress" -> { 127 + val messageFloat = jsonObject.get("messageFloat") 128 + if (messageFloat !is Number) { 129 + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) 130 + return 131 + } 132 + this.onProgress(mapOf(PROGRESS_KEY to messageFloat)) 133 + } 134 + } 135 + } 136 + 137 + companion object { 138 + const val PROGRESS_KEY = "progress" 139 + const val ERROR_KEY = "message" 140 + } 141 + }
+3 -1
modules/expo-bluesky-swiss-army/expo-module.config.json
··· 5 5 "ExpoBlueskySharedPrefsModule", 6 6 "ExpoBlueskyReferrerModule", 7 7 "ExpoBlueskyVisibilityViewModule", 8 + "ExpoHLSDownloadModule", 8 9 "ExpoPlatformInfoModule" 9 10 ] 10 11 }, ··· 13 14 "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", 14 15 "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", 15 16 "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", 16 - "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" 17 + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule", 18 + "expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule" 17 19 ] 18 20 } 19 21 }
+9 -1
modules/expo-bluesky-swiss-army/index.ts
··· 1 + import HLSDownloadView from './src/HLSDownload' 1 2 import * as PlatformInfo from './src/PlatformInfo' 2 3 import {AudioCategory} from './src/PlatformInfo/types' 3 4 import * as Referrer from './src/Referrer' 4 5 import * as SharedPrefs from './src/SharedPrefs' 5 6 import VisibilityView from './src/VisibilityView' 6 7 7 - export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} 8 + export { 9 + AudioCategory, 10 + HLSDownloadView, 11 + PlatformInfo, 12 + Referrer, 13 + SharedPrefs, 14 + VisibilityView, 15 + }
+31
modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift
··· 1 + import ExpoModulesCore 2 + 3 + public class ExpoHLSDownloadModule: Module { 4 + public func definition() -> ModuleDefinition { 5 + Name("ExpoHLSDownload") 6 + 7 + Function("isAvailable") { 8 + if #available(iOS 14.5, *) { 9 + return true 10 + } 11 + return false 12 + } 13 + 14 + View(HLSDownloadView.self) { 15 + Events([ 16 + "onStart", 17 + "onError", 18 + "onProgress", 19 + "onSuccess" 20 + ]) 21 + 22 + Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in 23 + view.downloaderUrl = downloaderUrl 24 + } 25 + 26 + AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in 27 + view.startDownload(sourceUrl: sourceUrl) 28 + } 29 + } 30 + } 31 + }
+148
modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift
··· 1 + import ExpoModulesCore 2 + import WebKit 3 + 4 + class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate { 5 + var webView: WKWebView! 6 + var downloaderUrl: URL? 7 + 8 + private var onStart = EventDispatcher() 9 + private var onError = EventDispatcher() 10 + private var onProgress = EventDispatcher() 11 + private var onSuccess = EventDispatcher() 12 + 13 + private var outputUrl: URL? 14 + 15 + public required init(appContext: AppContext? = nil) { 16 + super.init(appContext: appContext) 17 + 18 + // controller for post message api 19 + let contentController = WKUserContentController() 20 + contentController.add(self, name: "onMessage") 21 + let configuration = WKWebViewConfiguration() 22 + configuration.userContentController = contentController 23 + 24 + // create webview 25 + let webView = WKWebView(frame: .zero, configuration: configuration) 26 + 27 + // Use these for debugging, to see the webview itself 28 + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 29 + webView.layer.masksToBounds = false 30 + webView.backgroundColor = .clear 31 + webView.contentMode = .scaleToFill 32 + 33 + webView.navigationDelegate = self 34 + 35 + self.addSubview(webView) 36 + self.webView = webView 37 + } 38 + 39 + required init?(coder: NSCoder) { 40 + fatalError("init(coder:) has not been implemented") 41 + } 42 + 43 + // MARK: - view functions 44 + 45 + func startDownload(sourceUrl: URL) { 46 + guard let downloaderUrl = self.downloaderUrl, 47 + let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else { 48 + self.onError([ 49 + "message": "Downloader URL is not set." 50 + ]) 51 + return 52 + } 53 + 54 + self.onStart() 55 + self.webView.load(URLRequest(url: url)) 56 + } 57 + 58 + // webview message handling 59 + 60 + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 61 + guard let response = message.body as? String, 62 + let data = response.data(using: .utf8), 63 + let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { 64 + self.onError([ 65 + "message": "Failed to decode JSON post message." 66 + ]) 67 + return 68 + } 69 + 70 + switch payload.action { 71 + case .progress: 72 + guard let progress = payload.messageFloat else { 73 + self.onError([ 74 + "message": "Failed to decode JSON post message." 75 + ]) 76 + return 77 + } 78 + self.onProgress([ 79 + "progress": progress 80 + ]) 81 + case .error: 82 + guard let messageStr = payload.messageStr else { 83 + self.onError([ 84 + "message": "Failed to decode JSON post message." 85 + ]) 86 + return 87 + } 88 + self.onError([ 89 + "message": messageStr 90 + ]) 91 + } 92 + } 93 + 94 + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { 95 + guard #available(iOS 14.5, *) else { 96 + return .cancel 97 + } 98 + 99 + if navigationAction.shouldPerformDownload { 100 + return .download 101 + } else { 102 + return .allow 103 + } 104 + } 105 + 106 + // MARK: - wkdownloaddelegate 107 + 108 + @available(iOS 14.5, *) 109 + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { 110 + download.delegate = self 111 + } 112 + 113 + @available(iOS 14.5, *) 114 + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { 115 + download.delegate = self 116 + } 117 + 118 + @available(iOS 14.5, *) 119 + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { 120 + let directory = NSTemporaryDirectory() 121 + let fileName = "\(NSUUID().uuidString).mp4" 122 + let url = NSURL.fileURL(withPathComponents: [directory, fileName]) 123 + 124 + self.outputUrl = url 125 + completionHandler(url) 126 + } 127 + 128 + @available(iOS 14.5, *) 129 + func downloadDidFinish(_ download: WKDownload) { 130 + guard let url = self.outputUrl else { 131 + return 132 + } 133 + self.onSuccess([ 134 + "uri": url.absoluteString 135 + ]) 136 + self.outputUrl = nil 137 + } 138 + } 139 + 140 + struct WebViewActionPayload: Decodable { 141 + enum Action: String, Decodable { 142 + case progress, error 143 + } 144 + 145 + let action: Action 146 + let messageStr: String? 147 + let messageFloat: Float? 148 + }
+39
modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 3 + import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' 4 + 5 + import {HLSDownloadViewProps} from './types' 6 + 7 + const NativeModule = requireNativeModule('ExpoHLSDownload') 8 + const NativeView: React.ComponentType< 9 + HLSDownloadViewProps & { 10 + ref: React.RefObject<any> 11 + style: StyleProp<ViewStyle> 12 + } 13 + > = requireNativeViewManager('ExpoHLSDownload') 14 + 15 + export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> { 16 + private nativeRef: React.RefObject<any> = React.createRef() 17 + 18 + constructor(props: HLSDownloadViewProps) { 19 + super(props) 20 + } 21 + 22 + static isAvailable(): boolean { 23 + return NativeModule.isAvailable() 24 + } 25 + 26 + async startDownloadAsync(sourceUrl: string): Promise<void> { 27 + return await this.nativeRef.current.startDownloadAsync(sourceUrl) 28 + } 29 + 30 + render() { 31 + return ( 32 + <NativeView 33 + ref={this.nativeRef} 34 + style={{height: 0, width: 0}} 35 + {...this.props} 36 + /> 37 + ) 38 + } 39 + }
+22
modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx
··· 1 + import React from 'react' 2 + 3 + import {NotImplementedError} from '../NotImplemented' 4 + import {HLSDownloadViewProps} from './types' 5 + 6 + export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> { 7 + constructor(props: HLSDownloadViewProps) { 8 + super(props) 9 + } 10 + 11 + static isAvailable(): boolean { 12 + return false 13 + } 14 + 15 + async startDownloadAsync(sourceUrl: string): Promise<void> { 16 + throw new NotImplementedError({sourceUrl}) 17 + } 18 + 19 + render() { 20 + return null 21 + } 22 + }
+10
modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts
··· 1 + import {NativeSyntheticEvent} from 'react-native' 2 + 3 + export interface HLSDownloadViewProps { 4 + downloaderUrl: string 5 + onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void 6 + 7 + onStart?: () => void 8 + onError?: (e: NativeSyntheticEvent<{message: string}>) => void 9 + onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void 10 + }
+4
package.json
··· 59 59 "@emoji-mart/react": "^1.1.1", 60 60 "@expo/html-elements": "^0.4.2", 61 61 "@expo/webpack-config": "^19.0.0", 62 + "@ffmpeg/ffmpeg": "^0.12.10", 63 + "@ffmpeg/util": "^0.12.1", 62 64 "@floating-ui/dom": "^1.6.3", 63 65 "@floating-ui/react-dom": "^2.0.8", 64 66 "@formatjs/intl-locale": "^4.0.0", ··· 143 145 "expo-web-browser": "~13.0.3", 144 146 "fast-text-encoding": "^1.0.6", 145 147 "history": "^5.3.0", 148 + "hls-parser": "^0.13.3", 146 149 "hls.js": "^1.5.11", 147 150 "js-sha256": "^0.9.0", 148 151 "jwt-decode": "^4.0.0", ··· 224 227 "@testing-library/react-native": "^11.5.2", 225 228 "@tsconfig/react-native": "^2.0.3", 226 229 "@types/he": "^1.1.2", 230 + "@types/hls-parser": "^0.8.7", 227 231 "@types/jest": "^29.4.0", 228 232 "@types/lodash.chunk": "^4.2.7", 229 233 "@types/lodash.debounce": "^4.0.7",
+6
src/Navigation.tsx
··· 50 50 StarterPackScreenShort, 51 51 } from '#/screens/StarterPack/StarterPackScreen' 52 52 import {Wizard} from '#/screens/StarterPack/Wizard' 53 + import {VideoDownloadScreen} from '#/components/VideoDownloadScreen' 53 54 import {Referrer} from '../modules/expo-bluesky-swiss-army' 54 55 import {init as initAnalytics} from './lib/analytics/analytics' 55 56 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' ··· 363 364 name="StarterPackEdit" 364 365 getComponent={() => Wizard} 365 366 options={{title: title(msg`Edit your starter pack`), requireAuth: true}} 367 + /> 368 + <Stack.Screen 369 + name="VideoDownload" 370 + getComponent={() => VideoDownloadScreen} 371 + options={{title: title(msg`Download video`)}} 366 372 /> 367 373 </> 368 374 )
+4
src/components/VideoDownloadScreen.native.tsx
··· 1 + export function VideoDownloadScreen() { 2 + // @TODO redirect 3 + return null 4 + }
+215
src/components/VideoDownloadScreen.tsx
··· 1 + import React from 'react' 2 + import {parse} from 'hls-parser' 3 + import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types' 4 + 5 + interface PostMessageData { 6 + action: 'progress' | 'error' 7 + messageStr?: string 8 + messageFloat?: number 9 + } 10 + 11 + function postMessage(data: PostMessageData) { 12 + // @ts-expect-error safari webview only 13 + if (window?.webkit) { 14 + // @ts-expect-error safari webview only 15 + window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data)) 16 + // @ts-expect-error android webview only 17 + } else if (AndroidInterface) { 18 + // @ts-expect-error android webview only 19 + AndroidInterface.onMessage(JSON.stringify(data)) 20 + } 21 + } 22 + 23 + function createSegementUrl(originalUrl: string, newFile: string) { 24 + const parts = originalUrl.split('/') 25 + parts[parts.length - 1] = newFile 26 + return parts.join('/') 27 + } 28 + 29 + export function VideoDownloadScreen() { 30 + const ffmpegRef = React.useRef<any>(null) 31 + const fetchFileRef = React.useRef<any>(null) 32 + 33 + const [dataUrl, setDataUrl] = React.useState<any>(null) 34 + 35 + const load = React.useCallback(async () => { 36 + const ffmpegLib = await import('@ffmpeg/ffmpeg') 37 + const ffmpeg = new ffmpegLib.FFmpeg() 38 + ffmpegRef.current = ffmpeg 39 + 40 + const ffmpegUtilLib = await import('@ffmpeg/util') 41 + fetchFileRef.current = ffmpegUtilLib.fetchFile 42 + 43 + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm' 44 + 45 + await ffmpeg.load({ 46 + coreURL: await ffmpegUtilLib.toBlobURL( 47 + `${baseURL}/ffmpeg-core.js`, 48 + 'text/javascript', 49 + ), 50 + wasmURL: await ffmpegUtilLib.toBlobURL( 51 + `${baseURL}/ffmpeg-core.wasm`, 52 + 'application/wasm', 53 + ), 54 + }) 55 + }, []) 56 + 57 + const createMp4 = React.useCallback(async (videoUrl: string) => { 58 + // Get the master playlist and find the best variant 59 + const masterPlaylistRes = await fetch(videoUrl) 60 + const masterPlaylistText = await masterPlaylistRes.text() 61 + const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist 62 + 63 + // If URL given is not a master playlist, we probably cannot handle this. 64 + if (!masterPlaylist.isMasterPlaylist) { 65 + postMessage({ 66 + action: 'error', 67 + messageStr: 'A master playlist was not found in the provided playlist.', 68 + }) 69 + return 70 + } 71 + 72 + // Figure out what the best quality is. These should generally be in order, but we'll check them all just in case 73 + let bestVariant: Variant | undefined 74 + for (const variant of masterPlaylist.variants) { 75 + if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) { 76 + bestVariant = variant 77 + } 78 + } 79 + 80 + // Should only happen if there was no variants at all given to us. Mostly for types. 81 + if (!bestVariant) { 82 + postMessage({ 83 + action: 'error', 84 + messageStr: 'No variants were found in the provided master playlist.', 85 + }) 86 + return 87 + } 88 + 89 + const urlParts = videoUrl.split('/') 90 + urlParts[urlParts.length - 1] = bestVariant?.uri 91 + const bestVariantUrl = urlParts.join('/') 92 + 93 + // Download and parse m3u8 94 + const hlsFileRes = await fetch(bestVariantUrl) 95 + const hlsPlainText = await hlsFileRes.text() 96 + const playlist = parse(hlsPlainText) as MediaPlaylist 97 + 98 + // This one shouldn't be a master playlist - again just for types really 99 + if (playlist.isMasterPlaylist) { 100 + postMessage({ 101 + action: 'error', 102 + messageStr: 'An unknown error has occurred.', 103 + }) 104 + return 105 + } 106 + 107 + const ffmpeg = ffmpegRef.current 108 + 109 + // Get the correctly ordered file names. We need to remove the tracking info from the end of the file name 110 + const segments = playlist.segments.map(segment => { 111 + return segment.uri.split('?')[0] 112 + }) 113 + 114 + // Download each segment 115 + let error: string | null = null 116 + let completed = 0 117 + await Promise.all( 118 + playlist.segments.map(async segment => { 119 + const uri = createSegementUrl(bestVariantUrl, segment.uri) 120 + const filename = segment.uri.split('?')[0] 121 + 122 + const res = await fetch(uri) 123 + if (!res.ok) { 124 + error = 'Failed to download playlist segment.' 125 + } 126 + 127 + const blob = await res.blob() 128 + try { 129 + await ffmpeg.writeFile(filename, await fetchFileRef.current(blob)) 130 + } catch (e: unknown) { 131 + error = 'Failed to write file.' 132 + } finally { 133 + completed++ 134 + const progress = completed / playlist.segments.length 135 + postMessage({ 136 + action: 'progress', 137 + messageFloat: progress, 138 + }) 139 + } 140 + }), 141 + ) 142 + 143 + // Do something if there was an error 144 + if (error) { 145 + postMessage({ 146 + action: 'error', 147 + messageStr: error, 148 + }) 149 + return 150 + } 151 + 152 + // Put the segments together 153 + await ffmpeg.exec([ 154 + '-i', 155 + `concat:${segments.join('|')}`, 156 + '-c:v', 157 + 'copy', 158 + 'output.mp4', 159 + ]) 160 + 161 + const fileData = await ffmpeg.readFile('output.mp4') 162 + const blob = new Blob([fileData.buffer], {type: 'video/mp4'}) 163 + const dataUrl = await new Promise<string | null>(resolve => { 164 + const reader = new FileReader() 165 + reader.onloadend = () => resolve(reader.result as string) 166 + reader.onerror = () => resolve(null) 167 + reader.readAsDataURL(blob) 168 + }) 169 + return dataUrl 170 + }, []) 171 + 172 + const download = React.useCallback( 173 + async (videoUrl: string) => { 174 + await load() 175 + const mp4Res = await createMp4(videoUrl) 176 + 177 + if (!mp4Res) { 178 + postMessage({ 179 + action: 'error', 180 + messageStr: 'An error occurred while creating the MP4.', 181 + }) 182 + return 183 + } 184 + 185 + setDataUrl(mp4Res) 186 + }, 187 + [createMp4, load], 188 + ) 189 + 190 + React.useEffect(() => { 191 + const url = new URL(window.location.href) 192 + const videoUrl = url.searchParams.get('videoUrl') 193 + 194 + if (!videoUrl) { 195 + postMessage({action: 'error', messageStr: 'No video URL provided'}) 196 + } else { 197 + setDataUrl(null) 198 + download(videoUrl) 199 + } 200 + }, [download]) 201 + 202 + if (!dataUrl) return null 203 + 204 + return ( 205 + <div> 206 + <a 207 + href={dataUrl} 208 + ref={el => { 209 + el?.click() 210 + }} 211 + download="video.mp4" 212 + /> 213 + </div> 214 + ) 215 + }
+1
src/lib/routes/types.ts
··· 50 50 StarterPackShort: {code: string} 51 51 StarterPackWizard: undefined 52 52 StarterPackEdit: {rkey?: string} 53 + VideoDownload: undefined 53 54 } 54 55 55 56 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/routes.ts
··· 48 48 StarterPack: '/starter-pack/:name/:rkey', 49 49 StarterPackShort: '/starter-pack-short/:code', 50 50 StarterPackWizard: '/starter-pack/create', 51 + VideoDownload: '/video-download', 51 52 })
+45 -1
src/view/screens/Storybook/index.tsx
··· 1 1 import React from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 + import {deleteAsync} from 'expo-file-system' 4 + import {saveToLibraryAsync} from 'expo-media-library' 3 5 4 6 import {useSetThemePrefs} from '#/state/shell' 5 - import {isWeb} from 'platform/detection' 7 + import {useVideoLibraryPermission} from 'lib/hooks/usePermissions' 8 + import {isIOS, isWeb} from 'platform/detection' 6 9 import {CenteredView} from '#/view/com/util/Views' 10 + import * as Toast from 'view/com/util/Toast' 7 11 import {ListContained} from 'view/screens/Storybook/ListContained' 8 12 import {atoms as a, ThemeProvider, useTheme} from '#/alf' 9 13 import {Button, ButtonText} from '#/components/Button' 14 + import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army' 10 15 import {Breakpoints} from './Breakpoints' 11 16 import {Buttons} from './Buttons' 12 17 import {Dialogs} from './Dialogs' ··· 33 38 const t = useTheme() 34 39 const {setColorMode, setDarkTheme} = useSetThemePrefs() 35 40 const [showContainedList, setShowContainedList] = React.useState(false) 41 + const hlsDownloadRef = React.useRef<HLSDownloadView>(null) 42 + 43 + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 36 44 37 45 return ( 38 46 <CenteredView style={[t.atoms.bg]}> 39 47 <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 100}]}> 48 + <HLSDownloadView 49 + ref={hlsDownloadRef} 50 + downloaderUrl={ 51 + isIOS 52 + ? 'http://localhost:19006/video-download' 53 + : 'http://10.0.2.2:19006/video-download' 54 + } 55 + onSuccess={async e => { 56 + const uri = e.nativeEvent.uri 57 + const permsRes = await requestVideoAccessIfNeeded() 58 + if (!permsRes) return 59 + 60 + await saveToLibraryAsync(uri) 61 + try { 62 + deleteAsync(uri) 63 + } catch (err) { 64 + console.error('Failed to delete file', err) 65 + } 66 + Toast.show('Video saved to library') 67 + }} 68 + onStart={() => console.log('Download is starting')} 69 + onError={e => console.log(e.nativeEvent.message)} 70 + onProgress={e => console.log(e.nativeEvent.progress)} 71 + /> 72 + <Button 73 + variant="solid" 74 + color="primary" 75 + size="small" 76 + onPress={async () => { 77 + hlsDownloadRef.current?.startDownloadAsync( 78 + 'https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?download=true', 79 + ) 80 + }} 81 + label="Video download test"> 82 + <ButtonText>Video download test</ButtonText> 83 + </Button> 40 84 {!showContainedList ? ( 41 85 <> 42 86 <View style={[a.flex_row, a.align_start, a.gap_md]}>
+29
yarn.lock
··· 3925 3925 resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" 3926 3926 integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== 3927 3927 3928 + "@ffmpeg/ffmpeg@^0.12.10": 3929 + version "0.12.10" 3930 + resolved "https://registry.yarnpkg.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz#e3cce21f21f11f33dfc1ec1d5ad5694f4a3073c9" 3931 + integrity sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ== 3932 + dependencies: 3933 + "@ffmpeg/types" "^0.12.2" 3934 + 3935 + "@ffmpeg/types@^0.12.2": 3936 + version "0.12.2" 3937 + resolved "https://registry.yarnpkg.com/@ffmpeg/types/-/types-0.12.2.tgz#bc7eef321ae50225c247091f1f23fd3087c6aa1d" 3938 + integrity sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA== 3939 + 3940 + "@ffmpeg/util@^0.12.1": 3941 + version "0.12.1" 3942 + resolved "https://registry.yarnpkg.com/@ffmpeg/util/-/util-0.12.1.tgz#98afa20d7b4c0821eebdb205ddcfa5d07b0a4f53" 3943 + integrity sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ== 3944 + 3928 3945 "@floating-ui/core@^1.0.0": 3929 3946 version "1.6.0" 3930 3947 resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" ··· 8006 8023 version "1.2.0" 8007 8024 resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3" 8008 8025 integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g== 8026 + 8027 + "@types/hls-parser@^0.8.7": 8028 + version "0.8.7" 8029 + resolved "https://registry.yarnpkg.com/@types/hls-parser/-/hls-parser-0.8.7.tgz#26360493231ed8606ebe995976c63c69c3982657" 8030 + integrity sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ== 8031 + dependencies: 8032 + "@types/node" "*" 8009 8033 8010 8034 "@types/html-minifier-terser@^6.0.0": 8011 8035 version "6.1.0" ··· 13455 13479 integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== 13456 13480 dependencies: 13457 13481 "@babel/runtime" "^7.7.6" 13482 + 13483 + hls-parser@^0.13.3: 13484 + version "0.13.3" 13485 + resolved "https://registry.yarnpkg.com/hls-parser/-/hls-parser-0.13.3.tgz#5f7a305629cf462bbf16a4d080e03e0be714f1fe" 13486 + integrity sha512-DXqW7bwx9j2qFcAXS/LBJTDJWitxknb6oUnsnTvECHrecPvPbhRgIu45OgNDUU6gpwKxMJx40SHRRUUhdIM2gA== 13458 13487 13459 13488 hls.js@^1.5.11: 13460 13489 version "1.5.11"