[Video] Download videos (#4886)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>zio/stable
parent
b9975697e2
commit
11061b628e
|
@ -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)
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
# be ok.
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
Disallow: /video-download
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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`)}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export function VideoDownloadScreen() {
|
||||
// @TODO redirect
|
||||
return null
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -50,6 +50,7 @@ export type CommonNavigatorParams = {
|
|||
StarterPackShort: {code: string}
|
||||
StarterPackWizard: undefined
|
||||
StarterPackEdit: {rkey?: string}
|
||||
VideoDownload: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -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]}>
|
||||
|
|
29
yarn.lock
29
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"
|
||||
|
|
Loading…
Reference in New Issue