Compare commits

...

57 Commits

Author SHA1 Message Date
Kai 2697107f76 add support for hls sources with request modifiers
add support for encrypted hls streams

Changelog: changed
2025-02-11 00:16:57 -06:00
Kelvin e36047c890 Merge branch 'prevent-exception-replay' into 'master'
Prevent Exception Replay When Unsubscribing From Deleted Channel

See merge request videostreaming/grayjay!77
2025-02-10 19:15:09 +00:00
Kelvin 8f1199bd08 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-02-10 20:12:50 +01:00
Kelvin d6e045ea4e JSDOM, optional packages, Channel not crash if opened without plugin, downloads ordering fixes/naming 2025-02-10 20:12:43 +01:00
Kelvin 304e48996b Merge branch 'rotation-lock-fix' into 'master'
Fix Rotation Lock Activity Resume Issue

See merge request videostreaming/grayjay!83
2025-02-10 18:55:19 +00:00
Kelvin f350dc83b8 Merge branch 'brightness-fix' into 'master'
Restore Overlay Brightness When Re-entering Full Screen

See merge request videostreaming/grayjay!84
2025-02-10 18:53:38 +00:00
Kelvin ebb7beda8c Merge branch 'fix-background-playback' into 'master'
Background playback support for HLS and DASH

See merge request videostreaming/grayjay!80
2025-02-10 18:48:05 +00:00
Kelvin a01f3da66e Merge branch 'adaptive-streaming-auto-ui' into 'master'
Auto mode UI for adaptive streams (HLS and DASH)

