Revert "[Video] Download videos" (#4945)
parent
b6e515c664
commit
a5af24b53b
|
@ -256,9 +256,6 @@ 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,4 +7,3 @@
|
||||||
# be ok.
|
# be ok.
|
||||||
User-Agent: *
|
User-Agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Disallow: /video-download
|
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
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,7 +5,6 @@
|
||||||
"ExpoBlueskySharedPrefsModule",
|
"ExpoBlueskySharedPrefsModule",
|
||||||
"ExpoBlueskyReferrerModule",
|
"ExpoBlueskyReferrerModule",
|
||||||
"ExpoBlueskyVisibilityViewModule",
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
"ExpoHLSDownloadModule",
|
|
||||||
"ExpoPlatformInfoModule"
|
"ExpoPlatformInfoModule"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -14,8 +13,7 @@
|
||||||
"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,15 +1,7 @@
|
||||||
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 {
|
export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||||
AudioCategory,
|
|
||||||
HLSDownloadView,
|
|
||||||
PlatformInfo,
|
|
||||||
Referrer,
|
|
||||||
SharedPrefs,
|
|
||||||
VisibilityView,
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,148 +0,0 @@
|
||||||
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?
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
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,8 +59,6 @@
|
||||||
"@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",
|
||||||
|
@ -145,7 +143,6 @@
|
||||||
"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",
|
||||||
|
@ -227,7 +224,6 @@
|
||||||
"@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,7 +50,6 @@ 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'
|
||||||
|
@ -365,11 +364,6 @@ 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`)}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
export function VideoDownloadScreen() {
|
|
||||||
// @TODO redirect
|
|
||||||
return null
|
|
||||||
}
|
|
|
@ -1,215 +0,0 @@
|
||||||
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,7 +50,6 @@ 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,5 +48,4 @@ 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,17 +1,12 @@
|
||||||
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 {useVideoLibraryPermission} from 'lib/hooks/usePermissions'
|
import {isWeb} from 'platform/detection'
|
||||||
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'
|
||||||
|
@ -38,49 +33,10 @@ 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,23 +3925,6 @@
|
||||||
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"
|
||||||
|
@ -8024,13 +8007,6 @@
|
||||||
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"
|
||||||
|
@ -13480,11 +13456,6 @@ 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