[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/liked-by", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||||
|
|
||||||
|
// video download
|
||||||
|
e.GET("/video-download", server.WebGeneric)
|
||||||
|
|
||||||
// starter packs
|
// starter packs
|
||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
|
@ -7,3 +7,4 @@
|
||||||
# be ok.
|
# be ok.
|
||||||
User-Agent: *
|
User-Agent: *
|
||||||
Allow: /
|
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",
|
"ExpoBlueskySharedPrefsModule",
|
||||||
"ExpoBlueskyReferrerModule",
|
"ExpoBlueskyReferrerModule",
|
||||||
"ExpoBlueskyVisibilityViewModule",
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
|
"ExpoHLSDownloadModule",
|
||||||
"ExpoPlatformInfoModule"
|
"ExpoPlatformInfoModule"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -13,7 +14,8 @@
|
||||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||||
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
"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 * as PlatformInfo from './src/PlatformInfo'
|
||||||
import {AudioCategory} from './src/PlatformInfo/types'
|
import {AudioCategory} from './src/PlatformInfo/types'
|
||||||
import * as Referrer from './src/Referrer'
|
import * as Referrer from './src/Referrer'
|
||||||
import * as SharedPrefs from './src/SharedPrefs'
|
import * as SharedPrefs from './src/SharedPrefs'
|
||||||
import VisibilityView from './src/VisibilityView'
|
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",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@expo/html-elements": "^0.4.2",
|
"@expo/html-elements": "^0.4.2",
|
||||||
"@expo/webpack-config": "^19.0.0",
|
"@expo/webpack-config": "^19.0.0",
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||||
|
"@ffmpeg/util": "^0.12.1",
|
||||||
"@floating-ui/dom": "^1.6.3",
|
"@floating-ui/dom": "^1.6.3",
|
||||||
"@floating-ui/react-dom": "^2.0.8",
|
"@floating-ui/react-dom": "^2.0.8",
|
||||||
"@formatjs/intl-locale": "^4.0.0",
|
"@formatjs/intl-locale": "^4.0.0",
|
||||||
|
@ -143,6 +145,7 @@
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"hls-parser": "^0.13.3",
|
||||||
"hls.js": "^1.5.11",
|
"hls.js": "^1.5.11",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
@ -224,6 +227,7 @@
|
||||||
"@testing-library/react-native": "^11.5.2",
|
"@testing-library/react-native": "^11.5.2",
|
||||||
"@tsconfig/react-native": "^2.0.3",
|
"@tsconfig/react-native": "^2.0.3",
|
||||||
"@types/he": "^1.1.2",
|
"@types/he": "^1.1.2",
|
||||||
|
"@types/hls-parser": "^0.8.7",
|
||||||
"@types/jest": "^29.4.0",
|
"@types/jest": "^29.4.0",
|
||||||
"@types/lodash.chunk": "^4.2.7",
|
"@types/lodash.chunk": "^4.2.7",
|
||||||
"@types/lodash.debounce": "^4.0.7",
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
|
|
|
@ -50,6 +50,7 @@ import {
|
||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
} from '#/screens/StarterPack/StarterPackScreen'
|
} from '#/screens/StarterPack/StarterPackScreen'
|
||||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||||
|
import {VideoDownloadScreen} from '#/components/VideoDownloadScreen'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||||
|
@ -364,6 +365,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
getComponent={() => Wizard}
|
getComponent={() => Wizard}
|
||||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
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}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {rkey?: string}
|
StarterPackEdit: {rkey?: string}
|
||||||
|
VideoDownload: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|
|
@ -48,4 +48,5 @@ export const router = new Router({
|
||||||
StarterPack: '/starter-pack/:name/:rkey',
|
StarterPack: '/starter-pack/:name/:rkey',
|
||||||
StarterPackShort: '/starter-pack-short/:code',
|
StarterPackShort: '/starter-pack-short/:code',
|
||||||
StarterPackWizard: '/starter-pack/create',
|
StarterPackWizard: '/starter-pack/create',
|
||||||
|
VideoDownload: '/video-download',
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ScrollView, View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import {deleteAsync} from 'expo-file-system'
|
||||||
|
import {saveToLibraryAsync} from 'expo-media-library'
|
||||||
|
|
||||||
import {useSetThemePrefs} from '#/state/shell'
|
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 {CenteredView} from '#/view/com/util/Views'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
import {ListContained} from 'view/screens/Storybook/ListContained'
|
import {ListContained} from 'view/screens/Storybook/ListContained'
|
||||||
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
|
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {Breakpoints} from './Breakpoints'
|
import {Breakpoints} from './Breakpoints'
|
||||||
import {Buttons} from './Buttons'
|
import {Buttons} from './Buttons'
|
||||||
import {Dialogs} from './Dialogs'
|
import {Dialogs} from './Dialogs'
|
||||||
|
@ -33,10 +38,49 @@ function StorybookInner() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
const [showContainedList, setShowContainedList] = React.useState(false)
|
const [showContainedList, setShowContainedList] = React.useState(false)
|
||||||
|
const hlsDownloadRef = React.useRef<HLSDownloadView>(null)
|
||||||
|
|
||||||
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView style={[t.atoms.bg]}>
|
<CenteredView style={[t.atoms.bg]}>
|
||||||
<View style={[a.p_xl, a.gap_5xl, {paddingBottom: 100}]}>
|
<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 ? (
|
{!showContainedList ? (
|
||||||
<>
|
<>
|
||||||
<View style={[a.flex_row, a.align_start, a.gap_md]}>
|
<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"
|
resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a"
|
||||||
integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==
|
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":
|
"@floating-ui/core@^1.0.0":
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1"
|
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"
|
resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3"
|
||||||
integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g==
|
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":
|
"@types/html-minifier-terser@^6.0.0":
|
||||||
version "6.1.0"
|
version "6.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
|
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:
|
dependencies:
|
||||||
"@babel/runtime" "^7.7.6"
|
"@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:
|
hls.js@^1.5.11:
|
||||||
version "1.5.11"
|
version "1.5.11"
|
||||||
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"
|
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"
|
||||||
|
|
Loading…
Reference in New Issue