Compare commits

...

39 Commits

Author SHA1 Message Date
Kai f31abac409 require the selection of an audio source before downloading
Changelog: changed
2025-01-22 21:03:36 -06:00
Kai 8c879c68d9 fix PeerTube HLS downloads
Changelog: changed
2025-01-22 16:26:50 -06:00
Kai 0190bbffdd improve support for HLS and DASH downloads
Changelog: added
2025-01-22 16:05:44 -06:00
Koen J a410e2962a Only take one line for signing. 2025-01-20 14:04:55 +01:00
Koen J f5aa8f37bb Updated youtube. 2025-01-17 22:19:02 +01:00
Koen J 7e932df450 Updated submodules. 2025-01-17 22:01:26 +01:00
Koen J 3d4741727e Updated submodules. 2025-01-17 21:37:28 +01:00
Kelvin a03b63ef74 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-01-17 21:27:23 +01:00
Kelvin 15ce3e9f20 Timeout support 2025-01-17 21:27:12 +01:00
Kai DeLorenzo 1639bd7af1 Merge branch 'improve-full-screen-portrait-docs' into 'master'
improve full screen portrait docs

See merge request videostreaming/grayjay!79
2025-01-14 16:00:51 +00:00
Kai d474121f85 improve full screen portrait docs
Changelog: changed
2025-01-14 09:59:17 -06:00
Koen J 891d3cf966 Added debug message. 2025-01-06 16:55:02 +01:00
Koen J 561d5ec7ab Fixes to sync. 2025-01-06 15:56:31 +01:00
Koen J 7ce437d50a Updated submodule. 2025-01-06 11:42:14 +01:00
Kelvin 4b02d4ce90 Merge branch 'zvonimir-dev' into 'master'
workflow: Remove labeler

See merge request videostreaming/grayjay!74
2024-12-23 20:44:25 +00:00
Zvonimir Zrakić 3107185869 workflow: Remove labeler 2024-12-23 21:31:06 +01:00
Koen 2e3584a353 Merge branch 'zvonimir-dev' into 'master'
Improve issue templates

See merge request videostreaming/grayjay!73
2024-12-23 18:36:32 +00:00
Zvonimir Zrakić e5b1be195c fix: Fix issue templates, add new plugins 2024-12-23 18:45:35 +01:00
Zvonimir Zrakić dde30c9d76 Add VPN option and fix label typo 2024-12-23 18:45:18 +01:00
Koen 3830e65de8 Update bug_report.yml 2024-12-23 15:43:40 +00:00
Koen c589cf167e Merge branch 'zvonimir-dev' into 'master'
Update issue templates

See merge request videostreaming/grayjay!72
2024-12-21 22:05:42 +00:00
Zvonimir Zrakić 2fde367c82 Update issue templates 2024-12-21 22:05:42 +00:00
Kai DeLorenzo 8fd188268e Merge branch 'disable-download' into 'master'
disable download for Widevine sources

See merge request videostreaming/grayjay!70
2024-12-21 17:27:15 +00:00
Kai b65257df42 disable download for Widevine sources 2024-12-21 11:26:10 -06:00
Kelvin aaa2d7f08d Prevent crashes on non-existing assets 2024-12-19 22:00:56 +01:00
Kelvin f73e25ece6 Fix crash activity update support 2024-12-19 21:54:32 +01:00
Kelvin 78d427f208 Remove broken ref for now 2024-12-19 21:14:07 +01:00
Kelvin eaeaf3538f Better messaging on failed to connect sync 2024-12-18 22:20:11 +01:00
Kai DeLorenzo 85e381a85e Merge branch 'update-deps' into 'master'
update deps

See merge request videostreaming/grayjay!69
2024-12-18 20:37:07 +00:00
Kai 1b7ee8231b update deps 2024-12-18 14:36:40 -06:00
Kai DeLorenzo 1b8b8f5738 Merge branch 'tablet-rotation-issue' into 'master'
more recent landscape and rotation issues

See merge request videostreaming/grayjay!66
2024-12-14 20:42:11 +00:00
Kai 53df19b477 fixes for:
weird tablet issues on some screen sizes
horizontal maximized player on phones
always allow full screen rotation not working
2024-12-14 14:41:28 -06:00
Kai DeLorenzo ccf21b7580 Merge branch 'rotation-regression' into 'master'
fix rotation regression

See merge request videostreaming/grayjay!65
2024-12-14 03:13:21 +00:00
Kai 4189d62a57 fix rotation regression 2024-12-13 20:47:21 -06:00
Koen 9a3e3af614 Merge branch 'feat/apple-podcasts-plugin' into 'master'
Add Apple Podcasts plugin

See merge request videostreaming/grayjay!63
2024-12-13 18:55:16 +00:00
Stefan f7187400dc Add Apple Podcasts plugin 2024-12-13 17:59:56 +00:00
Kai DeLorenzo f55a7f0a7b Merge branch 'detect-system-setting-changes' into 'master'
detect system auto rotate setting changes

See merge request videostreaming/grayjay!62
2024-12-13 17:46:00 +00:00
Kai d6d35a645e detect system auto rotate setting changes
correctly handle lock button when full screen
2024-12-13 11:44:57 -06:00
Kai e719dcc7f5 detect system auto rotate setting changes
correctly handle lock button when full screen
2024-12-13 11:23:40 -06:00
46 changed files with 1037 additions and 305 deletions
+29 -15
View File
@@ -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)
+2 -4
View File
@@ -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.
-34
View File
@@ -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 }}
+6
View File
@@ -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());
@@ -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;
}
}
@@ -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()
}
}
@@ -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
}
@@ -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
@@ -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) };
@@ -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;
}
}
@@ -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}");
@@ -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)
}
}
@@ -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) {
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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