See merge request videostreaming/grayjay!76
2025-02-10 18:47:50 +00:00
Kelvin 72f5b5fbc0 Ref 2025-02-07 19:08:38 +01:00
Kelvin 330aa495c8 Playlist dup prevention, download search and ordering, optional package support 2025-02-06 21:36:33 +01:00
Kelvin 0b529ae94d Plugin changelog support, Hide hidden from search setting, No author change warning if missing pubkey, toast on add to playlist, better autoplay description, Playlist total duration label 2025-02-06 19:19:29 +01:00
Kelvin 83b35183d0 Channel shorts tab, Forced batch parallelization, Playlist download support for live sources, hardware codec query 2025-02-05 19:40:28 +01:00
Kelvin 2cd01eb1fe Merge 2025-02-03 21:38:18 +01:00
Kelvin 07378f665a Fix http memory leak for byte responses, refs 2025-02-03 21:36:41 +01:00
Kai DeLorenzo bfd5f24f4c Fix https://github.com/futo-org/grayjay-android/issues/727 2025-01-27 21:51:17 +00:00
Kai 3d617187af update rotation lock approach
Changelog: changed
2025-01-27 12:03:25 -06:00
Koen J d040b93ca9 Updated submodules and fixed IPv6 casting play address being wrong. 2025-01-23 14:26:08 +01: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 da58b72f9d add background playback support for videos without an explicit audio source
Changelog: changed
2025-01-14 11:36:37 -06: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
Kai 978f76ffb6 Added current quality to auto item
Changelog: added
2025-01-10 14:38:54 -06:00
Kai 084bac00f5 Clear feed loading exceptions to prevent replay of exceptions
Changelog: changed
2025-01-08 21:54:03 -06:00
Kai 94454172dd Add UI to show when adaptive streams (HLS and DASH) are in auto mode
Changelog: added
2025-01-08 20:43:38 -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
83 changed files with 1088 additions and 333 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
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -11,7 +11,8 @@ let Type = {
Streams: "STREAMS",
Mixed: "MIXED",
Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
Subscriptions: "SUBSCRIPTIONS",
Shorts: "SHORTS"
},
Order: {
Chronological: "CHRONOLOGICAL"
@@ -244,6 +245,7 @@ class PlatformVideo extends PlatformContent {
this.viewCount = obj.viewCount ?? -1; //Long
this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
}
}
class PlatformVideoDetails extends PlatformVideo {
@@ -260,6 +262,7 @@ class PlatformVideoDetails extends PlatformVideo {
this.rating = obj.rating ?? null; //IRating
this.subtitles = obj.subtitles ?? [];
this.isShort = !!obj.isShort ?? false;
}
}
@@ -226,6 +226,25 @@ fun Long.toHumanTime(isMs: Boolean): String {
else
return "${prefix}${minsStr}:${secsStr}"
}
fun Long.toHumanDuration(isMs: Boolean): String {
var scaler = 1;
if(isMs)
scaler = 1000;
val v = Math.abs(this);
val hours = Math.max(v/(secondsInHour*scaler), 0);
val mins = Math.max((v % (secondsInHour*scaler)) / (secondsInMinute * scaler), 0);
val minsStr = mins.toString();
val seconds = Math.max(((v % (secondsInHour*scaler)) % (secondsInMinute * scaler))/scaler, 0);
val secsStr = seconds.toString().padStart(2, '0');
val prefix = if (this < 0) { "-" } else { "" };
return listOf(
if(hours > 0) "${hours}h" else null,
if(mins > 0) "${mins}m" else null ,
if(seconds > 0) "${seconds}s" else null
).filterNotNull().joinToString(" ");
}
//TODO: Determine if below stuff should have its own proper class, seems a bit too complex for a utility method
fun String.fixHtmlWhitespace(): Spanned {
@@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
@@ -33,10 +33,10 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String {
return when (this) {
is Inet6Address -> {
"[${toString()}]"
"[${hostAddress}]"
}
is Inet4Address -> {
toString()
hostAddress
}
else -> {
throw Exception("Invalid address type")
@@ -254,6 +254,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
@FormField(R.string.hide_hidden_from_search, FieldForm.TOGGLE, R.string.hide_hidden_from_search_description, 7)
var hidefromSearch: Boolean = false;
fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0)
@@ -415,8 +418,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)
@@ -861,11 +864,13 @@ class Settings : FragmentedStorageFileJson() {
class Other {
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true;
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 3)
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4)
var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 4)
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5)
var polycentricLocalCache: Boolean = true;
}
@@ -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
@@ -368,8 +368,8 @@ class UIDialogs {
}
}
fun showChangelogDialog(context: Context, lastVersion: Int) {
val dialog = ChangelogDialog(context);
fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
val dialog = ChangelogDialog(context, changelogs);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
@@ -28,6 +28,9 @@ 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.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
@@ -269,12 +272,17 @@ class UISlideOverlays {
}
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
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 masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -355,7 +363,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, if (source is JSSource) source.hasRequestModifier else false);
slideUpMenuOverlay.hide()
}
@@ -475,7 +483,7 @@ class UISlideOverlays {
)
}
is IHLSManifestSource -> {
is JSHLSManifestSource -> {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
@@ -549,7 +557,7 @@ class UISlideOverlays {
);
}
is IHLSManifestAudioSource -> {
is JSHLSManifestAudioSource -> {
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
@@ -614,13 +622,13 @@ class UISlideOverlays {
menu.onOK.subscribe {
val sv = selectedVideo
if (sv is IHLSManifestSource) {
if (sv is JSHLSManifestSource) {
showHlsPicker(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
}
@@ -895,7 +903,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists();
}))
);
@@ -991,7 +1000,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video);
if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists();
}));
}
@@ -1018,7 +1028,8 @@ class UISlideOverlays {
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
if(StatePlaylists.instance.addToPlaylist(lastUpdated.id, video))
UIDialogs.appToast("Added to playlist [${lastUpdated?.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists();
}))
);
@@ -1040,7 +1051,9 @@ class UISlideOverlays {
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true);
UIDialogs.appToast("Added to watch later", false);
}),
)
);
@@ -1067,7 +1080,8 @@ class UISlideOverlays {
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video);
if(StatePlaylists.instance.addToPlaylist(playlist.id, video))
UIDialogs.appToast("Added to playlist [${playlist.name}]", false);
StateDownloads.instance.checkForOutdatedPlaylists();
}));
}
@@ -1281,7 +1281,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (toast.long)
delay(5000);
else
delay(3000);
delay(2500);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
@@ -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());
@@ -30,6 +30,7 @@ class ResultCapabilities(
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val TYPE_SHORTS = "SHORTS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
class HLSVariantVideoUrlSource(
override val name: String,
@@ -12,7 +13,8 @@ class HLSVariantVideoUrlSource(
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
val url: String,
val jsSource: JSSource? = null,
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
@@ -27,7 +29,8 @@ class HLSVariantAudioUrlSource(
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
val url: String,
val jsSource: JSSource? = null,
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
@@ -13,4 +13,6 @@ interface IPlatformVideo : IPlatformContent {
val viewCount: Long;
val isLive : Boolean;
val isShort: Boolean;
}
@@ -25,6 +25,7 @@ open class SerializedPlatformVideo(
override val duration: Long,
override val viewCount: Long,
override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
@@ -38,7 +38,8 @@ open class SerializedPlatformVideoDetails(
override val video: ISerializedVideoSourceDescriptor,
override val preview: ISerializedVideoSourceDescriptor?,
override val subtitles: List<SubtitleRawSource> = listOf()
override val subtitles: List<SubtitleRawSource> = listOf(),
override val isShort: Boolean = false
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
@@ -33,6 +33,7 @@ class SourcePluginConfig(
override val allowEval: Boolean = false,
override val allowUrls: List<String> = listOf(),
override val packages: List<String> = listOf(),
override val packagesOptional: List<String> = listOf(),
val settings: List<Setting> = listOf(),
@@ -52,6 +53,7 @@ class SourcePluginConfig(
var allowAllHttpHeaderAccess: Boolean = false,
var maxDownloadParallelism: Int = 0,
var reduceFunctionsInLimitedVersion: Boolean = false,
var changelog: HashMap<String, List<String>>? = null
) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -101,6 +103,10 @@ class SourcePluginConfig(
if(!packages.contains(pack))
return false;
}
for(pack in newConfig.packagesOptional) {
if(!packagesOptional.contains(pack))
return false;
}
//Developer Submit Url should be same or empty
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
return false;
@@ -129,7 +135,7 @@ class SourcePluginConfig(
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
if (currentlyInstalledPlugin != null) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) {
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey && !currentlyInstalledPlugin.config.scriptPublicKey.isNullOrEmpty()) {
list.add(Pair(
"Different Author",
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
@@ -178,6 +184,19 @@ class SourcePluginConfig(
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
}
fun getChangelogString(version: String): String?{
if(changelog == null || !changelog!!.containsKey(version))
return null;
val changelog = changelog!![version]!!;
if(changelog.size > 1) {
return "Changelog (${version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
return "Changelog (${version})\n" + changelog[0].trim();
}
return null;
}
companion object {
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
@@ -17,6 +18,7 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val viewCount: Long;
final override val isLive: Boolean;
final override val isShort: Boolean;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformVideo";
@@ -26,5 +28,6 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
duration = _content.getOrThrow<Int>(config, "duration", contextName).toLong();
viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
}
}
@@ -1,37 +1,24 @@
package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.app.PendingIntent.*
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.receivers.InstallReceiver
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
class ChangelogDialog(context: Context?) : AlertDialog(context) {
class ChangelogDialog(context: Context?, val changelogs: Map<Int, String>? = null) : AlertDialog(context) {
companion object {
private val TAG = "ChangelogDialog";
}
@@ -48,7 +35,11 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
private var _managedHttpClient = ManagedHttpClient();
private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> StateUpdate.instance.downloadChangelog(_managedHttpClient, version) })
private val _taskDownloadChangelog = TaskHandler<Int, String?>(StateApp.instance.scopeGetter, { version -> if(changelogs == null)
StateUpdate.instance.downloadChangelog(_managedHttpClient, version)
else
changelogs[version]
})
.success { setChangelog(it); }
.exception<Throwable> {
Logger.w(TAG, "Failed to load changelog.", it);
@@ -97,7 +88,7 @@ class ChangelogDialog(context: Context?) : AlertDialog(context) {
setVersion(version);
val currentVersion = BuildConfig.VERSION_CODE;
_buttonUpdate.visibility = if (currentVersion == _maxVersion) View.GONE else View.VISIBLE;
_buttonUpdate.visibility = if (currentVersion == _maxVersion || changelogs != null) View.GONE else View.VISIBLE;
}
private fun setVersion(version: Int) {
@@ -54,6 +54,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonInstall: LinearLayout;
private lateinit var _textPlugin: TextView;
private lateinit var _textChangelog: TextView;
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
@@ -94,6 +95,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonInstall = findViewById(R.id.button_install);
_textPlugin = findViewById(R.id.text_plugin);
_textChangelog = findViewById(R.id.text_changelog);
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
@@ -110,6 +112,27 @@ class PluginUpdateDialog : AlertDialog {
_updateSpinner = findViewById(R.id.update_spinner);
_iconPlugin = findViewById(R.id.icon_plugin);
try {
var changelogVersion = _newConfig.version.toString();
if (_newConfig.changelog != null && _newConfig.changelog?.containsKey(changelogVersion) == true) {
_textChangelog.movementMethod = ScrollingMovementMethod();
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
}
else
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
_buttonCancel1.setOnClickListener {
dismiss();
};
@@ -10,11 +10,10 @@ 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.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
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
@@ -28,12 +27,11 @@ 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.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
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
@@ -44,9 +42,9 @@ 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 +57,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -69,8 +68,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 {
@@ -93,10 +94,10 @@ class VideoDownload {
var audioSource: AudioUrlSource?;
@Contextual
@Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
val videoSourceToUse: IVideoSource? get () = if (videoSource?.container == "application/vnd.apple.mpegurl") videoSource else if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@Contextual
@Transient
val audioSourceToUse: IAudioSource? get () = if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
val audioSourceToUse: IAudioSource? get () = if (audioSource?.container == "application/vnd.apple.mpegurl") audioSource else if(requiresLiveAudioSource) audioSourceLive as IAudioSource? else audioSource as IAudioSource?;
var requireVideoSource: Boolean = false;
var requireAudioSource: Boolean = false;
@@ -131,6 +132,9 @@ class VideoDownload {
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
var progress: Double = 0.0;
var isCancelled = false;
@@ -180,7 +184,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources;
}
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasHLSRequestModifier: Boolean = false) {
this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -191,8 +195,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 || hasHLSRequestModifier
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier || hasHLSRequestModifier
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || this.hasVideoRequestModifier || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || this.hasAudioRequestModifier || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -285,9 +291,14 @@ class VideoDownload {
if(videoSource == null && targetPixelCount != null) {
val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) {
if (source is IHLSManifestSource) {
if (source is JSHLSManifestSource) {
try {
val playlistResponse = client.get(source.url)
val playlistResponse = if (source.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) {
@@ -310,12 +321,20 @@ class VideoDownload {
if(vsource == null)
vsource = VideoHelper.selectBestVideoSource(videoSources, targetPixelCount!!.toInt(), arrayOf())
// ?: throw IllegalStateException("Could not find a valid video source for video");
if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
}
if(vsource == null) {
videoSource = null;
if(original.video.videoSources.size == 0)
requireVideoSource = false;
}
else if (vsource is HLSVariantVideoUrlSource && vsource.container == "application/vnd.apple.mpegurl") {
videoSource = VideoUrlSource.fromUrlSource(vsource)
videoSourceLive = vsource.jsSource!!
}
else if(vsource is IVideoUrlSource)
videoSource = VideoUrlSource.fromUrlSource(vsource)
else if(vsource is JSSource && requiresLiveVideoSource)
@@ -329,9 +348,14 @@ class VideoDownload {
val video = original.video
if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) {
if (source is JSHLSManifestAudioSource) {
try {
val playlistResponse = client.get(source.url)
val playlistResponse = if (source.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) {
@@ -346,6 +370,26 @@ class VideoDownload {
}
}
}
for (source in video.videoSources) {
if (source is JSHLSManifestSource) {
try {
val playlistResponse = if (source.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))
}
}
} catch (e: Throwable) {
Log.i(TAG, "Failed to get HLS audio sources", e)
}
}
}
var asource: IAudioSource? = null;
if(targetAudioName != null) {
@@ -361,11 +405,21 @@ class VideoDownload {
asource = VideoHelper.selectBestAudioSource(audioSources, arrayOf(), null, targetBitrate)
?: if(videoSource != null ) null
else throw DownloadException("Could not find a valid video or audio source for download")
if(asource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
}
if(asource == null) {
audioSource = null;
if(!original.video.isUnMuxed || original.video.videoSources.size == 0)
requireVideoSource = false;
}
else if (asource is HLSVariantAudioUrlSource && asource.container == "application/vnd.apple.mpegurl") {
audioSource = AudioUrlSource.fromUrlSource(asource)
audioSourceLive = asource.jsSource!!
}
else if(asource is IAudioUrlSource)
audioSource = AudioUrlSource.fromUrlSource(asource)
else if(asource is JSSource && requiresLiveAudioSource)
@@ -448,9 +502,9 @@ class VideoDownload {
}
}
if(actualVideoSource is IVideoUrlSource)
if(videoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
@@ -488,9 +542,9 @@ class VideoDownload {
}
}
if(actualAudioSource is IAudioUrlSource)
if(audioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (audioSourceLive is JSSource) audioSourceLive else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
@@ -544,7 +598,15 @@ class VideoDownload {
}
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
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?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -552,13 +614,33 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
val response = if (source is JSSource && source.hasRequestModifier) {
val request = source.getRequestModifier()!!.modifyRequest(hlsUrl, mapOf())
client.get(request.url!!, request.headers.toMutableMap())
} else {
client.get(hlsUrl)
}
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 decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = if (source is JSSource && 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 +652,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)
@@ -610,10 +692,8 @@ class VideoDownload {
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}\""
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
@@ -623,7 +703,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 +710,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 +829,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 +856,31 @@ class VideoDownload {
}
return sourceLength!!;
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
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 +900,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 +912,7 @@ class VideoDownload {
if (read < 0)
break;
fileStream.write(buffer, 0, read);
segmentBuffer.write(buffer, 0, read);
totalRead += read;
@@ -834,6 +938,14 @@ 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;
}
@@ -1082,7 +1194,7 @@ class VideoDownload {
StateDownloads.instance.updateCachedVideo(existing);
}
else {
val newVideo = VideoLocal(videoDetails!!);
val newVideo = VideoLocal(videoDetails!!, OffsetDateTime.now());
if(localVideoSource != null)
newVideo.videoSource.add(localVideoSource);
if(localAudioSource != null)
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.stores.v2.IStoreItem
import java.io.File
import java.time.OffsetDateTime
@@ -70,14 +71,21 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isLive: Boolean get() = videoSerialized.isLive;
override val isShort: Boolean get() = videoSerialized.isShort;
//TODO: Offline subtitles
override val subtitles: List<ISubtitleSource> = listOf();
constructor(video: SerializedPlatformVideoDetails) {
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
var downloadDate: OffsetDateTime? = null;
constructor(video: SerializedPlatformVideoDetails, downloadDate: OffsetDateTime? = null) {
this.videoSerialized = video;
this.downloadDate = downloadDate;
}
constructor(video: IPlatformVideoDetails, subtitleSources: List<SubtitleRawSource>) {
this.videoSerialized = SerializedPlatformVideoDetails.fromVideo(video, subtitleSources);
downloadDate = OffsetDateTime.now();
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
@@ -32,6 +32,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package
import com.futo.platformplayer.getOrThrow
@@ -94,7 +95,11 @@ class V8Plugin {
withDependency(PackageBridge(this, config));
for(pack in config.packages)
withDependency(getPackage(pack));
withDependency(getPackage(pack)!!);
for(pack in config.packagesOptional)
getPackage(pack, true)?.let {
withDependency(it);
}
}
fun changeAllowDevSubmit(isAllowed: Boolean) {
@@ -254,13 +259,14 @@ class V8Plugin {
}
}
private fun getPackage(packageName: String): V8Package {
private fun getPackage(packageName: String, allowNull: Boolean = false): V8Package? {
//TODO: Auto get all package types?
return when(packageName) {
"DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(this, config)
"Utilities" -> PackageUtilities(this, config)
else -> throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
"JSDOM" -> PackageJSDOM(this, config)
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
};
}
@@ -5,6 +5,7 @@ interface IV8PluginConfig {
val allowEval: Boolean;
val allowUrls: List<String>;
val packages: List<String>;
val packagesOptional: List<String>;
}
@kotlinx.serialization.Serializable
@@ -13,17 +14,20 @@ class V8PluginConfig : IV8PluginConfig {
override val allowEval: Boolean;
override val allowUrls: List<String>;
override val packages: List<String>;
override val packagesOptional: List<String>;
constructor() {
name = "Unknown";
allowEval = false;
allowUrls = listOf();
packages = listOf();
packagesOptional = listOf();
}
constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf()) {
constructor(name: String, allowEval: Boolean, allowUrls: List<String>, packages: List<String> = listOf(), packagesOptional: List<String> = listOf()) {
this.name = name;
this.allowEval = allowEval;
this.allowUrls = allowUrls;
this.packages = packages;
this.packagesOptional = packagesOptional;
}
}
@@ -1,8 +1,12 @@
package com.futo.platformplayer.engine.packages
import android.media.MediaCodec
import android.media.MediaCodecList
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 +20,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 +42,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 +79,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}");
@@ -130,7 +189,44 @@ class PackageBridge : V8Package {
return false;
}
@V8Function
fun getHardwareCodecs(): List<String>{
return getSupportedHardwareMediaCodecs();
}
companion object {
private const val TAG = "PackageBridge";
private var _mediaCodecList: MutableList<String> = mutableListOf();
private var _mediaCodecListHardware: MutableList<String> = mutableListOf();
fun getSupportedMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecList;
}
}
fun getSupportedHardwareMediaCodecs(): List<String>{
synchronized(_mediaCodecList) {
if(_mediaCodecList.size <= 0)
updateMediaCodecList();
return _mediaCodecListHardware;
}
}
private fun updateMediaCodecList() {
_mediaCodecList.clear();
_mediaCodecListHardware.clear();
for(codec in MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos) {
if(!codec.isEncoder) {
_mediaCodecList.add(codec.canonicalName);
if (codec.isHardwareAccelerated)
_mediaCodecListHardware.add(codec.canonicalName);
}
}
}
}
}
@@ -21,9 +21,13 @@ import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence
class PackageHttp: V8Package {
@@ -42,6 +46,9 @@ class PackageHttp: V8Package {
override val name: String get() = "Http";
override val variableName: String get() = "http";
private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
@@ -51,6 +58,37 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth);
}
/*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
*/
private fun <T, R> autoParallelPool(data: List<T>, parallelism: Int, handle: (T)->R): List<Pair<R?, Throwable?>> {
synchronized(_batchPoolLock) {
val threadsToUse = if (parallelism <= 0) data.size else Math.min(parallelism, data.size);
if(_batchPool == null)
_batchPool = ForkJoinPool(threadsToUse);
var pool = _batchPool ?: return listOf();
if(pool.poolSize < threadsToUse) { //Resize pool
pool.shutdown();
_batchPool = ForkJoinPool(threadsToUse);
pool = _batchPool ?: return listOf();
}
val resultTasks = mutableListOf<ForkJoinTask<Pair<R?, Throwable?>>>();
for(item in data){
resultTasks.add(pool.submit<Pair<R?, Throwable?>> {
try {
return@submit Pair<R?, Throwable?>(handle(item), null);
}
catch(ex: Throwable) {
return@submit Pair<R?, Throwable?>(null, ex);
}
});
}
return resultTasks.map { it.join() };
}
}
@V8Function
fun newClient(withAuth: Boolean): PackageHttpClient {
val httpClient = if(withAuth) _clientAuth.clone() else _client.clone();
@@ -176,8 +214,6 @@ class PackageHttp: V8Package {
obj.set("url", url);
obj.set("code", code);
if(body != null) {
val buffer = runtime.createV8ValueArrayBuffer(body.size);
buffer.fromBytes(body);
obj.set("body", body);
}
obj.set("headers", headers);
@@ -236,16 +272,19 @@ class PackageHttp: V8Package {
//Finalizer
@V8Function
fun execute(): List<IBridgeHttpResponse?> {
return _reqs.parallelStream().map {
return _package.autoParallelPool(_reqs, -1) {
if(it.second.method == "DUMMY")
return@map null;
return@autoParallelPool null;
if(it.second.body != null)
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else
return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
}
.asSequence()
.toList();
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map {
if(it.second != null)
throw it.second!!;
else
return@map it.first;
}.toList();
}
}
@@ -439,11 +478,8 @@ class PackageHttp: V8Package {
else {
headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase()
if(lowerCaseHeader == "set-cookie") {
result[lowerCaseHeader] = values.filter{
!it.lowercase().contains("httponly")
};
}
if(lowerCaseHeader == "set-cookie" && !values.any { it.lowercase().contains("httponly") })
result[lowerCaseHeader] = values;
else
result[lowerCaseHeader] = values;
}
@@ -0,0 +1,20 @@
package com.futo.platformplayer.engine.packages
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.states.StateApp
class PackageJSDOM : V8Package {
@Transient
private val _config: IV8PluginConfig;
override val name: String get() = "JSDOM";
override val variableName: String get() = "packageJSDOM";
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config;
plugin.withDependency(StateApp.instance.contextOrNull ?: return, "scripts/JSDOM.js");
}
}
@@ -43,7 +43,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.max
class ChannelContentsFragment : Fragment(), IChannelTabFragment {
class ChannelContentsFragment(private val subType: String? = null) : Fragment(), IChannelTabFragment {
private var _recyclerResults: RecyclerView? = null;
private var _glmVideo: GridLayoutManager? = null;
private var _loading = false;
@@ -73,9 +73,12 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if (lastPolycentricProfile != null)
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile);
if(pager == null)
pager = StatePlatform.instance.getChannelContent(channel.url);
if(pager == null) {
if(subType != null)
pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else
pager = StatePlatform.instance.getChannelContent(channel.url);
}
return pager;
}
@@ -367,6 +370,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
companion object {
val TAG = "VideoListFragment";
fun newInstance() = ChannelContentsFragment().apply { }
fun newInstance(subType: String? = null) = ChannelContentsFragment(subType).apply { }
}
}
@@ -25,6 +25,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -457,6 +458,12 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
(_viewPager.adapter as ChannelViewPagerAdapter).insert(1, ChannelTab.SHORTS);
}
}
}
}
@@ -469,8 +476,13 @@ class ChannelFragment : MainFragment() {
R.string.subscribers
).lowercase() else ""
val supportsPlaylists =
StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
var supportsPlaylists = false;
try {
supportsPlaylists = StatePlatform.instance.getChannelClient(channel.url).capabilities.hasGetChannelPlaylists
} catch (ex: Throwable) {
//Ignore error
Logger.e(TAG, "Failed to check if supports playlists", ex);
}
val playlistPosition = 1
if (supportsPlaylists && !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(
ChannelTab.PLAYLISTS.ordinal.toLong()
@@ -17,6 +17,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers
@@ -222,6 +223,12 @@ class ContentSearchResultsFragment : MainFragment() {
setSortByOptions(null);
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
if(Settings.instance.search.hidefromSearch)
return super.filterResults(results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) });
return super.filterResults(results)
}
override fun reload() {
loadResults();
}
@@ -4,8 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -25,6 +30,7 @@ import com.futo.platformplayer.views.items.PlaylistDownloadItem
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class DownloadsFragment : MainFragment() {
private val TAG = "DownloadsFragment";
@@ -92,8 +98,12 @@ class DownloadsFragment : MainFragment() {
private val _listDownloadedHeader: LinearLayout;
private val _listDownloadedMeta: TextView;
private val _listDownloadSearch: EditText;
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null;
private var ordering: String? = "nameAsc";
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag;
@@ -104,6 +114,7 @@ class DownloadsFragment : MainFragment() {
_listActiveDownloadsContainer = findViewById(R.id.downloads_active_downloads_container);
_listActiveDownloadsMeta = findViewById(R.id.downloads_active_downloads_meta);
_listDownloadSearch = findViewById(R.id.downloads_search);
_listActiveDownloads = findViewById(R.id.downloads_active_downloads_list);
_listPlaylistsContainer = findViewById(R.id.downloads_playlist_container);
@@ -113,6 +124,30 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader = findViewById(R.id.downloads_videos_header);
_listDownloadedMeta = findViewById(R.id.downloads_videos_meta);
_listDownloadSearch.addTextChangedListener {
updateContentFilters();
}
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
spinnerSortBy.setSelection(0);
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> ordering = "nameAsc"
1 -> ordering = "nameDesc"
2 -> ordering = "downloadDateAsc"
3 -> ordering = "downloadDateDesc"
4 -> ordering = "releasedAsc"
5 -> ordering = "releasedDesc"
else -> ordering = null
}
updateContentFilters()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) {
it.onClick.subscribe {
@@ -125,7 +160,6 @@ class DownloadsFragment : MainFragment() {
reloadUI();
}
fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
@@ -184,7 +218,29 @@ class DownloadsFragment : MainFragment() {
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
}
_listDownloaded.setData(downloaded);
lastDownloads = downloaded;
_listDownloaded.setData(filterDownloads(downloaded));
}
fun updateContentFilters(){
val toFilter = lastDownloads ?: return;
_listDownloaded.setData(filterDownloads(toFilter));
}
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) {
vidsToReturn = when(ordering){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
else -> vidsToReturn
}
}
return vidsToReturn;
}
}
}
@@ -146,7 +146,7 @@ class PlaylistFragment : MainFragment() {
setName(it.name);
//TODO: Implement support for pagination
setVideos(it.videos, false);
setVideoCount(it.videos.size);
setMetadata(it.videos.size, it.videos.sumOf { it.duration });
setLoading(false);
}
.exception<Throwable> {
@@ -174,7 +174,7 @@ class PlaylistFragment : MainFragment() {
if (parameter != null) {
setName(parameter.name)
setVideos(parameter.videos, true)
setVideoCount(parameter.videos.size)
setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration })
setButtonDownloadVisible(true)
setButtonEditVisible(true)
@@ -187,7 +187,7 @@ class PlaylistFragment : MainFragment() {
} else {
setName(null)
setVideos(null, false)
setVideoCount(-1)
setMetadata(-1, -1);
setButtonDownloadVisible(false)
setButtonEditVisible(false)
}
@@ -195,7 +195,7 @@ class PlaylistFragment : MainFragment() {
_playlist = null
_url = parameter.url
setVideoCount(parameter.videoCount)
setMetadata(parameter.videoCount, -1);
setName(parameter.name)
setVideos(null, false)
setButtonDownloadVisible(false)
@@ -208,7 +208,7 @@ class PlaylistFragment : MainFragment() {
setName(null)
setVideos(null, false)
setVideoCount(-1)
setMetadata(-1, -1);
setButtonDownloadVisible(false)
setButtonEditVisible(false)
@@ -237,7 +237,19 @@ class SourceDetailFragment : MainFragment() {
BigButtonGroup(c, context.getString(R.string.update),
BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) {
checkForUpdatesSource();
}
},
if(config.changelog?.any() == true)
BigButton(c, context.getString(R.string.changelog), context.getString(R.string.changelog_plugin_description), R.drawable.ic_list) {
UIDialogs.showChangelogDialog(context, config.version, config.changelog!!.filterKeys { it.toIntOrNull() != null }
.mapKeys { it.key.toInt() }
.mapValues { config.getChangelogString(it.key.toString()) ?: "" });
}.apply {
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
}
else
null
)
);
@@ -544,7 +556,7 @@ class SourceDetailFragment : MainFragment() {
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
if (config.version <= c.version && config.name != "Youtube") {
Logger.i(TAG, "Plugin is up to date.");
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
return@launch;
@@ -204,8 +204,10 @@ class SubscriptionsFeedFragment : MainFragment() {
val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any())
handleExceptions(currentExs);
if(currentExs != _lastExceptions && currentExs.any()) {
handleExceptions(currentExs)
feed?.exceptions = listOf()
}
return@TaskHandler resp;
})
@@ -151,6 +151,7 @@ class TutorialFragment : MainFragment() {
override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
@@ -4,16 +4,21 @@ 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.Surface
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.view.ViewCompat.getDisplay
import androidx.core.view.WindowCompat
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
@@ -34,7 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.min
//region Fragment
@UnstableApi
@@ -85,6 +90,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 +101,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 +121,7 @@ class VideoDetailFragment() : MainFragment() {
isSmallWindow
&& newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
&& !isFullscreen
&& !isInPictureInPicture
&& state == State.MAXIMIZED
) {
_viewDetail?.setFullscreen(true)
@@ -154,6 +158,8 @@ class VideoDetailFragment() : MainFragment() {
) {
_viewDetail?.setFullscreen(true)
}
updateOrientation()
}
fun updateOrientation() {
@@ -161,15 +167,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 +188,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) {
@@ -195,7 +207,37 @@ class VideoDetailFragment() : MainFragment() {
} else if (rotationLock) {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
a.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
val display = getDisplay(_viewDetail!!)
val rotation = display!!.rotation
val orientation = resources.configuration.orientation
a.requestedOrientation = when (orientation) {
Configuration.ORIENTATION_PORTRAIT -> {
if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
if (rotation == Surface.ROTATION_0) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
Configuration.ORIENTATION_LANDSCAPE -> {
if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
if (rotation == Surface.ROTATION_90) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
} else {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
}
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
} else {
_portraitOrientationListener?.disableListener()
_landscapeOrientationListener?.disableListener()
@@ -392,6 +434,10 @@ class VideoDetailFragment() : MainFragment() {
updateOrientation()
}
}
_autoRotateObserver = AutoRotateObserver(requireContext(), Handler(Looper.getMainLooper())) {
updateOrientation()
}
_autoRotateObserver?.startObserving()
return _view!!;
}
@@ -496,6 +542,7 @@ class VideoDetailFragment() : MainFragment() {
_landscapeOrientationListener?.disableListener()
_portraitOrientationListener?.disableListener()
_autoRotateObserver?.stopObserving()
_viewDetail?.let {
_viewDetail = null;
@@ -657,3 +704,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)
}
}
@@ -171,7 +171,6 @@ import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong
@UnstableApi
@@ -649,18 +648,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 +658,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() };
@@ -876,20 +869,18 @@ class VideoDetailView : ConstraintLayout {
}
_slideUpOverlay?.hide();
} else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
}
else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!allowBackground) {
_player.switchToAudioMode();
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
_player.switchToVideoMode();
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
}
else null,
if(!isLimitedVersion)
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
@@ -922,18 +913,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)
}
}
})
}
@@ -1900,13 +1898,45 @@ class VideoDetailView : ConstraintLayout {
return super.onInterceptTouchEvent(ev);
}
//Actions
private fun showVideoSettings() {
Logger.i(TAG, "showVideoSettings")
_overlay_quality_selector?.selectOption("video", _lastVideoSource);
_overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
if (_lastVideoSource is IDashManifestSource || _lastVideoSource is IHLSManifestSource) {
val videoTracks =
_player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
var selectedQuality: Format? = null
if (videoTracks != null) {
for (i in 0 until videoTracks.mediaTrackGroup.length) {
if (videoTracks.mediaTrackGroup.getFormat(i).height == _player.targetTrackVideoHeight) {
selectedQuality = videoTracks.mediaTrackGroup.getFormat(i)
}
}
}
var videoMenuGroup: SlideUpMenuGroup? = null
for (view in _overlay_quality_selector!!.groupItems) {
if (view is SlideUpMenuGroup && view.groupTag == "video") {
videoMenuGroup = view
}
}
if (selectedQuality != null) {
videoMenuGroup?.getItem("auto")?.setSubText("")
_overlay_quality_selector?.selectOption("video", selectedQuality)
} else {
videoMenuGroup?.getItem("auto")
?.setSubText("${_player.exoPlayer?.player?.videoFormat?.width}x${_player.exoPlayer?.player?.videoFormat?.height}")
_overlay_quality_selector?.selectOption("video", "auto")
}
}
val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0
_overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
@@ -2080,17 +2110,15 @@ class VideoDetailView : ConstraintLayout {
call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray())
else null,
if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats
.map {
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it.label ?: it.containerMimeType ?: it.bitrate.toString(),
"${it.width}x${it.height}",
tag = it,
call = { _player.selectVideoTrack(it.height) });
}.toList().toTypedArray())
if (liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(
this.context, context.getString(R.string.stream_video), "video", (listOf(
SlideUpMenuItem(this.context, R.drawable.ic_movie, "Auto", tag = "auto", call = { _player.selectVideoTrack(-1) })
) + (liveStreamVideoFormats.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label
?: it.containerMimeType
?: it.bitrate.toString(), "${it.width}x${it.height}", tag = it, call = { _player.selectVideoTrack(it.height) });
}))
)
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
@@ -2384,8 +2412,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
@@ -20,6 +20,8 @@ import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.toHumanTime
import com.futo.platformplayer.views.lists.VideoListEditorView
abstract class VideoListEditorView : LinearLayout {
@@ -136,8 +138,14 @@ abstract class VideoListEditorView : LinearLayout {
_textName.text = name ?: "";
}
protected fun setVideoCount(videoCount: Int = -1) {
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos);
protected fun setMetadata(videoCount: Int = -1, duration: Long = -1) {
val parts = mutableListOf<String>()
if(videoCount >= 0)
parts.add("${videoCount} " + context.getString(R.string.videos));
if(duration > 0)
parts.add("${duration.toHumanDuration(false)} ");
_textMetadata.text = parts.joinToString("");
}
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
@@ -13,16 +13,15 @@ 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.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 +46,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 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? {
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtit
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
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.platforms.js.models.sources.JSSource
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.net.URI
@@ -61,7 +62,28 @@ 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,14 +108,14 @@ 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> {
fun parseAndGetVideoSources(source: JSSource, content: String, url: String): List<HLSVariantVideoUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getVideoSources()
return masterPlaylist.getVideoSources(source)
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
@@ -109,11 +131,11 @@ class HLS {
}
}
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
fun parseAndGetAudioSources(source: JSSource, content: String, url: String): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources()
return masterPlaylist.getAudioSources(source)
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
return if (source is IHLSManifestSource) {
@@ -317,7 +339,7 @@ class HLS {
return builder.toString()
}
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
fun getVideoSources(source: JSSource? = null): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
@@ -328,11 +350,11 @@ class HLS {
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url, source)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
fun getAudioSources(source: JSSource? = null): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
@@ -340,7 +362,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, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri, source)
else -> null
}
}
@@ -368,6 +390,11 @@ class HLS {
}
}
data class DecryptionInfo(
val keyUrl: String,
val iv: String
)
data class VariantPlaylist(
val version: Int?,
val targetDuration: Int?,
@@ -376,7 +403,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 {
@@ -336,8 +336,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate));
}
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource));
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, hasRequestModifier: Boolean = false) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource, hasRequestModifier));
}
private fun download(videoState: VideoDownload, notify: Boolean = true) {
@@ -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)
}
};
}
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -227,31 +228,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)
}
};
}
@@ -287,25 +307,32 @@ class StatePlaylists {
broadcastSyncPlaylist(playlist);
}
}
fun addToPlaylist(id: String, video: IPlatformVideo) {
fun addToPlaylist(id: String, video: IPlatformVideo): Boolean {
synchronized(playlistStore) {
val playlist = getPlaylist(id) ?: return;
val playlist = getPlaylist(id) ?: return false;
if(!Settings.instance.other.playlistAllowDups && playlist.videos.any { it.url == video.url })
return false;
playlist.videos.add(SerializedPlatformVideo.fromVideo(video));
playlist.dateUpdate = OffsetDateTime.now();
playlistStore.saveAsync(playlist, true);
broadcastSyncPlaylist(playlist);
return true;
}
}
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 +346,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)
@@ -55,7 +55,7 @@ class ToastView : LinearLayout {
translationY = 20.dp(context.resources).toFloat();
animate()
.alpha(1f)
.setDuration(700)
.setDuration(300)
.translationY(0f)
.start();
}
@@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -21,7 +22,7 @@ import com.google.android.material.tabs.TabLayout
enum class ChannelTab {
VIDEOS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT
VIDEOS, SHORTS, CHANNELS, PLAYLISTS, SUPPORT, ABOUT
}
class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
@@ -91,6 +92,19 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
}
}
ChannelTab.SHORTS -> {
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
onAddToClicked.subscribe(this@ChannelViewPagerAdapter.onAddToClicked::emit)
onAddToQueueClicked.subscribe(this@ChannelViewPagerAdapter.onAddToQueueClicked::emit)
onAddToWatchLaterClicked.subscribe(this@ChannelViewPagerAdapter.onAddToWatchLaterClicked::emit)
onLongPress.subscribe(this@ChannelViewPagerAdapter.onLongPress::emit)
}
}
ChannelTab.CHANNELS -> {
fragment = ChannelListFragment.newInstance()
.apply { onClickChannel.subscribe(onChannelClicked::emit) }
@@ -755,10 +755,6 @@ class GestureControlView : LinearLayout {
}
}
if (!Settings.instance.gestureControls.useSystemBrightness) {
_brightnessFactor = 1.0f;
}
if (Settings.instance.gestureControls.useSystemVolume) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
@@ -61,6 +61,15 @@ class SlideUpMenuGroup : LinearLayout {
return didSelect;
}
fun getItem(tag: Any?): SlideUpMenuItem? {
for(item in items) {
if(item.itemTag == tag){
return item
}
}
return null
}
private fun addItems(items: List<SlideUpMenuItem>) {
for (item in items) {
item.setParentClickListener { parentClickListener?.invoke() }
@@ -82,6 +82,10 @@ class SlideUpMenuItem : ConstraintLayout {
return isSelected;
}
fun setSubText(subText: String) {
_subtext.text = subText
}
fun setParentClickListener(listener: (()->Unit)?) {
_parentClickListener = listener;
}
@@ -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) {
@@ -110,8 +110,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private var _didCallSourceChange = false;
private var _lastState: Int = -1;
private var _targetTrackVideoHeight = -1;
private var _targetTrackAudioBitrate = -1;
var targetTrackVideoHeight = -1
private set
private var _targetTrackAudioBitrate = -1
private var _toResume = false;
@@ -278,7 +280,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
//TODO: Temporary solution, Implement custom track selector without using constraints
fun selectVideoTrack(height: Int) {
_targetTrackVideoHeight = height;
targetTrackVideoHeight = height;
updateTrackSelector();
}
fun selectAudioTrack(bitrate: Int) {
@@ -288,16 +290,22 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun updateTrackSelector() {
var builder = DefaultTrackSelector.Parameters.Builder(context);
if(_targetTrackVideoHeight > 0) {
if(targetTrackVideoHeight > 0) {
builder = builder
.setMinVideoSize(0, _targetTrackVideoHeight - 10)
.setMaxVideoSize(9999, _targetTrackVideoHeight + 10);
.setMinVideoSize(0, targetTrackVideoHeight - 10)
.setMaxVideoSize(9999, targetTrackVideoHeight + 10);
}
if(_targetTrackAudioBitrate > 0) {
builder = builder.setMaxAudioBitrate(_targetTrackAudioBitrate);
}
builder = if (isAudioMode) {
builder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
} else {
builder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false)
}
val trackSelector = exoPlayer?.player?.trackSelector;
if(trackSelector != null) {
trackSelector.parameters = builder.build();
@@ -737,6 +745,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val sourceAudio = _lastAudioMediaSource;
val sourceSubs = _lastSubtitleMediaSource;
updateTrackSelector()
beforeSourceChanged();
@@ -93,6 +93,26 @@
android:layout_marginStart="30dp"
android:layout_marginEnd="30dp" />
<TextView
android:id="@+id/text_changelog"
android:layout_width="match_parent"
android:layout_height="80dp"
android:textAlignment="textStart"
android:text="Changelog"
android:textSize="9.5sp"
android:textColor="@color/white"
android:fontFamily="monospace"
android:scrollbars="vertical"
android:background="@color/black"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:layout_marginTop="10dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
+31 -1
View File
@@ -99,7 +99,6 @@
</LinearLayout>
</LinearLayout>
<!--Playlists-->
<LinearLayout
android:id="@+id/downloads_playlist_container"
@@ -163,6 +162,37 @@
android:textColor="#ACACAC"
tools:text="(12 videos)" />
</LinearLayout>
<EditText
android:id="@+id/downloads_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/background_button_round"
android:hint="Seach.." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14dp"
android:textColor="@color/gray_ac"
android:fontFamily="@font/inter_light"
android:text="@string/sort_by"
android:paddingStart="20dp" />
<Spinner
android:id="@+id/spinner_sortby"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
+18 -5
View File
@@ -13,6 +13,8 @@
<string name="home">Home</string>
<string name="progress_bar">Progress Bar</string>
<string name="progress_bar_description">If a historical progress bar should be shown</string>
<string name="hide_hidden_from_search">Hide hidden from home in search</string>
<string name="hide_hidden_from_search_description">Hide videos and creators hidden from home also in search results</string>
<string name="recommendations">Recommendations</string>
<string name="more">More</string>
<string name="playlists">Playlists</string>
@@ -195,6 +197,7 @@
<string name="connected_to">Connected to</string>
<string name="volume">Volume</string>
<string name="changelog">Changelog</string>
<string name="changelog_plugin_description">Shows available changelogs for current and past versions</string>
<string name="some_example_changelog">Some example changelog.</string>
<string name="previous">Previous</string>
<string name="next">Next</string>
@@ -287,8 +290,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>
@@ -398,9 +401,9 @@
<string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string>
<string name="allow_under_cutout">Allow video under cutout</string>
<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="autoplay">Autoplay next video by default</string>
<string name="autoplay_description">Autoplay next video will be enabled by default whenever you watch a video</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>
@@ -424,6 +427,8 @@
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
<string name="playlist_allow_dups">Allow duplicate playlist videos</string>
<string name="playlist_allow_dups_description">Allow adding duplicate videos to playlists</string>
<string name="enable_polycentric">Enable Polycentric</string>
<string name="polycentric_local_cache">Enable Polycentric Local Caching</string>
<string name="polycentric_local_cache_description">Caches polycentric results on-device to reduce load times, changing requires app reboot</string>
@@ -953,6 +958,14 @@
<item>Watchtime Ascending</item>
<item>Watchtime Descending</item>
</string-array>
<string-array name="downloads_sortby_array">
<item>Name (Ascending)</item>
<item>Name (Descending)</item>
<item>Download Date (Oldest)</item>
<item>Download Date (Newest)</item>
<item>Release Date (Oldest)</item>
<item>Release Date (Newest)</item>
</string-array>
<string-array name="feed_style">
<item>Preview</item>
<item>List</item>
+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