Compare commits

...

127 Commits

Author SHA1 Message Date
Kelvin 713d46c781 Refs 2025-08-21 22:23:07 +02:00
Kelvin 0429665173 Fix title for relay server 2025-08-21 22:18:00 +02:00
Kelvin ac05edca77 Setting to disable short filling 2025-08-21 22:14:30 +02:00
Kelvin ad3dacf68f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-21 22:07:15 +02:00
Kelvin 91a8996c11 Shorts fix video size scaling for some aspect ratios, long press support for tags, home plugin filters now support long press to only select that one 2025-08-21 22:06:52 +02:00
Kelvin 40c4a51a2b Dialog input support, configurable relay server, radio views select/deselect all long press 2025-08-21 20:41:18 +02:00
Kelvin f8e0aaf4d2 Merge branch 'fix-logincall' into 'master'
fix: login prompt looping on search video

See merge request videostreaming/grayjay!144
2025-08-21 16:00:35 +00:00
zvonimir ad97b5a406 fix: login prompt looping on search video 2025-08-21 17:59:08 +02:00
Kelvin b0e0c1b75f Merge branch 'PiP-play-pause-fix' into 'master'
PiP Play Pause Fix

See merge request videostreaming/grayjay!143
2025-08-20 13:41:43 +00:00
Kai b1fce443e9 fix pause and play buttons not working correctly in PiP
Changelog: changed
2025-08-20 08:39:12 -04:00
Kelvin 66f8711055 Fix login warnings working on redirects 2025-08-19 17:52:14 +02:00
Kelvin b7c123c281 Refs 2025-08-19 16:45:57 +02:00
Kelvin 9481bbf3f1 Vod chat button fix, default settings in devportal 2025-08-19 16:42:17 +02:00
Kelvin 43ec7e821b Refs 2025-08-18 21:31:49 +02:00
Kelvin ca3454afbe Login warning fixes, uimod (disabled) 2025-08-18 19:35:12 +02:00
Kelvin 1edc8aabf8 Fix login dialog 2025-08-15 21:20:23 +02:00
Kelvin 91060faac9 VOD chat 2025-08-15 16:36:38 +02:00
Kelvin 17027ba364 Remote history sync on toggle 2025-08-14 21:03:39 +02:00
Kelvin 8569eaa5db Hide DevSubmit filter 2025-08-14 20:36:56 +02:00
Kelvin d32d817e0a Merge branch 'shorts-improv' into 'master'
Fix background play, disable artwork on background till improved, renamed...

See merge request videostreaming/grayjay!140
2025-08-14 11:26:47 +00:00
Kelvin a0f4cc760c Fix background play, disable artwork on background till improved, renamed variable that caused confusion 2025-08-14 12:35:46 +02:00
Kelvin 5247997ea5 Set plugin install request timeouts, fix messaging surrounding downloading icons 2025-08-13 19:36:26 +02:00
Kelvin 453030d561 Merge branch 'shorts-improv' into 'master'
Various shorts improvements, login warnings support, etc

See merge request videostreaming/grayjay!138
2025-08-13 16:11:30 +00:00
Kelvin e080702a52 Fix dislike color 2025-08-13 17:56:27 +02:00
Kelvin 3909343adc Pre-generate support shorts, subtitle size, short like/dislike color 2025-08-13 00:23:54 +02:00
Kelvin dc76934d0e Add explicit long type for dash dwonload length 2025-08-12 17:05:54 +02:00
Kelvin 6cf47d592a Various shorts improvements, login warnings support, etc 2025-08-12 02:03:04 +02:00
Kai 1507c70729 fix https://github.com/futo-org/grayjay-android/issues/2585
Changelog: changed
2025-08-11 16:45:03 -04:00
Kai d6a23ac0de fix PiP issue
reproduction steps

