mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 11:03:01 +02:00
Compare commits
39 Commits
275
...
adaptive-download
| Author | SHA1 | Date | |
|---|---|---|---|
| f31abac409 | |||
| 8c879c68d9 | |||
| 0190bbffdd | |||
| a410e2962a | |||
| f5aa8f37bb | |||
| 7e932df450 | |||
| 3d4741727e | |||
| a03b63ef74 | |||
| 15ce3e9f20 | |||
| 1639bd7af1 | |||
| d474121f85 | |||
| 891d3cf966 | |||
| 561d5ec7ab | |||
| 7ce437d50a | |||
| 4b02d4ce90 | |||
| 3107185869 | |||
| 2e3584a353 | |||
| e5b1be195c | |||
| dde30c9d76 | |||
| 3830e65de8 | |||
| c589cf167e | |||
| 2fde367c82 | |||
| 8fd188268e | |||
| b65257df42 | |||
| aaa2d7f08d | |||
| f73e25ece6 | |||
| 78d427f208 | |||
| eaeaf3538f | |||
| 85e381a85e | |||
| 1b7ee8231b | |||
| 1b8b8f5738 | |||
| 53df19b477 | |||
| ccf21b7580 | |||
| 4189d62a57 | |||
| 9a3e3af614 | |||
| f7187400dc | |||
| f55a7f0a7b | |||
| d6d35a645e | |||
| e719dcc7f5 |
@@ -1,19 +1,19 @@
|
||||
name: Bug Report
|
||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||
labels: ["bug", "new"]
|
||||
labels: ["Bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for taking the time to fill out this bug report.
|
||||
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
## Filing a bug report
|
||||
|
||||
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
||||
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
||||
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
|
||||
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
||||
|
||||
@@ -41,18 +41,21 @@ body:
|
||||
label: What plugins are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- All
|
||||
- Youtube
|
||||
- BiliBili (CN)
|
||||
- Twitch
|
||||
- Odysee
|
||||
- Rumble
|
||||
- Kick
|
||||
- PeerTube
|
||||
- Patreon
|
||||
- Nebula
|
||||
- SoundCloud
|
||||
- Other
|
||||
- "All"
|
||||
- "Youtube"
|
||||
- "Odysee"
|
||||
- "Rumble"
|
||||
- "Kick"
|
||||
- "Twitch"
|
||||
- "PeerTube"
|
||||
- "Patreon"
|
||||
- "Nebula"
|
||||
- "BiliBili (CN)"
|
||||
- "Bitchute"
|
||||
- "SoundCloud"
|
||||
- "Dailymotion"
|
||||
- "Apple Podcasts"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -72,6 +75,17 @@ body:
|
||||
- label: While logged out
|
||||
- label: N/A
|
||||
|
||||
- type: dropdown
|
||||
id: vpn
|
||||
attributes:
|
||||
label: Are you using a VPN?
|
||||
multiple: false
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: Documentation Issue
|
||||
description: Report an issue or suggest a change in the documentation.
|
||||
labels: ["documentation", "new"]
|
||||
labels: ["Documentation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a documentation change request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or other enhancement.
|
||||
labels: ["enhancement", "new"]
|
||||
labels: ["Enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -9,8 +9,6 @@ body:
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
|
||||
[External Contributions are closed at this time](https://github.com/tom-futo/grayjay-android/blob/master/CONTRIBUTION.md#contributing-to-core)
|
||||
|
||||
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
- type: textarea
|
||||
@@ -55,4 +53,4 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Issue labeler
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label-component:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# required for all workflows
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Parse issue form
|
||||
uses: stefanbuck/github-issue-parser@v3
|
||||
id: issue-parser
|
||||
with:
|
||||
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
|
||||
- name: Set labels based on plugin field
|
||||
uses: redhat-plumbers-in-action/advanced-issue-labeler@v2
|
||||
with:
|
||||
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
|
||||
section: plugin
|
||||
block-list: |
|
||||
None
|
||||
Other
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -82,3 +82,9 @@
|
||||
[submodule "app/src/stable/assets/sources/dailymotion"]
|
||||
path = app/src/stable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
[submodule "app/src/stable/assets/sources/apple-podcast"]
|
||||
path = app/src/stable/assets/sources/apple-podcast
|
||||
url = ../plugins/apple-podcasts.git
|
||||
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||
path = app/src/unstable/assets/sources/apple-podcasts
|
||||
url = ../plugins/apple-podcasts.git
|
||||
|
||||
@@ -415,8 +415,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||
var simplifySources: Boolean = true;
|
||||
|
||||
@FormField(R.string.force_enable_auto_rotate_in_full_screen, FieldForm.TOGGLE, R.string.force_enable_auto_rotate_in_full_screen_description, 5)
|
||||
var forceAllowFullScreenRotation: Boolean = true
|
||||
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
|
||||
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
|
||||
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.CookieManager
|
||||
import androidx.work.Data
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
|
||||
@@ -4,8 +4,13 @@ import android.app.NotificationManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
@@ -13,10 +18,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestAudioSourceDelegate
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
@@ -28,6 +36,10 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
@@ -36,6 +48,7 @@ import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
@@ -63,6 +76,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -269,14 +283,116 @@ class UISlideOverlays {
|
||||
|
||||
}
|
||||
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun showDashPicker(video: IPlatformVideoDetails, source: JSDashManifestSource, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay =
|
||||
SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val manifestResponse = ManagedHttpClient().get(sourceUrl)
|
||||
check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" }
|
||||
|
||||
val manifestContent = manifestResponse.body?.string()
|
||||
?: throw Exception("Manifest content is empty")
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
|
||||
|
||||
var selectedVideoVariant: IDashManifestSource? = null
|
||||
var selectedAudioVariant: IAudioSource? = null
|
||||
//TODO: Implement subtitles
|
||||
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
|
||||
|
||||
val manifestStream = ByteArrayInputStream(manifestContent.toByteArray())
|
||||
val playlist = DashManifestParser().parse(Uri.parse(sourceUrl), manifestStream)
|
||||
|
||||
playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
.flatMap { it.representations }.forEach {
|
||||
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.format.containerMimeType
|
||||
?: "", listOf(it.format.language, it.format.codecs).mapNotNull { x -> x?.ifEmpty { null } }
|
||||
.joinToString(", "), it.format.codecs, tag = it, call = {
|
||||
selectedAudioVariant = DashManifestAudioSourceDelegate(
|
||||
source, it.format.language
|
||||
?: Language.UNKNOWN, it.format.bitrate, it.format.containerMimeType!!
|
||||
)
|
||||
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, invokeParent = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
playlist.getPeriod(0).adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
.flatMap { it.representations }.forEach {
|
||||
videoButtons.add(
|
||||
SlideUpMenuItem(
|
||||
container.context, R.drawable.ic_movie, it.format.containerMimeType
|
||||
?: "", "${it.format.width}x${it.format.height}", it.format.codecs, tag = it, call = {
|
||||
selectedVideoVariant =
|
||||
DashManifestSourceDelegate(source, it.format.width, it.format.height, it.format.containerMimeType!!)
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
}, invokeParent = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
if (videoButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons))
|
||||
}
|
||||
if (audioButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons))
|
||||
}
|
||||
//TODO: Implement subtitles
|
||||
/*if (subtitleButtons.isNotEmpty()) {
|
||||
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons))
|
||||
}*/
|
||||
|
||||
slideUpMenuOverlay.onOK.subscribe {
|
||||
//TODO: Fix SubtitleRawSource issue
|
||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null)
|
||||
slideUpMenuOverlay.hide()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
slideUpMenuOverlay.setItems(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
return slideUpMenuOverlay.apply { show() }
|
||||
}
|
||||
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: JSSource, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
||||
|
||||
val masterPlaylistResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(sourceUrl, mapOf())
|
||||
ManagedHttpClient().get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
ManagedHttpClient().get(sourceUrl)
|
||||
}
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val resolvedSourceUrl = masterPlaylistResponse.url
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
@@ -285,14 +401,14 @@ class UISlideOverlays {
|
||||
//TODO: Implement subtitles
|
||||
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
|
||||
|
||||
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
|
||||
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
|
||||
var selectedVideoVariant: IHLSManifestSource? = null
|
||||
var selectedAudioVariant: IAudioSource? = null
|
||||
//TODO: Implement subtitles
|
||||
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
|
||||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, masterPlaylistResponse.url, source is IHLSManifestAudioSource)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
|
||||
@@ -306,7 +422,19 @@ class UISlideOverlays {
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
if (source is JSHLSManifestAudioSource) {
|
||||
source.setPreferredBitrate(it.bitrate)
|
||||
source.setPreferredLanguage(it.language)
|
||||
source.setPreferredContainer(it.container)
|
||||
selectedAudioVariant = source
|
||||
} else if (source is JSHLSManifestSource) {
|
||||
source.setPreferredBitrate(it.bitrate)
|
||||
source.setPreferredLanguage(it.language)
|
||||
selectedAudioVariant = source
|
||||
} else {
|
||||
throw Exception("Expected HLS Source")
|
||||
}
|
||||
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
@@ -333,9 +461,16 @@ class UISlideOverlays {
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
if (source !is JSHLSManifestSource){
|
||||
throw Exception("Expected HLS Source")
|
||||
}
|
||||
source.setPreferredWidth(it.width)
|
||||
source.setPreferredHeight(it.height)
|
||||
selectedVideoVariant = source
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
@@ -366,11 +501,11 @@ class UISlideOverlays {
|
||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (source is IHLSManifestSource) {
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedSourceUrl), null, null)
|
||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, resolvedSourceUrl), null)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
@@ -417,7 +552,7 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||
listOf(listOf(SlideUpMenuItem(
|
||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
container.context.getString(R.string.none),
|
||||
@@ -430,31 +565,11 @@ class UISlideOverlays {
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)) +
|
||||
)) else listOf()) +
|
||||
videoSources
|
||||
.filter { it.isDownloadable() }
|
||||
.map {
|
||||
when (it) {
|
||||
is IVideoUrlSource -> {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
is JSDashManifestRawSource -> {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
@@ -475,7 +590,15 @@ class UISlideOverlays {
|
||||
)
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
is JSDashManifestSource -> {
|
||||
SlideUpMenuItem(
|
||||
container.context, R.drawable.ic_movie, it.name, "DASH", tag = it, call = {
|
||||
showDashPicker(video, it, it.url, container)
|
||||
}, invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
is JSHLSManifestSource -> {
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
@@ -489,6 +612,26 @@ class UISlideOverlays {
|
||||
)
|
||||
}
|
||||
|
||||
is IVideoUrlSource -> {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideo = it
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w(TAG, "Unhandled source type for UISlideOverlay download items");
|
||||
null;//throw Exception("Unhandled source type")
|
||||
@@ -549,7 +692,7 @@ class UISlideOverlays {
|
||||
);
|
||||
}
|
||||
|
||||
is IHLSManifestAudioSource -> {
|
||||
is JSHLSManifestAudioSource -> {
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
@@ -614,13 +757,18 @@ class UISlideOverlays {
|
||||
|
||||
menu.onOK.subscribe {
|
||||
val sv = selectedVideo
|
||||
if (sv is IHLSManifestSource) {
|
||||
if (sv is JSHLSManifestSource) {
|
||||
showHlsPicker(video, sv, sv.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (sv is JSDashManifestSource) {
|
||||
showDashPicker(video, sv, sv.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
val sa = selectedAudio
|
||||
if (sa is IHLSManifestAudioSource) {
|
||||
if (sa is JSHLSManifestAudioSource) {
|
||||
showHlsPicker(video, sa, sa.url, container)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
@@ -122,7 +122,11 @@ class SyncPairActivity : AppCompatActivity() {
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_textError.text = e.message
|
||||
if(e.message == "Failed to connect") {
|
||||
_textError.text = "Failed to connect.\n\nThis may be due to not being on the same network, due to firewall, or vpn.\nSync currently operates only over local direct connections."
|
||||
}
|
||||
else
|
||||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -63,7 +65,7 @@ open class ManagedHttpClient {
|
||||
|
||||
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
||||
_builderTemplate = builder;
|
||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||
if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||
trustAllCertificates(builder);
|
||||
client = builder.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
|
||||
+10
-7
@@ -1,18 +1,21 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
import com.futo.platformplayer.others.Language
|
||||
|
||||
class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
||||
override val width : Int = 0;
|
||||
override val height : Int = 0;
|
||||
override val container : String = "HLS";
|
||||
override val width: Int = 0;
|
||||
override val height: Int = 0;
|
||||
override val container: String = "HLS";
|
||||
override val codec: String = "HLS";
|
||||
override val name : String = "HLS";
|
||||
override val bitrate : Int? = null;
|
||||
override val url : String;
|
||||
override val name: String = "HLS";
|
||||
override val bitrate: Int = 0;
|
||||
override val url: String;
|
||||
override val duration: Long = 0;
|
||||
override val language: String = Language.UNKNOWN;
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
constructor(url : String) {
|
||||
constructor(url: String) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -1,5 +1,29 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.IUnderlyingObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
|
||||
|
||||
interface IDashManifestSource : IVideoSource {
|
||||
val url : String;
|
||||
val url: String
|
||||
}
|
||||
|
||||
interface DashWrapper {
|
||||
val source: IDashManifestSource
|
||||
}
|
||||
|
||||
class DashManifestAudioSourceDelegate(
|
||||
override val source: JSDashManifestSource, override val language: String, override val bitrate: Int, override val container: String
|
||||
) : IDashManifestSource by source, IAudioSource, DashWrapper, IUnderlyingObject {
|
||||
override fun getUnderlyingObject(): V8ValueObject? {
|
||||
return source.getUnderlyingObject()
|
||||
}
|
||||
}
|
||||
|
||||
class DashManifestSourceDelegate(
|
||||
override val source: JSDashManifestSource, override val width: Int, override val height: Int, override val container: String
|
||||
) : IDashManifestSource by source, DashWrapper, IUnderlyingObject {
|
||||
override fun getUnderlyingObject(): V8ValueObject? {
|
||||
return source.getUnderlyingObject()
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IHLSManifestSource : IVideoSource {
|
||||
val url : String;
|
||||
interface IHLSManifestSource : IVideoSource, IAudioSource {
|
||||
val url : String
|
||||
}
|
||||
interface IHLSManifestAudioSource : IAudioSource {
|
||||
val url : String;
|
||||
val url : String
|
||||
}
|
||||
-2
@@ -4,8 +4,6 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
||||
+14
-6
@@ -2,23 +2,20 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orNull
|
||||
|
||||
class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
override val container : String get() = "application/vnd.apple.mpegurl";
|
||||
override var container : String = "application/vnd.apple.mpegurl";
|
||||
override val codec: String = "HLS";
|
||||
override val name : String;
|
||||
override val bitrate : Int = 0;
|
||||
override var bitrate : Int = 0;
|
||||
override val url : String;
|
||||
override val duration: Long;
|
||||
override val language: String;
|
||||
override var language: String;
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
@@ -34,6 +31,17 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
|
||||
fun setPreferredBitrate(bitrate: Int) {
|
||||
this@JSHLSManifestAudioSource.bitrate = bitrate;
|
||||
}
|
||||
|
||||
fun setPreferredLanguage(language: String) {
|
||||
this@JSHLSManifestAudioSource.language = language;
|
||||
}
|
||||
|
||||
fun setPreferredContainer(container: String) {
|
||||
this@JSHLSManifestAudioSource.container = container;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
|
||||
+21
-6
@@ -2,22 +2,21 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.others.Language
|
||||
|
||||
class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
override val width : Int = 0;
|
||||
override val height : Int = 0;
|
||||
override var width : Int = 0;
|
||||
override var height : Int = 0;
|
||||
override val container : String get() = "application/vnd.apple.mpegurl";
|
||||
override val codec: String = "HLS";
|
||||
override val name : String;
|
||||
override val bitrate : Int? = null;
|
||||
override var bitrate : Int = 0;
|
||||
override val url : String;
|
||||
override val duration: Long;
|
||||
override var language: String = Language.UNKNOWN
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
@@ -31,4 +30,20 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
|
||||
fun setPreferredWidth(width: Int) {
|
||||
this@JSHLSManifestSource.width = width
|
||||
}
|
||||
|
||||
fun setPreferredHeight(height: Int) {
|
||||
this@JSHLSManifestSource.height = height
|
||||
}
|
||||
|
||||
fun setPreferredBitrate(bitrate: Int) {
|
||||
this@JSHLSManifestSource.bitrate = bitrate;
|
||||
}
|
||||
|
||||
fun setPreferredLanguage(language: String) {
|
||||
this@JSHLSManifestSource.language = language;
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -1,7 +1,5 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.modifier.AdhocRequestModifier
|
||||
@@ -17,9 +15,12 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.orNull
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
|
||||
abstract class JSSource {
|
||||
interface IUnderlyingObject {
|
||||
fun getUnderlyingObject(): V8ValueObject?
|
||||
}
|
||||
|
||||
abstract class JSSource : IUnderlyingObject {
|
||||
protected val _plugin: JSClient;
|
||||
protected val _config: IV8PluginConfig;
|
||||
protected val _obj: V8ValueObject;
|
||||
@@ -88,7 +89,7 @@ abstract class JSSource {
|
||||
fun getUnderlyingPlugin(): JSClient? {
|
||||
return _plugin;
|
||||
}
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
override fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _obj;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
@@ -10,6 +15,8 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestAudioSourceDelegate
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSourceDelegate
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
@@ -28,25 +35,27 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.IJSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.IUnderlyingObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.polycentric.core.hexStringToByteArray
|
||||
import hasAnySource
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -59,6 +68,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -69,8 +80,10 @@ import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.time.times
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class VideoDownload {
|
||||
@@ -119,18 +132,21 @@ class VideoDownload {
|
||||
var requiresLiveVideoSource: Boolean = false;
|
||||
@Contextual
|
||||
@kotlinx.serialization.Transient
|
||||
var videoSourceLive: JSSource? = null;
|
||||
var videoSourceLive: IUnderlyingObject? = null;
|
||||
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
|
||||
|
||||
var requiresLiveAudioSource: Boolean = false;
|
||||
@Contextual
|
||||
@kotlinx.serialization.Transient
|
||||
var audioSourceLive: JSSource? = null;
|
||||
var audioSourceLive: IUnderlyingObject? = null;
|
||||
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
|
||||
private var hasVideoRequestModifier: Boolean = false
|
||||
private var hasAudioRequestModifier: Boolean = false
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
|
||||
@@ -191,8 +207,10 @@ class VideoDownload {
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || videoSource !is IVideoUrlSource
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || audioSource !is IAudioUrlSource
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -227,6 +245,7 @@ class VideoDownload {
|
||||
return items.joinToString(" • ");
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
suspend fun prepare(client: ManagedHttpClient) {
|
||||
Logger.i(TAG, "VideoDownload Prepare [${name}]");
|
||||
|
||||
@@ -282,21 +301,57 @@ class VideoDownload {
|
||||
}
|
||||
|
||||
videoDetails = SerializedPlatformVideoDetails.fromVideo(original, if (subtitleSource != null) listOf(subtitleSource!!) else listOf());
|
||||
if(videoSource == null && targetPixelCount != null) {
|
||||
if (videoSource == null && targetPixelCount != null) {
|
||||
val videoSources = arrayListOf<IVideoSource>()
|
||||
for (source in original.video.videoSources) {
|
||||
if (source is IHLSManifestSource) {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse = if ((source as JSSource).hasRequestModifier) {
|
||||
val request =
|
||||
source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||
val variantSources =
|
||||
HLS.parseAndGetVideoSources(source, playlistContent, source.url)
|
||||
val target =
|
||||
VideoHelper.selectBestVideoSource(variantSources, targetPixelCount!!.toInt(), arrayOf())
|
||||
if (target != null) {
|
||||
(source as JSHLSManifestSource).setPreferredWidth(target.width)
|
||||
source.setPreferredHeight(target.height)
|
||||
videoSources.add(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to get HLS video sources", e)
|
||||
}
|
||||
} else if (source is JSDashManifestSource) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(source.url)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val hlsManifestUrl = masterPlaylistResponse.url
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
|
||||
|
||||
val playlist =
|
||||
DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream)
|
||||
|
||||
val period = playlist.getPeriod(0)
|
||||
|
||||
val representation =
|
||||
period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
.flatMap { it.representations }.filter {
|
||||
(it.format.width * it.format.height).toLong() == targetPixelCount
|
||||
}[0]
|
||||
videoSources.add(DashManifestSourceDelegate(source, representation.format.width, representation.format.height, representation.format.containerMimeType!!))
|
||||
} else {
|
||||
videoSources.add(source)
|
||||
}
|
||||
@@ -320,22 +375,40 @@ class VideoDownload {
|
||||
videoSource = VideoUrlSource.fromUrlSource(vsource)
|
||||
else if(vsource is JSSource && requiresLiveVideoSource)
|
||||
videoSourceLive = vsource;
|
||||
else if (vsource is DashManifestSourceDelegate)
|
||||
videoSourceLive = vsource
|
||||
else
|
||||
throw DownloadException("Video source is not supported for downloading (yet) [" + vsource?.javaClass?.name + "]", false);
|
||||
}
|
||||
|
||||
if(audioSource == null && targetBitrate != null) {
|
||||
if (audioSource == null && targetBitrate != null) {
|
||||
var audioSources = mutableListOf<IAudioSource>()
|
||||
val video = original.video
|
||||
if (video is VideoUnMuxedSourceDescriptor) {
|
||||
for (source in video.audioSources) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
val playlistResponse =
|
||||
if ((source as JSSource).hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!
|
||||
.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||
val variantSources =
|
||||
HLS.parseAndGetAudioSources(source, playlistContent, source.url, true)
|
||||
val target =
|
||||
VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate)
|
||||
if (target != null) {
|
||||
(source as JSHLSManifestAudioSource).setPreferredBitrate(target.bitrate)
|
||||
source.setPreferredLanguage(target.language)
|
||||
source.setPreferredContainer(target.container)
|
||||
audioSources.add(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -346,6 +419,62 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (source in video.videoSources) {
|
||||
if (source is IHLSManifestSource) {
|
||||
try {
|
||||
val playlistResponse = if ((source as JSSource).hasRequestModifier) {
|
||||
val request =
|
||||
source.getRequestModifier()!!.modifyRequest(source.url, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(source.url)
|
||||
}
|
||||
if (playlistResponse.isOk) {
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
val variantSources =
|
||||
HLS.parseAndGetAudioSources(source, playlistContent, source.url, true)
|
||||
val target =
|
||||
VideoHelper.selectBestAudioSource(variantSources, arrayOf(), null, targetBitrate)
|
||||
if (target != null) {
|
||||
(source as JSHLSManifestSource).setPreferredBitrate(target.bitrate)
|
||||
source.setPreferredLanguage(target.language)
|
||||
audioSources.add(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to get HLS audio sources", e)
|
||||
}
|
||||
} else if (source is JSDashManifestSource) {
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(source.url)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val hlsManifestUrl = masterPlaylistResponse.url
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
|
||||
|
||||
val playlist =
|
||||
DashManifestParser().parse(Uri.parse(hlsManifestUrl), inputStream)
|
||||
|
||||
val period = playlist.getPeriod(0)
|
||||
|
||||
val representation =
|
||||
period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
.flatMap { it.representations }.filter {
|
||||
it.format.bitrate.toLong() == targetBitrate
|
||||
}[0]
|
||||
audioSources.add(
|
||||
DashManifestAudioSourceDelegate(
|
||||
source, representation.format.language
|
||||
?: Language.UNKNOWN, representation.format.bitrate, representation.format.containerMimeType!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var asource: IAudioSource? = null;
|
||||
if(targetAudioName != null) {
|
||||
@@ -370,6 +499,8 @@ class VideoDownload {
|
||||
audioSource = AudioUrlSource.fromUrlSource(asource)
|
||||
else if(asource is JSSource && requiresLiveAudioSource)
|
||||
audioSourceLive = asource;
|
||||
else if (asource is DashManifestAudioSourceDelegate)
|
||||
audioSourceLive = asource
|
||||
else
|
||||
throw DownloadException("Audio source is not supported for downloading (yet) [" + asource?.javaClass?.name + "]", false);
|
||||
}
|
||||
@@ -448,16 +579,23 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
videoFileSize = when (actualVideoSource) {
|
||||
is IVideoUrlSource -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
is JSDashManifestRawSource -> {
|
||||
downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
|
||||
is JSHLSManifestSource -> {
|
||||
downloadHlsSource(context, "Video", client, actualVideoSource, false, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
|
||||
is DashManifestSourceDelegate -> {
|
||||
downloadDashSource(context, "Video", client, actualVideoSource.source, actualVideoSource.url, File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
|
||||
else -> throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name)
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
||||
});
|
||||
})
|
||||
}
|
||||
if(actualAudioSource != null) {
|
||||
sourcesToDownload.add(async {
|
||||
@@ -488,16 +626,27 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
audioFileSize = when (actualAudioSource) {
|
||||
is IVideoUrlSource -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
is JSDashManifestRawAudioSource -> {
|
||||
downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
|
||||
is JSHLSManifestAudioSource -> {
|
||||
downloadHlsSource(context, "Audio", client, actualAudioSource, false, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
|
||||
is JSHLSManifestSource -> {
|
||||
downloadHlsSource(context, "Audio", client, actualAudioSource, true, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
|
||||
is DashManifestAudioSourceDelegate -> {
|
||||
downloadDashSource(context, "Audio", client, actualAudioSource.source, actualAudioSource.url, File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
|
||||
else -> throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name)
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
||||
});
|
||||
})
|
||||
}
|
||||
if (subtitleSource != null) {
|
||||
sourcesToDownload.add(async {
|
||||
@@ -544,7 +693,108 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun downloadDashSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsManifestUrl2: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if (targetFile.exists()) targetFile.delete()
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val manifestResponse = ManagedHttpClient().get(hlsManifestUrl2)
|
||||
check(manifestResponse.isOk) { "Failed to get DASH manifest: ${manifestResponse.code}" }
|
||||
|
||||
val resolvedUrl = manifestResponse.url
|
||||
|
||||
val manifestContent = manifestResponse.body?.string()
|
||||
?: throw Exception("Manifest content is empty")
|
||||
|
||||
val inputStream = ByteArrayInputStream(manifestContent.toByteArray())
|
||||
val playlist = DashManifestParser().parse(Uri.parse(resolvedUrl), inputStream)
|
||||
|
||||
val period = playlist.getPeriod(0)
|
||||
|
||||
val representation = when (name) {
|
||||
"Audio" -> {
|
||||
period.adaptationSets.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
.flatMap { it.representations }.filter {
|
||||
it.format.bitrate.toLong() == targetBitrate
|
||||
}[0]
|
||||
}
|
||||
"Video" -> {
|
||||
period.adaptationSets.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
.flatMap { it.representations }.filter {
|
||||
(it.format.width * it.format.height).toLong() == targetPixelCount
|
||||
}[0]
|
||||
}
|
||||
else -> {
|
||||
throw Exception("Unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
val segmentIndex = representation.index
|
||||
|
||||
if (segmentIndex != null) {
|
||||
val baseUrl = representation.baseUrls[0]
|
||||
val count = segmentIndex.getSegmentCount(C.TIME_UNSET)
|
||||
for (index in 0 until count) {
|
||||
val segmentUrl = if (index != 0L) segmentIndex.getSegmentUrl(index)
|
||||
.resolveUriString(baseUrl.url)
|
||||
else {
|
||||
val init = representation.initializationUri ?: continue
|
||||
init.resolveUriString(baseUrl.url)
|
||||
}
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential")
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outputStream = segmentFile.outputStream()
|
||||
outputStream.use { os ->
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength =
|
||||
downloadSource_Sequential(client, os, segmentUrl, null) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength =
|
||||
if (index == 0L) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength =
|
||||
averageSegmentLength * (count - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("No segment index available for representation: ${representation.format.id}")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile")
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished")
|
||||
} catch (ioex: IOException) {
|
||||
if (targetFile.exists()) targetFile.delete()
|
||||
if (ioex.message?.contains("ENOSPC") == true
|
||||
) throw Exception("Not enough space on device", ioex)
|
||||
else throw ioex
|
||||
} catch (ex: Throwable) {
|
||||
if (targetFile.exists()) targetFile.delete()
|
||||
throw ex
|
||||
} finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, audio: Boolean, hlsManifestUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -552,13 +802,68 @@ class VideoDownload {
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
val masterPlaylistResponse = ManagedHttpClient().get(hlsManifestUrl)
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val resolvedSourceUrl = masterPlaylistResponse.url
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val variantUrl = if (source is JSHLSManifestAudioSource){
|
||||
val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true)
|
||||
|
||||
val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate)
|
||||
if (variant !is IAudioUrlSource){
|
||||
throw Exception("Variant is not an audio source")
|
||||
}
|
||||
variant.getAudioUrl()
|
||||
}else if (audio && source is JSHLSManifestSource){
|
||||
val audioTracks = HLS.parseAndGetAudioSources(source, masterPlaylistContent, resolvedSourceUrl, true)
|
||||
|
||||
val variant = VideoHelper.selectBestAudioSource(audioTracks, arrayOf(), source.language, targetBitrate)
|
||||
if (variant !is IAudioUrlSource){
|
||||
throw Exception("Variant is not an audio source")
|
||||
}
|
||||
variant.getAudioUrl()
|
||||
}else if (source is JSHLSManifestSource) {
|
||||
val variants = HLS.parseAndGetVideoSources(source, masterPlaylistContent, resolvedSourceUrl)
|
||||
|
||||
val variant = VideoHelper.selectBestVideoSource(variants, targetPixelCount!!.toInt(), arrayOf())
|
||||
if (variant !is IVideoUrlSource){
|
||||
throw Exception("Variant is not a video source")
|
||||
}
|
||||
variant.getVideoUrl()
|
||||
} else {
|
||||
throw Exception("Source is not a HLS manifest")
|
||||
}
|
||||
|
||||
val response = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(variantUrl, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(variantUrl)
|
||||
}
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, variantUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = if (source.hasRequestModifier) {
|
||||
val request = source.getRequestModifier()!!.modifyRequest(variantPlaylist.decryptionInfo.keyUrl, mapOf())
|
||||
client.get(request.url!!, request.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
}
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv.hexStringToByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
@@ -570,7 +875,7 @@ class VideoDownload {
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed ->
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
@@ -608,12 +913,11 @@ class VideoDownload {
|
||||
return downloadedTotalLength;
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
@@ -623,7 +927,6 @@ class VideoDownload {
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
@@ -631,7 +934,6 @@ class VideoDownload {
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
@@ -751,7 +1053,7 @@ class VideoDownload {
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -778,7 +1080,31 @@ class VideoDownload {
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
// methods are auto generated
|
||||
data class DecryptionInfo(
|
||||
val key: ByteArray,
|
||||
val iv: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as DecryptionInfo
|
||||
|
||||
if (!key.contentEquals(other.key)) return false
|
||||
if (!iv.contentEquals(other.iv)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.contentHashCode()
|
||||
result = 31 * result + iv.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -798,6 +1124,8 @@ class VideoDownload {
|
||||
val sourceLength = result.body.contentLength();
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
val segmentBuffer = ByteArrayOutputStream()
|
||||
|
||||
var totalRead: Long = 0;
|
||||
try {
|
||||
var read: Int;
|
||||
@@ -808,7 +1136,7 @@ class VideoDownload {
|
||||
if (read < 0)
|
||||
break;
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
segmentBuffer.write(buffer, 0, read);
|
||||
|
||||
totalRead += read;
|
||||
|
||||
@@ -834,6 +1162,13 @@ class VideoDownload {
|
||||
result.body.close()
|
||||
}
|
||||
|
||||
if(decryptionInfo != null){
|
||||
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, decryptionInfo.iv)
|
||||
fileStream.write(decryptedData)
|
||||
}else {
|
||||
fileStream.write(segmentBuffer.toByteArray())
|
||||
}
|
||||
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
return sourceLength;
|
||||
}
|
||||
@@ -1025,7 +1360,7 @@ class VideoDownload {
|
||||
val expectedFile = File(videoFilePath!!);
|
||||
if(!expectedFile.exists())
|
||||
throw IllegalStateException("Video file missing after download");
|
||||
if (videoSource?.container != "application/vnd.apple.mpegurl") {
|
||||
if (videoSourceLive !is IHLSManifestSource && videoSourceLive !is IDashManifestSource) {
|
||||
if (expectedFile.length() != videoFileSize)
|
||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||
}
|
||||
@@ -1036,7 +1371,7 @@ class VideoDownload {
|
||||
val expectedFile = File(audioFilePath!!);
|
||||
if(!expectedFile.exists())
|
||||
throw IllegalStateException("Audio file missing after download");
|
||||
if (audioSource?.container != "application/vnd.apple.mpegurl") {
|
||||
if (audioSourceLive !is IHLSManifestAudioSource && audioSourceLive !is IHLSManifestSource && audioSourceLive !is IDashManifestSource) {
|
||||
if (expectedFile.length() != audioFileSize)
|
||||
throw IllegalStateException("Expected size [${audioFileSize}], but found ${expectedFile.length()}");
|
||||
}
|
||||
@@ -1121,7 +1456,7 @@ class VideoDownload {
|
||||
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||
|
||||
fun videoContainerToExtension(container: String): String? {
|
||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||
if (container.contains("video/mp4"))
|
||||
return "mp4";
|
||||
else if (container.contains("application/x-mpegURL"))
|
||||
return "m3u8";
|
||||
@@ -1133,21 +1468,26 @@ class VideoDownload {
|
||||
return "webm";
|
||||
else if (container.contains("video/x-matroska"))
|
||||
return "mkv";
|
||||
else if (container.contains("video/mp2t"))
|
||||
return "m2ts"
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "mp4"
|
||||
else
|
||||
return "video";
|
||||
}
|
||||
|
||||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
return "m4a";
|
||||
else if (container.contains("audio/mpeg"))
|
||||
return "mpga";
|
||||
return "mp3";
|
||||
// return "mpga";
|
||||
else if (container.contains("audio/mp3"))
|
||||
return "mp3";
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "m4a"
|
||||
else if (container.contains("audio/webm"))
|
||||
return "webma";
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "mp4";
|
||||
else
|
||||
return "audio";
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class VideoExport {
|
||||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
|
||||
val f = downloadRoot.createFile(v.container, outputFileName)
|
||||
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp2t" else v.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
@@ -81,7 +81,7 @@ class VideoExport {
|
||||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
|
||||
val f = downloadRoot.createFile(a.container, outputFileName)
|
||||
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "audio/mp3" else a.container, outputFileName)
|
||||
?: throw Exception("Failed to create file in external directory.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.engine.packages
|
||||
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.annotations.V8Property
|
||||
import com.caoccao.javet.utils.JavetResourceUtils
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
@@ -16,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -37,6 +40,18 @@ class PackageBridge : V8Package {
|
||||
_config = config;
|
||||
_client = plugin.httpClient;
|
||||
_clientAuth = plugin.httpClientAuth;
|
||||
|
||||
withScript("""
|
||||
function setTimeout(func, delay) {
|
||||
let args = Array.prototype.slice.call(arguments, 2);
|
||||
return bridge.setTimeout(func.bind(globalThis, ...args), delay || 0);
|
||||
}
|
||||
""".trimIndent());
|
||||
withScript("""
|
||||
function clearTimeout(id) {
|
||||
bridge.clearTimeout(id);
|
||||
}
|
||||
""".trimIndent());
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +77,48 @@ class PackageBridge : V8Package {
|
||||
value.close();
|
||||
}
|
||||
|
||||
var timeoutCounter = 0;
|
||||
var timeoutMap = HashSet<Int>();
|
||||
@V8Function
|
||||
fun setTimeout(func: V8ValueFunction, timeout: Long): Int {
|
||||
val id = timeoutCounter++;
|
||||
|
||||
val funcClone = func.toClone<V8ValueFunction>()
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
delay(timeout);
|
||||
synchronized(timeoutMap) {
|
||||
if(!timeoutMap.contains(id)) {
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
return@launch;
|
||||
}
|
||||
timeoutMap.remove(id);
|
||||
}
|
||||
try {
|
||||
_plugin.whenNotBusy {
|
||||
funcClone.callVoid(null, arrayOf<Any>());
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed timeout callback", ex);
|
||||
}
|
||||
finally {
|
||||
JavetResourceUtils.safeClose(funcClone);
|
||||
}
|
||||
};
|
||||
synchronized(timeoutMap) {
|
||||
timeoutMap.add(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@V8Function
|
||||
fun clearTimeout(id: Int) {
|
||||
synchronized(timeoutMap) {
|
||||
if(timeoutMap.contains(id))
|
||||
timeoutMap.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun toast(str: String) {
|
||||
Logger.i(TAG, "Plugin toast [${_config.name}]: ${str}");
|
||||
|
||||
+43
-6
@@ -4,8 +4,11 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.database.ContentObserver
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.OrientationEventListener
|
||||
import android.view.View
|
||||
@@ -85,6 +88,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private var _landscapeOrientationListener: LandscapeOrientationListener? = null
|
||||
private var _portraitOrientationListener: PortraitOrientationListener? = null
|
||||
private var _autoRotateObserver: AutoRotateObserver? = null
|
||||
|
||||
fun nextVideo() {
|
||||
_viewDetail?.nextVideo(true, true, true);
|
||||
@@ -95,10 +99,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
}
|
||||
|
||||
private fun isSmallWindow(): Boolean {
|
||||
return min(
|
||||
resources.configuration.screenWidthDp,
|
||||
resources.configuration.screenHeightDp
|
||||
) < resources.getInteger(R.integer.column_width_dp) * 2
|
||||
return resources.configuration.smallestScreenWidthDp < resources.getInteger(R.integer.column_width_dp) * 2
|
||||
}
|
||||
|
||||
private fun isAutoRotateEnabled(): Boolean {
|
||||
@@ -118,6 +119,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
isSmallWindow
|
||||
&& newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
&& !isFullscreen
|
||||
&& !isInPictureInPicture
|
||||
&& state == State.MAXIMIZED
|
||||
) {
|
||||
_viewDetail?.setFullscreen(true)
|
||||
@@ -154,6 +156,8 @@ class VideoDetailFragment() : MainFragment() {
|
||||
) {
|
||||
_viewDetail?.setFullscreen(true)
|
||||
}
|
||||
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
fun updateOrientation() {
|
||||
@@ -161,15 +165,16 @@ class VideoDetailFragment() : MainFragment() {
|
||||
val isFullScreenPortraitAllowed = Settings.instance.playback.fullscreenPortrait
|
||||
val isReversePortraitAllowed = Settings.instance.playback.reversePortrait
|
||||
val rotationLock = StatePlayer.instance.rotationLock
|
||||
val alwaysAllowReverseLandscapeAutoRotate = Settings.instance.playback.alwaysAllowReverseLandscapeAutoRotate
|
||||
|
||||
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: false
|
||||
val isLandscapeVideo: Boolean = _viewDetail?.isLandscapeVideo() ?: true
|
||||
|
||||
val isSmallWindow = isSmallWindow()
|
||||
val autoRotateEnabled = isAutoRotateEnabled()
|
||||
|
||||
// For small windows if the device isn't landscape right now and full screen portrait isn't allowed then we should force landscape
|
||||
if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT && !rotationLock && isLandscapeVideo) {
|
||||
if (Settings.instance.playback.forceAllowFullScreenRotation) {
|
||||
if (alwaysAllowReverseLandscapeAutoRotate){
|
||||
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
} else {
|
||||
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
@@ -181,6 +186,11 @@ class VideoDetailFragment() : MainFragment() {
|
||||
_landscapeOrientationListener?.enableListener()
|
||||
}
|
||||
}
|
||||
// For small windows if always all reverse landscape then we'll lock the orientation to landscape when system auto-rotate is off to make sure that locking
|
||||
// and unlockiung in the player settings keep orientation in landscape
|
||||
else if (isSmallWindow && isFullscreen && !isFullScreenPortraitAllowed && alwaysAllowReverseLandscapeAutoRotate && !rotationLock && isLandscapeVideo && !autoRotateEnabled) {
|
||||
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
// For small windows if the device isn't in a portrait orientation and we're in the maximized state then we should force portrait
|
||||
// only do this if auto-rotate is on portrait is forced when leaving full screen for autorotate off
|
||||
else if (isSmallWindow && !isMinimizingFromFullScreen && !isFullscreen && state == State.MAXIMIZED && resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
@@ -392,6 +402,10 @@ class VideoDetailFragment() : MainFragment() {
|
||||
updateOrientation()
|
||||
}
|
||||
}
|
||||
_autoRotateObserver = AutoRotateObserver(requireContext(), Handler(Looper.getMainLooper())) {
|
||||
updateOrientation()
|
||||
}
|
||||
_autoRotateObserver?.startObserving()
|
||||
|
||||
return _view!!;
|
||||
}
|
||||
@@ -496,6 +510,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
_landscapeOrientationListener?.disableListener()
|
||||
_portraitOrientationListener?.disableListener()
|
||||
_autoRotateObserver?.stopObserving()
|
||||
|
||||
_viewDetail?.let {
|
||||
_viewDetail = null;
|
||||
@@ -657,3 +672,25 @@ class PortraitOrientationListener(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoRotateObserver(context: Context, handler: Handler, private val onAutoRotateChanged: () -> Unit) : ContentObserver(handler) {
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
|
||||
onAutoRotateChanged()
|
||||
}
|
||||
|
||||
fun startObserving() {
|
||||
contentResolver.registerContentObserver(
|
||||
android.provider.Settings.System.getUriFor(android.provider.Settings.System.ACCELEROMETER_ROTATION),
|
||||
false,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
contentResolver.unregisterContentObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-19
@@ -649,18 +649,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
};
|
||||
|
||||
var hadDevice = false;
|
||||
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, session ->
|
||||
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
|
||||
if(hasDevice != hadDevice) {
|
||||
hadDevice = hasDevice;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateMoreButtons();
|
||||
}
|
||||
}
|
||||
};
|
||||
StateSync.instance.deviceRemoved.subscribe(this) { id ->
|
||||
val hasDevice = StateSync.instance.hasAtLeastOneOnlineDevice();
|
||||
if(hasDevice != hadDevice) {
|
||||
val devicesChanged = { id: String ->
|
||||
val hasDevice = StateSync.instance.hasAuthorizedDevice();
|
||||
if (hasDevice != hadDevice) {
|
||||
hadDevice = hasDevice;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
updateMoreButtons();
|
||||
@@ -668,6 +659,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { id, _ -> devicesChanged(id) };
|
||||
StateSync.instance.deviceRemoved.subscribe(this) { id -> devicesChanged(id) };
|
||||
|
||||
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
|
||||
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
|
||||
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
|
||||
@@ -922,18 +916,25 @@ class VideoDetailView : ConstraintLayout {
|
||||
};
|
||||
_slideUpOverlay?.hide();
|
||||
},
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
if (StateSync.instance.hasAuthorizedDevice()) {
|
||||
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
||||
val devices = StateSync.instance.getSessions();
|
||||
val devices = StateSync.instance.getAuthorizedSessions();
|
||||
val videoToSend = video ?: return@RoundButton;
|
||||
if(devices.size > 1) {
|
||||
//not implemented
|
||||
}
|
||||
else if(devices.size == 1){
|
||||
} else if(devices.size == 1){
|
||||
val device = devices.first();
|
||||
Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||
UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , {
|
||||
Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds/1000).toInt()));
|
||||
try {
|
||||
device.sendJsonData(GJSyncOpcodes.sendToDevices, SendToDevicePackage(videoToSend.url, (lastPositionMilliseconds / 1000).toInt()))
|
||||
Logger.i(TAG, "Send to device packet sent (public key: ${device.remotePublicKey}): " + videoToSend.url)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Send to device packet failed to send", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2384,8 +2385,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
fun isLandscapeVideo(): Boolean? {
|
||||
val videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
|
||||
val videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
|
||||
var videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
|
||||
var videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
|
||||
|
||||
if (video?.video?.videoSources?.isNotEmpty() == true && (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0)) {
|
||||
videoSourceWidth = video?.video?.videoSources!![0].width
|
||||
videoSourceHeight = video?.video?.videoSources!![0].height
|
||||
}
|
||||
|
||||
return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){
|
||||
null
|
||||
|
||||
@@ -13,16 +13,16 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlWidevineSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
@@ -47,8 +47,8 @@ class VideoHelper {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isDownloadable(source: IVideoSource) = source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource;
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IAudioUrlWidevineSource
|
||||
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource || source is IDashManifestSource) && source !is IWidevineSource
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
|
||||
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.futo.platformplayer.parsers
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
|
||||
@@ -7,13 +13,20 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.net.URLConnection
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class HLS {
|
||||
companion object {
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist {
|
||||
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
|
||||
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
|
||||
.parse(Uri.parse(sourceUrl), inputStream)
|
||||
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
|
||||
@@ -21,27 +34,38 @@ class HLS {
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
if (playlist is HlsMediaPlaylist) {
|
||||
independentSegments = playlist.hasIndependentSegments
|
||||
if (isAudioSource == true) {
|
||||
val firstSegmentUrlFile =
|
||||
Uri.parse(playlist.segments[0].url).buildUpon().clearQuery().fragment(null)
|
||||
.build().toString()
|
||||
mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null, URLConnection.guessContentTypeFromName(firstSegmentUrlFile)))
|
||||
} else {
|
||||
variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
|
||||
}
|
||||
} else if (playlist is HlsMultivariantPlaylist) {
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
line.startsWith("#EXT-X-MEDIA") -> {
|
||||
mediaRenditions.add(parseMediaRendition(line, baseUrl))
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA") -> {
|
||||
mediaRenditions.add(parseMediaRendition(line, baseUrl))
|
||||
}
|
||||
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||
independentSegments = true
|
||||
}
|
||||
|
||||
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
|
||||
independentSegments = true
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
line.startsWith("#EXT-X-SESSION-DATA") -> {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +85,26 @@ class HLS {
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val keyInfo =
|
||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||
|
||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||
val iv =
|
||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||
iv?.let { i ->
|
||||
DecryptionInfo(k, i)
|
||||
}
|
||||
}
|
||||
|
||||
val initSegment =
|
||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||
?.substringAfter("=")?.trim('"')
|
||||
val segments = mutableListOf<Segment>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
when {
|
||||
@@ -86,7 +129,7 @@ class HLS {
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments)
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||
}
|
||||
|
||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
@@ -109,10 +152,10 @@ class HLS {
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
|
||||
fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List<HLSVariantAudioUrlSource> {
|
||||
val masterPlaylist: MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = parseMasterPlaylist(content, url)
|
||||
masterPlaylist = parseMasterPlaylist(content, url, isAudioSource)
|
||||
return masterPlaylist.getAudioSources()
|
||||
} catch (e: Throwable) {
|
||||
if (content.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
@@ -203,10 +246,10 @@ class HLS {
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
return false;
|
||||
return false
|
||||
|
||||
if (value.contains(','))
|
||||
return true;
|
||||
return true
|
||||
|
||||
return _quoteList.contains(key)
|
||||
}
|
||||
@@ -270,7 +313,8 @@ class HLS {
|
||||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
val isForced: Boolean?,
|
||||
val container: String? = null
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
@@ -340,7 +384,7 @@ class HLS {
|
||||
|
||||
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
|
||||
return@mapNotNull when (it.type) {
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, it.container?: "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -368,6 +412,11 @@ class HLS {
|
||||
}
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String
|
||||
)
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
@@ -376,7 +425,8 @@ class HLS {
|
||||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
/***
|
||||
@@ -45,10 +46,16 @@ class StateAssets {
|
||||
var text: String?;
|
||||
synchronized(_cache) {
|
||||
if (!_cache.containsKey(path)) {
|
||||
text = context.assets
|
||||
?.open(path)
|
||||
?.bufferedReader()
|
||||
?.use { it.readText(); };
|
||||
try {
|
||||
text = context.assets
|
||||
?.open(path)
|
||||
?.bufferedReader()
|
||||
?.use { it.readText(); };
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("StateAssets", "Could not open asset: " + path, ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.put(path, text);
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.states.StatePlaylists.Companion
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.db.ManagedDBStore
|
||||
import com.futo.platformplayer.stores.db.types.DBHistory
|
||||
@@ -89,12 +90,14 @@ class StateHistory {
|
||||
if(isUserAction && _lastHistoryBroadcast != historyBroadcastSig) {
|
||||
_lastHistoryBroadcast = historyBroadcastSig;
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
try {
|
||||
Logger.i(TAG, "SyncHistory playback broadcasted (${liveObj.name}: ${position})");
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncHistory,
|
||||
listOf(historyVideo)
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(StatePlaylists.TAG, "Failed to broadcast sync history", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,31 +227,50 @@ class StatePlaylists {
|
||||
|
||||
private fun broadcastWatchLater(orderOnly: Boolean = false) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
if(orderOnly) listOf() else getWatchLater(),
|
||||
if(orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||
if(orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||
_watchlistOrderStore.values.toList()));
|
||||
try {
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
if (orderOnly) listOf() else getWatchLater(),
|
||||
if (orderOnly) mapOf() else _watchLaterAdds.all(),
|
||||
if (orderOnly) mapOf() else _watchLaterRemovals.all(),
|
||||
getWatchLaterLastReorderTime().toEpochSecond(),
|
||||
_watchlistOrderStore.values.toList()
|
||||
)
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to broadcast watch later", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
private fun broadcastWatchLaterAddition(video: SerializedPlatformVideo, time: OffsetDateTime) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(video),
|
||||
mapOf(Pair(video.url, time.toEpochSecond())),
|
||||
mapOf(),
|
||||
try {
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(video),
|
||||
mapOf(Pair(video.url, time.toEpochSecond())),
|
||||
mapOf(),
|
||||
|
||||
))
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to broadcast watch later addition", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
private fun broadcastWatchLaterRemoval(url: String, time: OffsetDateTime) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateSync.instance.broadcastJsonData(GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(),
|
||||
mapOf(),
|
||||
mapOf(Pair(url, time.toEpochSecond()))
|
||||
))
|
||||
try {
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage(
|
||||
listOf(),
|
||||
mapOf(),
|
||||
mapOf(Pair(url, time.toEpochSecond()))
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to broadcast watch later removal", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,12 +319,14 @@ class StatePlaylists {
|
||||
|
||||
private fun broadcastSyncPlaylist(playlist: Playlist){
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
try {
|
||||
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncPlaylists,
|
||||
SyncPlaylistsPackage(listOf(playlist), mapOf())
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to broadcast sync playlist", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -319,12 +340,14 @@ class StatePlaylists {
|
||||
_playlistRemoved.setAndSave(playlist.id, OffsetDateTime.now());
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
try {
|
||||
Logger.i(StateSubscriptionGroups.TAG, "SyncPlaylist (${playlist.name})");
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncPlaylists,
|
||||
SyncPlaylistsPackage(listOf(), mapOf(Pair(playlist.id, OffsetDateTime.now().toEpochSecond())))
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to broadcast sync playlists", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,12 +79,14 @@ class StateSubscriptionGroups {
|
||||
onGroupsChanged.emit();
|
||||
if(!preventSync) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
try {
|
||||
Logger.i(TAG, "SyncSubscriptionGroup (${subGroup.name})");
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncSubscriptionGroups,
|
||||
SyncSubscriptionGroupsPackage(listOf(subGroup), mapOf())
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to broadcast update subscription group", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -98,12 +100,14 @@ class StateSubscriptionGroups {
|
||||
if(isUserInteraction) {
|
||||
_groupsRemoved.setAndSave(id, OffsetDateTime.now());
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
if(StateSync.instance.hasAtLeastOneOnlineDevice()) {
|
||||
try {
|
||||
Logger.i(TAG, "SyncSubscriptionGroup delete (${group.name})");
|
||||
StateSync.instance.broadcastJsonData(
|
||||
GJSyncOpcodes.syncSubscriptionGroups,
|
||||
SyncSubscriptionGroupsPackage(listOf(), mapOf(Pair(id, OffsetDateTime.now().toEpochSecond())))
|
||||
);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to delete subscription group", e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ class StateSync {
|
||||
val deviceRemoved: Event1<String> = Event1()
|
||||
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
||||
|
||||
fun hasAuthorizedDevice(): Boolean {
|
||||
synchronized(_sessions) {
|
||||
return _sessions.any{ it.value.connected && it.value.isAuthorized };
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
@@ -216,6 +222,11 @@ class StateSync {
|
||||
return _sessions.values.toList()
|
||||
};
|
||||
}
|
||||
fun getAuthorizedSessions(): List<SyncSession> {
|
||||
return synchronized(_sessions) {
|
||||
return _sessions.values.filter { it.isAuthorized }.toList()
|
||||
};
|
||||
}
|
||||
|
||||
fun getSyncSessionData(key: String): SyncSessionData {
|
||||
return _syncSessionData.get(key) ?: SyncSessionData(key);
|
||||
@@ -349,8 +360,12 @@ class StateSync {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
session!!.authorize(s)
|
||||
Logger.i(TAG, "Connection authorized for ${remotePublicKey} by confirmation")
|
||||
try {
|
||||
session!!.authorize(s)
|
||||
Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation")
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to send authorize", e)
|
||||
}
|
||||
}
|
||||
}, cancelAction = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -404,11 +419,9 @@ class StateSync {
|
||||
broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||
}
|
||||
fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) {
|
||||
for(session in getSessions()) {
|
||||
for(session in getAuthorizedSessions()) {
|
||||
try {
|
||||
if (session.isAuthorized && session.connected) {
|
||||
session.send(opcode, subOpcode, data);
|
||||
}
|
||||
session.send(opcode, subOpcode, data);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
Logger.w(TAG, "Failed to broadcast (opcode = ${opcode}, subOpcode = ${subOpcode}) to ${session.remotePublicKey}: ${ex.message}}", ex);
|
||||
@@ -450,17 +463,6 @@ class StateSync {
|
||||
return session
|
||||
}
|
||||
|
||||
fun hasAtLeastOneDevice(): Boolean {
|
||||
synchronized(_authorizedDevices) {
|
||||
return _authorizedDevices.values.isNotEmpty()
|
||||
}
|
||||
}
|
||||
fun hasAtLeastOneOnlineDevice(): Boolean {
|
||||
synchronized(_sessions) {
|
||||
return _sessions.any{ it.value.connected && it.value.isAuthorized };
|
||||
}
|
||||
}
|
||||
|
||||
fun getAll(): List<String> {
|
||||
synchronized(_authorizedDevices) {
|
||||
return _authorizedDevices.values.toList()
|
||||
|
||||
@@ -189,9 +189,9 @@ class StateUpdate {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to check for updates.", e);
|
||||
|
||||
android.util.Log.e(TAG, "Failed to check for updates.", e);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to check for updates");
|
||||
UIDialogs.toast(context, "Failed to check for updates\n" + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +398,6 @@ class SyncSession : IAuthorizable {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T> sendJsonData(subOpcode: UByte, data: T) {
|
||||
send(Opcode.DATA.value, subOpcode, Json.encodeToString<T>(data));
|
||||
}
|
||||
@@ -409,12 +408,29 @@ class SyncSession : IAuthorizable {
|
||||
send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8));
|
||||
}
|
||||
fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) {
|
||||
val sock = _socketSessions.firstOrNull();
|
||||
if(sock != null){
|
||||
sock.send(opcode, subOpcode, ByteBuffer.wrap(data));
|
||||
val socketSessions = synchronized(_socketSessions) {
|
||||
_socketSessions.toList()
|
||||
}
|
||||
|
||||
if (socketSessions.isEmpty()) {
|
||||
Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets")
|
||||
return
|
||||
}
|
||||
|
||||
var sent = false
|
||||
for (socketSession in socketSessions) {
|
||||
try {
|
||||
socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data))
|
||||
sent = true
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates")
|
||||
}
|
||||
else
|
||||
throw IllegalStateException("Session has no active sockets");
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -300,6 +300,8 @@ class SyncSocketSession {
|
||||
}
|
||||
|
||||
private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) {
|
||||
Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})")
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PING.value -> {
|
||||
send(Opcode.PONG.value)
|
||||
|
||||
@@ -592,11 +592,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun setFullScreen(fullScreen: Boolean) {
|
||||
// prevent fullscreen before the video has loaded to make sure we know whether it's a vertical or horizontal video
|
||||
if(exoPlayer?.player?.videoSize?.height ?: 0 == 0 && fullScreen){
|
||||
return
|
||||
}
|
||||
|
||||
updateRotateLock()
|
||||
|
||||
if (isFullScreen == fullScreen) {
|
||||
|
||||
@@ -287,8 +287,8 @@
|
||||
<string name="planned_content_notifications_description">Schedules discovered planned content as notifications, resulting in more accurate notifications for this content.</string>
|
||||
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
|
||||
<string name="auto_update">Auto Update</string>
|
||||
<string name="force_enable_auto_rotate_in_full_screen">Force Enable Auto-Rotate In Full-Screen Mode</string>
|
||||
<string name="force_enable_auto_rotate_in_full_screen_description">Force enable auto-rotation between the two landscape orientations in full-screen mode, even when you disable auto-rotate at the system level.</string>
|
||||
<string name="always_allow_reverse_landscape_auto_rotate">Always allow reverse landscape auto-rotate</string>
|
||||
<string name="always_allow_reverse_landscape_auto_rotate_description">There will always be auto-rotation between the two landscape orientations in full-screen mode, even when you disable auto-rotate in system settings.</string>
|
||||
<string name="simplify_sources">Simplify sources</string>
|
||||
<string name="simplify_sources_description">Deduplicate sources by resolution so that only more relevant sources are visible.</string>
|
||||
<string name="automatic_backup">Automatic Backup</string>
|
||||
@@ -400,7 +400,7 @@
|
||||
<string name="allow_under_cutout_description">Allow video to go underneath the screen cutout in full screen.\nMay require restart</string>
|
||||
<string name="autoplay">Enable autoplay by default</string>
|
||||
<string name="autoplay_description">Autoplay will be enabled by default whenever you watch a video</string>
|
||||
<string name="allow_full_screen_portrait">Allow full-screen portrait</string>
|
||||
<string name="allow_full_screen_portrait">Allow full-screen portrait when watching horizontal videos</string>
|
||||
<string name="delete_watchlist_on_finish">Delete from WatchLater when watched</string>
|
||||
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
|
||||
<string name="background_switch_audio">Switch to Audio in Background</string>
|
||||
|
||||
+1
Submodule app/src/stable/assets/sources/apple-podcast added at f79c7141bc
Submodule app/src/stable/assets/sources/bilibili updated: 258c71e4f5...13b30fd76e
Submodule app/src/stable/assets/sources/odysee updated: 8ddb2e2f15...34dc142f81
Submodule app/src/stable/assets/sources/soundcloud updated: 9a10cb8e78...90bceac198
Submodule app/src/stable/assets/sources/youtube updated: 1be5025b47...f5afc782a9
+1
Submodule app/src/unstable/assets/sources/apple-podcasts added at f79c7141bc
Submodule app/src/unstable/assets/sources/bilibili updated: 258c71e4f5...13b30fd76e
Submodule app/src/unstable/assets/sources/odysee updated: 8ddb2e2f15...34dc142f81
Submodule app/src/unstable/assets/sources/soundcloud updated: 9a10cb8e78...90bceac198
Submodule app/src/unstable/assets/sources/youtube updated: 1be5025b47...f5afc782a9
+1
-1
Submodule dep/futopay updated: 829baef9f0...21afe43dff
+2
-2
@@ -6,8 +6,8 @@ sign_scripts() {
|
||||
local plugin_dir=$1
|
||||
|
||||
if [[ -d "$plugin_dir" ]]; then
|
||||
script_file=$(find "$plugin_dir" -maxdepth 2 -name '*Script.js')
|
||||
config_file=$(find "$plugin_dir" -maxdepth 2 -name '*Config.json')
|
||||
script_file=$(find "$plugin_dir" -maxdepth 2 -name '*Script.js' | head -n 1)
|
||||
config_file=$(find "$plugin_dir" -maxdepth 2 -name '*Config.json' | head -n 1)
|
||||
sign_script="$plugin_dir/sign.sh"
|
||||
|
||||
if [[ -f "$sign_script" && -n "$script_file" && -n "$config_file" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user