- play a video
- swipe home to enter PiP
- minimize the video and then close it with the X
- swipe home (PiP will launch even though it shouldn't because nothing is playing)

Changelog: changed
2025-08-11 14:48:23 -05:00
koen-futo 17df396672 Merge pull request #2597 from alpqn/patch-1
Fix typos
2025-08-11 10:56:28 +02:00
quonverbat 0c5ba0cd39 Fix typos 2025-08-10 16:22:56 +03:00
Koen 183aeb18a0 Merge branch 'plugin-add-mixcloud' into 'master'
Add mixcloud plugin

See merge request videostreaming/grayjay!137
2025-08-07 08:00:16 +00:00
Stefan 8d08e19cd2 Add mixcloud plugin 2025-08-07 08:00:17 +01:00
Kelvin a882d04d26 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-01 21:56:00 +02:00
Kelvin c4d06c1ba2 Hide sync ui, thumbnails nullable 2025-08-01 21:55:47 +02:00
Kelvin 4dfcd47901 Merge branch 'fix-null-thumbnails-error' into 'master'
fix: erroring out when thumbnails are null which causes sync to reset

See merge request videostreaming/grayjay!136
2025-07-31 16:37:29 +00:00
zvonimir 4c0c1abb4b fix: erroring out when thumbnails are null which causes sync to reset 2025-07-31 18:34:24 +02:00
Kelvin 6f44071186 Merge branch 'update-docs' into 'master'
docs: add section for Request Modifiers in Content types document

See merge request videostreaming/grayjay!134
2025-07-31 14:00:17 +00:00
Stefan 29910a2698 docs: add section for Request Modifiers in Content types document 2025-07-31 14:00:17 +00:00
Kelvin b5da0d4462 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-30 18:22:27 +02:00
Kelvin 99fb9b3462 VOD chat support 2025-07-30 18:22:15 +02:00
Koen J 5f0a89d13b Implemented URLs open outside of live chat webview. 2025-07-30 13:46:46 +02:00
Koen f311561e6f Merge branch 'fix-android-anr-swap-sources' into 'master'
Fix Android ANR in SwapSources.

See merge request videostreaming/grayjay!135
2025-07-29 09:51:15 +00:00
Koen J 2fc944ddd9 Cleanup. 2025-07-29 11:15:49 +02:00
Koen J a2970b86ee Fixed issue where Scan QR button vanishes due to missing owner activity and fixed issue where remembered devices do not show until at least one normal device is found. 2025-07-29 10:58:41 +02:00
Kelvin ac9a51f105 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-29 01:39:48 +02:00
Kelvin 90dca2537a getUserHistory support 2025-07-29 01:39:32 +02:00
Koen J 4df227147c Fix Android ANR in SwapSources. 2025-07-28 15:44:37 +02:00
Koen J 1fb55dca0a On casting device disconnect, only set play when ready to true if it was also playing on the TV device. 2025-07-24 13:44:04 +02:00
Koen J 3d7b347e49 Do not call playVideo on reconnects, but instead check MEDIA_STATUS. 2025-07-24 13:05:28 +02:00
Koen J 769ec9f59a Only auto relaunch player the first time ChromeCast is started, do not reset time to 0 if player is not found, stop casting if ChromeCast player is disconnected. 2025-07-24 12:23:23 +02:00
Koen J dee310de3d Potential crashfix for downloads. 2025-07-24 11:16:48 +02:00
Koen 0af4bad906 Merge branch 'hls-url-redirect-fix' into 'master'
Nebula Download Fix

See merge request videostreaming/grayjay!129
2025-07-22 11:54:41 +00:00
Koen 4731673ba3 Merge branch 'fix-fullscreen-ui-offset' into 'master'
Fullscreen UI Fix

See merge request videostreaming/grayjay!116
2025-07-22 09:41:42 +00:00
Koen J 8745221cbd Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into fix-fullscreen-ui-offset 2025-07-22 11:39:20 +02:00
Koen 742d95440e Merge branch 'pip-improvement' into 'master'
implement the quick PiP feature

See merge request videostreaming/grayjay!107
2025-07-22 09:38:39 +00:00
Koen J 180b320cd7 Merge branch 'pip-improvement' of gitlab.futo.org:videostreaming/grayjay into pip-improvement 2025-07-22 11:31:27 +02:00
Koen J cc8dffc485 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into pip-improvement 2025-07-22 11:30:35 +02:00
Koen a64fd2cf35 Merge branch 'align-manifests' into 'master'
Align Manifests

See merge request videostreaming/grayjay!123
2025-07-21 15:07:27 +00:00
Koen 4aceb364d9 Edit AndroidManifest.xml 2025-07-21 15:06:57 +00:00
Koen J 76d9bac0ec Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-21 17:03:14 +02:00
Koen J 2b8dc41d0d Artwork should only show when audio mode is not transitioning out. 2025-07-21 17:03:00 +02:00
Koen 33430c538c Merge branch 'preview-feed-fix-constraints' into 'master'
Feed Preview Graphical Bug

See merge request videostreaming/grayjay!121
2025-07-21 14:01:37 +00:00
Koen J 03e9cb398b Made isLimitedVersion check more specific. 2025-07-21 15:54:48 +02:00
Koen J 2aef2ebec1 Background playback fixes for limited version and artwork now shows while in background playback. 2025-07-21 15:43:43 +02:00
Koen J 5e5fffbf97 loadPager should not be called on init. Small fix for restoring brightness when exiting app. 2025-07-21 14:57:03 +02:00
Koen J 51ac604e31 Various crash fixes. 2025-07-21 14:41:36 +02:00
Koen 4e49b5bc63 Merge branch 'shorts-tab' into 'master'
shorts tab

See merge request videostreaming/grayjay!92
2025-07-21 12:33:14 +00:00
Koen 658cbc5e00 Edit FCastCastingDevice.kt 2025-07-18 08:34:22 +00:00
Koen J 2ceb4c5644 Fixed issue where streams are not proxied when a request modifier is present. 2025-07-17 11:15:38 +02:00
Kai 2738954af7 add hard coded padding to compensate for this bug
https://gitlab.futo.org/videostreaming/grayjay/-/merge_requests/133

Changelog: changed
2025-07-11 15:40:01 -05:00
Kai db5aaf0b84 fix delay when opening quality overlay
Changelog: changed
2025-07-11 13:50:32 -05:00
Kai e1abb7f8ae prevent scroll to top from showing when it shouldn't
fix zero state

Changelog: changed
2025-07-11 12:18:02 -05:00
Koen J 3310ac6008 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-11 10:16:21 +02:00
Kai 09879c83e9 fix sources button click
Changelog: changed
2025-07-10 10:45:24 -05:00
Kai 7aa8b6bc14 add zero state for shorts tab
Changelog: changed
2025-07-10 10:40:52 -05:00
Kai cac8a8fde4 add back button when in channel shorts
hide refresh button when in channel shorts

prevent main player being open when viewing shorts

show info toast when long pressing refresh button

switch bottom bar button ids back to their original values

Changelog: changed
2025-07-10 09:30:42 -05:00
Kelvin 01cb544dfd Dont lock clients when disabling 2025-07-07 16:42:37 +02:00
Kelvin b9239b6177 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 16:18:35 +02:00
Kelvin 96ca3f62a2 Missing invokev8 wrappers 2025-07-07 16:18:23 +02:00
Koen J 73ad783881 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 16:12:33 +02:00
Koen J 3bfcf65535 Fixes to reload required exception handling for casting. 2025-07-07 16:12:03 +02:00
Kelvin 8b3b27a2a8 Stop pagers silently if the underlying object is closed 2025-07-07 16:04:19 +02:00
Kelvin a4d4835a89 Reduice font size 2025-07-07 14:26:17 +02:00
Kelvin 56c0f7bfaf Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 14:14:51 +02:00
Kelvin 736424ae35 Refs 2025-07-07 14:14:39 +02:00
Koen J 37dc778009 Fixed casting. 2025-07-07 12:45:45 +02:00
Koen J cd3cea58a4 Fixed race condition when awaiting and changing video source.. 2025-07-07 10:52:42 +02:00
Koen J 8b53e9e5e3 Processed last feedback on minigame. 2025-07-05 18:09:49 +02:00
Koen J 08e98b089c Improvements to target tap loader game. 2025-07-05 17:32:31 +02:00
Koen J 5528d71da8 Show score toast. 2025-07-05 14:07:49 +02:00
Koen J 83f520ca44 Further fixes to TargetTApLoaderView. 2025-07-05 13:47:48 +02:00
Koen J cc247ce634 Attempt at a loader game. 2025-07-05 12:58:33 +02:00
Koen J 82f214f155 Merge branch 'shorts-tab' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-04 08:16:34 +02:00
Koen J 4ee127fe13 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-04 08:16:12 +02:00
Kai 1e4aefb7d5 fix https://github.com/futo-org/grayjay-android/issues/2386
Changelog: changed
2025-06-20 12:00:56 -05:00
Kai 2a825a9f83 platform icon
fix text shadow

Changelog: changed
2025-06-20 09:55:39 -05:00
Kai 6695774037 icon updates
Changelog: changed
2025-06-19 11:14:00 -05:00
Kai a10bc8c7de fix very wide screen videos enter PiP mode
Changelog: changed
2025-06-18 12:12:39 -05:00
Kai c1e6e401cc formatting
Changelog: changed
2025-06-18 09:36:31 -05:00
Kai 98b6213886 remove layout changed listener
Changelog: changed
2025-06-17 15:26:27 -05:00
Kai b6671c653c add text shadow
add HQ icon for quality settings

Changelog: changed
2025-06-17 13:45:15 -05:00
Kai 55d042bee3 add platform logo
Changelog: changed
2025-06-17 12:07:29 -05:00
Kai 80034ad131 update refresh text
Changelog: changed
2025-06-16 13:25:02 -05:00
Kai 30c41044da Merge branch 'master' into pip-improvement
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
2025-06-13 08:45:09 -05:00
Kai e369676808 remove spotify.com because those are not handled by the spotify plugin
Changelog: changed
2025-06-12 14:17:56 -05:00
Kai 2fa9e65bee align stable and unstable manifests
Changelog: changed
2025-06-12 14:13:08 -05:00
Kai cf96bd1ec0 Add functionality to open channel shorts in shorts fragment
Changelog: changed
2025-06-12 14:01:11 -05:00
Kai 1f5a069877 Fix like button checked when not logged into polycentric
Open video details when tapping title

Changelog: changed
2025-06-12 10:42:08 -05:00
Kai adc5013ea4 Fix the constraints on the feed preview items
Changelog: changed
2025-06-11 10:30:48 -05:00
Kai 515c5e00e9 Fix live stream PiP mode
Prevent splash screen when opening PiP mode

Changelog: changed
2025-06-10 15:27:14 -05:00
Kai ba9f843368 fix https://github.com/futo-org/grayjay-android/issues/2165
Changelog: changed
2025-06-06 15:45:12 -05:00
Kai 0653f88c49 Merge branch 'master' into shorts-tab
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
2025-06-04 13:03:40 -05:00
Kai 4ce9f64808 make code review changes
switch to TaskHandler
switch to XML

Changelog: changed
2025-06-04 12:59:48 -05:00
Kai 4fa0229ccb implement the quick PiP feature
https://developer.android.com/develop/ui/views/picture-in-picture#setautoenterenabled

Changelog: changed
2025-05-29 12:38:37 -05:00
Kai 42dd8d6152 add TODO
Changelog: changed
2025-04-14 12:40:47 -05:00
Kai 0a839b4814 fix crash
Changelog: changed
2025-04-11 10:43:33 -05:00
Kai 586db317dd catch exception remove UI lag
Changelog: changed
2025-04-10 10:17:10 -05:00
Kai ae36a24ad1 catch exception remove UI lag
Changelog: changed
2025-04-10 10:16:30 -05:00
Kai 9a435f8859 fix loading bar and app switching
Changelog: changed
2025-04-09 12:16:07 -05:00
Kai 81162c5df2 fix crash on early loading
Changelog: changed
2025-04-08 18:58:56 -05:00
Kai c7c3ddfc96 fix progress bar offset
Changelog: changed
2025-04-08 18:43:40 -05:00
Kai 830d3a9022 Merge branch 'master' into shorts-tab 2025-04-08 17:58:55 -05:00
Kai a1c2d19daf refactor shorts code
Changelog: changed
2025-04-08 17:58:31 -05:00
Kai bd87a47551 finished UI and interactions
Changelog: added
2025-04-01 11:25:07 -05:00
Kai DeLorenzo 76103a2a8c fix dialog missing 2025-03-19 15:12:48 -05:00
Kai f63f9dd6db initial POC shorts tab
Changelog: added
2025-03-07 14:27:18 -06:00
173 changed files with 5628 additions and 693 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ body:
label: Reproduction steps
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
placeholder: |
0. Play a Youtube video
0. Play a YouTube video
1. Press on Download button
2. Select quality 1440p
3. Grayjay crashes when attempting to download
@@ -83,7 +83,7 @@ body:
- "Spotify"
- "TedTalks"
- "Twitch"
- "Youtube"
- "YouTube"
- "Other"
validations:
required: true
+6
View File
@@ -106,3 +106,9 @@
[submodule "app/src/stable/assets/sources/crunchyroll"]
path = app/src/stable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git
[submodule "app/src/stable/assets/sources/mixcloud"]
path = app/src/stable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
+3 -2
View File
@@ -154,9 +154,10 @@ android {
}
dependencies {
implementation 'com.google.dagger:dagger:2.48'
//implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
implementation 'com.google.android.material:material:1.12.0'
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
implementation 'androidx.core:core-ktx:1.12.0'
+22 -2
View File
@@ -1022,15 +1022,35 @@
return x.value
});
let settingsToUse = __DEV_SETTINGS ?? {};
if (true) {
for (let setting of this.Plugin?.currentPlugin?.settings) {
if (typeof settingsToUse[setting.variable] == "undefined") {
switch (setting?.type?.toLowerCase()) {
case "boolean":
settingsToUse[setting.variable] = setting.default === 'true';
break;
case "dropdown":
let dropDownIndex = parseInt(setting.default);
if (dropDownIndex) {
settingsToUse[setting.variable] = setting.options[dropDownIndex];
}
break;
}
}
}
}
if(name == "enable") {
if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin;
else
parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1)
parameterVals[1] = __DEV_SETTINGS;
parameterVals[1] = settingsToUse;
else
parameterVals.push(__DEV_SETTINGS);
parameterVals.push(settingsToUse);
}
const func = source[name];
+13 -3
View File
@@ -251,6 +251,9 @@ class PlatformVideo extends PlatformContent {
this.duration = obj.duration ?? -1; //Long
this.viewCount = obj.viewCount ?? -1; //Long
this.playbackTime = obj.playbackTime ?? -1;
this.playbackDate = obj.playbackDate ?? undefined;
this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false;
}
@@ -464,14 +467,20 @@ class AudioUrlWidevineSource extends AudioUrlSource {
this.getLicenseRequestExecutor = () => {
return {
executeRequest: (url, _headers, _method, license_request_data) => {
return http.POST(
const response = http.POST(
url,
license_request_data,
{ Authorization: `Bearer ${obj.bearerToken}` },
false,
true
).body
}
);
if (!response.body) {
throw new ScriptException("Unable to acquire license key");
}
return response.body;
}
}
}
}
@@ -785,6 +794,7 @@ let plugin = {
//To override by plugin
const source = {
getHome() { return new ContentPager([], false, {}); },
getShorts() { return new VideoPager([], false, {}); },
enable(config){ },
disable() {},
@@ -25,6 +25,7 @@ import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
@@ -34,6 +35,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -603,6 +605,16 @@ class Settings : FragmentedStorageFileJson() {
else -> 2.0
}
}
@AdvancedField
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
var shortsPregenerate: Boolean = false;
@AdvancedField
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
@FormFieldWarning(R.string.shorts_fit_video_warning)
var shortsFitVideo: Boolean = false;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -1087,6 +1099,38 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
var syncServerUrl: String? = null;
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() {
SettingsActivity.getActivity()?.let { context ->
UIDialogs.showDialog(context, R.drawable.device_sync, false,
"Enter the url to your relay server",
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
null,
syncServerUrl ?: "",
"YourRelayServerDomain.com", 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
context.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
context.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
}
}
}
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -113,8 +113,8 @@ class UIDialogs {
currentDialog.code,
currentDialog.defaultCloseAction,
*currentDialog.actions.map {
return@map Action(it.text, {
it.action();
return@map Action.withInput(it.text, { str ->
it.invokeAction(str);
multiShowDialog(context, dialogDescriptor.drop(1), finally);
}, it.style);
}.toTypedArray());
@@ -203,7 +203,9 @@ class UIDialogs {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view);
@@ -226,6 +228,16 @@ class UIDialogs {
this.text = textDetails;
}
};
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
inputView.apply {
if (input == null && placeholder == null) this.visibility = View.GONE;
else {
this.text = input ?: "";
this.hint = placeholder ?: "";
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if (code == null) this.visibility = View.GONE;
else {
@@ -250,7 +262,7 @@ class UIDialogs {
buttonView.textSize = 14f;
buttonView.typeface = resources.getFont(R.font.inter_regular);
buttonView.text = act.text;
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
when(act.style) {
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
@@ -275,7 +287,7 @@ class UIDialogs {
};
dialog.setOnCancelListener {
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
actions[defaultCloseAction].action();
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
}
dialog.setOnDismissListener {
registerDialogClosed(dialog);
@@ -424,7 +436,7 @@ class UIDialogs {
}
fun showCastingDialog(context: Context) {
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
val d = StateCasting.instance.activeDevice;
if (d != null) {
val dialog = ConnectedCastingDialog(context);
@@ -432,6 +444,7 @@ class UIDialogs {
dialog.setOwnerActivity(context)
}
registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
} else {
@@ -444,21 +457,24 @@ class UIDialogs {
if (c is Activity) {
dialog.setOwnerActivity(c);
}
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
}
fun showCastingTutorialDialog(context: Context) {
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
val dialog = CastingHelpDialog(context);
registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingAddDialog(context: Context) {
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
val dialog = CastingAddDialog(context);
registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
@@ -531,17 +547,36 @@ class UIDialogs {
}
class Action {
val text: String;
val action: ()->Unit;
val action: ((DialogResult?)->Unit);
val style: ActionStyle;
var center: Boolean;
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = { action() };
this.style = style;
this.center = center;
}
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = action;
this.style = style;
this.center = center;
}
fun invokeAction(input: DialogResult? = null) {
this.action(input);
}
companion object {
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
return Action(text, action, style, center);
}
}
}
class DialogResult(
val text: String?
);
enum class ActionStyle {
NONE,
PRIMARY,
@@ -129,115 +129,163 @@ class UISlideOverlays {
val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts;
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
val menu = SlideUpMenuOverlay(
container.context,
container,
"Subscription Settings",
null,
true,
listOf()
);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities();
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities();
withContext(Dispatchers.Main) {
items.addAll(listOf(
SlideUpMenuItem(
container.context,
R.drawable.ic_notifications,
"Notifications",
"",
tag = "notifications",
call = {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
},
invokeParent = false
),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
-1, listOf()) else null,
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuRecycler(container.context, "as") {
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if(it is SubscriptionGroup.Selectable) {
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
?: return@subscribe;
groups.clear();
if(it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
withContext(Dispatchers.Main) {
items.addAll(
listOf(
SlideUpMenuItem(
container.context,
R.drawable.ic_notifications,
"Notifications",
"",
tag = "notifications",
call = {
subscription.doNotifications =
menu?.selectOption(null, "notifications", true, true)
?: subscription.doNotifications;
},
invokeParent = false
),
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
.isNotEmpty()
)
SlideUpMenuGroup(
container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
-1, listOf()
) else null,
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
.isNotEmpty()
)
SlideUpMenuRecycler(container.context, "as") {
val groups =
ArrayList<SubscriptionGroup>(
StateSubscriptionGroups.instance.getSubscriptionGroups()
.map {
SubscriptionGroup.Selectable(
it,
it.urls.contains(subscription.channel.url)
)
}
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if (it is SubscriptionGroup.Selectable) {
val actualGroup =
StateSubscriptionGroups.instance.getSubscriptionGroup(
it.id
)
?: return@subscribe;
groups.clear();
if (it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
}
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
container.context,
R.drawable.ic_live_tv,
"Livestreams",
"Check for livestreams",
tag = "fetchLive",
call = {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Streams",
"Check for streams",
tag = "fetchStreams",
call = {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Videos",
"Check for videos",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
},
invokeParent = false
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Content",
"Check for content",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
container.context,
R.drawable.ic_chat,
"Posts",
"Check for posts",
tag = "fetchPosts",
call = {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
},
invokeParent = false
) else null/*,,
StateSubscriptionGroups.instance.updateSubscriptionGroup(
actualGroup
);
groups.addAll(
StateSubscriptionGroups.instance.getSubscriptionGroups()
.map {
SubscriptionGroup.Selectable(
it,
it.urls.contains(subscription.channel.url)
)
}
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
}
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(
container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()
),
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
container.context,
R.drawable.ic_live_tv,
"Livestreams",
"Check for livestreams",
tag = "fetchLive",
call = {
subscription.doFetchLive =
menu?.selectOption(null, "fetchLive", true, true)
?: subscription.doFetchLive;
},
invokeParent = false
) else null,
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Streams",
"Check for streams",
tag = "fetchStreams",
call = {
subscription.doFetchStreams =
menu?.selectOption(null, "fetchStreams", true, true)
?: subscription.doFetchStreams;
},
invokeParent = false
) else null,
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Videos",
"Check for videos",
tag = "fetchVideos",
call = {
subscription.doFetchVideos =
menu?.selectOption(null, "fetchVideos", true, true)
?: subscription.doFetchVideos;
},
invokeParent = false
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Content",
"Check for content",
tag = "fetchVideos",
call = {
subscription.doFetchVideos =
menu?.selectOption(null, "fetchVideos", true, true)
?: subscription.doFetchVideos;
},
invokeParent = false
) else null,
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
container.context,
R.drawable.ic_chat,
"Posts",
"Check for posts",
tag = "fetchPosts",
call = {
subscription.doFetchPosts =
menu?.selectOption(null, "fetchPosts", true, true)
?: subscription.doFetchPosts;
},
invokeParent = false
) else null/*,,
SlideUpMenuGroup(container.context, "Actions",
"Various things you can do with this subscription",
@@ -245,61 +293,82 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
showCreateSubscriptionGroup(container, subscription.channel);
}, false)*/
).filterNotNull());
).filterNotNull()
);
menu.setItems(items);
menu.setItems(items);
if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
if (subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if (subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if (subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if (subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if (subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
if(subscription.doNotifications && !originalNotif) {
val mainContext = StateApp.instance.contextOrNull;
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
if (subscription.doNotifications && !originalNotif) {
val mainContext = StateApp.instance.contextOrNull;
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(
container.context,
"Enable 'Background Update' in settings for notifications to work"
);
if(mainContext is MainActivity) {
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
"You need to set a Background Updating interval for notifications", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
val intent = Intent(mainContext, SettingsActivity::class.java);
intent.putExtra("query", mainContext.getString(R.string.background_update));
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY));
}
return@subscribe;
}
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(container.context, "Android notifications are disabled");
if(mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
if (mainContext is MainActivity) {
UIDialogs.showDialog(
mainContext,
R.drawable.ic_settings,
"Background Updating Required",
"You need to set a Background Updating interval for notifications",
null,
0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
val intent = Intent(
mainContext,
SettingsActivity::class.java
);
intent.putExtra(
"query",
mainContext.getString(R.string.background_update)
);
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY)
);
}
return@subscribe;
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(
container.context,
"Android notifications are disabled"
);
if (mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
}
}
}
}
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save");
menu.setOk("Save");
menu.show();
menu.show();
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show subscription overlay.", e)
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
@@ -74,9 +75,26 @@ class LoginActivity : AppCompatActivity() {
finish();
};
var isFirstLoad = true;
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
if(warning != null) {
if(warning.once == true)
loginWarnings.remove(warning);
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
@@ -86,6 +104,35 @@ class LoginActivity : AppCompatActivity() {
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
/*
var specifiedScale = false;
var specifiedDesktop = false;
if(uiMods.size > 0 && url != null) {
synchronized(uiMods) {
val uimod = uiMods.find { url.matches(it.getRegex()) };
if(uimod != null) {
if(uimod.scale != null) {
currentScale =(uimod.scale * 100).toInt();
_webView.setInitialScale(currentScale);
specifiedScale = true;
}
if(uimod.desktop != null && uimod.desktop) {
_webView.settings.useWideViewPort = true;
specifiedDesktop = true;
}
}
}
}
if(!specifiedScale && currentScale != 100) {
currentScale = (100).toInt();
_webView.setInitialScale(currentScale);
}
if(!specifiedDesktop && currentDesktop) {
_webView.settings.useWideViewPort = false;
currentDesktop = false;
}
*/
}
_webView.settings.domStorageEnabled = true;
@@ -32,7 +32,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
@@ -63,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
@@ -114,7 +114,6 @@ import java.io.PrintWriter
import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue
@@ -171,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment;
lateinit var _fragHistory: HistoryFragment;
lateinit var _fragShorts: ShortsFragment;
lateinit var _fragSourceDetail: SourceDetailFragment;
lateinit var _fragDownloads: DownloadsFragment;
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
@@ -340,6 +340,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance();
_fragShorts = ShortsFragment.newInstance();
_fragSourceDetail = SourceDetailFragment.newInstance();
_fragDownloads = DownloadsFragment();
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
@@ -610,6 +611,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}, UIDialogs.ActionStyle.PRIMARY)
)
}
//startActivity(Intent(this, TestActivity::class.java))
}
/*
@@ -1253,6 +1256,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WebDetailFragment::class -> _fragWebDetail as T;
WatchLaterFragment::class -> _fragWatchlist as T;
HistoryFragment::class -> _fragHistory as T;
ShortsFragment::class -> _fragShorts as T;
SourceDetailFragment::class -> _fragSourceDetail as T;
DownloadsFragment::class -> _fragDownloads as T;
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
@@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.views.TargetTapLoaderView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_test);
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
view.startLoader(10000)
lifecycleScope.launch {
delay(5000)
view.startLoader()
}
}
companion object {
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable
@@ -36,6 +37,11 @@ interface IPlatformClient {
*/
fun getHome(): IPager<IPlatformContent>
/**
* Gets the shorts feed
*/
fun getShorts(): IPager<IPlatformVideo>
//Search
/**
* Gets search suggestion for the provided query string
@@ -176,6 +182,10 @@ interface IPlatformClient {
* Retrieves the subscriptions of the currently logged in user
*/
fun getUserSubscriptions(): Array<String>;
/**
* Retrieves the history of the currently logged in user
*/
fun getUserHistory(): IPager<IPlatformContent>;
fun isClaimTypeSupported(claimType: Int): Boolean;
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.live.LiveEventComment
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.logging.Logger
@@ -26,12 +27,17 @@ class LiveChatManager {
private val _emojiCache: EmojiCache = EmojiCache();
private val _pager: IPager<IPlatformLiveEvent>?;
private var _position: Long = 0;
private var _eventsPosition: Long = 0;
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
private var _startCounter = 0;
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
val isVOD get() = _pager is JSVODEventPager;
var viewCount: Long = 0
private set;
@@ -39,8 +45,24 @@ class LiveChatManager {
_scope = scope;
_pager = pager;
viewCount = initialViewCount;
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
handleEvents(pager.getResults());
if(pager is JSVODEventPager)
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
else
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
if(pager is JSVODEventPager) {
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
//TODO: Remove this once dripfeed is done properly
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
if(replayResults.size > 0) {
_eventsPosition = replayResults.maxOf { it.time };
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
}
else
_eventsPosition = _eventsPosition + 1500;
}
else
handleEvents(pager.getResults());
}
fun start() {
@@ -52,6 +74,10 @@ class LiveChatManager {
_startCounter++;
}
fun setVideoPosition(ms: Long) {
_position = ms;
}
fun getHistory(): List<IPlatformLiveEvent> {
synchronized(_history) {
return _history.toList();
@@ -85,13 +111,34 @@ class LiveChatManager {
try {
while(_startCounter == counter) {
var nextInterval = 1000L;
if(_pager is JSVODEventPager && _eventsPosition > _position) {
delay(500);
continue;
}
try {
if(_pager == null || !_pager.hasMorePages())
return@launch;
_pager.nextPage();
val newEvents = _pager.getResults();
val newEvents = if(_pager is JSVODEventPager) {
val requestPosition = _position;
_pager.nextPage(requestPosition.toInt());
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
if(replayResults.size > 0) {
_eventsPosition = replayResults.maxOf { it.time };
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
}
else
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
replayResults;
}
else {
_pager.nextPage();
_pager.getResults();
}
if(_pager is JSLiveEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
else if(_pager is JSVODEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
if(newEvents.size > 0)
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
val hasGetContentChapters: Boolean = false,
val hasPeekChannelContents: Boolean = false,
val hasGetChannelPlaylists: Boolean = false,
val hasGetContentRecommendations: Boolean = false
val hasGetContentRecommendations: Boolean = false,
val hasGetUserHistory: Boolean = false
) {
}
@@ -7,6 +7,7 @@ import com.futo.platformplayer.getOrThrow
interface IPlatformLiveEvent {
val type : LiveEventType;
var time: Long;
companion object {
@@ -18,12 +18,15 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
val colorName: String?;
val badges: List<String>;
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
override var time: Long = -1;
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
this.name = name;
this.message = message;
this.thumbnail = thumbnail;
this.colorName = colorName;
this.badges = badges ?: listOf();
this.time = time;
}
companion object {
@@ -39,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
obj.getOrThrow(config, "name", contextName),
obj.getOrThrow(config, "thumbnail", contextName, true),
obj.getOrThrow(config, "message", contextName),
colorName, badges);
colorName, badges,
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
}
}
}
@@ -21,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
var expire: Int = 6000;
override var time: Long = -1;
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
this.name = name;
@@ -10,6 +10,8 @@ class LiveEventEmojis: IPlatformLiveEvent {
val emojis: HashMap<String, String>;
override var time: Long = -1;
constructor(emojis: HashMap<String, String>) {
this.emojis = emojis;
}
@@ -14,6 +14,8 @@ class LiveEventRaid: IPlatformLiveEvent {
val targetUrl: String;
val isOutgoing: Boolean;
override var time: Long = -1;
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
this.targetName = name;
this.targetUrl = url;
@@ -10,6 +10,8 @@ class LiveEventViewCount: IPlatformLiveEvent {
val viewCount: Int;
override var time: Long = -1;
constructor(viewCount: Int) {
this.viewCount = viewCount;
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import java.time.OffsetDateTime
/**
* A search result representing a video (overview data)
@@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
val duration: Long;
val viewCount: Long;
val playbackTime: Long;
val playbackDate: OffsetDateTime?;
val isLive : Boolean;
val isShort: Boolean;
@@ -3,11 +3,10 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNames
@@ -18,7 +17,7 @@ open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val thumbnails: Thumbnails = Thumbnails(),
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
@JsonNames("datetime", "dateTime")
@@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
override val isLive: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override fun toJson() : String {
return Json.encodeToString(this);
}
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
@@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
@@ -124,6 +126,7 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit;
@@ -269,7 +272,8 @@ open class JSClient : IPlatformClient {
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
);
try {
@@ -328,6 +332,13 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getHome()"));
}
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
ensureEnabled()
return@isBusyWith JSVideoPager(config, this,
plugin.executeTyped("source.getShorts()"))
}
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
@JSDocsParameter("query", "Query to complete suggestions for")
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
@@ -702,6 +713,13 @@ open class JSClient : IPlatformClient {
.toTypedArray();
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
fun validate() {
try {
plugin.start();
@@ -1,6 +1,10 @@
package com.futo.platformplayer.api.media.platforms.js
@kotlinx.serialization.Serializable
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.util.Dictionary
@Serializable
class SourcePluginAuthConfig(
val loginUrl: String,
val completionUrl: String? = null,
@@ -11,5 +15,44 @@ class SourcePluginAuthConfig(
val userAgent: String? = null,
val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null
) { }
val loginWarning: String? = null,
val loginWarnings: List<Warning>? = null,
val uiMods: List<UIMod>? = null
) {
@Serializable
class Warning(
val url: String,
val text: String?,
val details: String? = null,
val once: Boolean? = true
) {
@Contextual
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
@Serializable
class UIMod(
val url: String,
val scale: Float?,
val desktop: Boolean?
) {
@Contextual
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
}
@@ -48,6 +48,7 @@ class SourcePluginConfig(
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true,
var enableInHome: Boolean = true,
var enableInShorts: Boolean = true,
var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null,
@@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
@@ -103,12 +109,22 @@ class SourcePluginDescriptor {
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
var enableHome: Boolean? = null;
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
var enableSearch: Boolean? = null;
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
var enableShorts: Boolean? = null;
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
var sync = Sync();
@Serializable
class Sync {
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
var enableHistorySync: Boolean? = null;
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@@ -143,6 +159,8 @@ class SourcePluginDescriptor {
tabEnabled.enableHome = config.enableInHome
if(tabEnabled.enableSearch == null)
tabEnabled.enableSearch = config.enableInSearch
if(tabEnabled.enableShorts == null)
tabEnabled.enableShorts = config.enableInShorts
}
}
@@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
@@ -85,12 +86,12 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
@@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -60,7 +61,7 @@ class JSComment : IPlatformComment {
if(!_hasGetReplies)
return null;
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return JSCommentPager(_config!!, plugin, obj);
}
@@ -19,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
protected var pager: V8ValueObject;
private var _lastResults: List<T>? = null;
private var _resultChanged: Boolean = true;
private var _hasMorePages: Boolean = false;
protected var _resultChanged: Boolean = true;
protected var _hasMorePages: Boolean = false;
//private var _morePagesWasFalse: Boolean = false;
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
@@ -41,7 +41,7 @@ abstract class JSPager<T> : IPager<T> {
}
override fun hasMorePages(): Boolean {
return _hasMorePages;
return _hasMorePages && !pager.isClosed;
}
override fun nextPage() {
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
@@ -68,12 +69,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
return null;
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
}
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -38,7 +39,7 @@ class JSSubtitleSource : ISubtitleSource {
throw IllegalStateException("This subtitle doesn't support getSubtitles..");
return _obj.getSourcePlugin()?.busy {
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
val v8String = _obj.invokeV8<V8ValueString>("getSubtitles", arrayOf<Any>());
return@busy v8String.value;
} ?: "";
}
@@ -0,0 +1,44 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.warnIfMainThread
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
override var nextRequest: Int;
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
}
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
warnIfMainThread("VODEventPager.nextPage");
val pluginV8 = plugin.getUnderlyingPlugin();
pluginV8.busy {
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
pager.invokeV8<V8Value>("nextPage", ms);
};
if(newPager is V8ValueObject)
pager = newPager;
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
_resultChanged = true;
}
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
}
override fun nextPage() = nextPage(0);
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
}
}
@@ -8,6 +8,10 @@ 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
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val contentType: ContentType get() = ContentType.MEDIA;
@@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
final override val duration: Long;
final override val viewCount: Long;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
final override val isLive: Boolean;
final override val isShort: Boolean;
@@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
viewCount = _content.getOrThrow(config, "viewCount", contextName);
isLive = _content.getOrThrow(config, "isLive", contextName);
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
if(playbackDateInt == null || playbackDateInt == 0.toLong())
playbackDate = null;
else
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
}
}
@@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
@@ -24,13 +25,17 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
private val _plugin: JSClient;
private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean;
private val _hasGetPlaybackTracker: Boolean;
private val _hasGetVODEvents: Boolean;
//Details
override val description : String;
@@ -46,7 +51,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin;
@@ -71,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
_hasGetComments = _content.has("getComments");
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
_hasGetVODEvents = _content.has("getVODEvents");
}
override fun getPlaybackTracker(): IPlaybackTracker? {
@@ -86,7 +91,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
private fun getPlaybackTrackerJS(): IPlaybackTracker? {
return _plugin.busy {
V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
val tracker = _content.invokeV8<V8Value>("getPlaybackTracker", arrayOf<Any>())
?: return@catchScriptErrors null;
if(tracker is V8ValueObject)
return@catchScriptErrors JSPlaybackTracker(_plugin, tracker);
@@ -111,7 +116,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
return _plugin.busy {
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
}
@@ -130,11 +135,22 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
return _plugin.busy {
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
val commentPager = _content.invokeV8<V8Value>("getComments", arrayOf<Any>());
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
return@busy null;
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
}
fun hasVODEvents(): Boolean{
return _hasGetVODEvents;
}
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
if(!_hasGetVODEvents)
return@busy null;
return@busy JSVODEventPager(_plugin.config, _plugin,
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
}
}
@@ -17,6 +17,7 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
@@ -57,12 +58,24 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
hasGenerate = _obj.has("generate");
}
private var _pregenerate: V8Deferred<String?>? = null;
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
var result: V8Deferred<V8ValueString>? = null;
@@ -18,6 +18,7 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -65,11 +66,22 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
hasGenerate = _obj.has("generate");
}
private var _pregenerate: V8Deferred<String?>? = null;
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -19,7 +18,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.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
@@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
override val isLive: Boolean = false;
override val isShort: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
constructor(file: File) {
id = PlatformID("Local", file.path, "LOCAL")
name = file.name;
@@ -7,12 +7,12 @@ import java.util.stream.IntStream
* A Content MultiPager that returns results based on a specified distribution
* TODO: Merge all basic distribution pagers
*/
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
private val dist : HashMap<IPager<IPlatformContent>, Float>;
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
private val dist : HashMap<IPager<T>, Float>;
private val distConsumed : HashMap<IPager<T>, Float>;
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
val distTotal = pagers.values.sum();
dist = HashMap();
@@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
}
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
if(options.size == 0)
return -1;
var bestIndex = 0;
@@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
return bestIndex;
}
}
@@ -62,6 +62,7 @@ class ChromecastCastingDevice : CastingDevice {
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
@@ -305,6 +306,7 @@ class ChromecastCastingDevice : CastingDevice {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
@@ -546,6 +548,7 @@ class ChromecastCastingDevice : CastingDevice {
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
@@ -558,7 +561,6 @@ class ChromecastCastingDevice : CastingDevice {
_transportId = transportId;
requestMediaStatus();
playVideo();
}
}
}
@@ -568,21 +570,22 @@ class ChromecastCastingDevice : CastingDevice {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
setTime(0.0)
_transportId = null
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
@@ -599,6 +602,7 @@ class ChromecastCastingDevice : CastingDevice {
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
@@ -636,10 +640,16 @@ class ChromecastCastingDevice : CastingDevice {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stop();
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
@@ -676,6 +686,10 @@ class ChromecastCastingDevice : CastingDevice {
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
@@ -348,7 +348,7 @@ class FCastCastingDevice : CastingDevice {
headerBytesRead += read
}
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
@@ -39,6 +39,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource
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.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -64,6 +65,7 @@ import java.net.URLDecoder
import java.net.URLEncoder
import java.util.Collections
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class StateCasting {
private val _scopeIO = CoroutineScope(Dispatchers.IO);
@@ -89,6 +91,7 @@ class StateCasting {
var _resumeCastingDevice: CastingDeviceInfo? = null;
private var _nsdManager: NsdManager? = null
val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0)
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
@@ -432,129 +435,112 @@ class StateCasting {
action();
}
fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean {
val ad = activeDevice ?: return false;
if (ad.connectionState != CastConnectionState.CONNECTED) {
return false;
}
fun cancel() {
_castId.incrementAndGet()
}
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
return withContext(Dispatchers.IO) {
val ad = activeDevice ?: return@withContext false;
if (ad.connectionState != CastConnectionState.CONNECTED) {
return@withContext false;
}
var sourceCount = 0;
if (videoSource != null) sourceCount++;
if (audioSource != null) sourceCount++;
if (subtitleSource != null) sourceCount++;
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
val castId = _castId.incrementAndGet()
if (sourceCount < 1) {
throw Exception("At least one source should be specified.");
}
var sourceCount = 0;
if (videoSource != null) sourceCount++;
if (audioSource != null) sourceCount++;
if (subtitleSource != null) sourceCount++;
if (sourceCount > 1) {
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
if (sourceCount < 1) {
throw Exception("At least one source should be specified.");
}
if (sourceCount > 1) {
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as local DASH");
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
}
} else {
Logger.i(TAG, "Casting as local DASH");
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
}
} else {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
if (isRawDash) {
Logger.i(TAG, "Casting as raw DASH");
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
if (isRawDash) {
Logger.i(TAG, "Casting as raw DASH");
try {
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e);
}
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
} else {
if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else {
if (ad is FCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as DASH indirect");
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
}
Logger.i(TAG, "Casting as DASH indirect");
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
}
}
}
} else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) {
val videoPath = "/video-${id}"
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
} else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}"
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
} else if(videoSource is IHLSManifestSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
}
} else if(audioSource is IHLSManifestAudioSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e);
}
}
} else if (audioSource is JSDashManifestRawAudioSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e);
}
}
} else {
var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
).filterNotNull().joinToString(", ");
throw UnsupportedCastException(str);
}
}
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
return true;
if (videoSource is IVideoUrlSource) {
val videoPath = "/video-${id}"
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
} else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}"
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
} else if(videoSource is IHLSManifestSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
}
} else if(audioSource is IHLSManifestAudioSource) {
if (proxyStreams || ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
} else if (audioSource is JSDashManifestRawAudioSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
} else {
var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
).filterNotNull().joinToString(", ");
throw UnsupportedCastException(str);
}
}
return@withContext true;
}
}
fun resumeVideo(): Boolean {
@@ -766,7 +752,7 @@ class StateCasting {
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
@@ -1129,9 +1115,14 @@ class StateCasting {
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean {
val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true
return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier
}
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
@@ -1236,7 +1227,7 @@ class StateCasting {
}
@OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
val ad = activeDevice ?: return listOf();
cleanExecutors()
@@ -1283,20 +1274,48 @@ class StateCasting {
}
}
var dashContent = withContext(Dispatchers.IO) {
var dashContent: String = withContext(Dispatchers.IO) {
stopVideo()
//TODO: Include subtitlesURl in the future
return@withContext if (audioSource != null && videoSource != null) {
JSDashManifestMergingRawSource(videoSource, audioSource).generate()
val deferred = if (audioSource != null && videoSource != null) {
JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO)
} else if (audioSource != null) {
audioSource.generate()
audioSource.generateAsync(_scopeIO)
} else if (videoSource != null) {
videoSource.generate()
videoSource.generateAsync(_scopeIO)
} else {
Logger.e(TAG, "Expected at least audio or video to be set")
null
}
if (deferred != null) {
try {
withContext(Dispatchers.Main) {
if (deferred.estDuration >= 0) {
onLoadingEstimate?.invoke(deferred.estDuration)
} else {
onLoading?.invoke(true)
}
}
deferred.await()
} finally {
if (castId == _castId.get()) {
withContext(Dispatchers.Main) {
onLoading?.invoke(false)
}
}
}
} else {
return@withContext null
}
} ?: throw Exception("Dash is null")
if (castId != _castId.get()) {
Log.i(TAG, "Get DASH cancelled.")
return emptyList()
}
for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
dashContent = mediaInitializationRegex.replace(dashContent) {
@@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
val remotes = mutableListOf<V8RemoteObject>();
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
val remotes = mutableListOf<V8RemoteObject?>();
for(obj in objs)
remotes.add(createRemoteObject(obj)!!);
remotes.add(createRemoteObject(obj));
return remotes;
}
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
@@ -106,7 +106,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
};
_buttonTutorial.setOnClickListener {
UIDialogs.showCastingTutorialDialog(context)
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
dismiss()
}
}
@@ -130,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
if (shouldShowCastingDialog) {
UIDialogs.showCastingDialog(context);
UIDialogs.showCastingDialog(context, ownerActivity);
}
dismiss();
@@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
dismiss()
UIDialogs.showCastingAddDialog(context)
UIDialogs.showCastingAddDialog(context, ownerActivity)
}
}
@@ -83,7 +83,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener {
UIDialogs.showCastingAddDialog(context);
UIDialogs.showCastingAddDialog(context, ownerActivity);
dismiss();
};
@@ -139,9 +139,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
}
}
}
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
}
override fun dismiss() {
@@ -303,9 +303,10 @@ class VideoDownload {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl))
}
}
} catch (e: Throwable) {
@@ -351,9 +352,10 @@ class VideoDownload {
try {
val playlistResponse = client.get(source.url)
if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string()
if (playlistContent != null) {
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl))
}
}
} catch (e: Throwable) {
@@ -717,7 +719,7 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written = 0;
var written: Long = 0;
var indexCounter = 0;
onProgress(foundCues.count().toLong(), 0, 0);
for(cue in foundCues) {
@@ -742,7 +744,7 @@ class VideoDownload {
indexCounter++;
}
sourceLength = written.toLong();
sourceLength = written;
Logger.i(TAG, "$name downloadSource Finished");
}
@@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val isShort: Boolean get() = videoSerialized.isShort;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
//TODO: Offline subtitles
override val subtitles: List<ISubtitleSource> = listOf();
@@ -136,7 +136,7 @@ class V8RemoteObject {
}
fun List<V8RemoteObject>.serialize() : String {
fun List<V8RemoteObject?>.serialize() : String {
return _gson.toJson(this);
}
}
@@ -194,7 +194,11 @@ class PackageBridge : V8Package {
val stackTrace = Thread.currentThread().stackTrace;
val callerMethod = stackTrace.findLast {
it.className == JSClient::class.java.name
it.className == JSClient::class.java.name &&
it.methodName != "isBusy" &&
it.methodName != "busy" &&
it.methodName != "getCopy" &&
it.methodName != "isBusyWith"
}?.methodName ?: "";
val session = StateApp.instance.sessionId;
val pluginId = _plugin.config.id;
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IReplacerPager
import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.PluginException
@@ -61,7 +62,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private var _query: String? = null
private var _searchView: SearchView? = null
val onContentClicked = Event2<IPlatformContent, Long>();
val onContentClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>();
val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onChannelClicked = Event1<PlatformAuthorLink>();
@@ -208,10 +209,13 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_searchView = searchView
updateSearchViewVisibility()
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
_adapterResults = PreviewContentListAdapter(lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
this.onContentClicked.subscribe { content, num ->
val results = ArrayList(_results)
this@ChannelContentsFragment.onContentClicked.emit(content, num, Pair(_pager!!, results))
}
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
@@ -148,7 +148,7 @@ class ChannelPlaylistsFragment : Fragment(), IChannelTabFragment {
_recyclerResults = view.findViewById(R.id.recycler_videos)
_adapterResults = PreviewContentListAdapter(
view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
lifecycleScope, view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar
).apply {
this.onContentUrlClicked.subscribe(this@ChannelPlaylistsFragment.onContentUrlClicked::emit)
this.onUrlClicked.subscribe(this@ChannelPlaylistsFragment.onUrlClicked::emit)
@@ -15,6 +15,7 @@ import android.view.ViewGroup
import android.widget.*
import androidx.core.animation.doOnEnd
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -375,6 +376,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
fun newInstance() = MenuBottomBarFragment().apply { }
@UnstableApi
//Add configurable buttons here
var buttonDefinitions = listOf(
ButtonDefinition(0, R.drawable.ic_home, R.drawable.ic_home_filled, R.string.home, canToggle = true, { it.currentMain is HomeFragment }, {
@@ -390,13 +392,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
ButtonDefinition(11, R.drawable.ic_smart_display, R.drawable.ic_smart_display_filled, R.string.shorts, canToggle = true, { it.currentMain is ShortsFragment && !(it.currentMain as ShortsFragment).isChannelShortsMode }, { it.navigate<ShortsFragment>(withHistory = false) }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition;
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
val intent = Intent(c, SettingsActivity::class.java);
@@ -211,6 +211,14 @@ class ChannelFragment : MainFragment() {
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
}
}
}
adapter.onAddToClicked.subscribe { content ->
_overlayContainer.let {
if (content is IPlatformVideo) _slideUpOverlay =
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
@@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer
@@ -34,6 +36,9 @@ import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.withTimestamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.floor
import kotlin.math.max
@@ -59,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
_exoPlayer = player;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this);
}
}
@@ -246,8 +251,15 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}
//TODO: Is this still necessary?
if(viewHolder.childViewHolder is ContentPreviewViewHolder)
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
if(viewHolder.childViewHolder is ContentPreviewViewHolder) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
(recyclerData.adapter as PreviewContentListAdapter?)?.preview(viewHolder.childViewHolder)
} catch (e: Throwable) {
Logger.e(TAG, "playPreview failed", e)
}
}
}
}
private fun stopVideo() {
@@ -279,6 +279,14 @@ class HomeFragment : MainFragment() {
else {
view.setToggle(!active);
}
}, { view, views, enabled ->
val toDisable = views.filter { it != view && it.tag == "plugins" };
if(!view.isActive)
view.handleClick();
for(tag in toDisable) {
if(tag.isActive)
tag.handleClick();
}
}).withTag("plugins")
})
else listOf())
@@ -0,0 +1,883 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.AccelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
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.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.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalSubtitleSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
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.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.views.buttons.ShortsButton
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.material.button.MaterialButton
//import com.google.android.material.button.MaterialButton
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import userpackage.Protocol
@UnstableApi
class ShortView : FrameLayout {
private lateinit var fragment: MainFragment
private val player: FutoShortPlayer
private val channelInfo: LinearLayout
private val creatorThumbnail: CreatorThumbnail
private val channelName: TextView
private val videoTitle: TextView
private val videoSubtitle: TextView
private val platformIndicator: PlatformIndicator
//TODO: Replace with non-material button
private val backButton: MaterialButton
private val backButtonContainer: ConstraintLayout
private val likeButton: ShortsButton
//private val likeCount: TextView
private val dislikeButton: ShortsButton
//private val dislikeCount: TextView
private val commentsButton: ShortsButton
private val shareButton: ShortsButton
private val refreshButton: ShortsButton
private val qualityButton: ShortsButton
private val playPauseOverlay: FrameLayout
private val playPauseIcon: ImageView
private val overlayLoading: FrameLayout
private val overlayLoadingSpinner: ImageView
private lateinit var overlayQualityContainer: FrameLayout
private var overlayQualitySelector: SlideUpMenuOverlay? = null
private var video: IPlatformVideo? = null
set(value) {
field = value
onVideoUpdated.emit(value)
}
private var videoDetails: IPlatformVideoDetails? = null
private var playWhenReady = false
private var _lastVideoSource: IVideoSource? = null
private var _lastAudioSource: IAudioSource? = null
private var _lastSubtitleSource: ISubtitleSource? = null
private var loadVideoTask: TaskHandler<String, IPlatformVideoDetails>? = null
private var loadLikesTask: TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>? =
null
val onResetTriggered = Event0()
private val onPlayingToggled = Event1<Boolean>()
private val onLikesLoaded = Event3<RatingLikeDislikes, Boolean, Boolean>()
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
private val onVideoUpdated = Event1<IPlatformVideo?>()
//TODO: Replace with non-material UI? Only true dependency on Material left
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
var likes: Long = 0
set(value) {
field = value
likeButton.withPrimaryText(value.toString());
//likeCount.text = value.toString()
}
var dislikes: Long = 0
set(value) {
field = value
dislikeButton.withPrimaryText(value.toString());
//dislikeCount.text = value.toString()
}
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
this.overlayQualityContainer = overlayQualityContainer
layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
)
this.fragment = fragment
bottomSheet.mainFragment = fragment
}
// Required constructor for XML inflation
constructor(context: Context) : this(context, null, null)
// Required constructor for XML inflation with attributes
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, null)
// Required constructor for XML inflation with attributes and style
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : super(
context, attrs, defStyleAttr ?: 0
) {
// Inflate the layout once here
inflate(context, R.layout.view_short, this)
// Initialize all val properties using findViewById
player = findViewById(R.id.short_player)
channelInfo = findViewById(R.id.channel_info)
creatorThumbnail = findViewById(R.id.creator_thumbnail)
channelName = findViewById(R.id.channel_name)
videoTitle = findViewById(R.id.video_title)
videoSubtitle = findViewById(R.id.video_subtitle)
platformIndicator = findViewById(R.id.short_platform_indicator)
backButton = findViewById(R.id.back_button)
backButtonContainer = findViewById(R.id.back_button_container)
likeButton = findViewById(R.id.like_button)
//likeCount = findViewById(R.id.like_count)
dislikeButton = findViewById(R.id.dislike_button)
//dislikeCount = findViewById(R.id.dislike_count)
commentsButton = findViewById(R.id.comments_button)
shareButton = findViewById(R.id.share_button)
refreshButton = findViewById(R.id.refresh_button)
qualityButton = findViewById(R.id.quality_button)
playPauseOverlay = findViewById(R.id.play_pause_overlay)
playPauseIcon = findViewById(R.id.play_pause_icon)
overlayLoading = findViewById(R.id.short_view_loading_overlay)
overlayLoadingSpinner = findViewById(R.id.short_view_loader)
player.setOnClickListener {
if (player.activelyPlaying) {
player.pause()
onPlayingToggled.emit(false)
} else {
player.play()
onPlayingToggled.emit(true)
}
}
onPlayingToggled.subscribe { playing ->
if (playing) {
playPauseIcon.setImageResource(R.drawable.ic_play)
playPauseIcon.contentDescription = context.getString(R.string.play)
} else {
playPauseIcon.setImageResource(R.drawable.ic_pause)
playPauseIcon.contentDescription = context.getString(R.string.pause)
}
showPlayPauseIcon()
}
onVideoUpdated.subscribe {
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
videoTitle.text = it?.name
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
channelName.text = it?.author?.name
}
backButton.setOnClickListener {
fragment.closeSegment()
}
channelInfo.setOnClickListener {
fragment.navigate<ChannelFragment>(video?.author)
}
videoTitle.setOnClickListener {
if (!bottomSheet.isAdded) {
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
}
}
commentsButton.onClick.subscribe {
if (!bottomSheet.isAdded) {
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
}
}
shareButton.onClick.subscribe {
val url = video?.shareUrl ?: video?.url
fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain"
}, null))
}
refreshButton.onClick.subscribe {
onResetTriggered.emit()
}
refreshButton.setOnLongClickListener {
UIDialogs.toast(context, "Reload all platform shorts pagers")
false
}
qualityButton.onClick.subscribe {
showVideoSettings()
}
likeButton.onClick.subscribe {
val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) {
likes++
} else {
likes--
}
if(checked)
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
else
likeButton.withIcon(R.drawable.ic_thumb_up_s)
if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
//dislikeButton.isChecked = false
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
dislikes--
}
onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs(
it, likes, checked, dislikes, !checked
)
)
}
}
dislikeButton.onClick.subscribe {
val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) {
dislikes++
} else {
dislikes--
}
//dislikeButton.isChecked = checked
if(checked)
dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked
else
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
//likeButton.isChecked = false
likeButton.withIcon(R.drawable.ic_thumb_up_s);
likes--
}
onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs(
it, likes, !checked, dislikes, checked
)
)
}
}
onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
likes = rating.likes
dislikes = rating.dislikes
//likeButton.isChecked = liked
//dislikeButton.isChecked = disliked
dislikeButton.visibility = VISIBLE
likeButton.visibility = VISIBLE
}
player.onPlaybackStateChanged.subscribe {
val videoSource = _lastVideoSource
if (videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
val videoTracks =
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
val audioTracks =
player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_AUDIO }
val videoTrackFormats = mutableListOf<Format>()
val audioTrackFormats = mutableListOf<Format>()
if (videoTracks != null) {
for (i in 0 until videoTracks.mediaTrackGroup.length) videoTrackFormats.add(videoTracks.mediaTrackGroup.getFormat(i))
}
if (audioTracks != null) {
for (i in 0 until audioTracks.mediaTrackGroup.length) audioTrackFormats.add(audioTracks.mediaTrackGroup.getFormat(i))
}
updateQualitySourcesOverlay(videoDetails, null, videoTrackFormats.distinctBy { it.height }
.sortedBy { it.height }, audioTrackFormats.distinctBy { it.bitrate }
.sortedBy { it.bitrate })
} else {
updateQualitySourcesOverlay(videoDetails, null)
}
}
}
private fun showPlayPauseIcon() {
val overlay = playPauseOverlay
overlay.alpha = 0f
overlay.scaleX = 0f
overlay.scaleY = 0f
overlay.visibility = VISIBLE
overlay.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(400)
.setInterpolator(OvershootInterpolator(1.2f)).start()
overlay.postDelayed({
hidePlayPauseIcon()
}, 1500)
}
private fun hidePlayPauseIcon() {
val overlay = playPauseOverlay
overlay.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(300)
.setInterpolator(AccelerateInterpolator()).withEndAction {
overlay.visibility = GONE
}.start()
}
// TODO merge this with the updateQualitySourcesOverlay for the normal video player
@androidx.annotation.OptIn(UnstableApi::class)
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
Logger.i(TAG, "updateQualitySourcesOverlay")
val video: IPlatformVideoDetails?
val localVideoSources: List<LocalVideoSource>?
val localAudioSource: List<LocalAudioSource>?
val localSubtitleSources: List<LocalSubtitleSource>?
val videoSources: List<IVideoSource>?
val audioSources: List<IAudioSource>?
if (videoDetails is VideoLocal) {
video = videoLocal?.videoSerialized
localVideoSources = videoDetails.videoSource.toList()
localAudioSource = videoDetails.audioSource.toList()
localSubtitleSources = videoDetails.subtitlesSources.toList()
videoSources = null
audioSources = null
} else {
video = videoDetails
videoSources = video?.video?.videoSources?.toList()
audioSources =
if (video?.video?.isUnMuxed == true) (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
else null
if (videoLocal != null) {
localVideoSources = videoLocal.videoSource.toList()
localAudioSource = videoLocal.audioSource.toList()
localSubtitleSources = videoLocal.subtitlesSources.toList()
} else {
localVideoSources = null
localAudioSource = null
localSubtitleSources = null
}
}
val doDedup = Settings.instance.playback.simplifySources
val bestVideoSources = if (doDedup) (videoSources?.map { it.height * it.width }?.distinct()
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))?.distinct()
?.filterNotNull()?.toList() ?: listOf() else videoSources?.toList() ?: listOf()
val bestAudioContainer =
audioSources?.let { VideoHelper.selectBestAudioSource(it, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS)?.container }
val bestAudioSources =
if (doDedup) audioSources?.filter { it.container == bestAudioContainer }
?.plus(audioSources.filter { it is IHLSManifestAudioSource || it is IDashManifestSource })
?.distinct()?.toList() ?: listOf() else audioSources?.toList() ?: listOf()
val canSetSpeed = true
val currentPlaybackRate = player.getPlaybackRate()
overlayQualitySelector =
SlideUpMenuOverlay(
this.context, overlayQualityContainer, context.getString(
R.string.quality
), null, true, if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate.toString())
onClick.subscribe { v ->
player.setPlaybackRate(v.toFloat())
setSelected(v)
}
} else null, if (localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(
this.context, context.getString(R.string.offline_video), "video", *localVideoSources.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", tag = it, call = { handleSelectVideoTrack(it) })
}.toList().toTypedArray()
)
else null, if (localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(
this.context, context.getString(R.string.offline_audio), "audio", *localAudioSource.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), tag = it, call = { handleSelectAudioTrack(it) })
}.toList().toTypedArray()
)
else null, if (localSubtitleSources?.isNotEmpty() == true) SlideUpMenuGroup(
this.context, context.getString(R.string.offline_subtitles), "subtitles", *localSubtitleSources.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray()
)
else null, 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", *liveStreamAudioFormats.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", tag = it, call = { player.selectAudioTrack(it.bitrate) })
}.toList().toTypedArray()
)
else null, if (bestVideoSources.isNotEmpty()) SlideUpMenuGroup(
this.context, context.getString(R.string.video), "video", *bestVideoSources.map {
val estSize = VideoHelper.estimateSourceSize(it)
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectVideoTrack(it) })
}.toList().toTypedArray()
)
else null, if (bestAudioSources.isNotEmpty()) SlideUpMenuGroup(
this.context, context.getString(R.string.audio), "audio", *bestAudioSources.map {
val estSize = VideoHelper.estimateSourceSize(it)
val prefix = if (estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), (prefix + it.codec.trim()).trim(), tag = it, call = { handleSelectAudioTrack(it) })
}.toList().toTypedArray()
)
else null, if (video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(
this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it, call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray()
)
else null
)
}
private fun handleSelectVideoTrack(videoSource: IVideoSource) {
Logger.i(TAG, "handleSelectAudioTrack(videoSource=$videoSource)")
if (_lastVideoSource == videoSource) return
_lastVideoSource = videoSource
playVideo(player.position)
}
private fun handleSelectAudioTrack(audioSource: IAudioSource) {
Logger.i(TAG, "handleSelectAudioTrack(audioSource=$audioSource)")
if (_lastAudioSource == audioSource) return
_lastAudioSource = audioSource
playVideo(player.position)
}
private fun handleSelectSubtitleTrack(subtitleSource: ISubtitleSource) {
Logger.i(TAG, "handleSelectSubtitleTrack(subtitleSource=$subtitleSource)")
var toSet: ISubtitleSource? = subtitleSource
if (_lastSubtitleSource == subtitleSource) toSet = null
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
player.swapSubtitles(toSet)
} catch (e: Throwable) {
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
}
}
_lastSubtitleSource = toSet
}
private fun showVideoSettings() {
Logger.i(TAG, "showVideoSettings")
overlayQualitySelector?.selectOption("video", _lastVideoSource)
overlayQualitySelector?.selectOption("audio", _lastAudioSource)
overlayQualitySelector?.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 overlayQualitySelector!!.groupItems) {
if (view is SlideUpMenuGroup && view.groupTag == "video") {
videoMenuGroup = view
}
}
if (selectedQuality != null) {
videoMenuGroup?.getItem("auto")?.setSubText("")
overlayQualitySelector?.selectOption("video", selectedQuality)
} else {
videoMenuGroup?.getItem("auto")
?.setSubText("${player.exoPlayer?.player?.videoFormat?.width}x${player.exoPlayer?.player?.videoFormat?.height}")
overlayQualitySelector?.selectOption("video", "auto")
}
}
val currentPlaybackRate = player.getPlaybackRate()
overlayQualitySelector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }
?.let {
(it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString())
}
overlayQualitySelector?.show()
}
@Suppress("unused")
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
this.fragment = fragment
this.bottomSheet.mainFragment = fragment
this.overlayQualityContainer = overlayQualityContainer
}
fun changeVideo(video: IPlatformVideo, isChannelShortsMode: Boolean) {
if (this.video?.url == video.url) {
return
}
this.video = video
refreshButton.visibility = if (isChannelShortsMode) {
GONE
} else {
GONE //TODO: Revert?
}
backButtonContainer.visibility = if (isChannelShortsMode) {
VISIBLE
} else {
GONE
}
loadVideo(video.url)
}
@Suppress("unused")
fun changeVideo(videoDetails: IPlatformVideoDetails) {
if (video?.url == videoDetails.url) {
return
}
this.video = videoDetails
this.videoDetails = videoDetails
}
fun play() {
loadLikes(this.video!!)
player.clear()
player.attach()
player.clear()
playVideo()
}
fun pause() {
player.pause()
}
fun stop() {
playWhenReady = false
player.clear()
player.detach()
}
fun cancel() {
loadVideoTask?.cancel()
loadLikesTask?.cancel()
}
private fun setLoading(isLoading: Boolean) {
if (isLoading) {
(overlayLoadingSpinner.drawable as Animatable?)?.start()
overlayLoading.visibility = VISIBLE
} else {
overlayLoading.visibility = GONE
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
}
}
private fun loadLikes(video: IPlatformVideo) {
likeButton.visibility = GONE
dislikeButton.visibility = GONE
loadLikesTask?.cancel()
loadLikesTask =
TaskHandler<IPlatformVideo, Pair<Protocol.Reference, Protocol.QueryReferencesResponse>>(
StateApp.instance.scopeGetter, {
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef =
video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val queryReferencesResponse = ApiMethods.getQueryReferences(
ApiMethods.SERVER, ref, null, null, arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)
)
.build(), Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder()
.setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)
).build()
), extraByteReferences = listOfNotNull(extraBytesRef)
)
Pair(ref, queryReferencesResponse)
}).success { (ref, queryReferencesResponse) ->
val likes = queryReferencesResponse.countsList[0]
val dislikes = queryReferencesResponse.countsList[1]
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())
onLikesLoaded.emit(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked)
onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like)
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike)
} else {
args.processHandle.opinion(ref, Opinion.neutral)
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill")
args.processHandle.fullyBackfillServersAnnounceExceptions()
Logger.i(TAG, "Finished backfill")
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(
ref, args.hasLiked, args.hasDisliked
)
}
}
loadLikesTask?.run(video)
}
private fun loadVideo(url: String) {
loadVideoTask?.cancel()
videoDetails = null
_lastVideoSource = null
_lastAudioSource = null
_lastSubtitleSource = null
setLoading(true)
Logger.i(TAG, "Shorts loadVideo [${url}]");
val timeLoadVideoStart = System.currentTimeMillis();
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
StateApp.instance.scopeGetter, {
val result = StatePlatform.instance.getContentDetails(it).await()
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
return@TaskHandler result
}).success { result ->
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
videoDetails = result
video = result
if(Settings.instance.playback.shortsPregenerate)
fragment.lifecycleScope.launch(Dispatchers.IO) {
if(result != null) {
val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
if(prefVid != null && prefVid is JSDashManifestRawSource) {
Logger.i(TAG, "Shorts pregenerating video (${result.name})");
prefVid.pregenerateAsync(fragment.lifecycleScope);
}
if(prefAud != null && prefAud is JSDashManifestRawAudioSource) {
Logger.i(TAG, "Shorts pregenerating audio (${result.name})");
prefAud.pregenerateAsync(fragment.lifecycleScope);
}
}
}
bottomSheet.video = result
setLoading(false)
if (playWhenReady) playVideo()
}.exception<NoPlatformClientException> {
Logger.w(TAG, "exception<NoPlatformClientException>", it)
UIDialogs.showDialog(
context, R.drawable.ic_sources, "No source enabled to support this video\n(${url})", null, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
)
}.exception<ScriptLoginRequiredException> { e ->
Logger.w(TAG, "exception<ScriptLoginRequiredException>", e)
UIDialogs.showDialog(
context, R.drawable.ic_security, "Authentication", e.message, null, 0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Login", {
val id = e.config.let { if (it is SourcePluginConfig) it.id else null }
val didLogin =
if (id == null) false else StatePlugins.instance.loginPlugin(context, id) {
loadVideo(url)
}
if (!didLogin) UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login")
}, UIDialogs.ActionStyle.PRIMARY)
)
}.exception<ContentNotAvailableYetException> {
Logger.w(TAG, "exception<ContentNotAvailableYetException>", it)
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
}.exception<ScriptImplementationException> {
Logger.w(TAG, "exception<ScriptImplementationException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
}.exception<ScriptAgeException> {
Logger.w(TAG, "exception<ScriptAgeException>", it)
UIDialogs.showDialog(
context, R.drawable.ic_lock, "Age restricted video", it.message, null, 0, UIDialogs.Action("Close", { }, UIDialogs.ActionStyle.PRIMARY)
)
}.exception<ScriptUnavailableException> {
Logger.w(TAG, "exception<ScriptUnavailableException>", it)
UIDialogs.showDialog(
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
)
}.exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
}.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
}
loadVideoTask?.run(url)
}
private fun playVideo(resumePositionMs: Long = 0) {
val videoDetails = this@ShortView.videoDetails
if (videoDetails === null) {
playWhenReady = true
return
}
updateQualitySourcesOverlay(videoDetails, null)
try {
val videoSource = _lastVideoSource
?: player.getPreferredVideoSource(videoDetails, Settings.instance.playback.getCurrentPreferredQualityPixelCount())
val audioSource = _lastAudioSource
?: player.getPreferredAudioSource(videoDetails, Settings.instance.playback.getPrimaryLanguage(context))
val subtitleSource = _lastSubtitleSource
?: (if (videoDetails is VideoLocal) videoDetails.subtitlesSources.firstOrNull() else null)
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if (videoSource == null && audioSource == null) {
UIDialogs.showDialog(
context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), context.getString(R.string.this_video_is_unavailable), null, 0, UIDialogs.Action(context.getString(R.string.close), { }, UIDialogs.ActionStyle.PRIMARY)
)
StatePlatform.instance.clearContentDetailCache(videoDetails.url)
return
}
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
player.setArtwork(resource.toDrawable(resources))
}
override fun onLoadCleared(placeholder: Drawable?) {
player.setArtwork(null)
}
})
else player.setArtwork(null)
*/
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
if (subtitleSource != null) player.swapSubtitles(subtitleSource)
player.seekTo(resumePositionMs)
} catch (e: Throwable) {
Logger.e(TAG, "playVideo failed", e)
}
}
_lastVideoSource = videoSource
_lastAudioSource = audioSource
_lastSubtitleSource = subtitleSource
} catch (ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex)
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex)
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to load media", ex)
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex)
}
}
companion object {
const val TAG = "VideoDetailView"
}
}
@@ -0,0 +1,370 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.SoundEffectConstants
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.system.measureTimeMillis
@UnstableApi
class ShortsFragment : MainFragment() {
override val isMainView: Boolean = true
override val isTab: Boolean = true
override val hasBottomBar: Boolean get() = true
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
private var nextPageTask: TaskHandler<ShortsFragment, List<IPlatformVideo>>? = null
//TODO: Reduce number of pagers (1, or at most 2)
private var mainShortsPager: IPager<IPlatformVideo>? = null
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
// the pager to call next on
private var currentShortsPager: IPager<IPlatformVideo>? = null
// the shorts array bound to the ViewPager2 adapter
private val currentShorts: MutableList<IPlatformVideo> = mutableListOf()
private var channelShortsPager: IPager<IPlatformVideo>? = null
private val channelShorts: MutableList<IPlatformVideo> = mutableListOf()
val isChannelShortsMode: Boolean
get() = channelShortsPager != null
private var viewPager: ViewPager2? = null
private lateinit var zeroState: LinearLayout
private lateinit var sourcesButton: BigButton
private lateinit var overlayLoading: FrameLayout
private lateinit var overlayLoadingSpinner: ImageView
private lateinit var overlayQualityContainer: FrameLayout
private var customViewAdapter: CustomViewAdapter? = null
// we just completely reset the data structure so we want to tell the adapter that
//TODO: Move most of this logic to ShortsView
@SuppressLint("NotifyDataSetChanged")
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
super.onShownWithView(parameter, isBack)
if (parameter is Triple<*, *, *>) {
setLoading(false)
channelShorts.clear()
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
channelShorts.addAll(parameter.third as ArrayList<IPlatformVideo>)
@Suppress("UNCHECKED_CAST") // TODO replace with a strongly typed parameter
channelShortsPager = parameter.second as IPager<IPlatformVideo>
currentShorts.clear()
currentShorts.addAll(channelShorts)
currentShortsPager = channelShortsPager
viewPager?.adapter?.notifyDataSetChanged()
viewPager?.post {
viewPager?.currentItem = channelShorts.indexOfFirst {
return@indexOfFirst (parameter.first as IPlatformVideo).id == it.id
}
}
} else if (isChannelShortsMode) {
channelShortsPager = null
channelShorts.clear()
currentShorts.clear()
if (loadPagerTask == null) {
currentShorts.addAll(mainShorts)
currentShortsPager = mainShortsPager
} else {
setLoading(true)
}
viewPager?.adapter?.notifyDataSetChanged()
viewPager?.currentItem = 0
}
updateZeroState()
}
override fun onCreateMainView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_shorts, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewPager = view.findViewById(R.id.view_pager)
zeroState = view.findViewById(R.id.zero_state)
sourcesButton = view.findViewById(R.id.sources_button)
overlayLoading = view.findViewById(R.id.short_view_loading_overlay)
overlayLoadingSpinner = view.findViewById(R.id.short_view_loader)
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
sourcesButton.onClick.subscribe {
navigate<SourcesFragment>()
}
setLoading(true)
Logger.i(TAG, "Creating adapter")
val customViewAdapter =
CustomViewAdapter(currentShorts, layoutInflater, this@ShortsFragment, overlayQualityContainer, { isChannelShortsMode }) {
if (!currentShortsPager!!.hasMorePages()) {
return@CustomViewAdapter
}
nextPage()
}
customViewAdapter.onResetTriggered.subscribe {
setLoading(true)
loadPager()
loadPagerTask!!.success {
setLoading(false)
}
}
val viewPager = viewPager!!
viewPager.adapter = customViewAdapter
this.customViewAdapter = customViewAdapter
if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
loadPager()
loadPagerTask!!.success {
setLoading(false)
updateZeroState()
}
} else {
setLoading(false)
updateZeroState()
}
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
fun play(adapter: CustomViewAdapter, position: Int) {
val recycler = (viewPager.getChildAt(0) as RecyclerView)
val viewHolder =
recycler.findViewHolderForAdapterPosition(position) as CustomViewHolder?
if (viewHolder == null) {
adapter.needToPlay = position
} else {
val focusedView = viewHolder.shortView
focusedView.play()
adapter.previousShownView = focusedView
}
}
override fun onPageSelected(position: Int) {
val adapter = (viewPager.adapter as CustomViewAdapter)
if (adapter.previousShownView == null) {
// play if this page selection didn't trigger by a swipe from another page
play(adapter, position)
} else {
adapter.previousShownView?.stop()
adapter.previousShownView = null
adapter.newPosition = position
}
}
// wait for the state to idle to prevent UI lag
override fun onPageScrollStateChanged(state: Int) {
super.onPageScrollStateChanged(state)
if (state == ViewPager2.SCROLL_STATE_IDLE) {
val adapter = (viewPager.adapter as CustomViewAdapter)
val position = adapter.newPosition ?: return
adapter.newPosition = null
play(adapter, position)
}
}
})
}
private fun updateZeroState() {
if (mainShorts.isEmpty() && !isChannelShortsMode && loadPagerTask == null) {
zeroState.visibility = View.VISIBLE
} else {
zeroState.visibility = View.GONE
}
}
private fun nextPage() {
Logger.i(TAG, "ShortsFragment nextPage");
lifecycleScope.launch(Dispatchers.IO) {
try {
val time = measureTimeMillis {
currentShortsPager!!.nextPage();
}
val newVideos = currentShortsPager!!.getResults();
val prevCount = customViewAdapter!!.itemCount
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
currentShorts.addAll(newVideos)
if (isChannelShortsMode) {
channelShorts.addAll(newVideos)
} else {
mainShorts.addAll(newVideos)
}
lifecycleScope.launch(Dispatchers.Main) {
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
}
nextPageTask = null
} catch (ex: Throwable) {
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
}
}
}
// we just completely reset the data structure so we want to tell the adapter that
@SuppressLint("NotifyDataSetChanged")
private fun loadPager() {
loadPagerTask?.cancel()
Logger.i(TAG, "Shorts loadPage");
var loadPageStart = System.currentTimeMillis();
val loadPagerTask =
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
val pager = StatePlatform.instance.getShorts();
return@TaskHandler pager
}).success { pager ->
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
mainShorts.clear()
mainShorts.addAll(pager.getResults())
mainShortsPager = pager
if (!isChannelShortsMode) {
currentShorts.clear()
currentShorts.addAll(mainShorts)
currentShortsPager = pager
// if the view pager exists go back to the beginning
viewPager?.adapter?.notifyDataSetChanged()
viewPager?.currentItem = 0
}
loadPagerTask = null
}.exception<Throwable> { err ->
val message = "Unable to load shorts $err"
Logger.w(TAG, message, err)
if (context != null) {
UIDialogs.showDialog(
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
"Close", { }, UIDialogs.ActionStyle.PRIMARY
)
)
}
return@exception
}
this.loadPagerTask = loadPagerTask
loadPagerTask.run(this)
}
private fun setLoading(isLoading: Boolean) {
if (isLoading) {
(overlayLoadingSpinner.drawable as Animatable?)?.start()
overlayLoading.visibility = View.VISIBLE
} else {
overlayLoading.visibility = View.GONE
(overlayLoadingSpinner.drawable as Animatable?)?.stop()
}
}
override fun onPause() {
super.onPause()
customViewAdapter?.previousShownView?.pause()
}
override fun onDestroy() {
super.onDestroy()
loadPagerTask?.cancel()
loadPagerTask = null
nextPageTask?.cancel()
nextPageTask = null
customViewAdapter?.previousShownView?.stop()
}
companion object {
private const val TAG = "ShortsFragment"
fun newInstance() = ShortsFragment()
}
class CustomViewAdapter(
private val videos: MutableList<IPlatformVideo>,
private val inflater: LayoutInflater,
private val fragment: MainFragment,
private val overlayQualityContainer: FrameLayout,
private val isChannelShortsMode: () -> Boolean,
private val onNearEnd: () -> Unit,
) : RecyclerView.Adapter<CustomViewHolder>() {
val onResetTriggered = Event0()
var previousShownView: ShortView? = null
var newPosition: Int? = null
var needToPlay: Int? = null
@OptIn(UnstableApi::class)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
val shortView = ShortView(inflater, fragment, overlayQualityContainer)
shortView.onResetTriggered.subscribe {
onResetTriggered.emit()
}
return CustomViewHolder(shortView)
}
@OptIn(UnstableApi::class)
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})")
holder.shortView.changeVideo(videos[position], isChannelShortsMode())
if (position == itemCount - 1) {
onNearEnd()
}
}
override fun onViewRecycled(holder: CustomViewHolder) {
super.onViewRecycled(holder)
holder.shortView.cancel()
}
override fun onViewAttachedToWindow(holder: CustomViewHolder) {
super.onViewAttachedToWindow(holder)
if (holder.absoluteAdapterPosition == needToPlay) {
holder.shortView.play()
needToPlay = null
previousShownView = holder.shortView
}
}
override fun getItemCount(): Int = videos.size
}
@OptIn(UnstableApi::class)
class CustomViewHolder(val shortView: ShortView) : RecyclerView.ViewHolder(shortView)
}
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.views.buttons.BigButton
@@ -152,6 +153,50 @@ class SourceDetailFragment : MainFragment() {
if(field is View)
field.isVisible = false;
}
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
val field = _settingsAppForm.findField("sync");
if(field is View)
field.isVisible = false;
}
else {
val field = _settingsAppForm.findField("syncHistory");
field?.onChanged?.subscribe { field, new, old ->
if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) {
UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?",
"This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0,
UIDialogs.Action("No", {
}),
UIDialogs.Action("Yes", {
UIDialogs.showDialogProgress(context, {
it.setText("Importing history..");
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
val client = StatePlatform.instance.getClient(config.id);
if (client != null && client is JSClient) {
val count = StateHistory.instance.syncRemoteHistory(client);
withContext(Dispatchers.Main) {
it.hide();
if(count > 0)
UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items");
else
UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items");
}
}
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.appToast("Sync History failed due to:\n" + ex.message);
it.hide();
}
}
}
});
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
_settingsAppForm.onChanged.clear();
_settingsAppForm.onChanged.subscribe { field, value ->
_settingsAppChanged = true;
@@ -25,10 +25,10 @@ data class SuggestionsFragmentData(val query: String, val searchType: SearchType
class SuggestionsFragment : MainFragment {
override val isMainView : Boolean = true;
override val hasBottomBar: Boolean = false;
override val hasBottomBar: Boolean = true;
override val isHistory: Boolean = false;
private var _recyclerSuggestions: RecyclerView? = null;
private var _recyclerSuggestions: RecyclerView? = null;
private var _llmSuggestions: LinearLayoutManager? = null;
private var _radioGroupView: RadioGroupView? = null;
private val _suggestions: ArrayList<String> = ArrayList();
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.views.pills.WidePillButton
import java.time.OffsetDateTime
@@ -152,6 +153,9 @@ class TutorialFragment : MainFragment() {
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override val isShort: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
@@ -337,13 +337,6 @@ class VideoDetailFragment() : MainFragment() {
closeVideoDetails();
};
it.onMaximize.subscribe { maximizeVideoDetail(it) };
it.onPlayChanged.subscribe {
if(isInPictureInPicture) {
val params = _viewDetail?.getPictureInPictureParams();
if (params != null)
activity?.setPictureInPictureParams(params);
}
};
it.onEnterPictureInPicture.subscribe {
Logger.i(TAG, "onEnterPictureInPicture")
isInPictureInPicture = true;
@@ -444,11 +437,16 @@ class VideoDetailFragment() : MainFragment() {
fun onUserLeaveHint() {
val viewDetail = _viewDetail;
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}");
if(viewDetail?.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
_leavingPiP = false;
if (viewDetail === null) {
return
}
if (viewDetail.shouldEnterPictureInPicture) {
_leavingPiP = false
}
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
val params = _viewDetail?.getPictureInPictureParams();
if(params != null) {
Logger.i(TAG, "enterPictureInPictureMode")
@@ -457,7 +455,7 @@ class VideoDetailFragment() : MainFragment() {
}
if (isFullscreen) {
viewDetail?.restoreBrightness()
viewDetail.restoreBrightness()
}
}
@@ -528,7 +526,7 @@ class VideoDetailFragment() : MainFragment() {
private fun stopIfRequired() {
var shouldStop = true;
if (_viewDetail?.allowBackground == true) {
if (_viewDetail?.isAudioOnlyUserAction == true) {
shouldStop = false;
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
shouldStop = false;
@@ -4,17 +4,18 @@ import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.support.v4.media.session.PlaybackStateCompat
import android.text.Spanned
import android.util.AttributeSet
@@ -80,6 +81,7 @@ 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.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.StateCasting
@@ -244,7 +246,13 @@ class VideoDetailView : ConstraintLayout {
private val _buttonPins: RoundButtonGroup;
//private val _buttonMore: RoundButton;
var preventPictureInPicture: Boolean = false;
var preventPictureInPicture: Boolean = false
set(value) {
if (field != value) {
field = value
onShouldEnterPictureInPictureChanged.emit()
}
}
private val _addCommentView: AddCommentView;
private var _tabIndex: Int? = null;
@@ -313,11 +321,24 @@ class VideoDetailView : ConstraintLayout {
val onClose = Event0();
val onFullscreenChanged = Event1<Boolean>();
val onEnterPictureInPicture = Event0();
val onPlayChanged = Event1<Boolean>();
val onVideoChanged = Event2<Int, Int>()
var allowBackground : Boolean = false
private set;
var isAudioOnlyUserAction: Boolean = false
private set(value) {
if (field != value) {
field = value
onShouldEnterPictureInPictureChanged.emit()
}
}
val shouldEnterPictureInPicture: Boolean
get() = !preventPictureInPicture &&
!StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() &&
!isAudioOnlyUserAction &&
isPlaying
val onShouldEnterPictureInPictureChanged = Event0();
val onTouchCancel = Event0();
private var _lastPositionSaveTime: Long = -1;
@@ -430,6 +451,29 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
_container_content_liveChat.onUrlClick.subscribe { uri ->
val c = context
if (c is MainActivity) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
if (!c.handleUrl(uri.toString())) {
Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
context.startActivity(this)
}
}
} catch (e: Throwable) {
Log.e(TAG, "Failed to handle live chat URL")
}
}
} else {
Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
context.startActivity(this)
}
}
}
_monetization.onSupportTap.subscribe {
_container_content_support.setPolycentricProfile(_polycentricProfile);
switchContentView(_container_content_support);
@@ -454,11 +498,6 @@ class VideoDetailView : ConstraintLayout {
_player.attachPlayer();
_container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
StateApp.instance.preventPictureInPicture.subscribe(this) {
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
preventPictureInPicture = true;
@@ -619,8 +658,13 @@ class VideoDetailView : ConstraintLayout {
}
};
onShouldEnterPictureInPictureChanged.subscribe {
val params = getPictureInPictureParams()
fragment.activity?.setPictureInPictureParams(params)
}
if (!isInEditMode) {
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { device, connectionState ->
if (_onPauseCalled) {
return@subscribe;
}
@@ -632,7 +676,7 @@ class VideoDetailView : ConstraintLayout {
setCastEnabled(true);
}
CastConnectionState.DISCONNECTED -> {
loadCurrentVideo(lastPositionMilliseconds);
loadCurrentVideo(lastPositionMilliseconds, playWhenReady = device.isPlaying);
updatePillButtonVisibilities();
setCastEnabled(false);
@@ -716,8 +760,8 @@ class VideoDetailView : ConstraintLayout {
};
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
_player.switchToAudioMode();
allowBackground = true;
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
StateApp.instance.contextOrNull?.let {
try {
if (it is MainActivity) {
@@ -806,6 +850,8 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = null;
_lastAudioSource = null;
_lastSubtitleSource = null;
_cast.cancel()
StateCasting.instance.cancel()
video = null;
_container_content_liveChat?.close();
_player.clear();
@@ -933,6 +979,7 @@ class VideoDetailView : ConstraintLayout {
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
@@ -958,15 +1005,26 @@ class VideoDetailView : ConstraintLayout {
}
}
_slideUpOverlay?.hide();
} else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents())
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) {
video?.let {
try {
loadVODChat(it);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to reopen vod chat", ex);
}
}
_slideUpOverlay?.hide();
} else null,
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;
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!isAudioOnlyUserAction) {
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
_player.switchToVideoMode();
allowBackground = false;
isAudioOnlyUserAction = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
@@ -1082,19 +1140,23 @@ class VideoDetailView : ConstraintLayout {
//Lifecycle
var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video)
fun onResume() {
Logger.v(TAG, "onResume");
_onPauseCalled = false;
val wasLoginCall = isLoginStop;
isLoginStop = false;
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
Logger.i(TAG, "_didStop: $_didStop");
//Recover cancelled loads
if(video == null) {
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
if(_searchVideo != null)
if(_searchVideo != null && !wasLoginCall)
setVideoOverview(_searchVideo!!, true, t);
else if(_url != null)
else if(_url != null && !wasLoginCall)
setVideo(_url!!, t, _playWhenReady);
}
else if(_didStop) {
@@ -1106,10 +1168,14 @@ class VideoDetailView : ConstraintLayout {
if(_player.isAudioMode) {
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
if(!allowBackground) {
if(!isAudioOnlyUserAction) {
_player.switchToVideoMode();
isAudioOnlyUserAction = false;
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
}
else {
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
}
}
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
_player.fitHeight();
@@ -1125,18 +1191,23 @@ class VideoDetailView : ConstraintLayout {
if(StateCasting.instance.isCasting)
return;
if(allowBackground)
if(isAudioOnlyUserAction)
StatePlayer.instance.startOrUpdateMediaSession(context, video);
else {
when (Settings.instance.playback.backgroundPlay) {
0 -> handlePause();
1 -> {
if(!(video?.isLive ?: false))
_player.switchToAudioMode();
if(!(video?.isLive ?: false)) {
_player.switchToAudioMode(video);
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
}
}
if (_player.isFullScreen) {
restoreBrightness()
}
}
fun onStop() {
Logger.i(TAG, "onStop");
@@ -1150,6 +1221,7 @@ class VideoDetailView : ConstraintLayout {
_taskLoadVideo.cancel();
handleStop();
_didStop = true;
onShouldEnterPictureInPictureChanged.emit()
Logger.i(TAG, "_didStop set to true");
StatePlayer.instance.rotationLock = false;
@@ -1738,12 +1810,19 @@ class VideoDetailView : ConstraintLayout {
_liveChat?.stop();
_liveChat = null;
var gotLive = false;
if (video.isLive && video.live != null) {
loadLiveChat(video);
gotLive = true;
}
if (video.isLive && video.live == null && !video.video.videoSources.any())
if (video.isLive && video.live == null && !video.video.videoSources.any()) {
startLiveTry(video);
gotLive = true;
}
if(!gotLive && video is JSVideoDetails && video.hasVODEvents()) {
Logger.i(TAG, "Loading VOD chat");
loadVODChat(video);
}
_player.updateNextPrevious();
updateMoreButtons();
@@ -1767,6 +1846,43 @@ class VideoDetailView : ConstraintLayout {
_taskLoadRecommendations.run(videoDetail.url)
}
}
fun loadVODChat(video: IPlatformVideoDetails) {
_liveChat?.stop();
_container_content_liveChat.cancel();
_liveChat = null;
if(video !is JSVideoDetails)
return;
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
var livePager: IPager<IPlatformLiveEvent>?;
try {
//TODO: Create video.getLiveEvents shortcut/optimalization
livePager = video.getVODEvents(video.url);
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to obtain VODEvents pager", ex);
livePager = null;
}
val liveChat = livePager?.let {
val liveChatManager = LiveChatManager(fragment.lifecycleScope, livePager, video.viewCount);
liveChatManager.start();
return@let liveChatManager;
}
_liveChat = liveChat;
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
_container_content_liveChat.load(fragment.lifecycleScope, liveChat, null, if(liveChat != null) video.viewCount else null);
switchContentView(_container_content_liveChat);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to switch content view to vod chat.", e);
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to load vod chat", ex);
}
}
}
fun loadLiveChat(video: IPlatformVideoDetails) {
_liveChat?.stop();
_container_content_liveChat.cancel();
@@ -1841,7 +1957,7 @@ class VideoDetailView : ConstraintLayout {
}
//Source Loads
private fun loadCurrentVideo(resumePositionMs: Long = 0) {
private fun loadCurrentVideo(resumePositionMs: Long = 0, playWhenReady: Boolean = true) {
_didStop = false;
val video = (videoLocal ?: video) ?: return;
@@ -1862,26 +1978,52 @@ class VideoDetailView : ConstraintLayout {
if (!isCasting) {
setCastEnabled(false);
val thumbnail = video.thumbnails.getHQThumbnail();
if (videoSource == null && !thumbnail.isNullOrBlank())
Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null);
}
});
else
_player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false, resume = resumePositionMs > 0);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs);
val isLimitedVersion = StatePlatform.instance.getContentClientOrNull(video.url)?.let {
if (it is JSClient)
return@let it.config.reduceFunctionsInLimitedVersion && BuildConfig.IS_PLAYSTORE_BUILD
else false;
} ?: false;
if (isLimitedVersion && _player.isAudioMode) {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
_player.setArtwork(BitmapDrawable(resources, resource));
}
override fun onLoadCleared(placeholder: Drawable?) {
_player.setArtwork(null);
}
});
else
_player.setArtwork(null);
}
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
_player.setSource(videoSource, audioSource, _playWhenReady && playWhenReady, false, resume = resumePositionMs > 0);
if(subtitleSource != null)
_player.swapSubtitles(subtitleSource);
_player.seekTo(resumePositionMs);
} catch (e: Throwable) {
Logger.e(TAG, "loadCurrentVideo failed", e)
}
}
}
else
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
else {
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "loadCurrentVideo failed (casting)", e)
}
}
}
_lastVideoSource = videoSource;
_lastAudioSource = audioSource;
@@ -1896,13 +2038,46 @@ class VideoDetailView : ConstraintLayout {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
}
}
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
private suspend fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)")
castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)
}
if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) {
_cast.setVideoDetails(video, resumePositionMs / 1000);
setCastEnabled(true);
} else throw IllegalStateException("Disconnected cast during loading");
private suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) {
try {
val plugin = if (videoSource is JSSource) videoSource.getUnderlyingPlugin()
else if (audioSource is JSSource) audioSource.getUnderlyingPlugin()
else null
val startId = plugin?.getUnderlyingPlugin()?.runtimeId
try {
val castingSucceeded = StateCasting.instance.castIfAvailable(contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed, onLoading = {
_cast.setLoading(it)
}, onLoadingEstimate = {
_cast.setLoading(it)
})
if (castingSucceeded) {
withContext(Dispatchers.Main) {
_cast.setVideoDetails(video, resumePositionMs / 1000);
setCastEnabled(true);
}
}
} catch (e: ScriptReloadRequiredException) {
Log.i(TAG, "Reload required exception", e)
if (plugin == null)
throw e
if (startId != -1 && plugin.getUnderlyingPlugin().runtimeId != startId)
throw e
StatePlatform.instance.handleReloadRequired(e, {
fetchVideo()
});
}
} catch (e: Throwable) {
Logger.e(TAG, "loadCurrentVideoCast", e)
}
}
//Events
@@ -1942,6 +2117,10 @@ class VideoDetailView : ConstraintLayout {
videoTrackFormats.distinctBy { it.height }.sortedByDescending { it.height },
audioTrackFormats.distinctBy { it.bitrate }.sortedByDescending { it.bitrate });
}
_layoutPlayerContainer.post {
onShouldEnterPictureInPictureChanged.emit()
}
}
private var _didTriggerDatasourceErrorCount = 0;
@@ -2338,6 +2517,7 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.resumeVideo()) {
_player.play();
}
onShouldEnterPictureInPictureChanged.emit()
//TODO: This was needed because handleLowerVolume was done.
//_player.setVolume(1.0f);
@@ -2354,6 +2534,7 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.pauseVideo()) {
_player.pause();
}
onShouldEnterPictureInPictureChanged.emit()
}
private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)")
@@ -2402,7 +2583,6 @@ class VideoDetailView : ConstraintLayout {
}
isPlaying = playing;
onPlayChanged.emit(playing);
updateTracker(lastPositionMilliseconds, playing, true);
}
@@ -2413,11 +2593,17 @@ class VideoDetailView : ConstraintLayout {
if(_lastVideoSource == videoSource)
return;
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
} catch (e: Throwable) {
Logger.e(TAG, "handleSelectVideoTrack failed", e)
}
}
_lastVideoSource = videoSource;
}
@@ -2428,11 +2614,17 @@ class VideoDetailView : ConstraintLayout {
if(_lastAudioSource == audioSource)
return;
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed)
else if (!_player.swapSources(_lastVideoSource, audioSource, true, true, true))
_player.hideControls(false); //TODO: Disable player?
} catch (e: Throwable) {
Logger.e(TAG, "handleSelectAudioTrack failed", e)
}
}
_lastAudioSource = audioSource;
}
@@ -2444,12 +2636,18 @@ class VideoDetailView : ConstraintLayout {
if(_lastSubtitleSource == subtitleSource)
toSet = null;
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else
_player.swapSubtitles(fragment.lifecycleScope, toSet);
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
val d = StateCasting.instance.activeDevice;
if (d != null && d.connectionState == CastConnectionState.CONNECTED)
castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed);
else {
_player.swapSubtitles(toSet);
}
} catch (e: Throwable) {
Logger.e(TAG, "handleSelectSubtitleTrack failed", e)
}
}
_lastSubtitleSource = toSet;
}
@@ -2536,6 +2734,9 @@ class VideoDetailView : ConstraintLayout {
setProgressBarOverlayed(false);
}
onFullscreenChanged.emit(fullscreen);
_layoutPlayerContainer.post {
onShouldEnterPictureInPictureChanged.emit()
}
}
private fun setCastEnabled(isCasting: Boolean) {
@@ -2553,8 +2754,7 @@ class VideoDetailView : ConstraintLayout {
_cast.visibility = View.VISIBLE;
} else {
StateCasting.instance.stopVideo();
_cast.stopTimeJob();
_cast.visibility = View.GONE;
_cast.cancel()
if (video?.isLive == false) {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
@@ -2564,6 +2764,8 @@ class VideoDetailView : ConstraintLayout {
if (changed) {
stopAllGestures();
}
onShouldEnterPictureInPictureChanged.emit()
}
fun isLandscapeVideo(): Boolean? {
@@ -2794,6 +2996,7 @@ class VideoDetailView : ConstraintLayout {
_overlayContainer.removeAllViews();
_overlay_quality_selector?.hide();
_container_content.visibility = GONE
_player.fillHeight(false)
_layoutPlayerContainer.setPadding(0, 0, 0, 0);
@@ -2802,6 +3005,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "handleLeavePictureInPicture")
if(!_player.isFullScreen) {
_container_content.visibility = VISIBLE
_player.fitHeight();
_layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt());
} else {
@@ -2817,29 +3021,40 @@ class VideoDetailView : ConstraintLayout {
videoSourceHeight = 9;
}
val aspectRatio = videoSourceWidth.toDouble() / videoSourceHeight;
val r = _player.getVideoRect()
if(aspectRatio > 2.38) {
videoSourceWidth = 16;
videoSourceHeight = 9;
// shrink the left and right equally to get the rect to be 16 by 9 aspect ratio
// we don't want a picture in picture mode that's more squashed than 16 by 9
val targetWidth = r.height() * 16 / 9
val shrinkAmount = (r.width() - targetWidth) / 2
r.left += shrinkAmount
r.right -= shrinkAmount
}
else if(aspectRatio < 0.43) {
videoSourceHeight = 16;
videoSourceWidth = 9;
}
val r = Rect();
_player.getGlobalVisibleRect(r);
r.right = r.right - _player.paddingEnd;
val playpauseAction = if(_player.playing)
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
else
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
return PictureInPictureParams.Builder()
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
.setSourceRectHint(r)
.setActions(listOf(toBackgroundAction, playpauseAction))
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setAutoEnterEnabled(shouldEnterPictureInPicture)
}
return params.build()
}
//Other
@@ -2857,6 +3072,8 @@ class VideoDetailView : ConstraintLayout {
private fun setLastPositionMilliseconds(positionMilliseconds: Long, updateHistory: Boolean) {
lastPositionMilliseconds = positionMilliseconds;
_liveChat?.setVideoPosition(lastPositionMilliseconds);
val v = video ?: return;
val currentTime = System.currentTimeMillis();
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
@@ -3064,8 +3281,13 @@ class VideoDetailView : ConstraintLayout {
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
val didLogin = if(id == null)
false
else StatePlugins.instance.loginPlugin(context, id) {
fetchVideo();
else {
isLoginStop = true;
StatePlugins.instance.loginPlugin(context, id) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
fetchVideo();
}
}
}
if(!didLogin)
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
@@ -3243,6 +3465,7 @@ class VideoDetailView : ConstraintLayout {
const val TAG_SHARE = "share";
const val TAG_OVERLAY = "overlay";
const val TAG_LIVECHAT = "livechat";
const val TAG_VODCHAT = "vodchat";
const val TAG_CHAPTERS = "chapters";
const val TAG_OPEN = "open";
const val TAG_SEND_TO_DEVICE = "send_to_device";
@@ -0,0 +1,454 @@
package com.futo.platformplayer.fragment.mainactivity.special
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.text.Spanned
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Button
import android.widget.FrameLayout
import android.widget.FrameLayout.GONE
import android.widget.FrameLayout.VISIBLE
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.net.toUri
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.MonetizationView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class CommentsModalBottomSheet : BottomSheetDialogFragment() {
var mainFragment: MainFragment? = null
private lateinit var containerContent: FrameLayout
private lateinit var containerContentMain: LinearLayout
private lateinit var containerContentReplies: RepliesOverlay
private lateinit var containerContentDescription: DescriptionOverlay
private lateinit var containerContentSupport: SupportOverlay
private lateinit var title: TextView
private lateinit var subTitle: TextView
private lateinit var channelName: TextView
private lateinit var channelMeta: TextView
private lateinit var creatorThumbnail: CreatorThumbnail
private lateinit var channelButton: LinearLayout
private lateinit var monetization: MonetizationView
private lateinit var platform: PlatformIndicator
private lateinit var textLikes: TextView
private lateinit var textDislikes: TextView
private lateinit var layoutRating: LinearLayout
private lateinit var imageDislikeIcon: ImageView
private lateinit var imageLikeIcon: ImageView
private lateinit var description: TextView
private lateinit var descriptionContainer: LinearLayout
private lateinit var descriptionViewMore: TextView
private lateinit var commentsList: CommentsList
private lateinit var addCommentView: AddCommentView
private var polycentricProfile: PolycentricProfile? = null
private lateinit var buttonPolycentric: Button
private lateinit var buttonPlatform: Button
private var tabIndex: Int? = null
private var contentOverlayView: View? = null
lateinit var video: IPlatformVideoDetails
private lateinit var behavior: BottomSheetBehavior<FrameLayout>
private val _taskLoadPolycentricProfile =
TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(
ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it)
}
override fun onCreateDialog(
savedInstanceState: Bundle?,
): Dialog {
val bottomSheetDialog =
BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme)
bottomSheetDialog.setContentView(R.layout.modal_comments)
behavior = bottomSheetDialog.behavior
// TODO figure out how to not need all of these non null assertions
containerContent = bottomSheetDialog.findViewById(R.id.content_container)!!
containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!!
containerContentReplies =
bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!!
containerContentDescription =
bottomSheetDialog.findViewById(R.id.videodetail_container_description)!!
containerContentSupport =
bottomSheetDialog.findViewById(R.id.videodetail_container_support)!!
title = bottomSheetDialog.findViewById(R.id.videodetail_title)!!
subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!!
channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!!
channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!!
creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!!
channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!!
monetization = bottomSheetDialog.findViewById(R.id.monetization)!!
platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!!
layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!!
textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!!
textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!!
imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!!
imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!!
description = bottomSheetDialog.findViewById(R.id.videodetail_description)!!
descriptionContainer =
bottomSheetDialog.findViewById(R.id.videodetail_description_container)!!
descriptionViewMore =
bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!!
addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!!
commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!!
buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!!
buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!!
commentsList.onAuthorClick.subscribe { c ->
if (c !is PolycentricPlatformComment) {
return@subscribe
}
val id = c.author.id.value
Logger.i(TAG, "onAuthorClick: $id")
if (id != null && id.startsWith("polycentric://")) {
val navUrl = "https://harbor.social/" + id.substring("polycentric://".length)
mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri()))
}
}
commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0
var metadata = ""
if (replyCount > 0) {
metadata += "$replyCount " + requireContext().getString(R.string.replies)
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c
containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, {
val newComment = parentComment.cloneWithUpdatedReplyCount(
(parentComment.replyCount ?: 0) + 1
)
commentsList.replaceComment(parentComment, newComment)
parentComment = newComment
})
} else {
containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) })
}
animateOpenOverlayView(containerContentReplies)
}
if (StatePolycentric.instance.enabled) {
buttonPolycentric.setOnClickListener {
setTabIndex(0)
StateMeta.instance.setLastCommentSection(0)
}
} else {
buttonPolycentric.visibility = GONE
}
buttonPlatform.setOnClickListener {
setTabIndex(1)
StateMeta.instance.setLastCommentSection(1)
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
addCommentView.setContext(video.url, ref)
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
setTabIndex(2, true)
} else {
when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
1 -> setTabIndex(1, true)
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
}
}
containerContentDescription.onClose.subscribe { animateCloseOverlayView() }
containerContentReplies.onClose.subscribe { animateCloseOverlayView() }
descriptionViewMore.setOnClickListener {
animateOpenOverlayView(containerContentDescription)
}
updateDescriptionUI(video.description.fixHtmlLinks())
val dp5 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics)
val dp2 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics)
//UI
title.text = video.name
channelName.text = video.author.name
if (video.author.subscribers != null) {
channelMeta.text = if ((video.author.subscribers
?: 0) > 0
) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""
(channelName.layoutParams as MarginLayoutParams).setMargins(
0, (dp5 * -1).toInt(), 0, 0
)
} else {
channelMeta.text = ""
(channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0)
}
video.author.let {
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl)
else monetization.setPlatformMembership(null, null)
}
val subTitleSegments: ArrayList<String> = ArrayList()
if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(
R.string.watching_now) else requireContext().getString(R.string.views)}")
if (video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0
val ago = video.datetime?.toHumanNowDiffString(true)
if (diff >= 0) subTitleSegments.add("$ago ago")
else subTitleSegments.add("available in $ago")
}
platform.setPlatformFromClientID(video.id.pluginId)
subTitle.text = subTitleSegments.joinToString("")
creatorThumbnail.setThumbnail(video.author.thumbnail, false)
setPolycentricProfile(null, animate = false)
_taskLoadPolycentricProfile.run(video.author.id)
when (video.rating) {
is RatingLikeDislikes -> {
val r = video.rating as RatingLikeDislikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = VISIBLE
textDislikes.visibility = VISIBLE
textDislikes.text = r.dislikes.toHumanNumber()
}
is RatingLikes -> {
val r = video.rating as RatingLikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = GONE
textDislikes.visibility = GONE
}
else -> {
layoutRating.visibility = GONE
}
}
monetization.onSupportTap.subscribe {
containerContentSupport.setPolycentricProfile(polycentricProfile)
animateOpenOverlayView(containerContentSupport)
}
monetization.onStoreTap.subscribe {
polycentricProfile?.systemState?.store?.let {
try {
val uri = it.toUri()
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
requireContext().startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e)
}
}
}
monetization.onUrlTap.subscribe {
mainFragment!!.navigate<BrowserFragment>(it)
}
addCommentView.onCommentAdded.subscribe {
commentsList.addComment(it)
}
channelButton.setOnClickListener {
mainFragment!!.navigate<ChannelFragment>(video.author)
}
return bottomSheetDialog
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
animateCloseOverlayView()
}
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
polycentricProfile = profile
val dp35 = 35.dp(requireContext().resources)
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
if (avatar != null) {
creatorThumbnail.setThumbnail(avatar, animate)
} else {
creatorThumbnail.setThumbnail(video.author.thumbnail, animate)
creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto())
}
val username = profile?.systemState?.username
if (username != null) {
channelName.text = username
}
monetization.setPolycentricProfile(profile)
}
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
val changed = tabIndex != index || forceReload
if (!changed) {
return
}
tabIndex = index
buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null))
buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null))
when (index) {
null -> {
addCommentView.visibility = GONE
commentsList.clear()
}
0 -> {
addCommentView.visibility = VISIBLE
fetchPolycentricComments()
}
1 -> {
addCommentView.visibility = GONE
fetchComments()
}
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
video.let {
commentsList.load(true) { StatePlatform.instance.getComments(it) }
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val video = video
val idValue = video.id.value
if (video.url.isEmpty()) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
commentsList.clear()
return
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }
}
private fun updateDescriptionUI(text: Spanned) {
containerContentDescription.load(text)
description.text = text
if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE
else descriptionContainer.visibility = GONE
}
private fun animateOpenOverlayView(view: View) {
if (contentOverlayView != null) {
Logger.e(TAG, "Content overlay already open")
return
}
behavior.isDraggable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
val animHeight = containerContentMain.height
view.translationY = animHeight.toFloat()
view.visibility = VISIBLE
view.animate().setDuration(300).translationY(0f).withEndAction {
contentOverlayView = view
}.start()
}
private fun animateCloseOverlayView() {
val curView = contentOverlayView
if (curView == null) {
Logger.e(TAG, "No content overlay open")
return
}
behavior.isDraggable = true
val animHeight = contentOverlayView!!.height
curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction {
curView.visibility = GONE
contentOverlayView = null
}.start()
}
companion object {
const val TAG = "ModalBottomSheet"
}
}
@@ -13,6 +13,8 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex
import com.futo.platformplayer.views.behavior.NonScrollingTextView
import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -91,7 +93,11 @@ class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMe
}
withContext(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
try {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start activity.", e)
}
}
}
}
@@ -48,6 +48,7 @@ class DownloadService : Service() {
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
private var _notificationManager: NotificationManager? = null;
private var _notificationChannel: NotificationChannel? = null;
private var _isForeground = false
private val _client = ManagedHttpClient(OkHttpClient.Builder()
//.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(InetAddress.getByName("192.168.1.175"), 8081)))
@@ -66,6 +67,7 @@ class DownloadService : Service() {
if(!FragmentedStorage.isInitialized) {
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
stopSelf()
closeDownloadSession();
return START_NOT_STICKY;
}
@@ -116,6 +118,22 @@ class DownloadService : Service() {
override fun onCreate() {
Logger.i(TAG, "onCreate");
super.onCreate()
setupNotificationRequirements()
val bootstrapNotif = NotificationCompat.Builder(this, DOWNLOAD_NOTIF_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle("Preparing downloads...")
.setOngoing(true)
.setSilent(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else
startForeground(DOWNLOAD_NOTIF_ID, bootstrapNotif)
_isForeground = true
}
override fun onBind(p0: Intent?): IBinder? {
@@ -246,15 +264,14 @@ class DownloadService : Service() {
}
private fun notifyDownload(download: VideoDownload?) {
val channel = _notificationChannel ?: return;
val channelId = DOWNLOAD_NOTIF_CHANNEL_ID
val bringUpIntent = Intent(this, MainActivity::class.java);
bringUpIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
bringUpIntent.action = "TAB";
bringUpIntent.putExtra("TAB", "Downloads");
var builder = if(download != null)
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
val builder = if(download != null)
NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_download)
.setOngoing(true)
.setSilent(true)
@@ -262,16 +279,16 @@ class DownloadService : Service() {
.setContentTitle("${download.state}: ${download.name}")
.setContentText(download.getDownloadInfo())
.setProgress(100, (download.progress * 100).toInt(), download.progress == 0.0)
.setChannelId(channel.id)
.setChannelId(channelId)
else
NotificationCompat.Builder(this, DOWNLOAD_NOTIF_TAG)
NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_download)
.setOngoing(true)
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE))
.setContentTitle("Preparing for download...")
.setContentText("Initializing download process...")
.setChannelId(channel.id)
.setChannelId(channelId)
val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
@@ -636,6 +636,20 @@ class StateApp {
}
}
}
scopeOrNull?.launch(Dispatchers.IO) {
val enabledPlugins = StatePlatform.instance.getEnabledClients();
for(plugin in enabledPlugins) {
try {
if(plugin is JSClient) {
if(plugin.descriptor.appSettings.sync.enableHistorySync == true)
StateHistory.instance.syncRemoteHistory(plugin);
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to update remote history for ${plugin.name}", ex);
}
}
}
}
fun mainAppStartedWithExternalFiles(context: Context) {
@@ -1,15 +1,18 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
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.states.StateApp.Companion
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringDateMapStorage
import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.stores.v2.ReconstructStore
@@ -19,7 +22,6 @@ import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.math.min
class StateHistory {
//Legacy
@@ -31,6 +33,8 @@ class StateHistory {
})
.load();
private val _remoteHistoryDatesStore = FragmentedStorage.get<StringDateMapStorage>("remoteHistoryDates");
private val historyIndex: ConcurrentMap<Any, DBHistory.Index> = ConcurrentHashMap();
val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor())
.withIndex({ it.url }, historyIndex, false, true)
@@ -186,8 +190,98 @@ class StateHistory {
val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 };
for(item in toDelete)
_historyDBStore.delete(item);
_remoteHistoryDatesStore.map = HashMap<String, Long>();
_remoteHistoryDatesStore.save();
}
fun syncRemoteHistory(plugin: JSClient): Int {
if (plugin.capabilities.hasGetUserHistory &&
plugin.isLoggedIn) {
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
val hist = StatePlatform.instance.getUserHistory(plugin.id);
return syncRemoteHistory(plugin.id, hist, 100, 3);
}
return 0;
}
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): Int {
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
var exceededDate = false;
try {
val toSync = mutableListOf<IPlatformVideo>();
var pageCount = 0;
var videoCount = 0;
var isFirst = true;
var oldestPlayback = OffsetDateTime.MAX;
var newestPlayback = OffsetDateTime.MIN;
do {
if (!isFirst) videos.nextPage();
val newVideos = videos.getResults();
var foundVideos = false;
var toSyncAddedCount = 0;
for(video in newVideos) {
if(video is IPlatformVideo && video.playbackDate != null) {
if(video.playbackDate!! < lastDate) {
exceededDate = true;
break;
}
if(video.playbackTime > 0) {
toSync.add(video);
toSyncAddedCount++;
foundVideos = true;
oldestPlayback = video.playbackDate!!;
if(newestPlayback == OffsetDateTime.MIN)
newestPlayback = video.playbackDate!!;
}
}
}
pageCount++;
videoCount += newVideos.size;
isFirst = false;
if(!foundVideos)
{
Logger.i(TAG, "Found no more videos in remote history");
break;
}
}
while(videos.hasMorePages() && videoCount <= maxVideosCount && pageCount <= maxPageCount && !exceededDate);
var updated = 0;
if(oldestPlayback < OffsetDateTime.MAX) {
for(video in toSync){
val hist = getHistoryByVideo(video, true, video.playbackDate);
if(hist != null && hist.position < video.playbackTime) {
Logger.i(TAG, "Updated history for video [${video.name}] from remote history");
updateHistoryPosition(video, hist, true, video.playbackTime, video.playbackDate, false);
updated++;
}
}
if(updated > 0) {
_remoteHistoryDatesStore.setAndSave(pluginId, newestPlayback);
try {
val client = StatePlatform.instance.getClient(pluginId);
UIDialogs.appToast("Updated ${updated} history from ${client.name}")
}
catch(ex: Throwable){}
}
return updated;
}
}
catch(ex: Throwable) {
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
}
return 0;
}
companion object {
val TAG = "StateHistory";
@@ -395,8 +395,9 @@ class StatePlatform {
}
suspend fun selectClients(afterLoad: (() -> Unit)?, vararg ids: String) {
withContext(Dispatchers.IO) {
var removed: MutableList<IPlatformClient>;
synchronized(_clientsLock) {
val removed = _enabledClients.toMutableList();
removed = _enabledClients.toMutableList();
_enabledClients.clear();
for (id in ids) {
val client = getClient(id);
@@ -412,11 +413,11 @@ class StatePlatform {
}
_enabledClientsPersistent.set(*ids);
_enabledClientsPersistent.save();
}
for (oldClient in removed) {
oldClient.disable();
onSourceDisabled.emit(oldClient);
}
for (oldClient in removed) {
oldClient.disable();
onSourceDisabled.emit(oldClient);
}
afterLoad?.invoke();
};
@@ -462,6 +463,47 @@ class StatePlatform {
pager.initialize();
return pager;
}
fun getShorts(): IPager<IPlatformVideo> {
Logger.i(TAG, "Platform - getShorts");
var clientIdsOngoing = mutableListOf<String>();
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInShorts else true };
StateApp.instance.scopeOrNull?.let {
it.launch(Dispatchers.Default) {
try {
// plugins that take longer than 5 seconds to load are considered "slow"
delay(5000);
val slowClients = synchronized(clientIdsOngoing) {
return@synchronized clients.filter { clientIdsOngoing.contains(it.id) };
};
for(client in slowClients)
UIDialogs.toast("${client.name} is still loading..\nConsider disabling it for Home", false);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show toast for slow source.", e)
}
}
}
val pages = clients.parallelStream()
.map {
Logger.i(TAG, "getShorts - ${it.name}")
synchronized(clientIdsOngoing) {
clientIdsOngoing.add(it.id);
}
val shortsResult = it.fromPool(_pagerClientPool).getShorts();
synchronized(clientIdsOngoing) {
clientIdsOngoing.remove(it.id);
}
return@map shortsResult;
}
.asSequence()
.toList()
.associateWith { 1f };
val pager = MultiDistributionContentPager(pages, 2);
pager.initialize();
return pager;
}
suspend fun getHomeRefresh(scope: CoroutineScope): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getHome (Refresh)");
val clients = getSortedEnabledClient().filter { if (it is JSClient) it.enableInHome else true };
@@ -994,6 +1036,16 @@ class StatePlatform {
return client.getLiveChatWindow(url);
}
//Account
fun getUserHistory(id: String): IPager<IPlatformContent> {
val client = getClient(id);
if(client is JSClient && client.isLoggedIn) {
return client.fromPool(_pagerClientPool).getUserHistory()
}
return EmptyPager<IPlatformContent>();
}
fun injectDevPlugin(source: SourcePluginConfig, script: String): String? {
var devId: String? = null;
@@ -38,6 +38,7 @@ class StatePlayer {
//Players
private var _exoplayer : PlayerManager? = null;
private var _thumbnailExoPlayer : PlayerManager? = null;
private var _shortExoPlayer: PlayerManager? = null
//Video Status
var rotationLock: Boolean = false
@@ -633,6 +634,13 @@ class StatePlayer {
}
return _thumbnailExoPlayer!!;
}
fun getShortPlayerOrCreate(context: Context) : PlayerManager {
if(_shortExoPlayer == null) {
val player = createExoPlayer(context);
_shortExoPlayer = PlayerManager(player);
}
return _shortExoPlayer!!;
}
@OptIn(UnstableApi::class)
private fun createExoPlayer(context : Context): ExoPlayer {
@@ -656,10 +664,13 @@ class StatePlayer {
fun dispose(){
val player = _exoplayer;
val thumbPlayer = _thumbnailExoPlayer;
val shortPlayer = _shortExoPlayer
_exoplayer = null;
_thumbnailExoPlayer = null;
_shortExoPlayer = null
player?.release();
thumbPlayer?.release();
shortPlayer?.release()
}
@@ -179,8 +179,9 @@ class StatePlugins {
}
StateApp.instance.scope.launch(Dispatchers.IO) {
StatePlatform.instance.reloadClient(context, id);
afterLogin.invoke();
StatePlatform.instance.reloadClient(context, id) {
afterLogin.invoke();
}
}
};
return true;
@@ -475,6 +476,7 @@ class StatePlugins {
delay(500);
val client = ManagedHttpClient();
client.setTimeout(10000);
try {
withContext(Dispatchers.Main) {
onProgress.invoke("Validating script", 0.25);
@@ -489,14 +491,14 @@ class StatePlugins {
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
}
val iconResp = client.get(absIconUrl);
if (iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
withContext(Dispatchers.Main) {
onProgress.invoke("Saving plugin", 0.75);
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
if (installEx != null)
throw installEx;
@@ -57,9 +57,12 @@ class StateSync {
return
}
var relayServerUrl = Settings.instance.synchronization.syncServer;
Logger.i(TAG, "Relay used: ${relayServerUrl}");
syncService = SyncService(
SERVICE_NAME,
RELAY_SERVER,
relayServerUrl,
RELAY_PUBLIC_KEY,
APP_ID,
StoreBasedSyncDatabaseProvider(),
@@ -34,15 +34,18 @@ class PlayerManager {
@Synchronized
fun attach(view: PlayerView, stateName: String) {
if(view != _currentView) {
_currentView?.player = null;
switchState(stateName);
view.player = player;
_currentView = view;
if (view != _currentView) {
_currentView?.player = null
_currentView = null
switchState(stateName)
view.player = player
_currentView = view
}
}
fun detach() {
_currentView?.player = null;
_currentView?.player = null
_currentView = null
}
fun getState(name: String): PlayerState {
@@ -0,0 +1,381 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.TypedValue
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.OvershootInterpolator
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.toColorInt
import kotlin.math.*
import kotlin.random.Random
import com.futo.platformplayer.UIDialogs
class TargetTapLoaderView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val primaryColor = "#2D63ED".toColorInt()
private val inactiveGlobalAlpha = 110
private val idleSpeedMultiplier = .015f
private val overshootInterpolator = OvershootInterpolator(1.5f)
private val floatAccel = .03f
private val idleMaxSpeed = .35f
private val idleInitialTargets = 10
private val idleHintText = "Waiting for media to become available"
private var expectedDurationMs: Long? = null
private var loadStartTime = 0L
private var playStartTime = 0L
private var loaderFinished = false
private var forceIndeterminate= false
private var lastFrameTime = System.currentTimeMillis()
private var score = 0
private var isPlaying = false
private val targets = mutableListOf<Target>()
private val particles = mutableListOf<Particle>()
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(0.7f, 1f, 1f, 1f)
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics)
textAlign = Paint.Align.LEFT
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
}
private val idleHintPaint = Paint(textPaint).apply {
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12f, resources.displayMetrics)
typeface = Typeface.DEFAULT
setShadowLayer(2f, 0f, 0f, Color.BLACK)
}
private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryColor }
private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = primaryColor; strokeWidth = 12f
style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND
}
private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(50, 0, 0, 0) }
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val backgroundPaint = Paint()
private var spinnerShader: SweepGradient? = null
private var spinnerAngle = 0f
private val MIN_SPAWN_RATE = 1f
private val MAX_SPAWN_RATE = 20.0f
private val HIT_RATE_INCREMENT = 0.15f
private val MISS_RATE_DECREMENT = 0.09f
private var spawnRate = MIN_SPAWN_RATE
private val frameRunnable = object : Runnable {
override fun run() { invalidate(); if (!loaderFinished) postDelayed(this, 16L) }
}
init { setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_DOWN) handleTap(e.x, e.y); true } }
fun startLoader(durationMs: Long? = null) {
val alreadyRunning = !loaderFinished
if (alreadyRunning && durationMs == null) {
expectedDurationMs = null
forceIndeterminate = true
return
}
expectedDurationMs = durationMs?.takeIf { it > 0 }
forceIndeterminate = expectedDurationMs == null
loaderFinished = false
isPlaying = false
score = 0
particles.clear()
spawnRate = MIN_SPAWN_RATE
post { if (targets.isEmpty()) prepopulateIdleTargets() }
loadStartTime = System.currentTimeMillis()
playStartTime = 0
removeCallbacks(frameRunnable)
post(frameRunnable)
if (!isIndeterminate) {
postDelayed({
if (!loaderFinished) {
forceIndeterminate = true
expectedDurationMs = null
}
}, expectedDurationMs!!)
}
}
fun finishLoader() {
loaderFinished = true
particles.clear()
isPlaying = false
invalidate()
}
fun stopAndResetLoader() {
if (score > 0) {
val elapsed = (System.currentTimeMillis() - (if (playStartTime > 0) playStartTime else loadStartTime)) / 1000.0
UIDialogs.toast("Nice! score $score | ${"%.1f".format(score / elapsed)} / s")
score = 0
}
loaderFinished = true
isPlaying = false
targets.clear()
particles.clear()
removeCallbacks(frameRunnable)
invalidate()
}
private val isIndeterminate get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L
private fun handleTap(x: Float, y: Float) {
val idx = targets.indexOfFirst { !it.hit && hypot(x - it.x, y - it.y) <= it.radius }
if (idx >= 0) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val t = targets[idx]
t.hit = true; t.hitTime = System.currentTimeMillis()
accelerateSpawnRate()
score += if (!isIndeterminate) 10 else 5
spawnParticles(t.x, t.y, t.radius)
if (!isPlaying) {
isPlaying = true
playStartTime = System.currentTimeMillis()
score = 0
spawnRate = MIN_SPAWN_RATE
targets.retainAll { it === t }
spawnTarget()
}
} else if (isPlaying) decelerateSpawnRate()
}
private inline fun accelerateSpawnRate() {
spawnRate = (spawnRate + HIT_RATE_INCREMENT).coerceAtMost(MAX_SPAWN_RATE)
}
private inline fun decelerateSpawnRate() {
spawnRate = (spawnRate - MISS_RATE_DECREMENT).coerceAtLeast(MIN_SPAWN_RATE)
}
private fun spawnTarget() {
if (loaderFinished || width == 0 || height == 0) {
postDelayed({ spawnTarget() }, 200L); return
}
if (!isPlaying) {
postDelayed({ spawnTarget() }, 500L); return
}
val radius = Random.nextInt(40, 80).toFloat()
val x = Random.nextFloat() * (width - 2 * radius) + radius
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
val baseSpeed = Random.nextFloat() + .1f
val speed = baseSpeed
val angle = Random.nextFloat() * TAU
val vx = cos(angle) * speed
val vy = sin(angle) * speed
val alpha = Random.nextInt(150, 255)
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
val delay = (1000f / spawnRate).roundToLong()
postDelayed({ spawnTarget() }, delay)
}
private fun prepopulateIdleTargets() {
if (width == 0 || height == 0) {
post { prepopulateIdleTargets() }
return
}
repeat(idleInitialTargets) {
val radius = Random.nextInt(40, 80).toFloat()
val x = Random.nextFloat() * (width - 2 * radius) + radius
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
val angle = Random.nextFloat() * TAU
val speed = (Random.nextFloat() * .3f + .05f) * idleSpeedMultiplier
val vx = cos(angle) * speed
val vy = sin(angle) * speed
val alpha = Random.nextInt(60, 110)
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
}
}
private fun spawnParticles(cx: Float, cy: Float, radius: Float) {
repeat(12) {
val angle = Random.nextFloat() * TAU
val speed = Random.nextFloat() * 5f + 2f
val vx = cos(angle) * speed
val vy = sin(angle) * speed
val col = ColorUtils.setAlphaComponent(primaryColor, Random.nextInt(120, 255))
particles += Particle(cx, cy, vx, vy, System.currentTimeMillis(), col)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val now = System.currentTimeMillis()
val deltaMs = now - lastFrameTime
lastFrameTime = now
drawBackground(canvas)
drawTargets(canvas, now)
drawParticles(canvas, now)
if (!loaderFinished) {
if (isIndeterminate) drawIndeterminateSpinner(canvas, deltaMs)
else drawDeterministicProgressBar(canvas, now)
}
if (isPlaying) {
val margin = 24f
val scoreTxt = "Score: $score"
val speedTxt = "Speed: ${"%.2f".format(spawnRate)}/s"
val maxWidth = width - margin
val needRight = max(textPaint.measureText(scoreTxt), textPaint.measureText(speedTxt)) > maxWidth
val alignX = if (needRight) (width - margin) else margin
textPaint.textAlign = if (needRight) Paint.Align.RIGHT else Paint.Align.LEFT
canvas.drawText(scoreTxt, alignX, textPaint.textSize + margin, textPaint)
canvas.drawText(speedTxt, alignX, 2*textPaint.textSize + margin + 4f, textPaint)
textPaint.textAlign = Paint.Align.LEFT
}
else if (loaderFinished)
canvas.drawText("Loading Complete!", width/2f, height/2f, textPaint.apply { textAlign = Paint.Align.CENTER })
else {
idleHintPaint.textAlign = Paint.Align.CENTER
canvas.drawText(idleHintText, width / 2f, height - 48f, idleHintPaint)
}
}
private fun drawBackground(canvas: Canvas) {
val colors = intArrayOf(
Color.rgb(20, 20, 40),
Color.rgb(15, 15, 30),
Color.rgb(10, 10, 20),
Color.rgb( 5, 5, 10),
Color.BLACK
)
val pos = floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f)
if (backgroundPaint.shader == null) {
backgroundPaint.shader = LinearGradient(
0f, 0f, 0f, height.toFloat(),
colors, pos,
Shader.TileMode.CLAMP
)
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)
}
private fun drawTargets(canvas: Canvas, now: Long) {
val expireMsActive = if (isIndeterminate) 2500L else 1500L
val it = targets.iterator()
while (it.hasNext()) {
val t = it.next()
if (t.hit && now - t.hitTime > 300L) { it.remove(); continue }
if (isPlaying && !t.hit && now - t.spawnTime > expireMsActive) {
it.remove(); decelerateSpawnRate(); continue
}
t.x += t.vx; t.y += t.vy
t.vx += (Random.nextFloat() - .5f) * floatAccel
t.vy += (Random.nextFloat() - .5f) * floatAccel
val speedCap = if (isPlaying) Float.MAX_VALUE else idleMaxSpeed
val mag = hypot(t.vx, t.vy)
if (mag > speedCap) {
val s = speedCap / mag
t.vx *= s; t.vy *= s
}
if (t.x - t.radius < 0 || t.x + t.radius > width) t.vx *= -1
if (t.y - t.radius < 0 || t.y + t.radius > height) t.vy *= -1
val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f,1f)
else {
val e = now - t.spawnAnimStart
if (e < 300L) overshootInterpolator.getInterpolation(e/300f)
else 1f + .02f * sin(((now - t.spawnAnimStart)/1000f)*TAU + t.pulseOffset)
}
val animAlpha = if (t.hit) ((1f - scale)*255).toInt() else 255
val globalAlpha = if (isPlaying) 255 else inactiveGlobalAlpha
val alpha = (animAlpha * t.baseAlpha /255f * globalAlpha/255f).toInt().coerceAtMost(255)
val r = max(1f, t.radius*scale)
val outerCol = ColorUtils.setAlphaComponent(primaryColor, alpha)
val midCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.7f).toInt())
val innerCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.4f).toInt())
outerRingPaint.color = outerCol; middleRingPaint.color = midCol; centerDotPaint.color = innerCol
glowPaint.shader = RadialGradient(t.x, t.y, r, outerCol, Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawCircle(t.x, t.y, r*1.2f, glowPaint)
canvas.drawCircle(t.x+4f, t.y+4f, r, shadowPaint)
canvas.drawCircle(t.x, t.y, r, outerRingPaint)
canvas.drawCircle(t.x, t.y, r*.66f, middleRingPaint)
canvas.drawCircle(t.x, t.y, r*.33f, centerDotPaint)
}
}
private fun drawParticles(canvas: Canvas, now: Long) {
val lifespan = 400L
val it = particles.iterator()
while (it.hasNext()) {
val p = it.next()
val age = now - p.startTime
if (age > lifespan) { it.remove(); continue }
val a = ((1f - age/lifespan.toFloat())*255).toInt()
particlePaint.color = ColorUtils.setAlphaComponent(p.baseColor, a)
p.x += p.vx; p.y += p.vy
canvas.drawCircle(p.x, p.y, 6f, particlePaint)
}
}
private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) {
val dur = expectedDurationMs ?: return
val prog = ((now - loadStartTime) / dur.toFloat()).coerceIn(0f, 1f)
val eased = AccelerateDecelerateInterpolator().getInterpolation(prog)
val h = 20f; val r=10f
canvas.drawRoundRect(RectF(0f, height-h, width*eased, height.toFloat()), r, r, progressBarPaint)
}
private fun drawIndeterminateSpinner(canvas: Canvas, dt: Long) {
val cx=width/2f; val cy=height/2f; val r=min(width,height)/6f
spinnerAngle = (spinnerAngle + .25f*dt)%360f
if(spinnerShader == null) spinnerShader = SweepGradient(cx,cy,intArrayOf(Color.TRANSPARENT,Color.WHITE,Color.TRANSPARENT),floatArrayOf(0f,.5f,1f))
spinnerPaint.shader = spinnerShader
val glow = Paint(spinnerPaint).apply{ maskFilter = BlurMaskFilter(15f,BlurMaskFilter.Blur.SOLID) }
val sweep = 270f
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,glow)
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,spinnerPaint)
}
private data class Target(
var x: Float,
var y: Float,
val radius: Float,
val spawnTime: Long,
var hit: Boolean = false,
var hitTime: Long = 0L,
val baseAlpha: Int = 255,
var vx: Float=0f,
var vy:Float=0f,
val spawnAnimStart: Long = System.currentTimeMillis(),
val pulseOffset: Float = Random.nextFloat() * TAU
)
private data class Particle(
var x:Float,
var y:Float,
val vx:Float,
val vy:Float,
val startTime:Long,
val baseColor:Int
)
private companion object { private const val TAU = (2 * Math.PI).toFloat() }
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import androidx.core.view.children
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
class ToggleBar : LinearLayout {
private val _tagsContainer: LinearLayout;
private var allowLongPress: Boolean = false;
override fun onAttachedToWindow() {
super.onAttachedToWindow();
}
@@ -48,12 +51,31 @@ class ToggleBar : LinearLayout {
for(button in buttons) {
_tagsContainer.addView(ToggleTagView(context).apply {
if(button.icon > 0)
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
this.setInfo(button.icon, button.name, button.isActive, button.isButton, button.tag);
else if(button.iconVariable != null)
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag);
else
this.setInfo(button.name, button.isActive, button.isButton);
this.setInfo(button.name, button.isActive, button.isButton, button.tag);
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
if(allowLongPress) {
this.onLongClick.subscribe({ view, enabled ->
for (tagView in _tagsContainer.children.filter { it is ToggleTagView }) {
if (tagView != view && tagView is ToggleTagView && !tagView.isButton) {
if (enabled && !tagView.isActive) {
tagView.handleClick();
} else if (!enabled && tagView.isActive) {
tagView.handleClick();
}
}
}
})
}
else if(button.actionLong != null) {
this.onLongClick.subscribe({ view, enabled ->
val tags = _tagsContainer.children.filter { it is ToggleTagView }.map { it as ToggleTagView }.toList();
button.actionLong!!(view, tags, enabled);
});
}
});
}
}
@@ -63,16 +85,18 @@ class ToggleBar : LinearLayout {
val icon: Int;
val iconVariable: ImageVariable?;
val action: (ToggleTagView, Boolean)->Unit;
val actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean) -> Unit)?;
val isActive: Boolean;
var isButton: Boolean = false
private set;
var tag: String? = null;
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit, actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean)->Unit)? = null) {
this.name = name;
this.icon = 0;
this.iconVariable = icon;
this.action = action;
this.actionLong = actionLong;
this.isActive = isActive;
}
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
@@ -80,6 +104,7 @@ class ToggleBar : LinearLayout {
this.icon = icon;
this.iconVariable = null;
this.action = action;
this.actionLong = null;
this.isActive = isActive;
}
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
@@ -87,6 +112,7 @@ class ToggleBar : LinearLayout {
this.icon = 0;
this.iconVariable = null;
this.action = action;
this.actionLong = null;
this.isActive = isActive;
}
@@ -9,8 +9,10 @@ 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
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.channel.tab.ChannelAboutFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelContentsFragment
import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment
@@ -38,6 +40,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
val onContentUrlClicked = Event2<String, ContentType>()
val onUrlClicked = Event1<String>()
val onContentClicked = Event2<IPlatformContent, Long>()
val onShortClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>()
val onChannelClicked = Event1<PlatformAuthorLink>()
val onAddToClicked = Event1<IPlatformContent>()
val onAddToQueueClicked = Event1<IPlatformContent>()
@@ -81,7 +84,9 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
when (_tabs[position]) {
ChannelTab.VIDEOS -> {
fragment = ChannelContentsFragment.newInstance().apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentClicked.subscribe { video, num, _ ->
this@ChannelViewPagerAdapter.onContentClicked.emit(video, num)
}
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
@@ -94,7 +99,7 @@ class ChannelViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifec
ChannelTab.SHORTS -> {
fragment = ChannelContentsFragment.newInstance(ResultCapabilities.TYPE_SHORTS).apply {
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onContentClicked::emit)
onContentClicked.subscribe(this@ChannelViewPagerAdapter.onShortClicked::emit)
onContentUrlClicked.subscribe(this@ChannelViewPagerAdapter.onContentUrlClicked::emit)
onUrlClicked.subscribe(this@ChannelViewPagerAdapter.onUrlClicked::emit)
onChannelClicked.subscribe(this@ChannelViewPagerAdapter.onChannelClicked::emit)
@@ -10,7 +10,7 @@ abstract class ContentPreviewViewHolder(itemView: View) : ViewHolder(itemView) {
abstract fun bind(content: IPlatformContent);
abstract fun preview(details: IPlatformContentDetails?, paused: Boolean);
abstract suspend fun preview(details: IPlatformContentDetails?, paused: Boolean);
abstract fun stopPreview();
abstract fun pausePreview();
abstract fun resumePreview();
@@ -11,7 +11,7 @@ class EmptyPreviewViewHolder(viewGroup: ViewGroup) : ContentPreviewViewHolder(Vi
override fun bind(content: IPlatformContent) {}
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {}
override fun stopPreview() {}
@@ -29,7 +29,7 @@ class PreviewChannelViewHolder : ContentPreviewViewHolder {
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
override fun stopPreview() = Unit;
override fun pausePreview() = Unit;
override fun resumePreview() = Unit;
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -15,6 +16,8 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.debug.Stopwatch
import com.futo.platformplayer.fragment.mainactivity.main.ShortView
import com.futo.platformplayer.fragment.mainactivity.main.ShortView.Companion
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
@@ -23,6 +26,9 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.internal.platform.Platform
class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewViewHolder> {
@@ -33,6 +39,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
private val _feedStyle : FeedStyle;
private var _paused: Boolean = false;
private val _shouldShowTimeBar: Boolean
private val _scope: CoroutineScope
val onUrlClicked = Event1<String>();
val onContentUrlClicked = Event2<String, ContentType>();
@@ -43,15 +50,9 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
val onAddToWatchLaterClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
private var _taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
StateApp.instance.scopeGetter, { (viewHolder, video) ->
val stopwatch = Stopwatch()
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
return@TaskHandler Pair(viewHolder, contentDetails)
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success { previewContentDetails(it.first, it.second) }
private var _taskLoadContent: TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>
constructor(context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
constructor(scope: CoroutineScope, context: Context, feedStyle : FeedStyle, dataSet: ArrayList<IPlatformContent>, exoPlayer: PlayerManager? = null,
initialPlay: Boolean = false, viewsToPrepend: ArrayList<View> = arrayListOf(),
viewsToAppend: ArrayList<View> = arrayListOf(), shouldShowTimeBar: Boolean = true) : super(context, viewsToPrepend, viewsToAppend) {
@@ -60,6 +61,24 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
this._initialPlay = initialPlay;
this._exoPlayer = exoPlayer;
this._shouldShowTimeBar = shouldShowTimeBar
this._scope = scope
_taskLoadContent = TaskHandler<Pair<ContentPreviewViewHolder, IPlatformContent>, Pair<ContentPreviewViewHolder, IPlatformContentDetails>>(
{ scope }, { (viewHolder, video) ->
val stopwatch = Stopwatch()
val contentDetails = StatePlatform.instance.getContentDetails(video.url).await();
stopwatch.logAndNext(TAG, "Retrieving video detail (IO thread)")
return@TaskHandler Pair(viewHolder, contentDetails)
}).exception<Throwable> { Logger.e(TAG, "Failed to retrieve preview content.", it) }.success {
_scope.launch(Dispatchers.Main) {
try {
previewContentDetails(it.first, it.second)
} catch (e: Throwable) {
Logger.e(TAG, "bindChild preview failed", e)
}
}
}
}
override fun getChildCount(): Int = _dataSet.size;
@@ -132,12 +151,18 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
_initialPlay = false;
if (_feedStyle != FeedStyle.THUMBNAIL) {
preview(holder);
_scope.launch(Dispatchers.Main) {
try {
preview(holder)
} catch (e: Throwable) {
Logger.e(TAG, "bindChild preview failed", e)
}
}
}
}
}
fun preview(viewHolder: ContentPreviewViewHolder) {
suspend fun preview(viewHolder: ContentPreviewViewHolder) {
Log.v(TAG, "previewing content");
if (viewHolder == _previewingViewHolder)
return
@@ -175,7 +200,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
onAddToWatchLaterClicked.clear();
}
private fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
private suspend fun previewContentDetails(viewHolder: ContentPreviewViewHolder, videoDetails: IPlatformContentDetails?) {
_previewingViewHolder?.stopPreview();
viewHolder.preview(videoDetails, _paused);
_previewingViewHolder = viewHolder;
@@ -25,7 +25,7 @@ class PreviewLockedViewHolder : ContentPreviewViewHolder {
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
override fun stopPreview() { }
override fun pausePreview() { }
override fun resumePreview() { }
@@ -185,7 +185,7 @@ class PreviewNestedVideoView : PreviewVideoView {
}
}
override fun preview(video: IPlatformContentDetails?, paused: Boolean) {
override suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) {
if(video != null) {
super.preview(video, paused);
} else if(_content is IPlatformVideoDetails) _contentNested?.let {
@@ -40,7 +40,7 @@ class PreviewNestedVideoViewHolder : ContentPreviewViewHolder {
view.bind(content);
}
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {
view.preview(details, paused);
}
@@ -58,7 +58,7 @@ class PreviewPlaceholderViewHolder : ContentPreviewViewHolder {
}
}
override fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) { }
override fun stopPreview() { }
override fun pausePreview() { }
override fun resumePreview() { }
@@ -28,7 +28,7 @@ class PreviewPlaylistViewHolder : ContentPreviewViewHolder {
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit;
override fun stopPreview() = Unit;
override fun pausePreview() = Unit;
override fun resumePreview() = Unit;
@@ -28,7 +28,7 @@ class PreviewPostViewHolder : ContentPreviewViewHolder {
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) {};
override fun stopPreview() {};
override fun pausePreview() {};
override fun resumePreview() {};
@@ -248,7 +248,7 @@ open class PreviewVideoView : LinearLayout {
_textVideoMetadata.text = metadata + timeMeta;
}
open fun preview(video: IPlatformContentDetails?, paused: Boolean) {
open suspend fun preview(video: IPlatformContentDetails?, paused: Boolean) {
if(video == null)
return;
Logger.i(TAG, "Previewing");
@@ -42,7 +42,7 @@ class PreviewVideoViewHolder : ContentPreviewViewHolder {
override fun bind(content: IPlatformContent) = view.bind(content);
override fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused);
override suspend fun preview(details: IPlatformContentDetails?, paused: Boolean) = view.preview(details, paused);
override fun stopPreview() = view.stopPreview();
override fun pausePreview() = view.pausePreview();
override fun resumePreview() = view.resumePreview();
@@ -108,12 +108,20 @@ class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
}
withContext(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
try {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start activity.", e)
}
}
}
} else {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
try {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start activity.", e)
}
}
}
}
@@ -0,0 +1,117 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.graphics.Bitmap
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel
class ShortsButton : LinearLayout {
private val _root: LinearLayout;
private val _icon: ImageView;
private val _textPrimary: TextView;
val onClick = Event0();
var iconId: Int? = null;
constructor(context : Context, text: String, icon: Int, action: ()->Unit) : super(context) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.button_icon);
_textPrimary = findViewById(R.id.button_text);
_root = findViewById(R.id.root);
withPrimaryText(text);
withIcon(icon);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
action();
onClick.emit();
UIDialogs.toast("Clicked button: " + _textPrimary.text);
};
}
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.image_icon);
_textPrimary = findViewById(R.id.text_title);
_root = findViewById(R.id.root);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
onClick.emit();
};
}
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.ShortsButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.ShortsButton_buttonIcon_s, -1);
val attrText = attrArr.getText(R.styleable.ShortsButton_buttonText_s) ?: "";
attrArr.recycle()
withIcon(attrIconRef);
withPrimaryText(attrText.toString());
}
fun withMargin(bottom: Int, side: Int = 0): ShortsButton {
setPadding(side, 0, side, bottom)
return this;
}
fun withPrimaryText(text: String): ShortsButton {
_textPrimary.text = text;
if(text.isNullOrBlank())
_textPrimary.visibility = View.GONE;
else
_textPrimary.visibility = View.VISIBLE;
return this;
}
fun withIcon(resourceId: Int): ShortsButton {
if (resourceId != -1) {
_icon.visibility = View.VISIBLE;
_icon.setImageResource(resourceId);
} else
_icon.visibility = View.GONE;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
iconId = resourceId;
return this;
}
fun withIcon(bitmap: Bitmap): ShortsButton {
_icon.visibility = View.VISIBLE;
_icon.setImageBitmap(bitmap);
iconId = -1;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
return this;
}
fun setButtonEnabled(enabled: Boolean) {
if(enabled) {
alpha = 1f;
isEnabled = true;
isClickable = true;
}
else {
alpha = 0.5f;
isEnabled = false;
isClickable = false;
}
}
}
@@ -30,6 +30,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -54,6 +55,7 @@ class CastView : ConstraintLayout {
private val _timeBar: DefaultTimeBar;
private val _background: FrameLayout;
private val _gestureControlView: GestureControlView;
private val _loaderGame: TargetTapLoaderView
private var _scope: CoroutineScope = CoroutineScope(Dispatchers.Main);
private var _updateTimeJob: Job? = null;
private var _inPictureInPicture: Boolean = false;
@@ -88,6 +90,9 @@ class CastView : ConstraintLayout {
_timeBar = findViewById(R.id.time_progress);
_background = findViewById(R.id.layout_background);
_gestureControlView = findViewById(R.id.gesture_control);
_loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
_gestureControlView.onSpeedHoldStart.subscribe {
@@ -197,6 +202,12 @@ class CastView : ConstraintLayout {
_updateTimeJob = null;
}
fun cancel() {
stopTimeJob()
setLoading(false)
visibility = View.GONE
}
fun stopAllGestures() {
_gestureControlView.stopAllGestures();
}
@@ -279,6 +290,7 @@ class CastView : ConstraintLayout {
_textDuration.text = (video.duration * 1000).formatDuration();
_timeBar.setPosition(position);
_timeBar.setDuration(video.duration);
setLoading(false)
}
@OptIn(UnstableApi::class)
@@ -295,6 +307,7 @@ class CastView : ConstraintLayout {
_updateTimeJob?.cancel();
_updateTimeJob = null;
_scope.cancel();
setLoading(false)
}
private fun getPlaybackStateCompat(): Int {
@@ -305,4 +318,19 @@ class CastView : ConstraintLayout {
else -> PlaybackStateCompat.STATE_PAUSED;
}
}
fun setLoading(isLoading: Boolean) {
if (isLoading) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader()
} else {
_loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader()
}
}
fun setLoading(expectedDurationMs: Int) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
}
}
@@ -14,6 +14,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getDataLinkFromUrl
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.IdenticonView
import userpackage.Protocol
@@ -82,14 +83,14 @@ class CreatorThumbnail : ConstraintLayout {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.crossfade()
.into(_imageChannelThumbnail);
.into(_imageChannelThumbnail)
} else {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(_imageChannelThumbnail);
}
}
@@ -50,6 +50,29 @@ class RadioGroupView : FlexboxLayout {
radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second));
radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px);
if(multiSelect)
radioView.onLongClick.subscribe {
val selected = !radioView.selected;
if (selected) {
selectedOptions.clear();
for(v in radioViews)
v.setIsSelected(true);
selectedOptions.addAll(options.map { it.second });
} else {
if(atLeastOne) {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
selectedOptions.add(option.second);
}
else {
for(v in radioViews)
v.setIsSelected(false);
selectedOptions.clear();
}
}
onSelectedChange.emit(selectedOptions);
}
radioView.onClick.subscribe {
val selected = !radioView.selected;
if (selected) {
@@ -20,6 +20,7 @@ class RadioView : LinearLayout {
val selected get() = _selected;
var onClick = Event0();
var onLongClick = Event0();
var onSelectedChange = Event1<Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -32,6 +33,13 @@ class RadioView : LinearLayout {
setIsSelected(!_selected)
}
};
_root.setOnLongClickListener {
onLongClick.emit();
if (_handleClick) {
setIsSelected(!_selected)
}
return@setOnLongClickListener true;
}
_root.setBackgroundResource(R.drawable.background_radio_unselected);
_textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67));
@@ -23,12 +23,16 @@ class ToggleTagView : LinearLayout {
private var _text: String = "";
private var _image: ImageView;
var tag: String? = null
private set;
var isActive: Boolean = false
private set;
var isButton: Boolean = false
private set;
var onClick = Event2<ToggleTagView, Boolean>();
var onLongClick = Event2<ToggleTagView, Boolean>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
@@ -36,10 +40,25 @@ class ToggleTagView : LinearLayout {
_textTag = findViewById(R.id.text_tag);
_image = findViewById(R.id.image_tag);
_root.setOnClickListener {
if(!isButton)
setToggle(!isActive);
onClick.emit(this, isActive);
handleClick();
}
_root.setOnLongClickListener {
if(onLongClick.hasListeners())
onLongClick.emit(this, isActive);
else {
if(!isButton) {
setToggle(!isActive);
}
onClick.emit(this, isActive);
}
return@setOnLongClickListener true;
}
}
fun handleClick() {
if(!isButton)
setToggle(!isActive);
onClick.emit(this, isActive);
}
fun setToggle(isActive: Boolean) {
@@ -70,9 +89,10 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
tag = toggle.tag;
}
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -80,8 +100,9 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
this.tag = tag;
}
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -89,13 +110,15 @@ class ToggleTagView : LinearLayout {
_image.visibility = View.VISIBLE;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
this.isButton = isButton;
this.tag = tag;
}
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
_image.visibility = View.GONE;
_text = text;
_textTag.text = text;
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
setToggle(isActive);
this.isButton = isButton;
this.tag = tag;
}
}
@@ -2,11 +2,14 @@ package com.futo.platformplayer.views.overlays
import android.animation.LayoutTransition
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PointF
import android.net.Uri
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.View
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
@@ -19,6 +22,7 @@ import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.ILiveEventChatMessage
@@ -41,6 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import toAndroidColor
import androidx.core.net.toUri
class LiveChatOverlay : LinearLayout {
@@ -92,6 +97,7 @@ class LiveChatOverlay : LinearLayout {
val onRaidNow = Event1<LiveEventRaid>();
val onRaidPrevent = Event1<LiveEventRaid>();
val onUrlClick = Event1<Uri>()
private val _argJsonSerializer = Json;
@@ -116,6 +122,18 @@ class LiveChatOverlay : LinearLayout {
view?.evaluateJavascript("setInterval(()=>{" + toRemoveJSInterval + "}, 1000)") {};
};
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
onUrlClick.emit(request.url)
return true
}
// API < 24
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
onUrlClick.emit(url.toUri())
return true
}
};
_chatContainer = findViewById(R.id.chatContainer);
@@ -202,6 +220,8 @@ class LiveChatOverlay : LinearLayout {
if(viewerCount != null)
_textViewers.text = viewerCount.toHumanNumber() + " " + context.getString(R.string.viewers);
else if(manager != null && manager.isVOD)
_textViewers.text = manager.viewCount.toHumanNumber() + " past viewers";
else if(manager != null)
_textViewers.text = manager.viewCount.toHumanNumber() + " " + context.getString(R.string.viewers);
else
@@ -19,7 +19,9 @@ class WebviewOverlay : LinearLayout {
inflate(context, R.layout.overlay_webview, this)
_topbar = findViewById(R.id.topbar);
_webview = findViewById(R.id.webview);
_webview.settings.javaScriptEnabled = true;
if (!isInEditMode){
_webview.settings.javaScriptEnabled = true;
}
_topbar.onClose.subscribe(this, onClose::emit);
}
@@ -68,15 +68,7 @@ class CommentsList : ConstraintLayout {
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadNextPage() });
};
private val _scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy);
onScrolled();
val totalScrollDistance = recyclerView.computeVerticalScrollOffset()
_layoutScrollToTop.visibility = if (totalScrollDistance > recyclerView.height) View.VISIBLE else View.GONE
}
};
private val _scrollListener: RecyclerView.OnScrollListener
private var _loader: (suspend () -> IPager<IPlatformComment>)? = null;
private val _adapterComments: InsertedViewAdapterWithLoader<CommentViewHolder>;
@@ -131,6 +123,14 @@ class CommentsList : ConstraintLayout {
_llmReplies = LinearLayoutManager(context);
_recyclerComments.layoutManager = _llmReplies;
_recyclerComments.adapter = _adapterComments;
_scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy);
onScrolled();
_layoutScrollToTop.visibility = if (_llmReplies.findFirstCompletelyVisibleItemPosition() > 5) View.VISIBLE else View.GONE
}
};
_recyclerComments.addOnScrollListener(_scrollListener);
}
@@ -0,0 +1,163 @@
package com.futo.platformplayer.views.video
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.LinearInterpolator
import androidx.annotation.Dimension
import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TimeBar
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.video.PlayerManager
@UnstableApi
class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
FutoVideoPlayerBase(PLAYER_STATE_NAME, context, attrs) {
companion object {
private const val TAG = "FutoShortVideoPlayer"
private const val PLAYER_STATE_NAME: String = "ShortPlayer"
}
private var playerAttached = false
private val videoView: PlayerView
private val progressBar: DefaultTimeBar
private lateinit var player: PlayerManager
private var progressAnimator: ValueAnimator = createProgressBarAnimator()
val onPlaybackStateChanged = Event1<Int>();
private var playerEventListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
if (events.containsAny(
Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED
)
) {
progressAnimator.cancel()
if (player.duration >= 0) {
progressAnimator.duration = player.duration
setProgressBarDuration(player.duration)
progressAnimator.currentPlayTime = player.currentPosition
}
if (player.isPlaying) {
progressAnimator.start()
}
}
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
onPlaybackStateChanged.emit(player.playbackState)
}
}
}
init {
LayoutInflater.from(context).inflate(R.layout.view_short_player, this, true)
videoView = findViewById(R.id.short_player_view)
progressBar = findViewById(R.id.short_player_progress_bar)
if(Settings.instance.playback.shortsFitVideo)
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
else
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F);
if (!isInEditMode) {
player = StatePlayer.instance.getShortPlayerOrCreate(context)
player.player.repeatMode = Player.REPEAT_MODE_ONE
}
progressBar.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
progressAnimator.cancel()
}
override fun onScrubMove(timeBar: TimeBar, position: Long) {}
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
if (canceled) {
progressAnimator.currentPlayTime = player.player.currentPosition
progressAnimator.duration = player.player.duration
progressAnimator.start()
return
}
// the progress bar should never be available to the user without the player being attached to this view
assert(playerAttached)
seekTo(position)
}
})
}
@OptIn(UnstableApi::class)
private fun createProgressBarAnimator(): ValueAnimator {
return ValueAnimator.ofFloat(0f, 1f).apply {
interpolator = LinearInterpolator()
addUpdateListener { animation ->
progressBar.setPosition(animation.currentPlayTime)
}
}
}
fun setProgressBarDuration(duration: Long) {
progressBar.setDuration(duration)
}
/**
* Attaches this short player instance to the exo player instance for shorts
*/
fun attach() {
// connect the exo player for shorts to the view for this instance
player.attach(videoView, PLAYER_STATE_NAME)
// direct the base player what exo player instance to use
changePlayer(player)
playerAttached = true
player.player.addListener(playerEventListener)
}
fun detach() {
playerAttached = false
player.player.removeListener(playerEventListener)
player.detach()
}
@OptIn(UnstableApi::class)
fun setArtwork(drawable: Drawable?) {
if (drawable != null) {
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL
videoView.defaultArtwork = drawable
} else {
videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF
videoView.defaultArtwork = null
}
}
fun getPlaybackRate(): Float {
return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f
}
fun setPlaybackRate(playbackRate: Float) {
val exoPlayer = exoPlayer?.player
Logger.i(TAG, "setPlaybackRate playbackRate=$playbackRate exoPlayer=${exoPlayer}")
val param = PlaybackParameters(playbackRate)
exoPlayer?.playbackParameters = param
}
}
@@ -126,7 +126,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase {
_evMuteChanged.add(callback);
}
fun setPreview(video: IPlatformVideoDetails) {
suspend fun setPreview(video: IPlatformVideoDetails) {
if (video.live != null) {
setSource(video.live, null,true, false);
} else {

Some files were not shown because too many files have changed in this diff Show More